你是否使用过 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 ( <view classname="container"> <view classname="conut"> <text>count: {this.state.count}</text> </view> <view> <text classname="text">{text}</text> </view> <button onclick={this.onaddclick} classname="btn">+1</button> <button onclick={this.onreduceclick} classname="btn">-1</button> </view> ) }}
如果使用过 taro 或者 remax 等框架,对上述代码应该有似曾相识的感觉,上述代码正式模仿这类框架的 react dsl 写法。如果想迫切看到实现这个需求的效果,可点击此项目源码进行获取源码,然后根据提示运行项目,即可观察到如下效果:
到这里,就清楚了知道这个项目的需求以及最终实现结果是什么,接下来便是重点阐述从需求点到结果这个过程的具体实现。
实现方案构建小程序框架产物开发过小程序的同学都知道,小程序框架包含主体和页面,其中主体是由三个文件生组成的,且必须放在根目录,这三个文件分别是: 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
<view classname="xxx" /> ==> <view class="xxx" />
事件转换:如onclick修改为bindtap
<view onclick=xxx /> ==> <view bindtap =xxx />
循环转换:map语法修改为wx:for
list.map(i => <text>{i}</text>) => <text wx:for="{{list}}">{{item}}</text>
语法转换远不止上面这些类型,如果要保证开发者可以使用各种jsx语法开发小程序,就需要尽可能穷举出所有语法转换规则,否则很可能开发者用了一个写法就不支持转换。而事实是,有些写法(比如动态生成jsx片段等等)是根本无法支持转换,这也是前文为什么说编译时实现方案的缺点是语法有限制,开发者不能随意编码,需要受限于框架本身开发规则。
由于上述需要转换jsx代码语法相对简单,只需要涉及几种简单语法规则转换,这里直接贴出转换后的wxml结果如下,对应的实现代码位于:packages/compile-core/transform/parsetemplate.ts。
<view class="container"> <view class="conut"><text>count: {{count}}</text></view> <view> <text class="text">{{text}}</text> </view> <button bindtap="onaddclick" class="btn">+1</button> <button bindtap="onreduceclick" class="btn">-1</button></view>
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渲染器,如下图所示。
而如果要将 react 运行到小程序平台,只需要开发一个小程序自定义渲染器即可。react 官方提供了一个react-reconciler 包专门来实现自定义渲染器,官方提供了一个简单demo重写了react-dom。
使用react-reconciler实现渲染器主要有两步,第一步:实现渲染函数(render方法),类似reactdom.render方法:
import reactreconciler from 'react-reconciler'import hostconfig from './hostconfig' // 宿主配置// 创建reconciler实例, 并将hostconfig传递给reconcilerconst reactreconcilerinst = reactreconciler(hostconfig)/** * 提供一个render方法,类似reactdom.render方法 * 与reactdom一样,接收三个参数 * render(<mycomponent />, 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(<app />, 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'; // 上文定义的containerimport 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进行渲染,由于小程序模板递归嵌套会有问题(微信小程序平台限制),因此需要提供多个同样组件类型的模板进行递归渲染,代码如下:
<template is="tpl" data="{{root: root}}" /> <!-- root为上述的json数据 --><template name="tpl"> <block wx:for="{{root.children}}" wx:key="id"> <template is="tpl_1_container" data="{{i: item, a: ''}}" /> </block></template> <template name="tpl_1_view"> <view style="{{i.props.style}}" class="{{i.props.class}}" bindtap="{{i.props.bindtap}}" > <block wx:for="{{i.children}}" wx:key="id"> <template is="{{'tpl_' + (tid + 1) + '_container'}}" data="{{i: item, a: a, tid: tid + 1 }}" /> </block> </view></template> <template name="tpl_2_view"> <view style="{{i.props.style}}" class="{{i.props.class}}" bindtap="{{i.props.bindtap}}" > <block wx:for="{{i.children}}" wx:key="id"> <template is="{{'tpl_' + (tid + 1) + '_container'}}" data="{{i: item, a: a, tid: tid + 1 }}" /> </block> </view></template> <template name="tpl_3_view"> <view style="{{i.props.style}}" class="{{i.props.class}}" bindtap="{{i.props.bindtap}}" > <block wx:for="{{i.children}}" wx:key="id"> <template is="{{'tpl_' + (tid + 1) + '_container'}}" data="{{i: item, a: a, tid: tid + 1 }}" /> </block> </view></template> ......
至此,就可以真正实现 react 代码运行到小程序了,可以在项目源码里执行npm run build:runtime看看效果。运行时方案优点是无语法限制,(不信的话,可以在本项目里随便写各种动态写法试试哦),而缺点时性能比较差,主要原因是因为其setdata数据量比较大(上文已经贴出的json数据,妥妥的比编译时方案大),因此性能就比编译时方案差。当然了,业界针对运行时方案也有做大量的性能优化,比如局部更新、虚拟列表等,由于篇幅问题,这里就不一一讲解(代码中也没有实现)。
总结本文以最简实现方式讲述了 react 构建小程序两种实现方案,这两种方案优缺点分明,都有各自的优势,对于追求性能好场的场景,编译时方案更为合适。对于着重开发体验且对性能要求不高的场景,运行时方案为首选。如果想了解更多源码实现,可以去看下 taro、remax 官方源码,欢迎互相讨论。
项目地址https://github.com/canfoo/react-wxapp
【相关学习推荐:小程序开发教程】
以上就是react如何构建小程序?两种实现方案分享的详细内容。