Taro3运行时机制剖析

2,616 阅读9分钟

闲来无事,抱着学习态度,研究一下Taro3运行时部分的源码,探究Taro内部的运行机制,积累一点跨端开发知识。 Taro可以让开发者编写一套代码,编译适配多个小程序平台,并且Taro目前也支持转换为鸿蒙应用,逐步丰富在跨端领域的应用面。

项目结构

Taro是典型的MonoRepo的项目结构,各个npm包的作用如下表:

RN相关包未列入

NPM包作用
运行时模拟BOM/DOM API,框架与小程序环境的对接处理
@tarojs/taro暴露给应用开发者的Taro核心api
@tarojs/sharedTaro内部使用的共用utils
@tarojs/api暴露给 @tarojs/taro 的所有端的公有 API
@tarojs/componentsTaro组件库,H5端则使用stencil实现的Web Components
@tarojs/components-reactReact版组件库,为了兼容低版本浏览器
@tarojs/taro-h5暴露给@tarojs/taro的H5端api
@tarojs/react小程序端React渲染器,实现HostConfig接口,小程序版react-dom
@tarojs/routerH5端路由系统,为了兼容小程序规范的路由系统
@tarojs/runtimeTaro运行时,完整实现了DOM、BOM API,以及createReactApp\createVueApp\createPageConfig方法,将框架DSL与小程序环境桥接在一起。
编译时通过webpack+babel编译工具组合,转换为小程序环境的代码
@tarojs/cliTaro命令行CLI
@tarojs/taro-loader暴露给mini-runner 与 webpack-runner 使用的 webpack loader
@tarojs/helper暴露给内部CLI或编译时的辅助方法,如常量、fs等
@tarojs/mini-runner小程序端的webpack启动器,编译主流程
@tarojs/webpack-runnerH5端的webpack启动器,编译主流程
@tarojs/runner-utils暴露给mini-runner 与 webpack-runner 使用的公用工具函数
@tarojs/plugin-platform-reactTaro插件,用于支持编译React/Preact/Nerv
@tarojs/plugin-platform-vue3Taro插件,用于支持编译Vue3
@tarojs/plugin-htmlTaro插件,用于支持编译到H5端
@tarojs/serviceTaro服务层,封装了Kernel(扩展编译时)以及TaroPlatformBase(扩展端平台插件)
端平台插件(5横1纵)
@tarojs/plugin-platform-weapp横向扩展微信小程序
@tarojs/plugin-platform-qq纵向扩展QQ小程序
@tarojs/plugin-platform-alipay横向扩展支付宝小程序
@tarojs/plugin-platform-jd横向扩展京东小程序
@tarojs/plugin-platform-swan横向扩展百度小程序
@tarojs/plugin-platform-tt横向扩展头条小程序

如果你对运行时感兴趣,则要重点关注 @tarojs/react@tarojs/plugin-platform-react@tarojs/runtime 三个包,前两者基于react-reconciler实现了自定义HostConfig接口,后者则模拟了BOM/DOM API实现了在小程序环境运行前端框架。吃透它们对Taro基于运行时的架构理解有很大帮助。

如果你对编译时感兴趣,则要重点关注 @tarojs/mini-runner@tarojs/taro-loader 两个包,理解它们可以帮助你精进webpack配置能力。

以后再单独研究一下编译时,不在本文范围内

运行时

Taro3整体架构

万变不离其宗”:无论前端框架如何演进,其在底层调用都是W3C规范中的原生API,如window、document等。如果在小程序环境中实现兼容W3C规范的DOM、BOM API,就可以让不同的前端框架运行在小程序环境了。而不同小程序端平台的差异性,如生命周期、组件库、API等差异,则通过指定一套端平台插件标准由各端实现差异点,在编译时注入到打包结果中。

小程序

开发者使用React或Vue框架撰写业务代码,通过taro build进行编译打包(触发编译时),而Taro在运⾏时中提供了 React 和 Vue 对应的适配器进⾏适配(createReactApp,createVueApp),然后调⽤Taro提供的 DOM、BOM API,调用不同端平台插件,最后把整个程序渲染到所有的⼩程序端上⾯。

image.png

下文以React前端框架为例

React

React框架可以简单分为三部分:react-core + react-reconciler + Renderer

react-core提供了供开发者调用的核心API,以jsx为DSL描述语言;

react-reconciler 内部基于“双缓存”的调和机制维护了Fiber组件树,并完整实现了Diff算法,决定何时更新、更新什么;

Renderer 则具体实现客户端的渲染,以及DOM事件处理;

react-dom是浏览器端的Renderer,调用DOM、BOM API来渲染界面。但在小程序环境中,则行不通。好在react-reconciler提供了HostConfig接口,理论上只要按照HostConfig接口实现对应方法,就可以在任意端完成界面渲染。

Taro提供了 @tarojs/taro-react 包,用来连接 react-reconcilertaro-runtime 的 BOM/DOM API。它就是基于 react-reconciler 的小程序专用 React 渲染器,连接 @tarojs/runtime 的 DOM 实例,相当于小程序版的react-dom,暴露的 API 也和 react-dom 保持一致。

H5环境下,taro-react直接使用 react-dom

image.png

1、实现宿主配置

完整实现HostConfig接口,在方法中调用对应的Taro BOM/DOM API

// taro-react/src/reconciler.ts
import { document } from '@tarojs/runtime' // 模拟了window、document全局对象
const hostConfig = {
    createInstance (type) {
        return document.createElement(type)
    },
    // ...
}
const TaroReconciler = Reconciler(hostConfig)
export { TaroReconciler }

2、实现渲染函数

核心逻辑是暴露 render 方法:

// taro-react/src/render.ts
import { TaroReconciler } from './reconciler'
class Root {
    constructor(renderer, domContainer){
	this.renderer = renderer
        // 值得注意的是,传入ConcurrentMode,则会启用了React18中并发新特性
	this.internalRoot = renderer.createContainer(domContainer, 0/** LegacyRoot: react-reconciler/src/ReactRootTags.js */, false, null)
    }
    render(children, cb){
	const { renderer, internalRoot } = this
	renderer.updateContainer(children, internalRoot, null, cb)
	return renderer.getPublicRootInstance(internalRoot)
    }
    unmount (cb: Callback) {
        this.renderer.updateContainer(null, this.internalRoot, null, cb)
    }
}
export function render(element, domContainer, cb){
    const root = new Root(TaroReconciler, domContainer)
    ContainerMap.set(domContainer, root)
    return root.render(element, cb)
}

3、封装为小程序版‘react-dom’

import {render} from './render'
export default {
  render,
  // 其他方法  
}

render何时调用?

如果前端框架为React时,在编译时,会引入插件 taro-plugin-react, 插件内会调用 modifyMiniWebpackChain —> setAlias

if(framework === 'react'){
    // 别名,在小程序内调用ReactDOM就是刚才封装的小程序版react-dom
    alias.set('react-dom$', '@tarojs/react')
}

用户编写的app.js会被 createReactApp 方法包裹,在createReactApp方法中会调用 ReactDOM.render 方法

// taro-plugin-react/src/runtime/connect.ts
if (process.env.TARO_ENV !== 'h5') {
    appWrapper = ReactDOM.render?.(h(AppWrapper), document.getElementById('app'))
}

image.png

至此搞定了在小程序环境中的逻辑层改造。

视图

小程序逻辑层描述业务逻辑,而视图层则负责展示内容,两者通过基于Data/Event的jsbridge进行通信。在小程序环境需要提前将视图描述出来,不可以在逻辑层去动态生成视图(所以React.createPortal用不了)。不过小程序提供了template的模板能力,支持在视图层通过mustache语法来动态渲染视图。

Taro使⽤了模板拼接的⽅式,根据运⾏时提供的 DOM 树数据结构,各 templates 递归地 相互引⽤,最终可以渲染出对应的动态 DOM 树。

模板化处理

首先,将小程序的所有组件挨个进行模版化处理,从而得到小程序组件对应的模版。如下图就是小程序的 view 组件模版经过模版化处理后的样子。⾸先需要在 template ⾥⾯写⼀个 view,把它所有的属性全部列出来(把所有的属性都列出来是因为⼩程序⾥⾯不能去动态地添加属性)。以下是view的模板化呈现:

<template name="tmpl_0_view">
    <view
        hover-class="{{xs.b(i.hoverClass,'none')}}"
        hover-stop-propagation="{{xs.b(i.hoverStopPropagation,false)}}"
        hover-start-time="{{xs.b(i.hoverStartTime,50)}}"
        hover-stay-time="{{xs.b(i.hoverStayTime,400)}}"
        bindtouchstart="eh"
        bindtouchmove="eh"
        bindtouchend="eh"
        bindtouchcancel="eh"
        bindlongpress="eh"
        animation="{{i.animation}}"
        bindanimationstart="eh"
        bindanimationiteration="eh"
        bindanimationend="eh"
        bindtransitionend="eh"
        style="{{i.st}}"
        class="{{i.cl}}"
        bindtap="eh"
        id="{{i.uid||i.sid}}"
        data-sid="{{i.sid}}"
    >
        <block wx:for="{{i.cn}}" wx:key="sid">
            <template is="{{xs.e(cid+1)}}" data="{{i:item,l:l}}" />
        </block>
    </view>
</template>
<template name="tmpl_1_view">...</template>

组件模板化的核心代码在 packages/shared/src/template.ts 文件中.

可以在打包出dist目录中的base.wxml中看到所有组件模板化的结果,当然Taro只会将项目中使用到的组件进行输出。

同时,Taro会建立一个根模板,每个页面都会从 taro_tmpl 开始递归渲染:

<wxs module="xs" src="./utils.wxs" />
<template name="taro_tmpl">
  <block wx:for="{{root.cn}}" wx:key="sid">
    <template is="tmpl_0_container" data="{{i:item,l:''}}" />
  </block>
</template>
<template name="tmpl_0_container">
  <template is="{{xs.a(0, i.nn, l)}}" data="{{i:i,cid:0,l:xs.f(l,i.nn)}}" />
</template>

<!-- 页面内调用 -->
<import src="../../base.wxml"/>
<template is="taro_tmpl" data="{{root:root}}" />

可以通过端平台插件的template来控制不同端平台模板的输出; 模板拥有自己的作用域,只能使用 data 传入的数据以及模板定义文件中定义的 <wxs /> 模块。

当业务触发this.setState时,进一步触发小程序的this.setData,从而更新data,触发模板更新。

可递归模板&非递归模板

可递归模板(左图)允许不断调用自身,如支付宝、头条、百度小程序,生成的 base.wxml 也就较小。

非递归模板(右图)则不允许调用自身,如微信、QQ、京东小程序,生成的 base.wxml 就比较大了,目前默认递归层级为 baseLevel = 16 。可以通过端平台插件的 nestElements 修改指定组件的递归层级。

递归模板 非递归模板

组件数据data

有了组件模板,就需要data来注入灵魂了(Taro中叫注水hydrate,这里并非ssr的hydrate),在运行时中会组建完整的data数据。

首先,在createPageConfig中会对config.data进行初始化,赋值{root:{cn:[]}},同时Taro会对onLoad生命周期进行特殊处理:

// packages/taro-runtime/src/dsl/common.ts
function createPageConfig(){
    // config会作为小程序 Page() 的入参
    const config = {
	// 在Taro中会对onLoad绑定this,详见具体源码
	onLoad(this){
            // 页面加载后触发onLoad生命周期,执行以下逻辑
            const pageElement = document.getElementById($taroPath) // pageElement是TaroRootElement的实例对象
            pageElement.ctx = this  // 这里的this指向的是小程序当前页面实例,在下文的performUpdate方法中会使用到
            pageElement.performUpdate(true, cb) // 这里开始正式触发页面渲染,
	}
    } 
    config.data = {root:{cn:[]}}
    return config
}

React在commit阶段会调用HostConfig里的appendInitialChild方法完成页面挂载,在Taro中则继续调用:appendInitialChild —> appendChild —> insertBefore —> enqueueUpdate

// packages/taro-runtime/src/dom/root.ts
class TaroRootElement extends TaroElement {
    updatePayloads = []
    enqueueUpdate (payload) {
        this.updatePayloads.push(payload)
    }
    performUpdate(){
	const ctx = this.ctx
	setTimeout(()=>{
            const data = Object.create(null)
            const resetPaths = new Set(['root.cn.[0]', 'root.cn[0]'])
            while (this.updatePayloads.length > 0) {
                const { path, value } = this.updatePayloads.shift()
                if (path.endsWith('cn')) {
                    resetPaths.add(path)
                }
                data[path] = value
            }
            for (const path in data) {
                resetPaths.forEach(p => {
                if (path.includes(p) && path !== p) {
                    delete data[path]
                }
            })
            const value = data[path]
            if (isFunction(value)) {
              data[path] = value()  // hydrate, 执行注水逻辑,会产生完整的data数据
            }
        }
            ctx.setData(normalUpdate, cb)  // 调用小程序this.setData,触发页面视图初始化
        }, 0)
    }
}

注意这一行代码 data[path] = value() ,value就是调用的hydrate方法,hydrate会将data”注满水“:

// packages/taro-runtime/src/hydrate.ts
function hydrate (node) {
    const data = {
        'nn': nodeName,
        'sid': node.sid
    }
    if (node.uid !== node.sid) {
        data.uid = node.uid
     }
    let { childNodes } = node
    data['cn'] = childNodes.map(hydrate) // 递归为子节点data注水
    data['cl'] = node.className
    return data
}

你可以在小程序IDE中的 ”AppData“ 标签栏中查看到完整的data数据结构。当在React中调用 this.setState 时,React内部会执行reconciler,进而触发 enqueueUpdate 方法,如下图:

image.png

事件处理

image.png

事件注册

在HostConfig接口中,有一个方法 commitUpdate,用于在react的commit阶段更新属性:

// packages/taro-react/src/reconciler.ts
const hostConfig = {
    // dom 是 TaroElement实例
    // 继承关系:TaroElement extends TaroNode extends TaroEventTarget
    commitUpdate (dom, _payload, _type, oldProps, newProps) {
    updateProps(dom, oldProps, newProps)
  }
}

进一步的调用方法:updateProps —> setProperty —> setEvent

// packages/taro-react/src/props.ts
function setEvent(dom, name, handler){ // 比如 name 为 'onClickCapture', handler为用户指定的回调事件
    const isCapture = name.endsWith('Capture')
    let eventName = name.toLowerCase().slice(2) // 去除on前缀
    if (isCapture) {
        eventName = eventName.slice(0, -7)
    }
    const compName = capitalize(toCamelCase(dom.tagName.toLowerCase()))
    // 小程序环境中点击事件为tap,而React则使用的click
    if (eventName === 'click') {
        eventName = 'tap'
    }
    // 通过addEventListener将事件注册到dom中
    if(isFunction(handler)){
        dom.addEventListener(eventName, handler, isCapture)
    }
}

进一步的看看dom.addEventListener做了什么?addEventListener是类TaroEventTarget的方法:

// packages/taro-runtime/src/dom/event-target.ts
class TaroEventTarget {
    // 事件映射表, type: callback[]
    __handlers = {}
    addEventListener(type, handler, options){
        const handlers = this.__handlers[type]
        if (isArray(handlers)) {
            handlers.push(handler)
        } else {
            this.__handlers[type] = [handler]
        }
    }
}

可以看到事件会注册到dom对象上,最终会放入到 dom 内部变量 __handlers 中保存。

事件触发

createPageConfig时,会将 config.eh 赋值为 eventHandler,从上面的模板中可以看到,所有组件中的事件都会由 eh 代理。

// packages/taro-runtime/src/dsl/common.ts
function createPageConfig(){
    const config = {...} // config会作为小程序 Page() 的入参
    config.eh = eventHandler
    config.data = {root:{cn:[]}}
    return config
}

看看eventHandler做了什么?

// packages/taro-runtime/src/dom/event.ts
function eventHandler(event){ // 注意这里的event是小程序事件对象MpEvent, event.type是小程序原生事件名(去除bind前缀)
    event.currentTarget ||= event.target
    // 获取到触发事件的节点
    const currentTarget = event.currentTarget
    // 获取节点id
    const id = currentTarget.dataset?.sid as string /** sid */ || currentTarget.id /** uid */ || ''
    const node = getDocument().getElementById(id) // node 是 TaroElement实例, (TaroElement extends TaroNode)
    if(node){
        const dispatch = () => {
            const e = createEvent(event, node) // e是TaroEvent实例
            node.dispatchEvent(e)
        }
        dispatch()
    }
}

MpEvent.type
回调函数入参的小程序原生事件对象的type属性会去掉bind前缀,比如在小程序模板中绑定 bindchange=”eh”,则 mpEvent.type 为 change。
那么在React绑定事件时用 on-* 替代 bind-* 即可(注意驼峰格式),如:

bindcolumenchange —> onColumnChange
bindstatechange —> onStateChange

你可以在 packages/taroize/src/events.ts 看到所有事件名映射关系。 在微信小程序中 bind 开头这样的用法,都需要转成以 on 开头的形式。

eventHandler会进一步触发dom.dispatchEvent(e)

// packages/taro-runtime/src/dom/element.ts
class TaroElement extends TaroNode {
    dispatchEvent(event){
        const listeners = this.__handlers[event.type]  // 取出回调函数数组
        for (let i = listeners.length; i--;) {
            result = listener.call(this, event)  // event是TaroEvent实例
        }
    }
}

Taro组件库

在小程序环境,将业务调用的组件与小程序原生组件进行映射:

// packages/taro-weapp/src/components-react.ts
export const View = 'view'

在编译时,mini-runner中的webpack会将业务代码中的组件引入 import {View} from '@tarojs/components' 重命名alias 为 import {View} from '@tarojs/plugin-platform-weapp/dist/components-react' ,根据 process.env.TARO_ENV 来调用到不同端平台插件。其他端平台也是如此,会重命名到各自端平台插件的 components-react 文件上。这样各个端平台可以自由扩展组件库。

而在H5环境中,为了兼容小程序组件的书写格式,Taro也针对H5实现了一套组件库 @tarojs/components

端平台插件

Taro为了方便扩展多个小程序端,将各个小程序公共部分封装为 TaroPlatformBase,而将每个小程序的兼容逻辑抽取出来,在各端平台插件中实现差异性,抽离为Taro端平台插件。

// Taro 项目配置
module.exports = {
  // ...
  plugins: [
    '@tarojs/plugin-platform-alipay-iot'
  ]
}

开放式编译平台架构图

横向扩展:扩展一个全新的编译平台;新的编译平台继承自TaroPlatforBase

纵向扩展:继承已有端平台插件,扩展出新的编译平台;

目前Taro内置了 weapp alipay jd tt qq swan 6个端平台,即使是内置的端平台插件也是继承自 TaroPlatformBase 来实现的。

参考

最后

码字不易,如果:

  • 这篇文章对你有用,请不要吝啬你的小手为我点赞;
  • 有不懂或者不正确的地方,请评论,我会积极回复或勘误;
  • 转载请注明出处;