模拟实现跨平台方案原理之双线程架构方案

976 阅读5分钟

背景

前段时间利用周末学习了下跨平台方案的底层实现原理,但是没有做一些实际练习,因此,打算利用纯前端技术来模拟实现一个简易版本的跨平台的多线程方案, 利用 webworker 来模拟类似 quick-js / js-core 等js运行时(js引擎),利用浏览器主渲染进程模拟视图层(也可以理解成 webview),视图框架依托 mvvm 模型的 vue-core 来实现。整体框架逻辑如下图所示。

动机

这次模拟双线程跨端方案实践的主要动机如下:

  • 熟悉跨平台多线程方案实现的底层工作原理
  • 熟悉 js 线程(js引擎)与视图层如何通信交互
  • 通过vnode 与真实ui 的映射操作进一步熟悉视图框架内部原理

具体内容

1. 先创建一个 worker 线程,为后续工作作准备。

如上面架构图所示,我们把 worker 线程比做逻辑层运行时环境(js 引擎)。 先创建一个 web worker。为了方便代码组织这里使用 URL.createObjectURL Blob 的方式创建 worker url。

// UI线程 (在这里也就是浏览器主线程)
import wkjs from './worker';
// 创建worker线程
let workerThread = initWorker(wkjs);

// 初始化成功通知
workerThread.postMessage({
  type: 'init',
  data: { msg: '渲染线程初始化' }
})

workerThread.onmessage = function(e) {
  const { type, data } = e.data;
  // ...
}

function initWorker(wkjs) {
  const workerUrl = URL.createObjectURL(
    new Blob([`(${wkjs})()`], 
    { type: 'application/javascript' })
  );
  const worker = new Worker(workerUrl)
  // worker.meta = { id: uuid(), worker }
  return worker
}
// 逻辑层 (这里就是 web worker 线程)
export default function wk() {
  // 引入框架脚本或者其他库
  importScripts([ ])
  // 监听 ui 线程消息
  self.onmessage = funtion(e) {
    const { type, data } = e.data;
    // ...

  }

  // vm 框架初始化 (vue-core)
  function initMainPage() {
    // ...

  }
}

2. 让 woker 线程正常运行 mvvm 框架层 和 业务逻辑层代码

这一环节涉及到的知识点较多,这里挑主要内容进行介绍。

首先,明确一下,我们在该线程的工作所要实现的的主要目标是,能够让业务代码跑起来,并生成 vnode 进而产生一系列操作 ui元素/节点的指令。主要流程如下:

业务代码 => MVVM框架 => vnode => patch 操作 => 产生一系列 node 操作指令 => 通知 ui 线程操作视图元素

我们按照这个思路一步一步进行编写代码。

假设我们的页面业务代码如下:

const initVuePage = {
  data() {
    return { 
      num: 1
    }
  },
  methods: {
    count() {
      this.num++
    }
  },
  template: `<section><button onClick="count">+1</button><p>{{ num }}</p></section>`
};

然后搭建一个 vue 框架运行时环境。这里需要引入 vue-core 库文件。 因为 vue3 已经对底层代码进行了分包,因此在 vue3 源码中修改对应配置文件进而打包出对于文件即可。主要是两个core 文件: compiler-core.global.jsruntime-core.global.jscompiler-core 用在运行时实时解析 vue模版组件成 render 函数, runtime-core 用 来实时进行数据的双向绑定 和 vnode 的 patch等。 至于为什么不用 runtime-dom.global.jscompiler-dom.global.js 文件,因为我们需要逻辑框架代码执行在 js 引擎并且不能也不需要在逻辑层去操作 dom。

进一步看源码,可以发现 VueRuntimeCore 的用法如下。

// Building a Custom Renderer
import 'runtime-core.global.js'
// 对应的真实 dom 操作
const nodeOps = {
  patchProp,
  insert,
  remove,
  createElement,
  // ...
}
const { render, createApp } = VueRuntimeCore.createRenderer(nodeOps)
// `render` is the low-level API
// `createApp` returns an app instance with configurable context shared
// by the entire app tree.
export { render, createApp }

现在,我们把 VueRuntimeCore 的代码补充到 woker 线程中进行框架的初始化。

export default function wk() {
  // 引入框架脚本或者其他库
  importScripts([
    'http://127.0.0.1:8080/compiler-core.global.js', 
    'http://127.0.0.1:8080/runtime-core.global.js',
  ]);
  // 监听 ui 线程消息
  self.onmessage = funtion(e) {
    const { type, data } = e.data;
    // ...
    switch (type) {
      case 'init': 
        initMainPage()
        break;

        default:
          // 
    }
  }
  // 初始页面
  const initVuePage = {
    // ...
  }
  // 对应的真实节点操作
  const nodeOpsCmd = {
    insert: function() {},
    remove: function() {},
    createElement: function() {},
    createText: function() {},
    setText: function() {},
    setElementText: function() {},
    setScopeId: function() {},
    parentNode: function() {},
    nextSibling: function() {},
    patchProp: function() {},
  }
  // vm 框架初始化 (vue-core)
  function initMainPage() {
    // 
    VueRuntimeCore
      .createRenderer(nodeOpsCmd)
      .createApp(initVuePage)

    // ...
  }
}

我们发现运行此代码时,并不能正常初始化远行, 会报以下错误: Component provided template option but runtime compilation is not supported 意思就是我们的 vue 页面模版并没有被正常的得到解析。

因此,跟踪源代码可以发现需要给 VueRuntimeCore 先注册一个 vue 模版解析器。 VueRuntimeCore.registerRuntimeCompiler(compileToFunction) 具体代码如下:

function initVNodeCompiler() {
    const compileCache = Object.create(null)

    function compileToFunction(
      template,
      options
    ) {
      if (typeof template !== 'string') {
        console.warn(`invalid template option: `, template)
        return () => {}
      }

      const key = template
      const cached = compileCache[key]
      if (cached) {
        return cached
      }

      const { code } = self.VueCompilerCore.baseCompile(
        template,
        Object.assign({
            hoistStatic: false,
            onError: console.error,
            onWarn: e => console.error(e)
          },
          options
        )
      )

      const render = new Function('Vue', code)(VueRuntimeCore);

      // mark the function as runtime compiled
      render._rc = true
      console.log(render)
      return (compileCache[key] = render)
    }

    VueRuntimeCore.registerRuntimeCompiler(compileToFunction)
  }

现在,逻辑层框架代码可以正常跑起来不会报错了。

3. 让 worker 线程具备通知 ui渲染线程进行 ui元素节点的操作、渲染、事件的绑定能力

由于上面的代码中 VueRuntimeCore 只是创建 app 实例,但是没有 mount ,因此不会进行 patch 流程。

下面我们先定义一个 虚拟的 root 节点,并执行 mount 操作,可以发现 nodeOpsCmd 中对应的节点函数会相应的执行。

// 定义 root 节点
const rootVnode = {
  nodeId: getElId(),
  root: true, 
  children: [], 
  parentNode: null 
}

vm = VueRuntimeCore
    .createRenderer(nodeOpsCmd)
    .createApp(initVuePage)
    .mount(rootVnode);

补充 nodeOpsCmd 函数内部的节点操作指令的通知能力,并且为了方便获取 parentNode nextSibling ,我们在 nodeOpsCmd 函数内部维护一套 el 元素的父子关系,即 children、 parentNode, 并且方便对节点进行唯一标识和元素事件的管理 每个节点都绑定有唯一 id 即 xz-${elId}。 完善之后 nodeOpsCmd 的代码如下。

let elId = 0;
const getElId = () => 'xz-' + elId++;

const nodeOpsCmd = {
  insert: function(child, parent, anchor = null) {
    // console.log('insert', child, parent, anchor)
    // parent.insertBefore(child, anchor || null)
    self.postMessage({ 
      type: 'nodeOps-insertBefore',
      data: { child: child, parent: parent, anchor }
    })
    // 维护 vnode parent 关系
    const i = parent.children.indexOf(child)
    parent.children.splice(i, 0, child)
    parent.children.forEach(v => (v.parentNode = parent))
    return parent
  },
  remove: function(child) {
    // console.log('remove', child)
    const parent = child.parentNode
    const i = parent.children.indexOf(child)
    parent.children.splice(i, 1)
    // if (parent) {
    //   parent.removeChild(child)
    // }
    self.postMessage({ type: 'nodeOps-remove', data: { child: child.nodeId } }) 
  },
  createElement: function(tag, isSVG, is, props) {
    // todo: event Map 维护 vm-instance ~ ctx.methods
    // let uid = uid;
    // console.log('createElement', tag, isSVG, is, props)
    // console.log('getCurrentInstance().uid', this)
    const el = {
      // instanceId: '',
      nodeId: getElId(),
      parentNode: null,
      children: [],
      tag,
      is: is ? { is } : undefined,
      props,
    }
    self.postMessage({ type: 'nodeOps-createElement', data: el })
    return el
  },
  createText: function(text) {
    // const ctx = this; 
    // console.log('createText', text)
    const el = {
      // instanceId: '',
      nodeId: getElId(),
      parentNode: null,
      tag: 'text',
      text,
    }
    // doc.createTextNode(text)
    self.postMessage({ type: 'nodeOps-createText', data: el })
    return el
  },
  createComment: function(text) {
    // console.log('createComment', text)
    // doc.createComment(text)
    // self.postMessage({ type: 'nodeOps-createComment', data: {args} }) 
  },
  setText: function(el, text) {
    // console.log('setText', el, text)
    // el.textContent = text
    self.postMessage({ type: 'nodeOps-setText', data: { el, text } }) 
  },
  setElementText: function(el, text) { 
    // console.log('setElementText', el, text)
    // el.textContent = text
    self.postMessage({
      type: 'nodeOps-setElementText', 
      data: { el: { nodeId: el.nodeId }, text }
    })
    return el
  },
  setScopeId: function(el, id) { 
    // console.log('setScopeId', el, id)
    // el.setAttribute(id, '')
    // self.postMessage({ type: 'nodeOps-setScopeId', data: {} }) 
    !el.attr && (el.attr = {}) 
    el.attr[id] = ''
    return el
  },
  parentNode: function(node) { 
    // console.log('parentNode', node, node.parentNode)
    // self.postMessage({ type: 'nodeOps-parentNode', data: {} }) 
    return node.parentNode
  },
  nextSibling: function(node) {
    // console.log('nextSibling', node)
    const children = node.parentNode.children;
    const nextSibling = children[children.indexOf(node) + 1];

    return nextSibling
  },
  patchProp: function(
    el,
    key,
    prevValue,
    nextValue,
    isSVG = false,
    prevChildren,
    parentComponent,
    parentSuspense,
  ) {
    // Todo: [ class  style  attrs  props ] 属性处理
    // event 处理
    if(/^on.*/.test(key)) {
      patchEvent(el, key, prevValue, nextValue, parentComponent)
    }
  }
}

此时 worker 线程已经具备了通知 ui 线程操作视图元素渲染的能力了。

4. UI 线程接受 worker 线程指令,并会绘制真实 ui 试图元素

其实这一步骤的原理很简单,就是接受到 worker 指令消息后作出对应的真实 ui 视图元素操作即可,由于这里基于浏览器因实现此对应的就是一些列 DOM 元素的操作。代码如下:

const fragment = document.querySelector('#app')

// 监听 worker 线程消息
workerThread.onmessage = function(event) {
  let { type = '', data = {} } = event.data;
  console.log('[from worker message]:', type, data);
  try {
    data = JSON.parse(data)
  } catch (error) {}

  switch (type) {
    case 'init': 
      // 通知 webview 主进程, woker 进程初始化成功
      console.log('[worker init]', '发送给 woker 线程 webview 初始化成功')
      break;
    case '[woker-receive-log]': 
      break
    case 'nodeOps-createElement': 
      // "nodeId":"xz-1","parentNode":null,"children":[],"tag":"section","props":null
      {
        const { nodeId, tag, text } = data;
        const ele = createEleDom(tag, text, nodeId);
        fragment.appendChild(ele);
        // console.dir(fragment)
      }
      break
    case 'nodeOps-insertBefore': 
      {
        let { parent, child, anchor } = data;
        let [parentId, childId] = [parent.nodeId, child.nodeId];

        if(parent.root === true) {
          parent = fragment // rootEl
        } else {
          parent = fragment.querySelector(`[nodeId='${parentId}']`)
        }

        child = fragment.querySelector(`[nodeId='${childId}']`)

        parent.insertBefore(child, anchor || null)

        // console.dir(fragment)
      }
      break
    case 'nodeOps-setElementText': 
      {
        const { el = {} , text = '' } = data
        const { nodeId } = el;
        fragment.querySelector(`[nodeId='${nodeId}']`).textContent = text
      }
      break
    
    default: 
      console.error(`type: ${type} is not found`);
  }
}
// 创建真实 dom
function createEleDom(tag, text, nodeId) {
  // console.log(tag)
  const ele = tag !== 'text' ? 
    document.createElement(tag) : 
    document.createTextNode(text);
  ele.setAttribute('nodeId', nodeId)
  return ele;
}

5. ui 事件触发进一步导致试图更新

具体实现就是 woker 线程通知 ui 进行事件绑定,真实 ui 元素事件操作时,实时通知到 woker 线程进行计算,并生成相应 ui 操作指令,进而使 UI 线程更新视图,具体代码如下:

// worker 线程的事件处理

const nodeOpsCmd = {
  patchProp: function(
    el,
    key,
    prevValue,
    nextValue,
    isSVG = false,
    prevChildren,
    parentComponent,
    parentSuspense,
  ) {
    // Todo: [ class  style  attrs  props ] 属性处理

    // event 处理
    if(/^on.*/.test(key)) {
      patchEvent(el, key, prevValue, nextValue, parentComponent)
    }
  }
}
// 定义一个事件中心
const eventCenter = new Map()
// 声明元素绑定事件通知函数
function patchEvent(el, key, prevValue, nextValue, instance) {
  // console.log('patchEvent', el, key, prevValue, nextValue, instance)
  el = el.nodeId
  key = key.slice(2).toLowerCase();
  //
  eventCenter.set(el, [
    () => {
      try {
        instance.ctx[nextValue](prevValue);
      } catch (error) { console.error(error) }
    }
  ]);
  // 
  self.postMessage({
    type: 'add-event',
    data: {
      nodeId: el,
      events: [key],
      mode: ''
    }
  })
}

// 渲染线程的事件处理
switch (type) {
  // 接受到 worker 的事件绑定消息
  case 'add-event':
  {
    const { events, mode = false, nodeId } = data
    // 对事件进行绑定
    for(let event of events) {
      fragment.querySelector(`[nodeId='${nodeId}']`).addEventListener(event, () => {
        // 通知 worker 线程  某个 ui 事件触发
        workerThread.postMessage({
          type: event,
          data: {
            nodeId
          }
        })
      }, mode)
    }
  }
  break
}

到此,模拟跨平台的双线程框架demo 已初步搭建完毕,用户的逻辑代码就通过该框架在浏览器中正常运行了。效果如图所示。

总结

虽然,这种双线程的跨平台架构实现可以满足这种 demo 场景。但是如果页面复杂一些就会存在严重的性能问题。因为每一个元素的操作 和事件的触发通知 都完全依赖通信层来进行,显然如果事件执行频繁或者元素操作复杂的页面是有很大性能瓶颈的。

另外,该 demo 只是演示了元素的操作,但是没有进行页面样式 layout 的计算,感兴趣的同学可以把该部分内容补充进来。

最后,通过该 demo 框架的实践,可以使更深的理解跨平台技术与现代化前端框架之间的内在联系,更深层的理解跨平台技术和前端框架的内部原理。

(该版本只是demo 级别的第一版,后面有时间会针对做一些优化重构,进一步编写优化后第二版。)