大家好,我是来自杭州端点科技的前端工程师,我目前在产品研发部门负责跨端框架 Octopus(已适配 iOS、Android、H5、微信、支付宝、钉钉、京东、字节,其他小程序平台逐步适配中)和跨端组件库,该部门包含的产品有:Trantor(Low code 研发平台)、装修、跨端框架。如果你对我们的工作内容有浓厚的兴趣,请投简历至邮箱:yiqiang.jyq@alibaba-inc.com。
前言
在开始之前我们先来聊一聊关于微信小程序的发展历史:2016 年 1 月 11 日,微信之父张小龙进行了公开亮相;2016 年 9 月 21 日,微信小程序进入了正式开启内测阶段;2017 年 1 月 9 号微信小程序终于正式发布了第一个版本,我也因此成为了第一批接触和开发微信小程序的开发人员;2017 年 12 月 28 日微信小程序加入小游戏程;2018 年 7 月 13 日微信 App 为小程序添加单独的入口……
随着时间的推移,微信小程序逐步稳定,也已经取得巨大的成功,吸引了越来越多的厂商加入这个游戏,开始纷纷打造自己的小程序平台,且都参考了微信小程序的设计。也正式因为小程序的成功才有了今天的内容,否则当业务需要适配 Web、iOS、Android 的时候就要负出高昂的人力和时间成本,这时候只编写一套代码就能够适配到多端的能力就显得极为需要(write once, run anywhere)。
Tips:以下所有提到的小程序均指微信小程序。
为什么要选择 react?
为什么是 React 而不是 Vue,两方面原因吧:1、对 React 熟,学习成本更低;2、之前我们已经有基于 React Naitve 的三端(iOS、Android、Web)统一方案,且有很多相对成熟的配套工具/库。以上两点原因后者占到比重更大,那么选择 React
就成了一个必然的结果。
为什么不用现有的方案?
目前市面上已经有一些不错的、基于 React 思想开发的框架,如:TaroJS、Remax、RaxJS,而他们的方案基本都是基于 Web 的标准,现在我们把视线拉回到上面我说的两点原因,由此可以得出两种结果:1、选一个适合自己的现有方案,然后自己改;2、从零开始自己写一个。显然,我们选的是后者。
如何将 react 运行到小程序中?
对 React 稍有研究的小伙伴都知道 React 是有一个单独的渲染器,即:react-reconciler。有了这个线索,似乎事情开始变得简单了,只需要维护一下 VNode
的增删改再使用小程序的模板能力(下文着重讲一下,这里先不展开来讲)进行动态渲染貌似就有希望,因此我们可以画出大致的流程图,编译时的内容本篇不做讲解。
react-reconciler 渲染器
现在我们回到 react-reconciler 这个包,来看看大概是怎么用的,更多细节请翻阅官方文档和源码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| const HostConfig = { appendChild(parent: VNode, child: VNode) { parent.appendChild(child) }, appendInitialChild: (parent: VNode, child: VNode) => { parent.appendChild(child) }, insertBefore(parent: VNode, child: VNode, beforeChild: VNode) { parent.insertBefore(child, beforeChild) }, removeChild(parent: VNode, child: VNode) { parent.removeChild(child) }, };
|
剩下的问题就是如何把 VNode 转成 JSON 以及通过调用 setData 和调用模板进行页面的拼装完成渲染。这两个问题其实都不难。先来看第一个问题:如何把 VNode 转成 JSON?
VNode 转 JSON
我们需要为 VNode 写一个 toJSON
的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
|
function toRawNode(node: VNode): VNodeJson { if (node.type === TYPE_TEXT) { return { id: node.id, te: node.type, tt: node.text, }; }
return { te: node.type, cn: [], id: node.props.id ?? node.id, } }
class VNode extends PureNode {
toJSON = (): any => { return { ...toRawNode(this), cn: this.childs.map((item) => item.toJSON()), }; }
}
|
通过调用 VNode 的 toJSON
方法,我们将可以得到类似以下的数据结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| { root: { cn: [{ te: 'view', id: 't3', cn: [{ te: 'text', id: 't2', cn: [{ te: 'plain-text', id: 't1', tt: 'Hello Terminus.' }] }] }] } }
|
setData 和调用模板
我们先来了解这两个知识点:setData
和 模板
。setData 是小程序提供的 API
,也是逻辑层和视图层通信的桥梁。
模板
其主要用途用于定义代码片段,可以在不同的地方调用。声明一个模板:
1 2 3 4 5 6
| <template name="msgItem"> <view> <text> {{index}}: {{msg}} </text> <text> Time: {{time}} </text> </view> </template>
|
使用 is
属性,声明需要使用的模板,然后将模板所需要的 data 传入,如:
1
| <template is="msgItem" data="{{...item}}"/>
|
1 2 3 4 5 6 7 8 9
| Page({ data: { item: { index: 0, msg: 'this is a template', time: '2016-09-15' } } })
|
你可以把所有的模板都放到同一个文件中维护,在这里我们统一放到 base.wxml 文件中。
base.wxml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| <wxs src='./helper.wxs' module="helper" />
<template name="OCTOPUS_BASE_TEMPLATE"> <block wx:for="{{root.cn}}" wx:key="id"> <template is="OCTOPUS_1_CONTAINER" data="{{i: item, ancestor: ''}}" /> </block> </template>
<template name="oc_button"> <button id="{{helper.v(i['id'])}}" class="{{helper.v(i['class'],i['c1'])}}" bindtap="eh" style="{{helper.v(i['c2'])}}" size="{{helper.v(i['c48'])}}" type="{{helper.v(i['c47'])}}" plain="{{helper.v(i['c49'])}}" disabled="{{helper.v(i['c50'])}}" loading="{{helper.v(i['c51'])}}" form-type="{{helper.v(i['c52'])}}" open-type="{{helper.v(i['c53'])}}" hover-class="{{helper.v(i['c9'])}}" hover-stop-propagation="{{helper.v(i['c10'])}}" hover-start-time="{{helper.v(i['c6'])}}" hover-stay-time="{{helper.v(i['c7'])}}" lang="{{helper.v(i['lang'])}}" session-from="{{helper.v(i['sessionFrom'])}}" send-message-title="{{helper.v(i['sendMessageTitle'])}}" send-message-path="{{helper.v(i['sendMessagePath'])}}" send-message-img="{{helper.v(i['sendMessageImg'])}}" app-parameter="{{helper.v(i['appParameter'])}}" show-message-card="{{helper.v(i['showMessageCard'])}}" bindgetuserinfo="eh" bindcontact="eh" bindgetphonenumber="eh" binderror="eh" bindopensetting="eh" bindlaunchapp="eh" public-id="{{helper.v(i['publicId'])}}"> <block wx:for="{{i.cn}}" wx:key="id"> <template is="{{'OCTOPUS_' + (tid + 1) + '_CONTAINER'}}" data="{{i: item, ancestor: ancestor + ',' + i.typ, tid: tid + 1 }}" /> </block> </button> </template>
...
<template name="OCTOPUS_1_CONTAINER" data="{{i: i}}"> <template is="{{helper.tid(i.te, ancestor, i.id)}}" data="{{i: i, ancestor: ancestor + ',' + i.te, tid: 1 }}" /> </template>
<template name="OCTOPUS_2_CONTAINER" data="{{i: i}}"> <template is="{{helper.tid(i.te, ancestor, i.id)}}" data="{{i: i, ancestor: ancestor + ',' + i.te, tid: 2 }}" /> </template>
...
|
再来看调用 setData 和调用模板进行页面的拼装完成渲染这个过程。
前半部分:调用 setData 我们需要手动维护一下页面的实例,然后调用当前页面实例的 setData。
1 2 3 4 5 6 7 8 9
| ...
const start = new Date().getTime();
this.context.setData(payload, () => { if (process.env.NODE_ENV !== 'production') { console.log('Set data time:', new Date().getTime() - start, 'ms', payload); } });
|
后半部分:在页面的视图文件中调用对应的模板进行页面的拼装完成渲染。
1 2 3
| <import src="/base.wxml" />
<template is="OCTOPUS_BASE_TEMPLATE" data="{{root: root}}" />
|
流程示意图:
渲染结果:
示例
我们来看个例子:
1 2 3 4 5 6 7 8 9 10 11
| import React from 'react'; import { View, Text } from 'react-native'; import './index.module.less';
export default function basicComponents() { return ( <View style={{ height: '100%', alignItems: 'center', justifyContent: 'center' }}> <Text style={{ fontSize: 30 }}>Hello Terminus.</Text> </View> ); }
|
最后的运行效果:
模板代码展示
编译后的 JS 和当前页面实例展示
总结
到这里,我们已经详细地介绍了如何把 React 代码运行到小程序中:React reconciler 渲染器 + 单独维护 VNode,然后调用 VNode 的 toJSON 方法转成 JSON 对象,再调用当前页面实例的 setData 把 VNode tree 传到视图层,视图层根据 VNode tree 递归调用对应的模板进行渲染。
最后
打造一个好用的跨端框架是非常具有挑战性和创新的事情,其难度堪比古人上青天 ,但古人有云:路漫漫其修远兮,吾将上下而求索《屈原·离骚》。道路且长,行则将至《荀子·修身》。
非常感谢大家的阅读和支持,本篇的内容就到这里,后面有时间会再出几期关于跨端框架的内容,如果你有任何好的建议或疑问欢迎在评论区留言,我们下期见。