渲染 Web Components 到 Native 方案

怎么渲染 Web Components 到 Native?这里拿 Omi 框架 为例,因为 Omi 是基于 Web Components 设计的。

行业现状

现在渲染到 native 有两个流派:

  • Flutter
    • 使用 Skia 高性能渲染引擎 GPU 直绘
    • 使用 Dart 语言开发
  • React Native、Weex、Taro、Hippy、Plato
    • 通过 Bridge 与 JSCore 传输指令绘制
    • 使用 Javascript 语言开发
    • JSCore 和 Native 各自维护同样的一棵 DOM 树

这里 Omi 使用第二种方式实现 → omi-native

预研

因为一切 Web Components 的根基都是 HTMLElement。可以看到 Omi 的自定义元素是通过继承 WeElement:

  1. import { render, WeElement, define } from 'omi'
  2. define('my-counter', class extends WeElement {
  3. static observe = true
  4. data = {
  5. count: 1
  6. }
  7. sub = () => {
  8. this.data.count--
  9. }
  10. add = () => {
  11. this.data.count++
  12. }
  13. render() {
  14. return (
  15. <div>
  16. <button onClick={this.sub}>-</button>
  17. <span>{this.data.count}</span>
  18. <button onClick={this.add}>+</button>
  19. </div>
  20. )
  21. }
  22. })
  23. render(<my-counter />, 'body')

而通过 Omi 源码可以发现,WeElement 是继承自 HTMLElement

  1. class WeElement extends HTMLElement {
  2. ...
  3. }

既然要在 JSCore 里向 Native 发送指令,那么首先要保证能正常运行。但是在 JSCore 里是没有 DOM 和 BOM, HTMLElement 属于 DOM, 自然也就没有。所以 Omi 的项目在 JSCore 里会报错。所以解决这个问题的答案也就浮出水面。

模拟 HTMLElement

在浏览器的设计当中:

  • HTMLElement 继承自父接口 Element 和 GlobalEventHandlers 的属性
  • Element 继承自 Node (常用的 appendChild、removeChild、insertBefore 都定义在 Node 中)
  • Node 从其父类EventTarget 继承属性

但是我们实现未必需要和浏览器的实现完全一致,更加不用实现所有的 API。所以 omi-native 仅仅实现了:

  • Element
  • HTMLElement
  • Document

其中 HTMLElement 继承自 Element,具体需要实现哪些API,这里优先梳理出 Omi 使用的 DOM API:

  • HTMLElement
    • connectedCallback
    • disconnectedCallback
  • Element
    • addEventListener
    • removeEventListener
    • removeAttribute
    • setAttribute
    • removeChild
    • appendChild
    • replaceChild
    • style
  • Document
    • createElement

所以只要实现包括上面这些 API 就能保证 Omi 项目能够在 JSCore 里跑起来不报错。但是仅仅不报错,是不够的,还需要来回发送指令。
指令传输的意义在于让 Native 维护的 DOM Tree 和 JSCore 维护的 DOM Tree 保持一致。而指令发送的频率会直接影响耗时,指令发送频率越低越好。所以在把 bridge 通讯注入到 appendChild、removeChild 等方法中时,遵循的原则是:

  • 只有真正落在树上的 DOM 操作才发送指令

所以可想而知,document.createElement 或者悬空节点的 appendChildremoveChild 是不发送任何指令

生命周期

Omi 自定义元素的生命周期如下所以:

Lifecycle method When it gets called
install before the component gets mounted to the DOM
installed after the component gets mounted to the DOM
uninstall prior to removal from the DOM
beforeUpdate before update
afterUpdate after update
beforeRender before render()

怎么保证 Omi 的生命周期在 JSCore 中正常执行。通过 Omi WeElement 的源码可以发现:

  1. connectedCallback() {
  2. ...
  3. ...
  4. this.install()
  5. const shadowRoot = this.attachShadow({ mode: 'open' })
  6. this.css && shadowRoot.appendChild(cssToDom(this.css()))
  7. this.beforeRender()
  8. options.afterInstall && options.afterInstall(this)
  9. ...
  10. ...
  11. this.installed()
  12. this._isInstalled = true
  13. }
  14. disconnectedCallback() {
  15. this.uninstall()
  16. if (this.store) {
  17. for (let i = 0, len = this.store.instances.length; i < len; i++) {
  18. if (this.store.instances[i] === this) {
  19. this.store.instances.splice(i, 1)
  20. break
  21. }
  22. }
  23. }
  24. }

Omi 的生命周期完全依赖 HTMLElementconnectedCallbackdisconnectedCallback

  • connectedCallback 元素被插入到页面时候触发
  • disconnectedCallback 元素从页面移除时触发

既然 HTMLElementElement 都是自己实现,所以可以控制 connectedCallbackdisconnectedCallback 的执行时机。因为你知道元素什么时候被插入到 DOM 树里。比如 append 的时候:

  1. appendChild(node) {
  2. if (!node.parentNode) {
  3. linkParent(node, this)
  4. insertIndex(node, this.childNodes, this.childNodes.length, true)
  5. if(this.connectedCallback){
  6. this.connectedCallback()
  7. }
  8. ...
  9. }

比如移除时:

  1. removeChild(node) {
  2. if (node.parentNode) {
  3. removeIndex(node, this.childNodes, true)
  4. if(this.disconnectedCallback){
  5. this.disconnectedCallback()
  6. }
  7. }
  8. ...
  9. }

事件绑定

由于 JS 里事件绑定的回调函数包含上下文信息,不能传输给客户端,所以只需要告诉 native 元素的 id 和事件绑定的类型,当客户端触发的时候只需传输回元素的 id 和事件的类型。

  1. addEventListener(type, handler) {
  2. if (!this.event[type]) {
  3. this.event[type] = handler
  4. this.ownerDocument.addEvent(this.ref, type)
  5. }
  6. }

→ 戳这里看下源码