### [React如何构建小程序?两种实现方案分享](https://www.prosaas.cn/article/16329) **Published:** 2021-12-23T02:27:47 **Author:** 5201314 **Excerpt:** React如何构建小程序?下面本篇文章给大家通过1500行代码揭秘React如何运行到小程序平台,介绍一下Re… React如何构建小程序?下面本篇文章给大家通过1500行代码揭秘React如何运行到小程序平台,介绍一下React 构建小程序两种实现方案,希望对大家有所帮助! ![](https://img.php.cn/upload/article/000/000/024/61c3db46d72dc398.png) 你是否使用过 Taro、Remax 类似的框架?你是否想了解这类框架如何实现 React 代码运行到小程序平台?如果是的话,那么也许你可以花喝一杯咖啡的时间继续往下阅读,本文将通过两种方案实现 React 运行到小程序平台。如果你现在就想阅读这1500行的实现代码,那么可以直接点击项目源码进行获取(也许要多喝几杯咖啡)。 ## 项目描述 为了更清晰描述实现过程,我们把实现方案当作一个项目来对待。 项目需求:使如下计数器功能的 React 代码运行到微信小程序平台。 import React, { Component } from 'react' import { View, Text, Button } from '@leo/components' import './index.css' export default class Index extends Component { constructor() { super() this.state = { count: 0 } this.onAddClick = this.onAddClick.bind(this) this.onReduceClick = this.onReduceClick.bind(this) } componentDidMount () { console.log('执行componentDidMount') this.setState({ count: 1 }) } onAddClick() { this.setState({ count: this.state.count + 1 }) } onReduceClick() { this.setState({ count: this.state.count - 1 }) } render () { const text = this.state.count % 2 === 0 ? '偶数' : '奇数' return ( count: {this.state.count} {text} ) } } 如果使用过 Taro 或者 Remax 等框架,对上述代码应该有似曾相识的感觉,上述代码正式模仿这类框架的 React DSL 写法。如果想迫切看到实现这个需求的效果,可点击此项目源码进行获取源码,然后根据提示运行项目,即可观察到如下效果: ![](https://img.php.cn/upload/image/134/532/181/1640225706275631.gif) 到这里,就清楚了知道这个项目的需求以及最终实现结果是什么,接下来便是重点阐述从需求点到结果这个过程的具体实现。 ## 实现方案 ### 构建小程序框架产物 开发过小程序的同学都知道,小程序框架包含主体和页面,其中主体是由三个文件生组成的,且必须放在根目录,这三个文件分别是: `app.js` (必需,小程序逻辑),`app.json`(必需,小程序公共配置),`app.wxss`(非必须,小程序公共样式表)。所以要将 React 代码构建成小程序代码,首先需要先生成`app.js`和`app.json`文件。因为本次转换未涉及到`app.js`文件,所以`app.js`内容可以直接写死 `App({})`代替。`app.json`是配置文件,可以直接在 React 工程新增一个`app.config.js`用来填写配置内容,即 React 代码工程目录如下: ├── src │ ├── app.config.js // 小程序配置文件,用来生成app.json内容 │ └── pages │ └── index │ ├── index.css │ └── index.jsx // React代码,即上述计数器代码 └── tsconfig.json `app.config.js`内容即是小程序全局配置内容,如下: module.exports = { pages: \['pages/index/index'\], window: { navigationBarTitleText: 'react-wxapp', navigationBarBackgroundColor: '#282c34' } }; 有了这个配置文件,就可以通过如下方式生成`app.js`和`app.json`文件。 /\*outputDir为小程序代码生成目录\*/ fs.writeFileSync(path.join(outputDir, './app.js'), \`App({})\`) fs.writeFileSync(path.join(outputDir, './app.json'), JSON.stringify(config, undefined, 2)) // config即为app.config.js文件内容 小程序页面则是由四种类型文件构成,分别是`js`(必需,页面逻辑)、`wxml`(必需,页面结是构)、`json`(非必需、页面配置)、`wxss`(非必需、页面样式表)。而React代码转小程序,主要是考虑如何将React代码转换程序对应的`js`和`wxml`类型文件,后文会详细阐述。 ### React运行到小程序平台方案分析 实现React代码运行到小程序平台上主要有两种方式,一种是编译时实现,一种是运行时实现,如果你已经查看的本项目项目源码,就可以发现源码里也体现出了这两种方式(编译时实现目录:`packages/compile-core`;运行时实现目录:`packages/runtime-core`)。 编译时方式主要通过静态编译将 JSX 转换成小程序对应的 template 来实现渲染,类似 Taro1.0 和 2.0,此方式性能接近原生小程序,但是语法却有很大的限制。运行时实现是通过`react-reconciler`重新在小程序平台定义一个 React 渲染器,使得 React 代码可以真正运行到小程序里,类似 Taro3.0、Remax 等,因此这种方式无语法限制,但是性能会比较差。本项目源码正是参照 Taro、Remax 这类框架源码并简化很多细节进行实现的,因此这个项目源码只是适合来学习的,并不能投入实际业务进行使用。 接下来将分别讲述如何通过编译时和运行时这两种方式来实现 React 运行到小程序平台。 ### 编译时实现 在讲述具体实现流程之前,首先需要了解下编译时实现这个名词的概念,首先这里的编译并非传统的高大上“编译”,传统意义上的编译一般将高级语言往低级语言进行编译,但这里只是将同等水平语言转换,即将`javascript`代码字符串编译成另一种`javascript`代码字符串,因此这里的编译更类似于“转译”。其次,虽然这里称编译时实现,并非所有实现过程都是编译的,还是需要少部分实现需要运行时配合,因此这种方式称为重编译轻运行方式更为合适。同样的,运行时实现也含有少量编译时实现,亦可称为重运行轻编译方式。 为了方便实现将`javascript`代码字符串编译成另一种`javascript`代码字符串,这里直接采用`Babel`工具,由于篇幅问题,这里就不详细讲述`Babel`用法了,如果对`Babel`不熟的话,可以看看这篇文章简单了解下(没错,就是给自己打广告)。接下来我们来分析编译时实现步骤有哪些: **1\. JSX转换成对应小程序的模板** React是通过`JSX`来渲染视图的,而小程序则通过`wxml`来渲染视图,要将 React 运行到小程序上,其重点就是要如何实现`JSX`转换成对应的小程序的`wxml`,其转换规则就是将`JSX`使用语法转换成小程序相同功能的语法,例如: - 标签元素转换:`View`、`Text`、`Button`等标签直接映射为小程序基础组件本身(改为小写) - 样式类名转换:`className`修改为`class` ==> - 事件转换:如`onClick`修改为`bindtap` ==> - 循环转换:`map`语法修改为`wx:for` list.map(i => {i}) => {{item}} 语法转换远不止上面这些类型,如果要保证开发者可以使用各种`JSX`语法开发小程序,就需要尽可能穷举出所有语法转换规则,否则很可能开发者用了一个写法就不支持转换。而事实是,有些写法(比如动态生成JSX片段等等)是根本无法支持转换,这也是前文为什么说编译时实现方案的缺点是语法有限制,开发者不能随意编码,需要受限于框架本身开发规则。 由于上述需要转换`JSX`代码语法相对简单,只需要涉及几种简单语法规则转换,这里直接贴出转换后的`wxml`结果如下,对应的实现代码位于:`packages/compile-core/transform/parseTemplate.ts`。 count: {{count}} {{text}} **2\. 运行时适配** 如前文所说,虽然这个方案称为编译时实现,但是要将`React`代码在小程序平台驱动运行起来,还需要在运行时做下适配处理。适配处理主要在小程序`js`逻辑实现,内容主要有三块:数据渲染、事件处理、生命周期映射。 小程序`js`逻辑是通过一个`object`参数配置声明周期、事件等来进行注册,并通过`setData`方法触发视图渲染: Component({ data: {}, onReady () { this.setData(..) }, handleClick () {} }) 而计数器`React`代码是通过`class`声明一个组件逻辑,类似: class CustomComponent extends Component { state = { } componentDidMount() { this.setState(..) } handleClick () { } } 从上面两段代码可以看出,小程序是通过`object`声明逻辑,React 则是通过`class`进行声明。除此之外,小程序是通过`setData`触发视图(`wxml`)渲染,React 则是通过 `setState` 触发视图(`render`方法)渲染。所以要使得 React 逻辑可以运行到小程序平台,可以加入一个运行时垫片,将两者逻辑写法通过垫片对应起来。再介绍运行时垫片具体实现前,还需要对上述 React 计数器代码进行简单的转换处理,处理完的代码如下: import React, { Component } from "../../npm/app.js"; // 1.app.js为垫片实现文件 export default class Index extends Component { static $$events = \["onAddClick", "onReduceClick"\]; // 2.收集JSX事件名称 constructor() { super(); this.state = { count: 0 }; this.onAddClick = this.onAddClick.bind(this); this.onReduceClick = this.onReduceClick.bind(this); } componentDidMount() { console.log('执行componentDidMount'); this.setState({ count: 1 }); } onAddClick() { this.setState({ count: this.state.count + 1 }); } onReduceClick() { this.setState({ count: this.state.count - 1 }); } createData() { // 3.render函数改为createData,删除 this.\_\_state = arguments\[0\]; // 原本的JSX代码,返回更新后的state // 提供给小程序进行setData const text = this.state.count % 2 === 0 ? '偶数' : '奇数'; Object.assign(this.\_\_state, { text: text }); return this.\_\_state; } } Page(require('../../npm/app.js').createPage(Index))。 // 4.使用运行时垫片提供的createPage // 方法进行初始化 // 方法进行初始化复制代码 如上代码,需要处理的地方有4处: - `Component`进行重写,重写逻辑在运行时垫片文件内实现,即`app.js`,实现具体逻辑后文会贴出。 - 将原本`JSX`的点击事件对应的回调方法名称进行收集,以便在运行时垫片在小程序平台进行事件注册。 - 因为原本`render`方法内`JSX`片段转换为`wxml`了,所以这里`render`方法可将`JSX`片段进行删除。另外因为`React`每次执行`setState`都会触发`render`方法,而`render`方法内会接受到最新的`state`数据来更新视图,因此这里产生的最新`state`正是需要提供给小程序的`setData`方法,从而触发小程序的数据渲染,为此将`render`名称重命名为`createData`(生产小程序的`data`数据),同时改写内部逻辑,将产生的最新`state`进行返回。 - 使用运行时垫片提供的`createPage`方法进行初始化(`createPage`方法实现具体逻辑后文会贴出),同时通过小程序平台提供的`Page`方法进行注册,从这里可得知`createPage`方法返回的数据肯定是一个`object`类型。 运行时垫片(app.js)实现逻辑如下: export class Component { // 重写Component的实现逻辑 constructor() { this.state = {} } setState(state) { // setState最终触发小程序的setData update(this.$scope.$component, state) } \_init(scope) { this.$scope = scope } } function update($component, state = {}) { $component.state = Object.assign($component.state, state) let data = $component.createData(state) // 执行createData获取最新的state data\['$leoCompReady'\] = true $component.state = data $component.$scope.setData(data) // 将state传递给setData进行更新 } export function createPage(ComponentClass) { // createPage实现逻辑 const componentInstance = new ComponentClass() // 实例化传入进来React的Class组件 const initData = componentInstance.state const option = { // 声明一个小程序逻辑的对象字面量 data: initData, onLoad() { this.$component = new ComponentClass() this.$component.\_init(this) update(this.$component, this.$component.state) }, onReady() { if (typeof this.$component.componentDidMount === 'function') { this.$component.componentDidMount() // 生命逻辑映射 } } } const events = ComponentClass\['$$events'\] // 获取React组件内所有事件回调方法名称 if (events) { events.forEach(eventHandlerName => { if (option\[eventHandlerName\]) return option\[eventHandlerName\] = function () { this.$component\[eventHandlerName\].call(this.$component) } }) } return option } 上文提到了重写`Component`类和`createPage`方法具体实现逻辑如上代码所示。 `Component`内声明的`state`会执行一个`update`方法,`update`方法里主要是将 React 产生的新`state`和旧`state`进行合并,然后通过上文说的`createData`方法获取到合并后的最新`state`,最新的`state`再传递给小程序进行`setData`,从而实现小程序数据渲染。 `createPage`方法逻辑首先是将 React 组件实例化,然后构建出一个小程序逻辑的对应字面量,并将 React 组件实例相关方法和这个小程序逻辑对象字面量进行绑定:其次进行生命周期绑定:在小程序`onReady`周期里出发 React 组件对应的`componentDidMount`生命周期;最好进行事件绑定:通过上文提到的回调事件名,取出React 组件实例内的对应的事件,并将这些事件注册到小程序逻辑的对应字面量内,这样就完成小程序平台事件绑定。最后将这个对象字面量返回,供前文所说的`Page`方法进行注册。 到此,就可以实现 React 代码运行到小程序平台了,可以在项目源码里执行 `npm run build:compile` 看看效果。编译时实现方案主要是通过静态编译`JSX`代码和运行时垫片结合,完成 React 代码运行到小程序平台,这种方案基本无性能上的损耗,且可以在运行时垫片做一些优化处理(比如去除不必要的渲染数据,减少setData数据量),因此其性能与使用小程序原生语法开发相近甚至某些场景会更优。然而这种方案的缺点就是语法限制问题(上文已经提过了),使得开发并不友好,因此也就有了运行时实现方案的诞生。 ### 运行时实现 从上文可以看出,编译时实现之所以有语法限制,主要因为其不是让 React 真正运行到小程序平台,而运行时实现方案则可以,其原理是在小程序平台实现一个 React 自定义渲染器,用来渲染 React 代码。这里我们以 remax 框架实现方式来进行讲解,本项目源码中的运行时实现也正是参照 remax 框架实现的。 如果使用过 React 开发过 Web,入口文件有一段类似这样的代码: import React from 'react' import ReactDom from 'react-dom' import App from './App' ReactDom.render( App, document.getElementById('root') ) 可以看出渲染 Web 页面需要引用一个叫 `react-dom` 模块,那这个模块作用是什么?`react-dom`是 Web 平台的渲染器,主要负责将 React 执行后的`Vitrual DOM`数据渲染到 Web 平台。同样的,React 要渲染到 Native,也有一个针对 Native 平台的渲染器:`React Native`。 React实现多平台方式,是在每个平台实现一个React渲染器,如下图所示。 ![](https://img.php.cn/upload/image/706/825/995/1640225876896223.png) 而如果要将 React 运行到小程序平台,只需要开发一个小程序自定义渲染器即可。React 官方提供了一个react-reconciler 包专门来实现自定义渲染器,官方提供了一个简单demo重写了`react-dom`。 使用`react-reconciler`实现渲染器主要有两步,第一步:实现渲染函数(`render`方法),类似`ReactDOM.render`方法: import ReactReconciler from 'react-reconciler' import hostConfig from './hostConfig' // 宿主配置 // 创建Reconciler实例, 并将HostConfig传递给Reconciler const ReactReconcilerInst = ReactReconciler(hostConfig) /\*\* \* 提供一个render方法,类似ReactDom.render方法 \* 与ReactDOM一样,接收三个参数 \* render(, container, () => console.log('rendered')) \*/ export function render(element, container, callback) { // 创建根容器 if (!container.\_rootContainer) { container.\_rootContainer = ReactReconcilerInst.createContainer(container, false); } // 更新根容器 return ReactReconcilerInst.updateContainer(element, container.\_rootContainer, null, callback); } 第二步,如上图引用的`import hostConfig from './hostConfig'` ,需要通过`react-reconciler`实现宿主配置(`HostConfig`),`HostConfig`是宿主环境提供一系列适配器方案和配置项,定义了如何创建节点实例、构建节点树、提交和更新等操作,完整列表可以点击查看。值得注意的是在小程序平台未提供`DOM API`操作,只能通过`setData`将数据传递给视图层。因此`Remax`重新定义了一个`VNode`类型的节点,让 React 在`reconciliation`过程中不是直接去改变`DOM`,而先更新`VNode`,`hostConfig`文件内容大致如下: interface VNode { id: number; // 节点 id,这是一个自增的唯一 id,用于标识节点。 container: Container; // 类似 ReactDOM.render(, document.getElementById('root') 中的第二个参数 children: VNode\[\]; // 子节点。 type: string | symbol; // 节点的类型,也就是小程序中的基础组件,如:view、text等等。 props?: any; // 节点的属性。 parent: VNode | null; // 父节点 text?: string; // 文本节点上的文字 appendChild(node: VNode): void; removeChild(node: VNode): void; insertBefore(newNode: VNode, referenceNode: VNode): void; ... } // 实现宿主配置 const hostConfig = { ... // reconciler提交后执行,触发容器更新数据(实际会触发小程序的setData) resetAfterCommit: (container) => { container.applyUpdate(); }, // 创建宿主组件实例,初始化VNode节点 createInstance(type, newProps, container) { const id = generate(); const node = new VNode({ ... }); return node; }, // 插入节点 appendChild(parent, child) { parent.appendChild(child); }, // insertBefore(parent, child, beforeChild) { parent.insertBefore(child, beforeChild); }, // 移除节点 removeChild(parent, child) { parent.removeChild(child); } ... }; 除了上面的配置内容,还需要提供一个容器用来将`VNode`数据格式化为`JSON`数据,供小程序`setData`传递给视图层,这个容器类实现如下: class Container { constructor(context) { this.root = new VNode({..}); // 根节点 } toJson(nodes ,data) { // 将VNode数据格式化JSON const json = data || \[\] nodes.forEach(node => { const nodeData = { type: node.type, props: node.props || {}, text: node.text, id: node.id, children: \[\] } if (node.children) { this.toJson(node.children, nodeData.children) } json.push(nodeData) }) return json } applyUpdate() { // 供HostConfig配置的resetAfterCommit方法执行 const root = this.toJson(\[this.root\])\[0\] console.log(root) this.context.setData({ root}); } ... } 紧接着,我们封装一个`createPageConfig`方法,用来执行渲染,其中`Page`参数为 React 组件,即上文计数器的组件。 import \* as React from 'react'; import Container from './container'; // 上文定义的Container import render from './render'; // 上文定义的render方法 export default function createPageConfig(component) { // component为React组件 const config = { // 小程序逻辑对象字面量,供Page方法注册 data: { root: { children: \[\], } }, onLoad() { this.container = new Container(this, 'root'); const pageElement = React.createElement(component, { page: this, }); this.element = render(pageElement, this.container); } }; return config; } 到这里,基本已经实现完小程序渲染器了,为了使代码跑起来,还需要通过静态编译改造下 React 计数器组件,其实就是在末尾插入一句代码: import React, { Component } from 'react'; export default class Index extends Component { constructor() { super(); this.state = { count: 0 }; this.onAddClick = this.onAddClick.bind(this); this.onReduceClick = this.onReduceClick.bind(this); } ... } // app.js封装了上述createPage方法 Page(require('../../npm/app.js').createPage(Index)) 通过这样,就可以使得React代码在小程序真正运行起来了,但是这里我们还有个流程没介绍,上述`Container`类的`applyUpdate`方法中生成的页面`JSON`数据要如何更新到视图?首先我们先来看下这个`JSON`数据长什么样子: // 篇幅问题,这里只贴部分数据 { "type": "root", "props": {}, "id": 0, "children": \[{ "type": "view", "props": { "class": "container" }, "id": 12, "children": \[{ "type": "view", "props": { "class": "conut" }, "id": 4, "children": \[{ "type": "text", "props": {}, "id": 3, "children": \[{ "type": "plain-text", "props": {}, "text": "count: ", "id": 1, "children": \[\] }, { "type": "plain-text", "props": {}, "text": "1", "id": 2, "children": \[\] }\] }\] } ... ... }\] } 可以看出`JSON`数据,其实是一棵类似`Tree UI`的数据,要将这些数据渲染出页面,可以使用小程序提供的`Temlate`进行渲染,由于小程序模板递归嵌套会有问题(微信小程序平台限制),因此需要提供多个同样组件类型的模板进行递归渲染,代码如下: