Skip to main content

为何我们要用 React 来写小程序 - Taro 诞生记

从去年微信小程序的诞生,到今年的逐渐火热,以及异军突起的轻应用、百度小程序等的出现,前端可以延伸的领域已经越来越广,当然也意味着业务在不断扩大。这时候,如何通过技术手段来提升开发效率,应对不断增长的业务,就是一个值得探索的话题。本文将对 Taro 诞生的故事,进行深入浅出地介绍,记录下这个忙碌的春夏之交发生的故事。

image

让人又爱又恨的微信小程序#

2017-1-9 微信小程序(以下简称小程序)诞生以来,就伴随着赞誉与争议不断。从发布上线时的不被大多数人看好,到如今的逐渐火热,甚至说是如日中天也不为过,小程序用时间与实践证明了自己的价值。同时于开发者来说,小程序的生态不断在完善,许多的坑已被踩平,虽然还是存在一些令人诟病的问题,但已经足见微信的诚意了。这个时候要是还没有上手把玩过小程序,就显得非常OUT了。

小程序对于前端程序员来说应该算得上是福音了,用前端相关的技术,获得丝般顺滑的 Native 体验,前端们又可以在产品小姐姐面前硬气一把了。可以说小程序给前端程序员打开了一扇新的大门,大家都应该感谢微信,但是从开发的角度来说,小程序的开发体验就非常值得商榷了,不仅语法上显得有些不伦不类,而且有些莫名其妙的坑也经常让人不经意间感叹一下和谐社会,从市面上层出不穷的小程序开发框架就可见一斑。以下就盘点部分小程序开发的痛点。

代码组织与语法#

在小程序中,一个页面 page 可能拥有 page.jspage.wxsspage.wxmlpage.json 四个文件

这样在开发的时候就需要来回进行文件切换,尤其是在同时开发模板和逻辑的时候,切来切去会显得尤其麻烦,影响开发效率,但小程序原生只支持这么写,就显得比较尴尬了。

而在语法上,小程序的语法可以说既像 React ,又像 Vue,不能说显得有点不伦不类吧,但在使用上总是感觉有些别扭,对于开发者来说,等于又要学习一套新的语法,提升了学习成本。而且,小程序的模板由于没有编辑器插件的支持,书写的时候也没有智能提示与 lint 检查,书写起来显得有些麻烦。

命名规范#

在小程序中到处可见规范不统一的情况

例如组件的属性,以最简单的 <button /> 组件为例,在小程序官方文档中,该组件的属性部分截图如下,大家可以感受下

<button /> 组件属性名既有以中划线分割多个单词的情况 session-form,也有多个单词连写的情况 bindgetphonenumber。当然这也不是最严重的,你可以说事件绑定的规范就是 bind + 事件名 ,而其他属性的规范就是中划线分割单词,我一度以为小程序就是这个作为标准,直到我看到了 <progress /> 组件

这和说好的不一样啊喂!

同样的情况也出现在 页面组件 的生命周期方法中,页面 的生命周期方法有 onLoadonReadyonUnload 等,但到了 组件 中则是 createdattachedready 等,这样规范又不统一了,为啥 页面 的生命周期方法是 on+Xxx 的格式,但到了 组件 里缺不一样了呢,有点费解。

开发方式#

小程序官方提供了 微信开发工具 作为开发编译工具,而对于代码本身没有提供一个类似 webpack 的工程化开发工具,来解决开发中的一些问题,所以小程序原生的开发方式显得不那么现代化,这也是很多小程序开发框架致力于解决的问题。例如,在小程序开发中

  • 不能使用 npm 管理依赖,在小程序中需要手动把第三方代码文件下载到本地,然后再 reuqire 进行使用,显得不那么优雅
  • 不能使用 Sass 等 CSS 预处理器,由于没有预编译的概念,小程序开发中无法使用市面上流行的 CSS 预处理器,这样会使得样式代码难以管理
  • 不完整的 ES Next 语法支持,小程序默认只能支持极少一部分 ES6 规范的语法,而 ES 是不断往前发展的,一些非常优秀的新语法特性就不能使用了
  • 手动的文件处理,像图片压缩、代码压缩等等的一些文件操作,必须手工来处理,显得有些繁琐

以上就是从开发者的角度看到的一些小程序的开发问题,不过纵然有千般困难,我们总要面对,作为新时代的前端开发工程师,我们不能一味忍受问题,要保持技术的头脑,以技术作为武器,用技术手段去提升的我们开发体验。

突发奇想:我能不能用React来写小程序#

目前前端界言及前端框架,必离不开依然保持着统治地位的 ReactVue,这两个都是非常优秀的前端 UI 框架,而且在网上也经常能看到两个框架的粉丝之间热情交流,碰撞出一些思想火花,显得社区异常活跃。

而我们团队也在去年勇敢地抛弃了历史包袱,非常荣幸地引入了 React 开发方式,让我们团队丢掉了煤油灯,开始通上了电。而且也研发出了一款优秀的类 React 框架 Nerv ,让我们和 React 开发思想结合得更深。

与小程序的开发方式相比,React 明显显得更加现代化、规范化,而且 React 天生组件化更适合我们的业务开发,JSX 也比字符串模板有更强的表现力。那么这时候我们就在思考,我们能不能用 React 来写小程序?

理性地探索#

类比#

通过对比体验 小程序和 React ,我们还是能发现两者之间相似的地方

生命周期#

小程序的生命周期和 React 的生命周期,在很大程度上是类似的,我们甚至能找到他们之间的对应关系

app 及页面的生命周期

小程序React
onLaunchcomponentWillMount
onLoadcomponentWillMount
onReadycomponentDidMount
onShow不支持,需要特殊处理
onHide不支持,需要特殊处理
onUnloadcomponentWillUnmount

可以看出,对于 app页面 来说,除了 onShowonHide 两个方法,其他方法都能在 React 中找到对应。

数据更新方式#

React 中,组件的内部数据是用 state 来进行管理的,而在小程序中组件的内部数据都是用 data 来进行管理,两者具有一定相似性。而同时在 React 中,我们更新数据使用的是 setState 方法,传入新的数据或者生成新数据的函数,从而更新相应视图。在小程序中,则对应的有 setData 方法,传入新的数据,从而更新视图。

两者都是以数据驱动视图的方式进行更新,而且 api 神似。

事件绑定#

小程序中绑定事件使用的是 bind + 事件名 的方式,例如点击事件,小程序中是 bindtap

<view bindtap="handlClick">1</view>

而在 React 里,则是 on + 事件名 的方式,例如点击事件, React web 中是 onClick

<View onClick={this.handlClick}>1</View>

虽然看上去不一样,但其实是可以类比的,我们只需要在编译时将 on + 事件名 的形式编译成 bind + 事件名 的形式就可以了。

如此看来,两者之间有些相似,用 React 来写小程序貌似是可行的,但接下来我们就发现了巨大的差异。

巨大的差异#

React 与小程序之间最大的差异就是他们的模板了,在 React 中,是使用 JSX 来作为组件的模板的,而小程序则与 Vue 一样,是使用字符串模板的。这样两者之间就有着巨大的差异了。

JSX

render () {
return (
<View className='index'>
{this.state.list.map((item, idx) => (
<View key={idx}>{item}</View>
))}
<Button onClick={this.goto}>走你</Button>
</View>
)
}

小程序模板

<view class="index">
<view wx:key={idx} wx:for="{{list}}" wx:for-item="item" wx:for-index="idx">{{item}}</view>
<view bindtap="goto">走你</view>
</view>

众所周知,JSX 其实本质上就是 JS,我们可以在里面写任意的逻辑代码,这样一来就比字符串模板的表现力与操作性要强多了,况且,小程序的字符串模板功能比较羸弱,只有一些比较基本的功能。那这样的话,要如何来实现用 JSX 来写小程序模板呢。

编译原理的力量#

我们可以仔细来分析我们的需求,我们期望使用 JSX 来书写小程序模板,但小程序显然是不支持执行 JSX 代码的(要是支持的话,Taro 应该也就不存在了吧),我们也不能期望微信能给我们开个后门来跑 JSX。那么这个时候我们就想,我们要是能够将 JSX 编译成小程序模板就好了。

事实上在我们平时的开发中,这种编译的操作到处可见,babel 就是我们最常用的 JS 代码编译器,一般浏览器是不能支持一些非常新的语法特性的,但我们又想使用它们,这个时候就可以借助 babel 来将我们的高版本的 ES 代码,编译成浏览器可以运行的 ES 代码。而我们像要将 JSX编译成小程序模板,也是同样的道理。我们首先来了解一下 Babel 的运行机制。

Babel 作为一个 代码编译器 ,能够将 ES6/7/8 的代码编译成 ES5 的代码,其核心利用的就是计算中非常基础的编译原理知识,将输入语言代码,通过编译器执行,输出目标语言的代码。编译原理的一般过程就是,输入源程序,经过词法分析、语法分析,构造出语法树,再经过语义分析,理解程序正确与否,再对语法树做出需要的操作与优化,最终生成目标代码。

Babel 的编译过程亦是如此,主要包含三个阶段

  • 解析过程,在这个过程中进行词法、语法分析,以及语义分析,生成符合 ESTree 标准 虚拟语法树(AST)
  • 转换过程,针对 AST 做出已定义好的操作,babel 的配置文件 .babelrc 中定义的 presetplugin 就是在这一步中执行并改变 AST 的
  • 生成过程,将前一步转换好的 AST 生成目标代码的字符串

为了更好地理解这些过程,大家可以利用 Ast Explorer 这个网站接一下自己的代码,感受一下每一部分代码所对应的 AST 结构。

可以看到,一份源码经过编译器解析后,会变成类似如下的结构

{
type: "Program",
start: 0,
end: 78,
loc: { start, end }
sourceType: "module",
body: [
{ type: "VariableDeclaration", ... },
{ type: "VariableDeclaration", ... },
{ type: "FunctionDeclaration", ... },
{ type: "ExpressionStatement", ... }
]
...
}

其中,body 里包含的就是我们示例代码的语法树结构,第一个 VariableDeclaration 对应的是 const a = 1,第三个 FunctionDeclaration 对应的则是 function sum (a, b) { },分别就是 JS 中的变量定义与函数定义,每一个树节点里都会包含许多子节点,这样就形成了一个树形结构,更多的节点类型,请参考 babel types

当然我们在这儿只是简单介绍下编译原理与 babel,编译原理是一门非常深奥的课程, babel 也是一个非常优秀的工具,希望在后续的文章中能和大家再详细探讨这一部分内容。

再次回到我们的需求,将 JSX 编译成小程序模板,非常幸运的是 babel 的核心编译器 babylon 是支持对 JSX 语法的解析的,我们可以直接利用它来帮我们构造 AST,而我们需要专注的核心就是如何对 AST 进行转换操作,得出我们需要的新 AST,再将新 AST 进行递归遍历,生成小程序的模板。

JSX 代码

<View className='index'>
<Button className='add_btn' onClick={this.props.add}>+</Button>
<Button className='dec_btn' onClick={this.props.dec}>-</Button>
<Button className='dec_btn' onClick={this.props.asyncAdd}>async</Button>
<View>{this.props.counter.num}</View>
<A />
<Button onClick={this.goto}>走你</Button>
<Image src={sd} />
</View>

编译生成小程序模板

<import src="../../components/A/A.wxml" />
<block>
<view class="index">
<button class="add_btn" bindtap="add">+</button>
<button class="dec_btn" bindtap="dec">-</button>
<button class="dec_btn" bindtap="asyncAdd">async</button>
<view>{{counter.num}}</view>
<template is="A" data="{{...$$A}}"></template>
<button bindtap="goto">走你</button>
<image src="{{sd}}" />
</view>
</block>

这时候,聪明的你应该就能发现问题的难点所在了,要知道小程序的模板只是字符串,而 JSX 则是真正的 JS 代码扩展,其语法之丰富,显然不是字符串模板所能比,在这一步中,我们要做的操作,包括但不仅限于如下

  • 理解三目运算符与逻辑表达式,例如三目运算符 abc ? : <View>1</View> : <View>2</View> 需要编译成 <view wx:if="{{abc}}">1</view><view wx:else>2</view>
  • 理解数组 map 语法,例如 map 的使用 abc.map(item => <View>item</View>) 需要编译成 <view wx:for="{{abc}}" wx:for-item="item">item</view>
  • 等等

以上仅仅是我们转换规则的冰山一角,JSX 的写法极其灵活多变,我们只能通过穷举的方式,将常用的、React 官方推荐的写法作为转换规则加以支持,而一些比较生僻的,或者是不那么推荐的写的写法则不做支持,转而以 eslint 插件的方式,提示用户进行修改。目前我们支持的 JSX 转换规则,大致能覆盖到 JSX 80% 的写法操作。

关于 JSX 转小程序模板这一部分,我们将在后续的技术原理分析系列文章中,详细为大家介绍。

还能不能干点别的#

经过我们一次次的探索,以及一波波猛如虎的操作,我们已经可以将类 React 代码转成小程序可以跑的代码了,也就是说我们已经可以正式以 React 的方式来写小程序的代码了。喜大普奔!但是我们激动之余,冷静下来继续思考,我们还能不能干点别的有意思的事情呢。

分析一下需求#

我们发现,在平常的工作中,我们业务通常有一些多端的需求,就是要求小程序要有,H5 要有,甚至 RN 也能有就最好了,我猜产品经理还看不上快应用,不然肯定要求我们快应用也上一套吧,反正你们不是经常号称代码优秀、高度可复用么。这个时候,你就会发现,差不多的界面和逻辑,你可能需要重复写上好几轮,这时候要是有个多端代码生成工具就好了,只写一份代码,可以多端运行。Write once, run anywhere,相信是所有工程师的梦想。

依然编译原理的力量#

这时候我们回忆一下前文的内容,将一份代码编译成多端代码,这不正是编译原理干的事么,我们可以输入一份源代码,针对不同的端设定好对应的转换规则,再一键转换出对应端的代码。而且由于我们已经遵循 React 语法了,那我们再转成 H5 端(使用 Nerv)与 RN 端(使用 React)也就有了天然的优势。

设计思路补完#

但是仔细思考我们又会发现,仅仅将代码按照对应语法规则转换过去后,还远远不够,因为不同端会有自己的原生组件,端能力 API 等等,代码直接转换过去后,可能不能直接执行。例如,小程序中普通的容器组件用的是 <view />,而在 H5 中则是 <div />;小程序中提供了丰富的端能力 API,例如网络请求、文件下载、数据缓存等,而在 H5 中对应功能的 API 则不一致。

所以,为了弥补不同端的差异,我们需要订制好一个统一的组件库标准,以及统一的 API 标准,在不同的端依靠它们的语法与能力去实现这个组件库与 API,同时还要为不同的端编写相应的运行时框架,负责初始化等等操作。通过以上这些操作,我们就能实现一份一键生成多端的需求了。在 Taro 最初的设计中,我们组件库与 API 的标准就是源自小程序的,因为我们觉得既然已经有定义好的组件库与 API 标准,那为啥不直接拿来使用呢,这样不仅省去了定制标准的冥思苦想,同时也省去了为小程序开发组件库与 API 的麻烦,只需要让其他端来向小程序靠齐就好。

可能有些人会有疑问,既然是为不同的端实现了对应的组件库与端能力 API (小程序除外,因为组件库和 API 的标准都是源自小程序),那么是怎么能够只写一份代码就够了呢?因为我们有编译的操作,在书写代码的时候,只需要引入标准组件库 @tarojs/components 与运行时框架 @tarojs/taro ,代码经过编译之后,会变成对应端所需要的库。

既然组件库以及端能力都是依靠不同的端做不同实现来抹平差异,那么同样的,如果我们想为 Taro 引入更多的功能支持的话,有时候也需要按照这个套路来。例如,为了提升开发便利性,我们为 Taro 加入了 Redux 支持,我们的做法就是,在小程序端,我们实现了 @tarojs/redux 这个库来作为小程序的 Redux 辅助库,并且以他作为基准库,它具有和 react-redux 一致的 API,在书写代码的时候,引用的都是 @tarojs/redux ,经过编译后,在 H5 端会替换成 nerv-reduxNervRedux 辅助库),在 RN 端会替换成 react-redux。这样就实现了 Redux 在 Taro 中的多端支持。

以上就是 Taro 的整体设计思路,里面还有很多细节没有展开去阐述,可能大家会觉得有些意犹未尽,后续我们将会产出一系列的文章来阐述 Taro 的技术细节,例如 《Taro 开发工具原理分析》、《Taro 代码编译的背后》、《深入浅出 JSX 转小程序模板》等等。

最后的最后#

Taro 从立项之初到现在已经差不多有了三个月左右的时间,从最初的激烈讨论方案,各种思想的碰撞,到方案逐渐成型,进入火热的开发迭代,再到现在的小程序端和 H5 端顺利支持,从而决定走向开源。这一路走来,收获颇丰,既有跟团队小伙伴一起创造的激动,也有无数个日夜加班的苦思。Taro 是凹凸实验室的诚意之作,我们也将会一直维护下去,希望 Taro 能越来越好,帮助更多人创造更多价值。

项目官网:https://taro.jd.com/

项目 GitHub:https://github.com/NervJS/taro

同时,有任何关于 Taro 希望沟通交流的,欢迎~