Weex 中的 virtual-DOM 介绍

概述

Weex 在 JS 端有一层 virtual-DOM 的设计,这一层设计一方面使得 Weex 能够通过 JS 控制 native 的视图层,另外也提供了一个相对中立的规范,供上层 JS 框架调用。

Weex 中的 virtual-DOM 介绍

传统的 DOM 大概是这个样子的

// 构造函数
HTMLElement
HTMLInputElement
Text
Comment
// 创建元素
var text = document.createTextNode('User Name:')
var el = document.createElement('input')
var note = document.createComment(someNoteTextHere)
// 特性
el.setAttribute('placeholder', 'Hello')
// 样式
el.style.width = '200px'
// 属性和方法
el.value = username
// DOM 事件
el.addEventListener('focus', eventHandler)
// DOM 0 级事件
el.onchange = changeHandler
// DOM 数管理
// document.body 作为现成的页面根元素
document.body.appendChild(text)
document.body.appendChild(el)
document.body.insertBefore(note, el)

Weex 对 DOM 设计的简化和取舍

我们对 virtual-DOM 的设计很大程度上借鉴了 HTML DOM 的设计,不论从 API 还是 class,但做了一定的简化和取舍,主要包括以下几点:

  1. 传统的 HTML DOM 分了很多种 nodeType,比如 ElementTextNodeCommentCDATAEntityAttributeFragment … 等, Weex 只保留了 ElementComment ,一个 Element 对应着 native 的一个 View,而 Comment 通常对 native 来说是无意义的,但是它可以帮助 JS 上层的框架用作一些特殊处理时的 placeholder。
  2. 传统的 HTML DOM 是既有 attribute 又有 property 的,property 里还包括 style、方法调用这样的特殊 property, Weex 只保留了 attributes,没有 properties ,但支持一个特殊的维度,就是样式 style。
  3. 传统的 HTML DOM 是支持同一个 Element 绑定多个事件的,从 JS 和 native 通信的角度,这样做是没有必要的, 所以 Weex 只提供了 DOM Level 0 的事件模型,也就是 onxxx="fn" 如果同一个 Element 需要在业务层绑定多个事件,可以在 virtual-DOM 上层再进行封装
  4. 传统的 HTML DOM 事件是存在捕获和冒泡阶段的, Weex 做了精简,没有支持冒泡或捕获事件 ,只有在 native 层的当前元素触发该事件才会 fireEvent 给 JS。
  5. 传统的 HTML DOM 针对每个页面有唯一且现成的 documentdocument.documentElementdocument.body,但是在 Weex 中,由于每个页面需要的初始化 body 类型是有选择的,基本上分 scrollerdivlist 这三种,根据页面不同的展示特征而定, 所以 Weex 页面的 document.body 是需要手动创建的,并且有机会制定其类型为 scrollerdivlist 其中的一种。
  6. Weex 不支持 XML 的 namespace 语法

所以综上所述,这是一个非常精简版的 virtual-DOM 设计。我们在 Weex 中所能感受到的各种视觉效果和交互效果,实际上都是通过这样的 virtual-DOM 结构进行分解和执行的。

示例说明

接下来结合几个例子介绍一下

创建元素

document.createElement('div')
new Element('div') // just the same as `document.createElement`
new Element('text') // no `TextNode` but `<text>` element
new Element('text', {
  attr: {
    value: username
  },
  style: {
    fontSize: 14 // no `px` unit
  }
})
new Comment(someNoteTextHere)

// especially `<body>` need to be created with a certain type
// between `div`, `scroller` and `list`
// once the `<body>` created, you can access `document.body`
document.createBody('div')
// just the same as:
var body = document.createBody('div')
document.documentElement.appendChild(body)

创建的方式和传统的 DOM 是很接近的,只不过我们这里没有 TextNode 这种类型,另外 body 是需要自行创建的

比如展示一张图片

document.createBody('div')
var el = document.createElement('image')
el.setAttr('src', imageUrl)
el.setStyle('width', 200)
el.setStyle('height', 200)
document.body.appendChild(el)

特性 (attr) 和样式 (style)

我们可以通过 setAttrsetStyle 来对特性和样式进行设置,同时,我们也可以直接访问:

el.ref // 每个被创建的元素的唯一标识,通常用来传递给 native 端做结点识别
el.attr.src // imageUrl
el.style.width // 200
el.style.height // 200

来获取当前元素的值

处理文本结点

由于我们对 DOM 模型做了简化,所以 TextNode 在 Weex 的 virtual-DOM 里是没有的,我们将其简化成了父元素的 value 特性值,并引入了 <text> 类型的元素,这样文本结点就可以通过这样的方式和其它元素的结构统一,比如:

<!-- 传统 DOM 的结构 -->
<div>
  Hello
  <span style="font-weight: bold;">World</span>
</div>

<!-- Weex 中对应的 DOM 结构 -->
<div>
  <text>Hello</text>
  <text style="font-weight: bold;">World</text>
</div>

<!-- Weex 中等价的 DOM 结构 -->
<div>
  <text value="Hello"></text>
  <text style="font-weight: bold;" value="World"></text>
</div>

所以我们可以把代码写成:

var text = document.createElement('text')
text.setAttr('value', username)

事件管理

绑定事件和解绑事件分别是 addEvent(type, handler)removeEvent(type) 两个非常简单的 API

text.addEvent('click', function (e) {
  e.target // text
  e.target.attr.value // username
  e.timestamp
  e.type // 'click'
})

DOM 树管理

主要用到的几个操作和传统 DOM 的一样:

  • element.parentNode
  • element.children[]
  • element.nextSibling
  • element.previousSibling
  • parent.appendChild(child)
  • parent.insertBefore(child, before)
  • parent.removeChild(child)

额外的,我们还提供了

  • parent.insertAfter(child, after):和 insertBefore 相反
  • parent.clear():删除所有子元素

用注释处理占位符

我们可以创建一些 Comment 类型的结点,作为某些特殊处理时用到的占位符,比如:

document.createComment('start') // <!--start-->
document.createComment('end') // <!--end-->

假设我想划分一个固定的范围将来收集一个或多个可改变的元素,比如:

<div>
  <something-before></something-before>
  <something-before></something-before>
  <something-before></something-before>
  <foo></foo>
  <bar></bar>
  ...
  <something-after></something-after>
  <something-after></something-after>
  <something-after></something-after>
</div>

那么你完全可以提前在这段连续的 DOM 结点两侧设置好两个注释结点

<div>
  <something-before></something-before>
  <something-before></something-before>
  <something-before></something-before>
  <!--start-->
  <foo></foo>
  <bar></bar>
  ...
  <!--end-->
  <something-after></something-after>
  <something-after></something-after>
  <something-after></something-after>
</div>

这两个结点不会对 native 端真正的渲染造成困扰

而为了方便开发者很好的区分 el.children[] 中哪些元素会真正被渲染到 native 端,我们提供了另外一个字段,叫 el.pureChildren[]。比如上述的例子中,根元素的 children 大致内容是:

<something-before>x3, <!--start-->, <foo>, <bar>, ..., <!--end-->, <something-after>x3

而根元素的 pureChildren 则大致内容是

<something-before>x3, <foo>, <bar>, ..., <something-after>x3

这样就同时方便了上层框架的结构化管理以及 native 端接受指令的纯净度

DOM 操作对应的背后的 native 指令

我们把所有的 DOM 操作归纳成了下面几种指令,每当我们用 JS 操作 virtual-DOM 的时候,实际上背后是这些命令在驱动 native 渲染层进行渲染的:

  • document.createBody(type) -> nativeDomModule.createBody(type)
  • el.appendChild(child) -> nativeDomModule.addElement(el.ref, child.toJSON(), -1)
  • el.insertBefore(child, before) -> nativeDomModule.addElement(el.ref, child.toJSON(), el.pureChildren.indexOf(before))
  • el.removeChild(child) -> nativeDomModule.removeElement(child.ref)
  • el.setAttr(k, v) -> nativeDomModule.updateAttr(el.ref, {[k]: v})
  • el.setStyle(k, v) -> nativeDomModule.updateStyle(el.ref, {[k]: v})
  • el.addEvent(type, handler) -> nativeDomModule.addEvent(el.ref, type)
  • el.removeEvent(type) -> nativeDomModule.removeEvent(el.ref, type)

其中的一个细节是,在 el.addEvent(type, handler) 的时候,函数 handler 实际上并没有传递给 native 端,因为 native 端不需要知道这个函数是什么,handler 是在 JS 这边自己记录下来的,当有事件触发时,JS 会收到来自 native 的 fireEvent 事件,然后再在 JS 端匹配需要被触发的 handler

更细节的 API 设计详见文档:https://github.com/alibaba/weex/blob/dev/doc/specs/virtual-dom-apis.md

可改进的空间

目前随着 Weex 的技术形态不断发展,之前精简掉的一部分内容可能还是会随时拿出来进行讨论,这里分享几个自己最近想到的

1 Element 的 property

没有 property 对于某些元素来说确实会显得有些不方便,比如 <web> 元素在处理前进后退刷新的时候实际上是和另外一个完全解耦的 native webview module 配合使用的,大概写法:

var el = document.createElement('web')
web.setAttr('src', pageUrl)
...
// 目前的写法
var webviewModule = require('@weex-module/webview')
webviewModule.goBack(el)

// 希望未来可以直接在 元素 上调用自己的方法
el.goBack()

但是 JS 中判断一个元素具备哪些可调用的方法,在 ES 的 Proxy 特性普及之前是很难直接支持的,一个看上去可行的办法是每个组件可以在 JS 引擎初始化的时候注册自己可被调用的方法名称,这样我们在创建元素的时候有机会通过这些知识,把该类型元素支持的成员方法对应的绑定在元素上。

2 性能

目前 DOM 操作的性能还有待提高,尤其是 JS 和 native 之间的通信的时间代价是一个不可忽视的成本,如何尽量回避这部分的开销一直是团队通过各种努力改进的地方

3 全局事件

在传统的网页中,我们通常习惯把网页全局或具体元素无关的事件绑定在 window 上 (由于一些历史原因,部分全局事件在 documentdocument.body 上一样可以绑得上),但是:

  1. Weex 没有 window 这样的 host
  2. 这种中心化的设计容易使本来各自独立的模块相互“打架”,目前这种实践在 HTML5 最新的规范设计原则中已经不被推荐了

所以我们倾向于各子功能模块且具体元素无关的事件绑定在各自的功能模块上,另外有些事件特别简单,做成模块会显得有点笨重,或许在未来一个合适的时机我们还会把全局事件引入,但不会推荐大家滥用

上层 JS Framework 对 virtual-DOM 的使用

Weex JS Framework

Weex 的 JS Framework 在基础的 DOM 设计之上主要封装了一个叫做 Block 的东西

block

Block 是用头尾两个 Comment 结点来标识一块 DOM 区间的,Block 的数据结构其实就是:

  1. 一个绑定的目标,通常情况下是一个根元素,但也可以是另一个 Block,这样可以形成 Block 嵌套
  2. 一个开头的注释结点
  3. 一个结尾的注释结点

它主要用在模板编译的时候,比如 if, repeat, 动态组件类型等场景

如果一个元素具有 if 指令,比如 <foo if="{{expr}}">,那么我会在当前位置创建两个 start 和 end 的 Comment 结点,将来 {{expr}} 的值发生改变的时候,我们可以这样判断:

  • 如果为真,那么就生成 <foo> 元素,放在 start 和 end 中间
  • 如果为假,那么把 start 和 end 中间的元素删掉

同理,如果我们面对 <foo repeat="{{list}}">,则每次 list 发生变化的时候,我们可以这样判断:

  • 把 start 和 end 之间的所有元素找出来形成一个列表
  • 和新的 list 应该生成的元素列表进行比对
  • 在 start 和 end 之间的范围内完成元素的更新

其它场景道理接近,这里不再赘述

然后我们又基于 Block 封装了几个常用操作:

  • createBlock(vm, target)
  • attachTarget(vm, target, dest)
  • moveTarget(vm, target, after)
  • removeTarget(vm, target)

这其中 targetdestafter 都既可以是 Element 也可以是 Block,所以抽象层面可以同等对待,实际渲染层面,只会渲染 Element。我们可以以此进行更高集成度的 virtual-DOM 操作。

代码在 https://github.com/alibaba/weex/blob/dev/html5/default/vm/dom-helper.js

Vue 2.0 for Weex

对于 Vue 2.0 在 Weex 上的应用,也简单介绍一下,其实 Vue 2.0 本身已经有一个 virtual-DOM 层了,并抽象了这么几个 functional 的操作:

  • createElement(tagName)
  • createElementNS(namespace, tagName)
  • createTextNode(text)
  • insertBefore(node, target, before)
  • removeChild(node, child)
  • appendChild(node, child)
  • parentNode(node)
  • nextSibling(node)
  • tagName(node)
  • setTextContent(node, text)
  • childNodes(node)

其和 Weex 本身的 virtual-DOM 设计几乎是对应的,只有几个小的地方,我们做了特殊处理:

  1. 我们没有 namespace,所以第二个 API 我们就没有支持
  2. 我们没有 TextNode,所以我们做了巧妙的转换,将其转换成 parentNodevalue attribute

其它基本都是“无脑”转换和适配就搞定了

代码在 https://github.com/weexteam/weex-vue-framework/blob/weex-port/src/platforms/weex/runtime/node-ops.js

额外提一下,就是 Vue 2.0 里有 functional component 这个概念,上层语法上是一个标签,但实际上真正的 DOM 结构里是没有这个标签的,他只表示一个抽象的功能,比如 <transition>,这个地方脑洞很大,虽然不是我们这里想介绍的 virtual-DOM,但是一个如虎添翼配合使用的很赞的点。

Rx for Weex

这部分内容就有待 @元彦 为我们补充了,时间精力关系,这里不再展开:)

总结

以上介绍了 Weex 的 virtual-DOM 设计,以及我们基于 native 的实际情况和传统 DOM 在设计上的平衡和取舍,也举了一些例子和细节,最后介绍了 Weex JS Framework 和 Vue 2.0 在 Weex 上的 virtual-DOM 上层实践。希望对大家更好的理解 Weex 的工作原理,更好的实践 Weex 有所帮助

谢谢

上一篇:Silverlight 5 beta新特性探索系列:2.在XAML代码中设置断点和Binding绑定调试【附带源码实例】


下一篇:Silverlight 5 beta新特性探索系列:4.Silverlight 5 beta中鼠标双击/鼠标多重点击的实现