
很久以前,我写过两篇文章(《Weex 框架中 JS Framework 的结构》,《详解 Weex JS Framework 的编译过程》)介绍过 JS Framework。但是文章写于 2016 年 8 月份,这都是一年半以前的事了,说是“详解”其实解释得并不详细,而且是基于旧版 .we 框架写的,DSL 和底层框架各部分的功能解耦得的并不是很清楚。这一年多以来 JS Framework 已经有了很大的变化,不仅支持了 Vue 和 Rax,原生容器和底层接口也做了大量改造,这里再重新介绍一遍。 在 Weex 框架中的位置 Weex 是一个既支持多个前端框架又能跨平台渲染的框架,JS Framework 介于前端框架和原生渲染引擎之间,处于承上启下的位置,也是跨框架跨平台的关键。无论你使用的是 Vue 还是 Rax,无论是渲染在 Android 还是 iOS,JS Framework 的代码都会运行到(如果是在浏览器和 WebView 里运行,则不依赖 JS Framework)。 像 Vue 和 Rax 这类前端框架虽然内部的渲染机制、Virtual DOM 的结构都是不同的,但是都是用来描述页面结构以及开发范式的,对 Weex 而言只属于语法层,或者称之为 DSL (Domain Specific Language)。无论前端框架里数据管理和组件管理的策略是什么样的,它们最终都将调用 JS Framework 提供的接口来调用原生功能并且渲染真实 UI。底层渲染引擎中也不必关心上层框架中组件化的语法和更新策略是怎样的,只需要处理 JS Framework 中统一定义的节点结构和渲染指令。多了这么一层抽象,有利于标准的统一,也使得跨框架和跨平台成为了可能。 图虽然这么画,但是大部分人并不区分得这么细,喜欢把 Vue 和 Rax 以及下边这一层放一起称为 JS Framework。 主要功能 如果将 JS Framework 的功能进一步拆解,可以分为如下几个部分: 适配前端框架 构建渲染指令树 JS-Native 通信 JS Service 准备环境接口 适配前端框架 前端框架在 Weex 和浏览器中的执行过程不一样,这个应该不难理解。如何让一个前端框架运行在 Weex 平台上,是 JS Framework 的一个关键功能。 以 Vue.js 为例,在浏览器上运行一个页面大概分这么几个步骤:首先要准备好页面容器,可以是浏览器或者是 WebView,容器里提供了标准的 Web API。然后给页面容器传入一个地址,通过这个地址最终获取到一个 HTML 文件,然后解析这个 HTML 文件,加载并执行其中的脚本。想要正确的渲染,应该首先加载执行 Vue.js 框架的代码,向浏览器环境中添加 Vue 这个变量,然后创建好挂载点的 DOM 元素,最后执行页面代码,从入口组件开始,层层渲染好再挂载到配置的挂载点上去。 在 Weex 里的执行过程也比较类似,不过 Weex 页面对应的是一个 js 文件,不是 HTML 文件,而且不需要自行引入 Vue.js 框架的代码,也不需要设置挂载点。过程大概是这样的:首先初始化好 Weex 容器,这个过程中会初始化 JS Framework,Vue.js 的代码也包含在了其中。然后给 Weex 容器传入页面地址,通过这个地址最终获取到一个 js 文件,客户端会调用 createInstance 来创建页面,也提供了刷新页面和销毁页面的接口。大致的渲染行为和浏览器一致,但是和浏览器的调用方式不一样,前端框架中至少要适配客户端打开页面、销毁页面(push、pop)的行为才可以在 Weex 中运行。 在 JS Framework 里提供了如上图所示的接口来实现前端框架的对接。图左侧的四个接口与页面功能有关,分别用于获取页面节点、监听客户端的任务、注册组件、注册模块,目前这些功能都已经转移到 JS Framework 内部,在前端框架里都是可选的,有特殊处理逻辑时才需要实现。图右侧的四个接口与页面的生命周期有关,分别会在页面初始化、创建、刷新、销毁时调用,其中只有 createInstance 是必须提供的,其他也都是可选的(在新的 Sandbox 方案中,createInstance 已经改成了 createInstanceContext)。详细的初始化和渲染过程会在后续章节里展开。 构建渲染指令树 不同的前端框架里 Virtual DOM 的结构、patch 的方式都是不同的,这也反应了它们开发理念和优化策略的不同,但是最终,在浏览器上它们都使用一致的 DOM API 把 Virtual DOM 转换成真实的 HTMLElement。在 Weex 里的逻辑也是类似的,只是在最后一步生成真实元素的过程中,不使用原生 DOM API,而是使用 JS Framework 里定义的一套 Weex DOM API 将操作转化成渲染指令发给客户端。 JS Framework 提供的 Weex DOM API 和浏览器提供的 DOM API 功能基本一致,在 Vue 和 Rax 内部对这些接口都做了适配,针对 Weex 和浏览器平台调用不同的接口就可以实现跨平台渲染。 此外 DOM 接口的设计相当复杂,背负了大量的历史包袱,也不是所有特性都适合移动端。JS Framework 里将这些接口做了大量简化,借鉴了 W3C 的标准,只保留了其中最常用到的一部分。目前的状态是够用、精简高效、和 W3C 标准有很多差异,但是已经成为 Vue 和 Rax 渲染原生 UI 的事实标准,后续还会重新设计这些接口,使其变得更标准一些。JS Framework 里 DOM 结构的关系如下图所示: 前端框架调用这些接口会在 JS Framework 中构建一颗树,这颗树中的节点不包含复杂的状态和绑定信息,能够序列化转换成 JSON 格式的渲染指令发送给客户端。这棵树曾经有过很多名字:Virtual DOM Tree、Native DOM Tree,我觉的其实它应该算是一颗 “Render Directive Tree”,也就是渲染指令树。叫什么无所谓了,反正它就是 JS Framework 内部的一颗与 DOM 很像的树。 这颗树的层次结构和原生 UI 的层次结构是一致的,当前端的节点有更新时,这棵树也会跟着更新,然后把更新结果以渲染指令的形式发送给客户端。这棵树并不计算布局,也没有什么副作用,操作也都是很高效的,基本都是 O(1) 级别,偶尔有些 O(n) 的操作会遍历同层兄弟节点或者上溯找到根节点,不会遍历整棵树。 JS-Native 通信 在开发页面过程中,除了节点的渲染以外,还有原生模块的调用、事件绑定、回调等功能,这些功能都依赖于 js 和 native 之间的通信来实现。 首先,页面的 js 代码是运行在 js 线程上的,然而原生组件的绘制、事件的捕获都发生在 UI 线程。在这两个线程之间的通信用的是 callNative 和 callJS 这两个底层接口(现在已经扩展到了很多个),它们默认都是异步的,在 JS Framework 和原生渲染器内部都基于这两个方法做了各种封装。 callNative 是由客户端向 JS 执行环境中注入的接口,提供给 JS Framework 调用,界面的节点(上文提到的渲染指令树)、模块调用的方法和参数都是通过这个接口发送给客户端的。为了减少调用接口时的开销,其实现在已经开了更多更直接的通信接口,其中有些接口还支持同步调用(支持返回值),它们在原理上都和 callNative 是一样的。 callJS 是由 JS Framework 实现的,并且也注入到了执行环境中,提供给客户端调用。事件的派发、模块的回调函数都是通过这个接口通知到 JS Framework,然后再将其传递给上层前端框架。 JS Service Weex 是一个多页面的框架,每个页面的 js bundle 都在一个独立的环境里运行,不同的 Weex 页面对应到浏览器上就相当于不同的“标签页”,普通的 js 库没办法实现在多个页面之间实现状态共享,也很难实现跨页通信。 在 JS Framework 中实现了 JS Service 的功能,主要就是用来解决跨页面复用和状态共享的问题的,例如 BroadcastChannel 就是基于 JS Service 实现的,它可以在多个 Weex 页面之间通信。 准备环境接口 由于 Weex 运行环境和浏览器环境有很大差异,在 JS Framework 里还对一些环境变量做了封装,主要是为了解决解决原生环境里的兼容问题,底层使用渲染引擎提供的接口。主要的改动点是: console: 原生提供了 nativeLog 接口,将其封装成前端熟悉的 console.xxx 并可以控制日志的输出级别。 timer: 原生环境里 timer 接口不全,名称和参数不一致。目前来看有了原生 C/C++ 实现的 timer 后,这一层可以移除。 freeze: 冻结当前环境里全局变量的原型链(如 Array.prototype)。 另外还有一些 ployfill:Promise 、Arary.from 、Object.assign 、Object.setPrototypeOf 等。 这一层里的东西可以说都是用来“填坑”的,也是与环境有关 Bug 的高发地带,如果你只看代码的话会觉得莫名奇妙,但是它很可能解决了某些版本某个环境中的某个神奇的问题,也有可能触发了一个更神奇的问题。随着对 JS 引擎本身的优化和定制越来越多,这一层代码可以越来越少,最终会全部移除掉。 执行过程 上面是用空间角度介绍了 JS Framework 里包含了哪些部分,接下来从时间角度介绍一下某些功能在 JS Framework 里的处理流程。 框架初始化 JS Framework 以及 Vue 和 Rax 的代码都是内置在了 Weex SDK 里的,随着 Weex SDK 一起初始化。SDK 的初始化一般在 App 启动时就已经完成了,只会执行一次。初始化过程中与 JS Framework 有关的是如下这三个操作: 初始化 JS 引擎,准备好 JS 执行环境,向其中注册一些变量和接口,如 WXEnvironment、callNative。 执行 JS Framework 的代码。 注册原生组件和原生模块。 针对第二步,执行 JS Framework 的代码的过程又可以分成如下几个步骤: 注册上层 DSL 框架,如 Vue 和 Rax。这个过程只是告诉 JS Framework 有哪些 DSL 可用,适配它们提供的接口,如 init、createInstance,但是不会执行前端框架里的逻辑。 初始化环境变量,并且会将原生对象的原型链冻结,此时也会注册内置的 JS Service,如 BroadcastChannel。 如果 DSL 框架里实现了 init 接口,会在此时调用。 向全局环境中注入可供客户端调用的接口,如 callJS、createInstance、registerComponents,调用这些接口会同时触发 DSL 中相应的接口。 再回顾看这两个过程,可以发现原生的组件和模块是注册进来的,DSL 也是注册进来的,Weex 做的比较灵活,组件模块是可插拔的,DSL 框架也是可插拔的,有很强的扩展能力。 JS Bundle 的执行过程 在初始化好 Weex SDK 之后,就可以开始渲染页面了。通常 Weex 的一个页面对应了一个 js bundle 文件,页面的渲染过程也是加载并执行 js bundle 的过程,大概的步骤如下图所示: 首先是调用原生渲染引擎里提供的接口来加载执行 js bundle,在 Android 上是 renderByUrl,在 iOS 上是 renderWithURL。在得到了 js bundle 的代码之后,会继续执行 SDK 里的原生 createInstance 方法,给当前页面生成一个唯一 id,并且把代码和一些配置项传递给 JS Framework 提供的 createInstance 方法。 在 JS Framework 接收到页面代码之后,会判断其中使用的 DSL 的类型(Vue 或者 Rax),然后找到相应的框架,执行 createInstanceContext 创建页面所需要的环境变量。 在旧的方案中,JS Framework 会调用 runInContex 函数在特定的环境中执行 js 代码,内部基于 new Function 实现。在新的 Sandbox 方案中,js bundle 的代码不再发给 JS Framework,也不再使用 new Function,而是由客户端直接执行 js 代码。 页面的渲染 Weex 里页面的渲染过程和浏览器的渲染过程类似,整体可以分为【创建前端组件】-> 【构建 Virtual DOM】->【生成“真实” DOM】->【发送渲染指令】->【绘制原生 UI】这五个步骤。前两个步骤发生在前端框架中,第三和第四个步骤在 JS Framework 中处理,最后一步是由原生渲染引擎实现的。下图描绘了页面渲染的大致流程: 创建前端组件 以 Vue.js 为例,页面都是以组件化的形式开发的,整个页面可以划分成多个层层嵌套和平铺的组件。Vue 框架在执行渲染前,会先根据开发时编写的模板创建相应的组件实例,可以称为 Vue Component,它包含了组件的内部数据、生命周期以及 render 函数等。 如果给同一个模板传入多条数据,就会生成多个组件实例,这可以算是组件的复用。如上图所示,假如有一个组件模板和两条数据,渲染时会创建两个 Vue Component 的实例,每个组件实例的内部状态是不一样的。 构建 Virtual DOM Vue Component 的渲染过程,可以简单理解为组件实例执行 render 函数生成 VNode 节点树的过程,也就是构建 Virtual DOM 的生成过程。自定义的组件在这个过程中被展开成了平台支持的节点,例如图中的 VNode 节点都是和平台提供的原生节点一一对应的,它的类型必须在 Weex 支持的原生组件范围内。 生成“真实” DOM 以上过程在 Weex 和浏览器里都是完全一样的,从生成真实 DOM 这一步开始,Weex 使用了不同的渲染方式。前面提到过 JS Framework 中提供了和 DOM 接口类似的 Weex DOM API,在 Vue 里会使用这些接口将 VNode 渲染生成适用于 Weex 平台的 Element 对象,和 DOM 很像,但并不是“真实”的 DOM。 发送渲染指令 在 JS Framework 内部和客户端渲染引擎约定了一系列的指令接口,对应了一个原子的 DOM 操作,如 addElement removeElement updateAttrs updateStyle 等。JS Framework 使用这些接口将自己内部构建的 Element 节点树以渲染指令的形式发给客户端。 绘制原生 UI 客户端接收 JS Framework 发送的渲染指令,创建相应的原生组件,最终调用系统提供的接口绘制原生 UI。具体细节这里就不展开了。 事件的响应过程 无论是在浏览器还是 Weex 里,事件都是由原生 UI 捕获的,然而事件处理函数都是写在前端里的,所以会有一个传递的过程。 如上图所示,如果在 Vue.js 里某个标签上绑定了事件,会在内部执行 addEventListener 给节点绑定事件,这个接口在 Weex 平台下调用的是 JS Framework 提供的 addEvent 方法向元素上添加事件,传递了事件类型和处理函数。JS Framework 不会立即向客户端发送添加事件的指令,而是把事件类型和处理函数记录下来,节点构建好以后再一起发给客户端,发送的节点中只包含了事件类型,不含事件处理函数。客户端在渲染节点时,如果发现节点上包含事件,就监听原生 UI 上的指定事件。 当原生 UI 监听到用户触发的事件以后,会派发 fireEvent 命令把节点的 ref、事件类型以及事件对象发给 JS Framework。JS Framework 根据 ref 和事件类型找到相应的事件处理函数,并且以事件对象 event 为参数执行事件处理函数。目前 Weex 里的事件模型相对比较简单,并不区分捕获阶段和冒泡阶段,而是只派发给触发了事件的节点,并不向上冒泡,类似 DOM 模型里 level 0 级别的事件。 上述过程里,事件只会绑定一次,但是很可能会触发多次,例如 touchmove 事件,在手指移动过程中,每秒可能会派发几十次,每次事件都对应了一次 fireEvent -> invokeHandler 的处理过程,很容易损伤性能,浏览器也是如此。针对这种情况,可以使用用 expression binding 来将事件处理函数转成表达式,在绑定事件时一起发给客户端,这样客户端在监听到原生事件以后可以直接解析并执行绑定的表达式,而不需要把事件再派发给前端。 演进方向 其实在 Weex 里,能跨多个渲染引擎通用的不止 JS Framework,还有 Weex Core,它们要解决的问题差不多,然而 JS Framework 是用 javascript 写的,Weex Core 是用 C/C++ 写的,实现的功能更底层一些,其实你可以将 JS Framework 理解为 js 版本的 Weex Core。不过 Weex Core 目前的功能还比较少,和 JS Framework 没有重叠,只包含了对 JS 引擎的优化和新的 CSS 布局引擎。 文章最开始的第一张图,其实应该画成这样: 随着技术的演进,JS Framework 的大部分功能将逐渐转移到 Weex Core 中,文章里介绍的用 js 实现的功能,最终将会用 C/C++ 实现,性能会有大幅提升,结构也会简化。 这个过程不是简单的复制复制代码、调调接口就能完成的事,语言都变了,大部分接口和特性都要重新设计,还得做到向下兼容,而且也不能是埋头做个三五个月然后再出结果,每个步骤都要保证功能可用。不能停车,边开边升级发动机。 具体来讲,首先要做的就是在 Weex Core 中实现一份 DOM 接口,将会设计得更加标准、更加符合规范,有了原生的 document 、Element 这些对象以后,前端框架就可以直接调用原生接口不必经过 JS Framework,渲染性能会有提升,JS Framework 里的那颗不知道怎么称呼的树也就可以拿掉了,也不需要将节点发送客户端了,这样通信的逻辑也可以大幅简化。等把原生模块的调用、回调、事件响应这些问题解决了之后,JS-Native 之间的通信也可以拿掉了。 就像上面这幅图画得那样,JS Framework 会变成非常薄的一层,仅负责适配前端框架和修复一些兼容性的问题,最终在未来某个版本里,可能根本就不存在了。 写在最后 如果你对 Weex 的 JS Framework 有什么新的想法和建议,欢迎来找我聊。@门柳
系列文章的目录在 这里 在配置完开发环境之后,就可以开始写应用了。在写应用之前,肯定会先划分好页面,一个一个独立着写的。 不过,首先要明确的是,使用 Weex 写出来的是原生应用,页面的概念还和 Web 上一样吗? Weex 里“页面”的概念 写的有些乱,与下边内容关系不太,不理解可以忽略。 Weex 的实例在 Web 上是和“浏览器页签”的概念相对应的,通常一个 Weex 实例就是一个“页面”。 在 Web 上,只要没有新开页签,网页中的各种跳转,都是记录在 History 对象中的,对于 Weex 而言,这些都相当与在一个“页面”之内。在 Native 上,页面的跳转是记录在 Navigator 中的,内部也有一个栈来存储跳转记录,可以 push 、 pop。 要想在原生页面之间(Weex 多实例之间)跳转,可以使用 navigator 模块,也可以看看 weex-router。 想深入理解这方面的概念,还需要了解一些 Native 开发的知识。原生开发里边,页面是个比较上层的概念,可以来回切换和销毁,在背后还有生命周期更长程序在运行,Weex Runtime 就运行在这一层里,多个 Weex 的实例会共用一个 Runtime。 因为 vue-router 的设计和实现都是在同一个页面内的,是 SPA 概念里的产物。其实前端路由也是基于 SPA 产生的概念,在 Weex 或者原生应用这种多页跳转的场景里可能并不合适,慎重使用。虽然 weex-hackernews 项目里用了 vue-router ,但是依然在同一个 Weex 实例中。相关话题我在 《使用 vue-router》这篇文章里详细再说。 编写入口文件 以 weex-hackernews 为例,看 webpack.config.js 中的配置,src/entry.js 就是入口文件。项目代码中因为用了 Vuex 和 vue-router,入口文件还有点长,其实只需要引入口组件,配置挂载点,然后初始化 Vue 实例就行了。 import Vue from 'vue' import App from './App.vue' // 将 App 组件挂载到 #root 之上,生成的 DOM 节点会替换 #root 标签 App.el = '#root' // 创建 Vue 实例 new Vue(App) 这还要求你在 Web 入口 HTML 文件里手动写上 #root 的标签: <div id="root"></div> 至于 Android 和 iOS 平台,只要指定了 el 属性即可,属性值就无所谓了,Weex 会默认将其挂载到容器根视图中(可由 Native 端配置)。 使用单文件组件 在配置好入口文件之后,就可以开始写组件了,推荐写单文件组件,也就是 .vue 文件。语法就不再重复介绍,官方文档中写得很详细,能支持 ES6+,甚至可以配置 TypeScript 、Sass 、Stylus 、PostCSS 、Jade 等语法。 需要注意的是,针对 Web 平台,依然使用 Webpack + vue-loader 的方式编译 .vue 文件,但是针对 Android 和 iOS 平台,你必须使用 Webpack + weex-loader 才可以。也就是说,针对 Web 和 Native 平台要分别生成两份不同的 js bundle,具体的配置参考 webpack.config.js。 只使用 Vue Runtime 的功能 Weex 已经将 Vue Runtime 的代码整合进了 SDK 中,Vue 中支持的语法特性也能用在 Weex 中。注意,是 Vue Runtime (运行时构建)。如果你下载过 Vue 2.0 对外发布的版本,应该能发现除了区分产品版和开发版,既区分 “Standalone” 版本和 “Runtime-only” 版本,具体的差异可以看官方安装指南(其实有些名词不太好翻译,看英文原版文档或许能理解得更准确一些)。 简单来说,Vue 在 Weex 中使用的是“预编译”模式,只保留了运行时,不支持实时编译模板。简单来说,就是不支持下边几个特性: 定义组件时不支持使用 template 属性。 不支持使用 x-templates。 不支持使用 Vue.compile。 如何做到跨平台? 写代码的过程和写其他 Vue 2.0 项目是一样的,没什么可说的。但是既然说了是写原生应用,就要注意跨平台的问题,虽然 Weex 抹平了很多差异,但是平台差异是客观存在的。Web 和 Android 和 iOS 毕竟都不相同,想写跨平台的应用,了解平台差异还是很有必要的。 了解平台差异 推荐阅读官方文档: 《Weex 和 Web 平台的差异》 大概摘录一下是这样的: Weex 环境中没有 DOM 不支持 DOM 操作,原生平台没有 DOM 概念。 并不支持 Web 中所有的事件类型,详情请参考《通用事件》。 不区分事件的捕获阶段和冒泡阶段,相当于 DOM 0 级事件。 Weex 环境中没有 BOM 没有 window 、screen 对象,可以通过 WXEnvironment 获取设备环境信息。 没有 document 对象,没有选择器方法。 没有 history 、location 、navigator 对象,有 navigator 模块。 能够调用移动设备原生 API 可以通过模块调用设备原生 API,如 clipboard 、 navigator 、storage 等。 使用通用组件 为了保证三端可用,不能使用浏览器提供的标签,只能用 Weex 提供的标签(组件)。如果你写了 <figure> 或者 <menu> 这类组件,在 Web 端可以看到效果,但是在客户端上渲染效果是不确定的。而且,客户端上没有 SEO 和语义化的需求,HTML5 中的大多数标签都可以通过别名实现(参考 weex-component-alias)。 具体组件的使用方法参考官方文档。 Weex 支持的组件列表 使用通用样式 CSS 的属性特别多,写法也特别多,能力很强大,也有很多坑。一方面新标准在不断的提出,像 CSS Grid 和 Houdini 这些概念也逐渐受到关注;另一方面旧标准是很难再废除的,新旧语法总能摩擦出各种奇葩的行为,CSS 也是容易滋生“奇技淫巧”的地方,也有各种关于“最佳实践”的话题,很多人乐于此道。浏览器内部为了处理这些边界情况肯定用了大量“特殊技巧”。 然而在实际使用中,绝大多数的网页只用了其中很少一部分属性(参考 Global CSS property usage)。Weex 是由 Native 平台解析的样式,Android 和 iOS 本身并不支持 CSS ,所有解析都是原生渲染器实时计算的,所以必然要再性能和可用性之间做平衡。既然不能支持、也不打算支持所有 CSS 特性,那就必然要分清主次,优先支持常用样式。 Weex 对 CSS 样式的支持情况,可以参考这篇文档。简单概括下边几点: 支持基本的盒模型。 支持 position 定位布局。 支持使用 flexbox 布局。 使用限制 只支持单个类名选择器,不支持关系选择器,也不支持属性选择器。 默认是组件级别的作用域,没有全局样式。 不支持样式继承(因为有作用域隔离)。 考虑到样式的数据绑定,样式属性暂不支持简写。
这是 Weex Tips 系列文章中的一篇,汇总目录在 这里。 首先建议先看一下有关组件定义的官方文档,其中介绍了生命周期。 在 Weex 项目里有个 issue 专门描述了 Weex 的生命周期,里面有几张很清晰的图,我这里只讲一下用法。 如果想了解组件的编译细节可以参考:《详解 Weex JS Framework 的编译过程》。 如果看完了上边的链接,对 Weex 生命周期的理解应该很到位了,可以简单总结成这么一张图: 注:在新版本的 JS Framework(>0.15.6)中才支持 destroyed 生命周期。 生命周期的用法 <script> module.exports = { data: {}, methods: {}, init: function () { console.log('在初始化内部变量,并且添加了事件功能后被触发'); }, created: function () { console.log('完成数据绑定之后,模板编译之前被触发'); }, ready: function () { console.log('模板已经编译并且生成了 Virtual DOM 之后被触发'); }, destroyed: function () { console.log('在页面被销毁时调用'); } } </script> 注意这几个生命周期函数 init 、created 、ready 、destroyed 和 data 、methods 属性是平级的,不要将其放在 methods 中,虽然当前版本兼容这种用法,以后也有可能会放弃支持。 除此之外,不建议在根对象上定义其他属性,数据应该放在 data 属性中,自定义的方法放在 methods 中,方法的执行环境(this)是当前组件对应的 Vm 对象,接口参考:Instance Apis。 init 在 init 方法执行时,刚初始化了内部变量,添加了事件的功能。此时还没有执行数据绑定,也没有创建 Virtual-DOM ,所以不能通过 this 获取到 data 中的数据,不能调用到 methods 中定义的方法,也不能获取到 Virtual-DOM 的节点。 可以在这个方法中可以初始化一些内部变量,也可以绑定一些自定义的事件。 created created 的名称有点令人迷惑,会让人以为节点全部都创建完成了,其实只是刚完成了数据绑定,还没开始编译模板。此时可以通过 this 操作 data 中的数据,也可以调用 methods 中的方法,但是获取不到 Virtual-DOM 的节点。 由于还没开始执行节点的渲染,可以在 created 方法中修改 data 中的数据(例如某些需要动态计算的属性),此时的修改不会触发额外的渲染。 ready ready 开始执行时,表示当前组件已经渲染完成。这个过程是自底向上触发的,会首先先执行子组件的 ready 方法。也就是说,在父组件执行 ready 时,所有子组件都已经渲染完成,而且已经执行完各自的 ready 方法。 此时可以通过 this.$el(id) 获取到节点的 Virtual-DOM,也可以通过 this.$vm(id) 获取到子组件的 Vm 实例。 不过,在 ready 方法中要小心地操作 data 中的数据,避免频繁赋值,因为此时已经完成了数据和 UI 的绑定,每次修改值都可能触发局部页面重新渲染。建议先取出需要频繁改动的值,然后等操作执行结束后,再一并赋值: module.exports = { data: { count: 0 }, ready: function () { var count = this.count; for (var i = 0; i < 999; i++) { count += Math.random(); } this.count = count; } } 如代码所示,在修改 this.count 前先获取它的值,在执行完操作后再赋值回去,如果在循环体中直接设置 this.count 的值,页面将触发 999 次局部刷新,很可能会导致页面卡顿。 对于复杂的数据对象,也建议用 取值 -> 修改 -> 赋值 的方式更新数据。 destroyed destroyed 方法将在组件销毁(通常是页面跳转)时被调用。和 ready 类似,它也是自底向上执行,先触发子组件的 destroyed 方法,再触发自身。而且框架会先执行开发者定义的 destroyed 方法,然后再清除内部属性。 如果添加了一些属性到全局或者 this 上,建议在 destroyed 方法中手动清除,避免内存泄漏。 其他建议 无论在何时,都不建议获取 Vm 和 Virtual-DOM 内部属性,这部分数据对开发者是透明的,而且在版本迭代过程中很可能会修改。 如果有特殊开发需求,建议联系 Weex 开发组的同学讨论解决方法。
之前写了一篇文章《Weex 框架中 JS Framework 的结构》概述了 JS Framework 的整体结构,其中编译过程写的有些简略,这里再详细介绍一下。 一句话概括 JS Framework 的编译过程就是: 将 JS Bundle 转换成 Virtual DOM 发送到原生模块渲染。 这个过程涉及三种数据类型:JS Bundle 、Virtual DOM 、Vm 。 JS Bundle 是由 .we 文件转换过来的,会被视为代码而执行。 Virtual DOM 是描述页面结构的 JSON 数据,用于给原生模块发送消息。 Vm 是 View Model 的简写,属于 MVVM 结构中的一部分,会执行模板编译、数据绑定等操作。 简化版的 Vm 构造函数如下: // html5/default/vm/index.js function Vm() { // ... // 初始化生命周期 initEvents(this, externalEvents) this.$emit('hook:init') // ... // 监听 data 中的数据 initState(this) this.$emit('hook:created') // ... // 启动模板编译 build(this) } 在 Vm 构造函数最后调用 build 函数启动模板的编译,是一种尾递归,便于 js 引擎优化。 整体编译流程 在调用 build 触发编译后,真正实现编译功能的是 compile 方法(代码位置在 html5/default/vm/compiler.js 中)。build 方法并不递归,它做的只是根据配置项选择合适的参数,然后调用 compile 方法。 compile 方法 compile 接受四个参数: vm: 待编译的 Vm 对象。 target: 需要编译的节点,是模板中的标签经过 transformer 转换后的结构。 dest: 当前节点父节点的 Virtual DOM。 meta: 元数据,在内部调用时可以用来传递数据。 在 compile 函数的最后,会调用 compileNativeComponent 编译生成原生组件,除了它和 createBlock 两个方法以外,其他都是会触发递归的。 分发编译逻辑 compile 函数内部并没有渲染逻辑,他只是将不同类型的节点交给不同的函数来编译;换句话说说,它是负责逻辑的分发和实现递归的。同样职责的函数还有 compileChildren ,他会对每个子节点(模板中的子标签)调用 compile。 compileFragment 方法可以编译数组,其中每个数据项都会调用 compile 方法,但是会共用一个 Block。 创建 Block 在上述的方法中,createBlock 是实际的创建节点的操作,可以视为递归的终止条件。创建的 block 是封装后的 Virtual DOM 节点,结构如下: { blockId, start, // 节点的起点位置,Comment 节点,在当前元素的前一个位置 element, // 实际的节点元素,Element 节点 end, // 节点的结束位置,Comment 节点,在当前元素的后一个位置 } 这样做的目的一方面是将 Element 和 Fragment 操作统一化,另一方面是为了 UI 更新时能够快速定位到节点。 编译指令 Weex 的模板标签上支持使用指令,最基本的是 if 和 repeat 指令,详细用法参考官方文档。 if 指令 if 指令可以控制节点的显示和隐藏,用法如下: <text if={{visible}}>Show something here.</text> 这段模板会被 transformer 转换成下面这种结构: { "type": "text", "shown": function () { return this.visible } } 编译 if 指令时,先会创建一个 Block,然后创建 Watcher 执行数据绑定。当 this.visible 发生变化时,会更新视图,若 shown 函数返回为真,则会调用 compile 方法重新编译节点,否则会调用 removeTarget 将节点从父节点中删除。 repeat 指令 repeat 指令可以根据模板循环渲染数组中的所有数据。假设有如下某个 .we 文件: <script> module.exports = { data: { images: [ { source: 'somewhere/a.png' }, { source: 'somewhere/b.png' }, { source: 'somewhere/c.png' } ] } } </script> <template> <list> <cell repeat={{images}}> <image src="{{source}}"></image> </cell> </list> </template> 编译生成的结果如下: { "type": "list", "children": [ { "type": "cell", "append": "tree", "repeat": function () { return this.images }, "children": [ { "type": "image", "attr": { "src": function () { return this.source } } } ] } ] } 遇到 repeat 指令时,会调用 compileRepeat 方法,它先整理好数据,然后调用 bindRepeat 执行编译。编译过程中会将 repeat 指令所在的节点视为模板,循环展开对应的数据,逐条调用 compileItem 渲染每个节点。期间也会添加数据绑定,当数组中的数据有变化时,会自动更新 List。 append 属性 如果仔细看一下上面 repeat 指令转换出来的代码,会发现 cell 节点上有一个 append 属性,这个属性在官方文档中写的比较详细了,它是用来控制渲染次序的,属于比较底层的属性,在内部指令中用到了,开发者通常不会用到。这里再总结一下: append="tree" 会先编译子节点,再编译自身。编译速度快,但是容易造成较长时间的白屏。 append="node" 会先编译自身,再编译子节点。整体编译速度略慢,但是用户体验好一些。 默认的编译方式是 node,先创建容器,再创建内容。 不过 repeat 指令默认的编译方式是 tree;由于内容可变,它是先编译生成所有子节点,然后再编译自身,避免频繁地插入操作,这种编译方式也比较符合列表的特性。 编译组件 除了内置标签以外,Weex 还支持自定义的组件(标签),这是最基本也是最好用特性了。 编译自定义的组件 每个组件(.we 文件)都对应的是一个 Vm 实例,当编译过程中遇到某标签不是内置标签而是一个自定义的组件时,会创建一个新的 Vm 实例,合并父子组件中添加的样式,同时也添加了生命周期的钩子: init: 设置子组件 id。 created: bindSubVm 合并父子组件中定义的属性。 ready: 调用 compileChildren 开始编译子组件中的节点。 值得注意的是,创建新的 Vm,又会执行本文从开头开始讲的所有步骤:初始化数据、数据绑定、递归编译各种节点…… 所以说整个编译过程包含了大量的递归,函数调用栈比较深,会消耗大量的内存和时间。 编译生成原生组件 在 compile 函数结尾会调用 compileNativeComponent 绘制原生组件,也就是说,原生组件的绘制是在递归编译过程中进行的,不需要等待完整的 Virtual DOM tree 拼好之后再绘制原生 UI,页面可以实现流式渲染,持续不断的渲染碎片化的 Virtual DOM。 compile 的各种流程产出的是 Virtal DOM,而 compileNativeComponent 实现的是将碎片化的 Virtual DOM 通过 callNative 发送渲染指令给原生模块,通知其绘制 Native UI。原生模块在绘制 UI 的过程中,如果发生了错误,会返回 -1 存放在 app.lastSignal 中,此时 JS Framework 会终止编译。 结语 接前一篇讲 JS Framework 的文章,这篇文章详细介绍了组件的编译流程,涉及很多技术细节,这些细节有可能在以后的版本中有改动。了解 JS Framework 的实现细节,有助于开发者在使用时能避开不恰当的用法,少踩一些坑,希望这篇文章能对大家有所帮助,如果有疑问或者有不同看法,欢迎来找我探讨。
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 的理解,如果发现了不严谨地方或者有其他观点,欢迎一起探讨。