闲来无事,抱着学习态度,研究一下Taro3运行时部分的源码,探究Taro内部的运行机制,积累一点跨端开发知识。 Taro可以让开发者编写一套代码,编译适配多个小程序平台,并且Taro目前也支持转换为鸿蒙应用,逐步丰富在跨端领域的应用面。
项目结构
Taro是典型的MonoRepo的项目结构,各个npm包的作用如下表:
RN相关包未列入
NPM包 | 作用 |
---|---|
运行时 | 模拟BOM/DOM API,框架与小程序环境的对接处理 |
@tarojs/taro | 暴露给应用开发者的Taro核心api |
@tarojs/shared | Taro内部使用的共用utils |
@tarojs/api | 暴露给 @tarojs/taro 的所有端的公有 API |
@tarojs/components | Taro组件库,H5端则使用stencil实现的Web Components |
@tarojs/components-react | React版组件库,为了兼容低版本浏览器 |
@tarojs/taro-h5 | 暴露给@tarojs/taro的H5端api |
@tarojs/react | 小程序端React渲染器,实现HostConfig接口,小程序版react-dom |
@tarojs/router | H5端路由系统,为了兼容小程序规范的路由系统 |
@tarojs/runtime | Taro运行时,完整实现了DOM、BOM API,以及createReactApp\createVueApp\createPageConfig方法,将框架DSL与小程序环境桥接在一起。 |
编译时 | 通过webpack+babel编译工具组合,转换为小程序环境的代码 |
@tarojs/cli | Taro命令行CLI |
@tarojs/taro-loader | 暴露给mini-runner 与 webpack-runner 使用的 webpack loader |
@tarojs/helper | 暴露给内部CLI或编译时的辅助方法,如常量、fs等 |
@tarojs/mini-runner | 小程序端的webpack启动器,编译主流程 |
@tarojs/webpack-runner | H5端的webpack启动器,编译主流程 |
@tarojs/runner-utils | 暴露给mini-runner 与 webpack-runner 使用的公用工具函数 |
@tarojs/plugin-platform-react | Taro插件,用于支持编译React/Preact/Nerv |
@tarojs/plugin-platform-vue3 | Taro插件,用于支持编译Vue3 |
@tarojs/plugin-html | Taro插件,用于支持编译到H5端 |
@tarojs/service | Taro服务层,封装了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配置能力。
以后再单独研究一下编译时,不在本文范围内
运行时
“万变不离其宗”:无论前端框架如何演进,其在底层调用都是W3C规范中的原生API,如window、document等。如果在小程序环境中实现兼容W3C规范的DOM、BOM API,就可以让不同的前端框架运行在小程序环境了。而不同小程序端平台的差异性,如生命周期、组件库、API等差异,则通过指定一套端平台插件标准由各端实现差异点,在编译时注入到打包结果中。
小程序
开发者使用React或Vue框架撰写业务代码,通过taro build进行编译打包(触发编译时),而Taro在运⾏时中提供了 React 和 Vue 对应的适配器进⾏适配(createReactApp
,createVueApp
),然后调⽤Taro提供的 DOM、BOM API,调用不同端平台插件,最后把整个程序渲染到所有的⼩程序端上⾯。
下文以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-reconciler
和 taro-runtime
的 BOM/DOM API。它就是基于 react-reconciler
的小程序专用 React 渲染器,连接 @tarojs/runtime
的 DOM 实例,相当于小程序版的react-dom,暴露的 API 也和 react-dom 保持一致。
H5环境下,taro-react直接使用 react-dom
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'))
}
至此搞定了在小程序环境中的逻辑层改造。
视图
小程序逻辑层描述业务逻辑,而视图层则负责展示内容,两者通过基于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 方法,如下图:
事件处理
事件注册
在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
来实现的。
参考
最后
码字不易,如果:
- 这篇文章对你有用,请不要吝啬你的小手为我点赞;
- 有不懂或者不正确的地方,请评论,我会积极回复或勘误;
- 转载请注明出处;