Weex 具有移动端跨平台的特性,JS Framework 是其中比较关键的一层。首先来看一下 JS Framework 在 Weex 中的位置:
从图中可以看出 Weex 整体的工作流程。首先开发者可以声明式的定义组件,形成 .we
文件,通过 weex-toolkit 提供的工具将 .we
文件转为 JS Bundle。JS Framework 接收并执行 JS Bundle 的代码,并且执行数据绑定、模板编译等操作,然后输出 json 格式的 Virtual DOM 传递给移动端,同时也提供了 callNative
和 callJS
接口,方便 JS Framework 和 Native 的通信。同样的一份 json 数据,在不同平台的渲染引擎中能够渲染成不同版本的 UI,这也是 Weex 可以实现动态化的原因。
简而言之,JS Framework 的输入是 JS Bundle,输出是 json 格式的 Virtual DOM,同时也提供了与 Native 通信的方法。
代码结构
文中代码的版本是 v0.15 。
weex/html5/default
├── api // 定义 Vm 上的接口
│ ├── methods.js
│ └── modules.js
├── app // 页面实例相关代码
│ ├── bundle.js
│ ├── ctrl.js
│ ├── differ.js
│ ├── downgrade.js
│ ├── index.js
│ └── register.js
├── core // 数据监听相关代码
│ ├── LICENSE
│ ├── array.js
│ ├── dep.js
│ ├── object.js
│ ├── observer.js
│ ├── state.js
│ └── watcher.js
├── util // 工具函数
│ ├── LICENSE
│ └── index.js
├── vm // 组件模型相关代码
│ ├── compiler.js
│ ├── directive.js
│ ├── dom-helper.js
│ ├── events.js
│ └── index.js
├── config.js
└── index.js // 入口文件
初始化
出于性能考虑,JS Framework 自身只会在应用启动时初始化一次,多个页面共享一份 Weex 实例和方法,包括与 Native 的通信。虽然 Weex 只有一份,但是每个 JS Bundle 是会创建不同的 App 实例的,每个实例都有唯一 id
,在与 Native 通信时也要传递 id
参数。具体细节可以参考:《Weex 在 JS Runtime 内的多实例管理》 。
初始化 JS Framework
Weex 实例包含了如下方法:
注:在 web 环境下,挂在 window 上的变量名是小写
weex
,而且经过了封装,并非 JS Framework 直接暴露的接口。
创建 App 实例
在获取到 JS Bundle 后,会调用 createInstance
创建页面实例。它首先会 new App()
创建新的 App 实例对象,并且把对象放入 instanceMap
中。app 实例中有如下几个常用属性:
id
与 Native 端通信时的唯一标识。vm
View Model,组件模型,包含了数据绑定相关功能。doc
Virtual DOM 中的根节点。
由于 JS Bundle 是工具打包生成的 js 代码,app 实例创建完成后,会通过 new Function
的方式来执行。在代码中用到的 define
、require
、bootstrap
、document
、register
、render
等方法都是在 JS Framework 的 init
中定义的,以参数的方式传递到 JS Bundle 中。new Function
中代码将会在全局环境中执行,并不能获取到 JS Framework 执行环境中的数据(除了以参数传递过去的那些)。JS Bundle 本身也用了立即执行函数做封装,并不会污染全局环境。
注: 使用
new Function
可能会导致一些性能问题,目前正在尝试其他执行方式,新版本创建 App 实例的过程可能会有所不同。
执行 JS Bundle 中的代码
在加载 JS Bundle 过程中,会首先执行 define & require 的功能,用户自定义的模块,放在了 app.customComponentMap
中,然后对调用 bootstrap
方法启动根组件。bootstrap
方法首先会校验一下参数和环境,如果不符合条件可能会触发页面降级(也可以手动设置使页面降级,这一特性可以在 Native 出现问题时,使页面降级为 html5 运行)。
bootstrap
最后会创建应用的 Vm
实例,整个过程可以分成三个步骤:
- initEvents 初始化事件和生命周期。
- initState 实现数据绑定功能。
- 编译模板并且绘制 Native UI。
初始化事件和生命周期
initEvents 会依次绑定三类事件:options
参数中定义的事件、externalEvents
外部事件、内置的生命周期事件,前两项通常都为 null
,生命周期包含了init
、created
、 ready
三个钩子。生命周期函数可以在组件中定义,具体触发时机如下:
module.exports = {
data: {},
methods: {},
init: function () {
console.log('在初始化内部变量,并且添加了事件功能后被触发');
},
created: function () {
console.log('完成数据绑定之后,模板编译之前被触发');
},
ready: function () {
console.log('模板已经编译并且生成了 Virtual DOM 之后被触发');
}
}
事件绑定完毕后会立即触发 hook:init
事件,并且将 _inited
属性设置为 true。
实现数据绑定功能
数据绑定的核心思想是基于 ES5 的 Object.defineProperty
方法,在 vm 实例上创建了一系列的 getter / setter,支持数组和深层对象,在设置属性值的时候,会派发更新事件。这部分功能的实现借鉴了 vue 的思路以及部分代码。数据绑定的过程主要涉及了三个对象:
在执行数据绑定之前,会将参数中传递的数据 merge 到 _data
属性中来,然后执行 initState
,分为三个步骤:
- initData,设置 proxy,监听
_data
中的属性;然后添加 reactiveGetter & reactiveSetter 实现数据监听。 (这个过程比较繁琐,涉及很多技巧,以后新开文章讲解) - initComputed,初始化计算属性,只有 getter,在
_data
中没有对应的值。 - initMethods 将
_method
中的方法挂在实例上。
创建的 Observer
的实例会挂载到 _data.__ob__
属性中。数据绑定结束后会触发 hook:created
事件,并且将 _created
属性设置为 true。
编译模板
模板编译函数 build
会调用 compile
函数,compile
会递归编译整个模板,这个过程会展开自定义的组件,编译指令,也会执行一些数据绑定,最终生成 Virtual DOM。其中,真正创建节点的是 createBody
和 createElement
两个方法,createBody
只会在创建根节点时调用。
此外还有一个比较常用的方法:createBlock
,它会创建一个特殊格式的 Block,在真实 Element
的开始和结束位置会添加两个 Comment
节点,在编译过程中可以和 Element 同等对待。之所以这么设计,是为了方便编译 if
、repeat
等指令,当其绑定的数据项发生变化时,可以快速定位到需要改变的 DOM 节点,仅在 start 和 end 两个 Comment 元素之间执行操作。
在编译过程中,会根据节点的类型不同,将编译逻辑分派到不同的函数中,主要包含以下几种:
compileRepeat
: 编译repeat
指令,同时会执行数据绑定,在数据变动时会触发 DOM 节点的更新。compileShown
: 编译if
指令,也会执行数据绑定。compileFragment
: 编译多个节点,创建 Fragment 片段。compileChildren
: 编译子组件,用于实现递归。compileType
: 编译动态类型的组件。compileCustomComponent
: 编译展开用户自定义的组件,这个过程会递归创建子vm
,并且绑定父子关系,也会触发子组件的生命周期函数。compileNativeComponent
: 编译内置原生组件。这个方法会调用createBody
或createElement
与原生模块通信并创建 Native UI。
绘制 Native UI
在 JS Framework 中实现的 Virtual DOM,包含了四类对象:Document
、Node
、Element
、Comment
,接口的定义也基本上都和 W3C 标准保持一致,不过要更为精简一些。
不过,这里创建的是 Virtual DOM,如何在不同的平台上创建 Native UI ?
在 Document
对象中包含一个 listener
属性,它可以向 Native 端发送消息,每当创建元素或者是有更新操作时,listener 就会拼装出制定格式的 action,并且最终调用 callNative
把 action 传递给原生模块,原生模块中也定义了相应的方法来执行 action 。
例如当某个元素执行了 element.appendChild()
时,就会调用 listener.addElement()
,然后就会拼成一个如下格式的 action 通过 callNative 传递给原生模块。
{
module: 'dom',
method: 'addElement',
args: [] // 传递给原生模块的参数
}
模板编译的过程需要递归生成整个 Virtual DOM tree,期间还会与原生模块密集通信,会消耗很多内存和计算资源,这个过程通常也是性能瓶颈。
在模板编译完成后,会触发 hook:ready
事件。
结语
这篇文章简单讲述了 JS Framework 的功能以及实现方法,是我自己对 JS Framework 的理解,如果发现了不严谨地方或者有其他观点,欢迎一起探讨。