Weex 框架中 JS Framework 的结构

Weex 具有移动端跨平台的特性,JS Framework 是其中比较关键的一层。首先来看一下 JS Framework 在 Weex 中的位置:
Weex 框架中 JS Framework 的结构

从图中可以看出 Weex 整体的工作流程。首先开发者可以声明式的定义组件,形成 .we 文件,通过 weex-toolkit 提供的工具将 .we 文件转为 JS Bundle。JS Framework 接收并执行 JS Bundle 的代码,并且执行数据绑定、模板编译等操作,然后输出 json 格式的 Virtual DOM 传递给移动端,同时也提供了 callNativecallJS 接口,方便 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 实例包含了如下方法:

Weex 框架中 JS Framework 的结构

注:在 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 的方式来执行。在代码中用到的 definerequirebootstrapdocumentregisterrender 等方法都是在 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 实例,整个过程可以分成三个步骤:

  1. initEvents 初始化事件和生命周期。
  2. initState 实现数据绑定功能。
  3. 编译模板并且绘制 Native UI。

初始化事件和生命周期

initEvents 会依次绑定三类事件:options 参数中定义的事件、externalEvents 外部事件、内置的生命周期事件,前两项通常都为 null,生命周期包含了initcreatedready 三个钩子。生命周期函数可以在组件中定义,具体触发时机如下:

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 的思路以及部分代码。数据绑定的过程主要涉及了三个对象:

Weex 框架中 JS Framework 的结构

在执行数据绑定之前,会将参数中传递的数据 merge 到 _data 属性中来,然后执行 initState,分为三个步骤:

  1. initData,设置 proxy,监听 _data 中的属性;然后添加 reactiveGetter & reactiveSetter 实现数据监听。 (这个过程比较繁琐,涉及很多技巧,以后新开文章讲解)
  2. initComputed,初始化计算属性,只有 getter,在 _data 中没有对应的值。
  3. initMethods 将 _method 中的方法挂在实例上。

创建的 Observer 的实例会挂载到 _data.__ob__ 属性中。数据绑定结束后会触发 hook:created 事件,并且将 _created 属性设置为 true。

编译模板

模板编译函数 build 会调用 compile 函数,compile 会递归编译整个模板,这个过程会展开自定义的组件,编译指令,也会执行一些数据绑定,最终生成 Virtual DOM。其中,真正创建节点的是 createBodycreateElement 两个方法,createBody 只会在创建根节点时调用。

此外还有一个比较常用的方法:createBlock,它会创建一个特殊格式的 Block,在真实 Element 的开始和结束位置会添加两个 Comment 节点,在编译过程中可以和 Element 同等对待。之所以这么设计,是为了方便编译 ifrepeat 等指令,当其绑定的数据项发生变化时,可以快速定位到需要改变的 DOM 节点,仅在 start 和 end 两个 Comment 元素之间执行操作。

在编译过程中,会根据节点的类型不同,将编译逻辑分派到不同的函数中,主要包含以下几种:

  • compileRepeat: 编译 repeat 指令,同时会执行数据绑定,在数据变动时会触发 DOM 节点的更新。
  • compileShown: 编译 if 指令,也会执行数据绑定。
  • compileFragment: 编译多个节点,创建 Fragment 片段。
  • compileChildren: 编译子组件,用于实现递归。
  • compileType: 编译动态类型的组件。
  • compileCustomComponent: 编译展开用户自定义的组件,这个过程会递归创建子 vm,并且绑定父子关系,也会触发子组件的生命周期函数。
  • compileNativeComponent: 编译内置原生组件。这个方法会调用 createBodycreateElement 与原生模块通信并创建 Native UI。

绘制 Native UI

在 JS Framework 中实现的 Virtual DOM,包含了四类对象:DocumentNodeElementComment,接口的定义也基本上都和 W3C 标准保持一致,不过要更为精简一些。

Weex 框架中 JS Framework 的结构

不过,这里创建的是 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 的理解,如果发现了不严谨地方或者有其他观点,欢迎一起探讨。

上一篇:设计模式学习(五):行为型模式


下一篇:美柚与MaxCompute数据同步架构说明