#2020征文-其它#鸿蒙 “JS 小程序” 数据绑定原理详解 精华
在几天前开源的华为 HarmonyOS (鸿蒙)中,提供了一种“微信小程序”式的跨平台开发框架,通过 Toolkit 将应用代码编译打包成 JS Bundle,解析并生成原生 UI 组件。
按照入门文档,很容易就能跑通 demo,唯一需要注意的是弹出网页登录时用 chrome 浏览器可能无法成功:
JS 应用框架部分的代码主要在 ace_lite_jsfwk 仓库 中,其模块组成如下图所示:
其中为了实现声明式 API 开发中的单向数据绑定机制,在 ace_lite_jsfwk 代码仓库的 packages/runtime-core/src 目录中实现了一个 ViewModel 类来完成数据劫持。
这部分的代码总体上并不复杂,在国内开发社区已经很习惯 Vue.js 和微信小程序开发的情况下,虽有不得已而为之的仓促,但也算水到渠成的用一套清晰的开源方案实现了类似的开发体验,也为更广泛的开发者快速入场丰富 HarmonyOS 生态开了个好头。
本文范围局限在 ace_lite_jsfwk 代码仓库中,且主要谈论 JS 部分。为叙述方便,对私有方法/作用域内部函数等名词不做严格区分。
ViewModel 类
构造函数
主要工作就是依次解析唯一参数 options 中的属性字段:
- 对于 options.render,赋值给 vm.$render 后,在运行时交与“JS 应用框架”层的 C++ 代码生成的原生 UI 组件,并由其渲染方法调用:
- 对于 options.styleSheet,也是直接把样式丢给由 src/core/stylemgr/app_style_manager.cpp 定义的 C++ 类 AppStyleManager 去处理
- 对于 options 中其他的自定义方法,直接绑定到 vm 上
options.data
同样在构造函数中,对于最主要的 options.data,做了两项处理:
- 首先,遍历 data 中的属性字段,通过 Object.defineProperty 代理 vm 上对应的每个属性, 使得对 vm.foo = 123 这样的操作实际上是背后 options.data.foo 的代理:
- 其次,通过 Subject.of(data) 将 data 注册为被观察的对象,具体逻辑后面会解释。
组件的 $watch 方法
作为文档中唯一提及的组件“事件方法”,和 $render() 及组件生命周期等方法一样,也是直接由 C++ 实现。除了可以在组件实例中显式调用 this.$watch,组件渲染过程中也会自动触发,比如处理属性时的调用顺序:
- Component::Render()
- Component::ParseOptions()
- 在 Component::ParseAttrs(attrs) 中求出 newAttrValue = ParseExpression(attrKey, attrValue)
- ParseExpression 的实现为:
在上面的代码中,通过 InsertWatcherCommon 间接实例化一个 Watcher: Watcher *node = new Watcher()
通过 ParseExpression 中的 propValue = jerryx_get_property_str(watcher, "_lastValue") 一句,结合 JS 部分 ViewModel 类的源码可知,C++ 部分的 watcher 概念对应的正是 JS 中的 observer:
下面就来看看 Observer 的实现。
Observer 观察者类
构造函数和 update()
主要工作就是将构造函数的几个参数存储为实例私有变量,其中
- _ctx 上下文变量对应的就是一个要观察的 ViewModel 实例,参考上面的 $watch 部分代码
- 同样,_getter、_fn、_meta 也对应着 $watch 的几个参数
- 构造函数的最后一句是 this._lastValue = this._get(),这就涉及到了 _lastValue 私有变量、_get() 私有方法,并引出了与之相关的 update() 实例方法等几个东西。
- 显然,对 _lastValue 的首次赋值是在构造函数中通过 _get() 的返回值完成的:
稍微解释一下这段乍看有些恍惚的代码 -- 按照 ECMAScript Language 官方文档中的规则,简单来说就是会按照 “执行 try 中 return 之前的代码” --> “执行并缓存 try 中 return 的代码” --> “执行 finally 中的代码” --> “返回缓存的 try 中 return 的代码” 的顺序执行:
比如有如下代码:
输出结果为:
了解这个概念就好了,后面我们会在运行测试用例时看到更具体的效果。
- 其后,_lastValue 再次被赋值就是在 update() 中完成的了:
逻辑简单清晰,对新旧值做比较,并取出 context/meta 等一并给组件中传入等 callback 调用。
新旧值的比较就是用很典型的办法,也就是经过判断后可被观察的 Object 类型对象,直接用 !== 严格相等性比较,同样,这由 JS 本身按照 ECMAScript Language 官方文档中的相关计算方法执行就好了:
另外我们可以了解到,该 update() 方法只有 Subject 实例会调用,这个同样放到后面再看。
订阅/取消订阅
- 通过 subject.attach(key, this) 记录当前 observer 实例
- 上述调用返回一个函数并暂存在 observer 实例本身的 _detaches 数组中,用以在将来取消订阅
unsubscribe 的逻辑就很自然了,执行动作的同时,也会影响到 observer/subject 中各自的私有数组。
顺便查询一下可知,只有 Subject 类里面的一处调用了订阅方法:
经过了上面这些分析,Subject 类的逻辑也呼之欲出。
Subject 被观察主体类
Subject.of() 和构造函数
正如在 ViewModel 构造函数中最后部分看到的,用静态方法 Subject.of() 在事实上提供 Subject 类的实例化 -- 此方法只是预置了一些可行性检测和防止对同一目标重复实例化等处理。
真正的构造函数完成两项主要任务:
- 将 subject 实例本身指定到 目标(也就是 ViewModel 实例化时的 options.data) 的一个私有属性(即 data["__ob__"])上
- 调用私有方法 hijack(),再次(第一次是在 ViewModel 构造函数中)遍历目标 data 中的属性,而这主要是为了
- 在 getter 中触发栈顶(也就是 ObserverStack.top())的 observer 的订阅
- 在 setter 中通过 notify() 方法通知所有订阅了此属性的 observer 们
当然逻辑中还考虑了嵌套数据的情况,并对数组方法做了特别的劫持,这些不展开说了。
attach(key, observer) 函数
- subject 对象的 _obsMap 对象中,每个 key 持有一个数组保存订阅该 key 的 observer 们
- 正如前面在 Observer 的订阅方法中所述,传入的 observer 实例按 key 被推入 _obsMap 对象中的子数组里
- 返回一个和传入 observer 实例对应的取消订阅方法,供 observer.unsubscribe() 调用
notify() 函数
唯一做的其实就是构造函数中分析的,在被劫持属性 setter 被触发时调用每个 observer.update()。
ObserverStack 观察者栈对象
在 Observer/Subject 的介绍中,已经反复提及过 ObserverStack 对象,再次确认,也的确就是被这两个类的实例引用过:
ObserverStack 对象作为 observer 实例动态存放的地方,并以此成为每次 get 数据时按序执行 watcher 的媒介。其实现也平平无奇非常简单:
理解 VM 执行过程
光说不练假把式,光练不说傻把式,连工带料,连盒儿带药,您吃了我的大力丸,甭管你让刀砍着、斧剁着、车轧着、马趟着、牛顶着、狗咬着、鹰抓着、鸭子踢着 下面我们就插入适当的注释,并实际运行一个自带的测试用例,来看看这部分实际的执行效果:
运行结果:
总结
在 runtime-core 中,用非常简单而不失巧妙的代码,完成了 ViewModel 类最基础的功能,为响应式开发提供了比较完整的基本支持。
征文大赛正在火热进行中,楼主这么优秀的文章真的不考虑参加吗?
例如这篇在标题开头添加“#2020征文-其他#“,
再找到相应的专栏位置投稿,
就可以参加比赛啦!
详细步骤可以点击链接https://harmonyos.51cto.com/posts/1940进行了解
用更多的文章来赢取更多的奖励和人气吧!期待楼主后续的活跃表现。