Oasis's Cloud

一个人的首要责任,就是要有雄心。雄心是一种高尚的激情,它可以采取多种合理的形式。
—— 《一个数学家的辩白》

Taro 运行时机制解析

作者:oasis


引言

本文旨在探索 Taro 如何处理运行时,特别是如何将 React 组件渲染到小程序环境中。核心问题是:Taro 如何构建出小程序所需的 App、Page 结构,以及如何实现 React 组件树到小程序模板树的映射。

小程序的基本结构

小程序运行需要特定的结构和 API。首先了解小程序的基本结构:

App 结构

App({
    onLaunch() {}
})

Page 结构

Page({
    data: {},
    onLoad() {},
    onShow() {},
    onReady() {},
})

小程序运行需要上面的这种结构和 API。所以 Taro 工程在编译后应该也具备 App、Page 这些内容。这里的关键要看 Taro 如何构建出 App、Page 等内容

小程序模板示例

Page({
  data: {
    demo: {
      motto: 'Hello World',
      oasis: '!!!'
    }
  },
})
<template name="odd">
  <view> {{ demo.motto }} </view>
</template>
<template name="even">
  <view> {{ demo.oasis }} </view>
</template>

<block wx:for="{{[1, 2, 3, 4, 5]}}">
  <template is="{{item % 2 == 0 ? 'even' : 'odd'}}" data="{{ demo }}"/>
</block>

Taro 编译后的运行情况

Root 数据结构

源项目中的 pages/index.jsx 中的元素嵌套关系如下:

img_1.png

编译后的 root 数据结构:

img.png

const root = {
    cn: [
        {
            cl: 'index',
            cn: [
                {
                    cn: [
                        {
                            nn: '8',
                            sid: '_AG',
                            v: 'Hello world!'
                        }
                    ],
                    nn: '4',
                    sid: '_AH'
                }
            ],
            nn: '2',
            sid: '_AI'
        }
    ]
}

这里可以看做虚拟节点树,实际最终要把这棵树的渲染映射到小程序 xml 模板上。

用最简化的例子来看,pages 下的 index 页面中的 xml 文件加载 base.xml 中的模板,采用编译后的数据 root,进行数据的传递,在通过 xs 计算映射的模板后,最终映射到 tmpl_0_8

<template name="tmpl_0_8">
  <block>{{i.v}}</block>
</template>

渲染触发的入口

实际编译后小程序项目中 pages/index.js 文件内容:

import { createPageConfig } from '@tarojs/runtime'
import component from "!!../../../node_modules/.pnpm/@tarojs+taro-loader@4.0.0_webpack@5.91.0_@swc+core@1.3.96_/node_modules/@tarojs/taro-loader/lib/entry-cache.js?name=pages/index/index!./index.jsx"
var config = {"navigationBarTitleText":"首页"};

var inst = Page(createPageConfig(component, 'pages/index/index', {root:{cn:[]}}, config || {}))

export default component

createPageConfig 来自 @tarojs/runtime,这里创建 page json 的静态结构。在这个步骤中,只初始化了 root 数据,实际的数据处理在哪?这个问题比较重要。

useState 来自 webpack/container/remote/react,这里在前面创建的数据基础上,由 React 来驱动数据更新。

这里既要考虑小程序渲染模板树的收集,又要考虑 React 如何驱动渲染:

Taro 的架构设计

自定义渲染器

Taro 小程序采用了 React 创建组件,但是渲染采用了基于 react-reconciler 创建的自定义渲染器:

const TaroReconciler = Reconciler(hostConfig)

TaroReconciler 在封装 render 方法时候,会在 taro-framework-reactconnect.ts 中调用。createReactApp 设置 setReconciler 层的目的是实现解耦,让 React 层插件化。

平台适配

taro-platform-weapp 会重新设计 react-reconciler

import { mergeInternalComponents, mergeReconciler } from '@tarojs/shared'
import { components, hostConfig } from './runtime-utils'

mergeReconciler(hostConfig)
mergeInternalComponents(components)

taro-framework-react 中会把 React 相关的内容进行 alias 映射:

alias.set('react-reconciler$', 'react-reconciler/cjs/react-reconciler.production.min.js')
alias.set(/^(?!.*mobx-react$).*react$/, newFilePath)
alias.set('react/jsx-runtime$', 'react/cjs/react-jsx-runtime.production.min.js')

Taro3 的解释型架构

Taro3 可以大致理解为解释型架构,这个工作主要是在运行时”对代码进行解释”。

升级为 Taro3 后,你会发现 package.json 文件里面多了个 @taro/runtime 的依赖。打开包所在目录,会惊奇的发现我们在 web 中才会有的 BOM 跟 DOM 相关的关键字。原来 Taro3 自己实现了一套类似浏览器的 BOM/DOM 那一套 API,通过 webpack 的 plugin 注入到小程序的逻辑层。

打包编译后,你最终的代码都是基于 BOM/DOM 这几个 API 来实现你的具体功能。不管什么平台,都有自己一套元素 dom 的规则,都有各自平台的类似 bom 的全局 api 规则,Taro3 做的就是整合这些厂家的规则封装为类似 BOM/DOM 的思想去用。也就是说,我不管你开发时用的什么框架,我只要保证你运行时能帮你适配到各个平台即可。

这样做的最直观的好处就是,不再受限制与框架本身了。理论上来说,Taro 不仅可以支持 Vue 和 React,也能用 jQuery、Angular 等等的库进行跨端开发。

站在 React 的使用者角度:通过 taro2 和 taro3 两个项目开发经验来说,还有一点最直观的感受,taro3 中写 JSX 更加舒服了!其实就更加友好的支持 JSX 这一点,应该是顺理成章的,因为 taro 的架构其实就是无限接近于 React 的开发体验,适配的工作是通过运行时的 BOM/DOM 去完成的,而不是像之前版本一样,通过穷举的方式对 JSX 的写法进行适配。

关键点:既然 Taro3 自己实现了 BOM/DOM 这一套 API,而 React 中的渲染器,如 react-dom 中调用的是浏览器的 BOM/DOM 的 API,那 Taro 肯定会有自己一套渲染器来链接 React 的协调器(reconciler,diff 算法所在阶段)和 taro-runtime 的 BOM/DOM API。源码路径:@tarojs/react,description 里面的描述如下:”like react-dom, but for mini apps.”

数据流概览

核心机制详解

createReactApp

createReactApp 方法是 Taro 框架的一个核心方法,用于创建 React 应用并将其适配到小程序环境。

主要功能和实现细节

初始化设置: - 接收主要参数:App 组件、React 实例、ReactDOM 实例和配置对象 - 设置 React 相关的全局引用 - 创建应用实例的引用

AppWrapper 核心组件: - 继承自 React.Component - 管理页面的生命周期 - 维护页面数组和元素数组 - 提供页面挂载和卸载的方法

页面管理: - mount 方法:负责新页面的挂载 - unmount 方法:处理页面的卸载 - 使用 connectReactPage 连接 React 组件与小程序页面

渲染机制: - 使用 React 18 的新 API 或降级使用旧版本的渲染方式 - 处理页面栈的管理和渲染 - 支持异步的页面加载和卸载

生命周期适配: - 将 React 的生命周期与小程序的生命周期进行对接 - 处理页面切换和状态管理

这个实现的主要目的是: - 让 React 组件能够在小程序环境中正常运行 - 处理好小程序的页面栈管理 - 确保 React 组件的生命周期能够正确响应小程序的事件 - 提供统一的页面管理机制

通过这种方式,开发者可以使用熟悉的 React 组件开发方式来开发小程序,而 Taro 框架则负责处理底层的适配工作。

TaroRootElement 数据收集机制

TaroRootElement 中的 performUpdate 会收集 data 数据,并调用小程序的 setData 更新数据。

数据收集流程

1. TaroNode 类中的基础节点操作

TaroNode = class _TaroNode extends TaroEventTarget {
    updateChildNodes(isClean) {
        const cleanChildNodes = () => [];
        const rerenderChildNodes = () => {
            const childNodes = this.childNodes.filter((node) => !isComment(node));
            return childNodes.map(hydrate);
        };
        this.enqueueUpdate({
            path: `${this._path}.${CHILDNODES}`,
            value: isClean ? cleanChildNodes : rerenderChildNodes
        });
    }
}

这里定义了 updateChildNodes 方法,它负责收集和更新子节点数据。当子节点发生变化时,会调用 hydrate 方法处理每个子节点。

2. hydrate 方法

function hydrate(node) {
    var _a2;
    componentsAlias2 || (componentsAlias2 = getComponentsAlias2());
    SPECIAL_NODES || (SPECIAL_NODES = _chunk_KEK4XUFF_js__WEBPACK_IMPORTED_MODULE_0__.hooks.call("getSpecialNodes"));
    const nodeName = node.nodeName;
    let compileModeName = null;
    if (isText(node)) {
        return {
            sid: node.sid,
            ["v"]: node.nodeValue,
            ["nn"]: ((_a2 = componentsAlias2[nodeName]) === null || _a2 === void 0 ? void 0 : _a2._num) || "8"
        };
    }
    const data = {
        ["nn"]: nodeName,
        sid: node.sid
    };
    if (node.uid !== node.sid) {
        data.uid = node.uid;
    }
    if (!node.isAnyEventBinded() && SPECIAL_NODES.indexOf(nodeName) > -1) {
        data["nn"] = `static-${nodeName}`;
        if (nodeName === VIEW && !isHasExtractProp(node)) {
            data["nn"] = PURE_VIEW;
        }
    }
    const { props } = node;
    // ...
}

这个方法负责将节点转换为小程序可以理解的数据格式,包括: - 节点 ID(sid) - 节点名称(nn) - 节点值(v) - 特殊节点的处理

3. 节点的插入和删除操作

insertBefore(newChild, refChild, isReplace) {
    if (newChild.nodeName === DOCUMENT_FRAGMENT) {
        // ...
    }

    if (this._root) {
        if (!refChild) {
            const isOnlyChild = childNodesLength === 1;
            if (isOnlyChild) {
                this.updateChildNodes();
            } else {
                this.enqueueUpdate({
                    path: newChild._path,
                    value: this.hydrate(newChild)
                });
            }
        } else if (isReplace) {
            this.enqueueUpdate({
                path: newChild._path,
                value: this.hydrate(newChild)
            });
        } else {
            const mark = childNodesLength * 2 / 3;
            if (mark > index) {
                this.updateChildNodes();
            } else {
                this.updateSingleChild(index);
            }
        }
    }
    MutationObserver2.record({
        type: "childList",
        target: this,
        addedNodes: [newChild],
        removedNodes: isReplace ? [refChild] : [],
        nextSibling: isReplace ? refChild.nextSibling : refChild || null,
        previousSibling: newChild.previousSibling
    });
    return newChild;
}

insertBefore 方法中,会根据不同情况选择更新策略: - 如果是唯一子节点,更新整个子节点树 - 如果有参考节点,更新单个节点 - 根据节点位置决定是更新整个树还是单个节点

4. 数据更新的入队

enqueueUpdate(payload) {
    var _a2;
    (_a2 = this._root) === null || _a2 === void 0 ? void 0 : _a2.enqueueUpdate(payload);
}

所有节点的更新都会通过 enqueueUpdate 方法进入更新队列。

5. 最终在 performUpdate 中处理更新

performUpdate(initRender = false, prerender) {
    this.pendingUpdate = true;
    const ctx = _chunk_KEK4XUFF_js__WEBPACK_IMPORTED_MODULE_0__.hooks.call("proxyToRaw", this.ctx);
    setTimeout(() => {
        const setDataMark = `${SET_DATA} 开始时间戳 ${Date.now()}`;
        perf.start(setDataMark);
        const data = /* @__PURE__ */ Object.create(null);
        const resetPaths = new Set(initRender ? [
            "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 ((0,_chunk_KEK4XUFF_js__WEBPACK_IMPORTED_MODULE_0__.isFunction)(value)) {
                data[path] = value();
            }
        }
        if ((0,_chunk_KEK4XUFF_js__WEBPACK_IMPORTED_MODULE_0__.isFunction)(prerender)) return prerender(data);
        this.pendingUpdate = false;
        let normalUpdate = {};
        const customWrapperMap = /* @__PURE__ */ new Map();
        if (initRender) {
            normalUpdate = data;
        } else {
            for(const p in data) {
                const dataPathArr = p.split(".");
                const found = findCustomWrapper(this, dataPathArr);
                if (found) {
                    const { customWrapper, splitedPath } = found;
                    customWrapperMap.set(customWrapper, Object.assign(Object.assign({}, customWrapperMap.get(customWrapper) || {}), {
                        [`i.${splitedPath}`]: data[p]
                    }));
                } else {
                    normalUpdate[p] = data[p];
                }
            }
        }
        const customWrapperCount = customWrapperMap.size;
        const isNeedNormalUpdate = Object.keys(normalUpdate).length > 0;
        const updateArrLen = customWrapperCount + (isNeedNormalUpdate ? 1 : 0);
        let executeTime = 0;
        const cb = () => {
            if (++executeTime === updateArrLen) {
                perf.stop(setDataMark);
                this.flushUpdateCallback();
                initRender && perf.stop(PAGE_INIT);
            }
        };
        if (customWrapperCount) {
            customWrapperMap.forEach((data2, ctx2) => {
                if (options.debug) {
                    console.log("custom wrapper setData: ", data2);
                }
                ctx2.setData(data2, cb);
            });
        }
        if (isNeedNormalUpdate) {
            if (options.debug) {
                console.log("page setData:", normalUpdate);
            }
            ctx.setData(normalUpdate, cb);
        }
    }, 0);
}

这个方法会: - 收集所有待更新的数据 - 处理更新路径 - 区分普通更新和自定义组件更新 - 最终通过 setData 更新到视图层

整个过程形成了一个完整的数据收集链路: - 节点变化触发更新 - 更新进入队列 - hydrate 处理节点数据 - performUpdate 统一处理更新 - setData 更新到视图层

这样的设计确保了 React 组件树的变化能够被正确地收集和同步到小程序的数据层。

createReactApp 和 Page 中的数据通信

从代码中可以看出,createReactApp 和 Page 的数据通信主要通过以下几个关键步骤:

1. 在 createReactApp 中创建 AppWrapper 组件,它负责管理所有页面:

class AppWrapper extends react.Component {
    mount(pageComponent, id, cb) {
        const pageWrapper = connectReactPage(react, id)(pageComponent);
        const key = id + pageKeyId();
        const page = () => h$1(pageWrapper, {
            key,
            tid: id
        });
        this.pages.push(page);
        this.forceUpdate((...args) => {
            _chunk_R2V3WQ7B_js__WEBPACK_IMPORTED_MODULE_0__.perf.stop(_chunk_R2V3WQ7B_js__WEBPACK_IMPORTED_MODULE_0__.PAGE_INIT);
            return cb(...args);
        });
    }
    unmount(id, cb) {
        const elements = this.elements;
        const idx = elements.findIndex((item) => item.props.tid === id);
        elements.splice(idx, 1);
        this.forceUpdate(cb);
    }
    render() {
        const { pages, elements } = this;
        while(pages.length > 0) {
            const page = pages.pop();
            elements.push(page());
        }
        // ...
    }
}

2. 当页面需要挂载时,通过 connectReactPage 创建页面包装器:

function connectReactPage(R, id) {
    return (Page) => {
        const isReactComponent = isClassComponent(R, Page);
        const inject = (node) => node && (0,_chunk_R2V3WQ7B_js__WEBPACK_IMPORTED_MODULE_0__.injectPageInstance)(node, id);
        const refs = isReactComponent ? {
            ref: inject
        } : {
            forwardedRef: inject,
            // 兼容 react-redux 7.20.1+
            reactReduxForwardedRef: inject
        };
        if (reactMeta.PageContext === _chunk_KEK4XUFF_js__WEBPACK_IMPORTED_MODULE_1__.EMPTY_OBJ) {
            reactMeta.PageContext = R.createContext("");
        }
        return class PageWrapper extends R.Component {
            static getDerivedStateFromError(error) {
                var _a, _b;
                (_b = (_a = _chunk_R2V3WQ7B_js__WEBPACK_IMPORTED_MODULE_0__.Current.app) === null || _a === void 0 ? void 0 : _a.onError) === null || _b === void 0 ? void 0 : _b.call(_a, error.message + error.stack);
                return {
                    hasError: true
                };
            }
            // React 16 uncaught error 会导致整个应用 crash,
            // 目前把错误缩小到页面
            componentDidCatch(error, info) {
                if (true) {
                    console.warn(error);
                    console.error(info.componentStack);
                }
            }
            render() {
                const children = this.state.hasError ? [] : h$1(reactMeta.PageContext.Provider, {
                    value: id
                }, h$1(Page, Object.assign(Object.assign({}, this.props), refs)));
                if (false) {} else {
                    return h$1("root", {
                        id
                    }, children);
                }
            }
            constructor() {
                super(...arguments);
                this.state = {
                    hasError: false
                };
            }
        };
    };
}

3. 页面配置通过 createPageConfig 创建,在这里处理页面的生命周期和数据更新:

function createPageConfig(component, pageName, data, pageConfig) {
    const id = pageName !== null && pageName !== void 0 ? pageName : `taro_page_${pageId()}`;
    const [ONLOAD, ONUNLOAD, ONREADY, ONSHOW, ONHIDE, LIFECYCLES, SIDE_EFFECT_LIFECYCLES] = _chunk_KEK4XUFF_js__WEBPACK_IMPORTED_MODULE_0__.hooks.call("getMiniLifecycleImpl").page;
    let pageElement = null;
    let unmounting = false;
    let prepareMountList = [];

    function setCurrentRouter(page) {
        const router = false ? 0 : page.route || page.__route__ || page.$taroPath;
        Current.router = {
            params: page.$taroParams,
            path: addLeadingSlash(router),
            $taroPath: page.$taroPath,
            onReady: getOnReadyEventKey(id),
            onShow: getOnShowEventKey(id),
            onHide: getOnHideEventKey(id)
        };
        if (!(0,_chunk_KEK4XUFF_js__WEBPACK_IMPORTED_MODULE_0__.isUndefined)(page.exitState)) {
            Current.router.exitState = page.exitState;
        }
    }

    let loadResolver;
    let hasLoaded;
    const config = {
        [ONLOAD] (options2 = {}, cb) {
            hasLoaded = new Promise((resolve) => {
                loadResolver = resolve;
            });
            perf.start(PAGE_INIT);
            Current.page = this;
            this.config = pageConfig || {};
            const uniqueOptions = Object.assign({}, options2, {
                $taroTimestamp: Date.now()
            });
            const $taroPath = this.$taroPath = getPath(id, uniqueOptions);
            if (this.$taroParams == null) {
                this.$taroParams = uniqueOptions;
            }
            setCurrentRouter(this);
            if (true) {
                window2.trigger(CONTEXT_ACTIONS.INIT, $taroPath);
            }
            const mount = () => {
                Current.app.mount(component, $taroPath, () => {
                    pageElement = env.document.getElementById($taroPath);
                    (0,_chunk_KEK4XUFF_js__WEBPACK_IMPORTED_MODULE_0__.ensure)(pageElement !== null, "没有找到页面实例。");
                    safeExecute($taroPath, ON_LOAD, this.$taroParams);
                    loadResolver();
                    if (true) {
                        pageElement.ctx = this;
                        pageElement.performUpdate(true, cb);
                    } else {}
                });
            };
            if (unmounting) {
                prepareMountList.push(mount);
            } else {
                mount();
            }
        }
    };
    // ...
}

主要通信流程

数据流向: - AppWrapper 维护页面栈 - 通过 mount/unmount 方法管理页面的加载和卸载 - 页面实例通过 performUpdate 方法更新数据 - 最终通过小程序的 setData 实现数据同步

更新机制: - 页面更新时会调用 TaroRootElementperformUpdate(见上一节)

事件处理: - React 事件通过 unstable_batchedUpdates 处理批量更新

var unstable_batchedUpdates = (fn, a) => {
    if (isInsideEventHandler) {
        return fn(a);
    }
    isInsideEventHandler = true;
    try {
        return TaroReconciler.batchedUpdates(fn, a);
    } finally {
        isInsideEventHandler = false;
        finishEventHandler();
    }
};

整个通信过程形成了一个完整的链路: - createReactApp 创建应用实例 - connectReactPage 连接 React 组件与小程序页面 - createPageConfig 处理页面配置和生命周期 - performUpdate 统一处理数据更新 - setData 同步到小程序层

这样的设计确保了 React 组件和小程序页面之间的数据能够保持同步,同时保持了各自的生命周期管理。

AppWrapper 页面栈管理

页面栈维护机制

1. AppWrapper 通过两个数组维护页面状态:

2. 页面切换时的数据处理:

createPageConfig 中,通过 Current 对象维护当前页面的状态: - Current.page:当前页面实例 - Current.router:当前路由信息 - Current.app:应用实例

3. 页面间共享数据的方式:

a. 通过 Current 对象:

function setCurrentRouter(page) {
    const router = false ? 0 : page.route || page.__route__ || page.$taroPath;
    Current.router = {
        params: page.$taroParams,
        path: addLeadingSlash(router),
        $taroPath: page.$taroPath,
        onReady: getOnReadyEventKey(id),
        onShow: getOnShowEventKey(id),
        onHide: getOnHideEventKey(id)
    };
    if (!(0,_chunk_KEK4XUFF_js__WEBPACK_IMPORTED_MODULE_0__.isUndefined)(page.exitState)) {
        Current.router.exitState = page.exitState;
    }
}

Current 对象作为全局状态管理器,可以在不同页面间共享数据。

b. 通过 PageContext:

React 的 Context 机制用于跨组件传递数据。

if (reactMeta.PageContext === _chunk_KEK4XUFF_js__WEBPACK_IMPORTED_MODULE_1__.EMPTY_OBJ) {
    reactMeta.PageContext = R.createContext("");
}

总结

Taro 的运行时机制通过以下几个核心部分实现了 React 到小程序的适配:

  1. 自定义渲染器:基于 react-reconciler 创建,连接 React 协调器和小程序的 BOM/DOM API
  2. 数据收集:通过 TaroRootElementTaroNode 收集 React 组件树的变化
  3. 数据同步:通过 performUpdatesetData 将数据同步到小程序视图层
  4. 页面管理:通过 AppWrappercreatePageConfig 管理小程序页面栈和生命周期
  5. 状态共享:通过 Current 对象和 PageContext 实现页面间数据共享

这样的架构设计使得开发者可以使用熟悉的 React 开发方式,而 Taro 框架负责处理底层的适配工作,实现了”一次编写,多端运行”的目标。

img.png