老板给的需求是「小程序、H5、App 都要有,人只有这几个」。这种时候多端框架就成了必选项。我们那阵子用 Taro 做了一个三端并行的项目,写一套 React-like 代码同时出微信小程序、H5 和 React Native 包。真跑起来才发现,「一套代码多端运行」这句话里,省掉的是重复的业务逻辑,省不掉的是对每个端的了解。样式在哪一端会失效、哪个 API 只有小程序有、RN 为什么点不动 View,这些都得自己踩。这篇把 Taro 的编译原理先讲清楚,再把当时记下来的坑和技巧归拢成几类,方便你上手前先有个心理预期。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- Vue-like、React-like 的代码凭什么能在小程序里跑起来
- 编译时处理和运行时适配各自负责什么,AST 在中间起什么作用
- Taro 的多端能力是怎么拼出来的,runtime、组件、redux、router 分别对应什么
- React-like 不等于 React,具体差在哪些地方
- 样式、端能力、UI 组件库这三类差异怎么填坑
- 条件编译、
process.env.TARO_ENV、多端同名文件这三个技巧怎么用 - React Native 端的启动流程和样式限制
- 2019 年的 Taro 1.x 和现在的版本差在哪,哪些结论已经过期
# 一、小程序开发框架解决的是什么问题
小程序有自己的一套 DSL,wxml 加 wxss 加 js,跟你熟悉的 React 或者 Vue 都不一样。业务方要的是同一套页面在多个端上跑,开发者想用的是自己顺手的语法,中间这道鸿沟就是多端框架存在的理由。

那么问题来了,Vue-like、React-like 的代码,凭什么能在小程序里运行?

# 1.1 基本原理
答案拆开只有两句话:
- 编译时处理,把你写的语法转译成小程序语法
- 运行时适配,管理生命周期、数据,处理事件等
这两件事缺一不可。光有编译,转出来的模板没有数据驱动和生命周期,就是一堆死页面;光有运行时,你写的 JSX 小程序根本不认。Taro 走的是编译加运行时的组合路线,重活压在编译期,运行期只留必要的适配层。
编译这一步的流程,就是编译原理课上那条链路:
源代码->词法/语法/语义分析->抽象语法树->转换->目标代码

中间那个「抽象语法树」是整套方案的支点。源码被解析成一棵结构化的树之后,改代码这件事就从「字符串替换」变成了「操作树节点」。把 JSX 里的 <View> 节点换成 <view>,把 onClick 属性换成 bindtap,都是在树上做增删改,改完再打印回目标语法。

这也解释了后面会讲到的一堆限制。既然是静态分析代码再改写,那你写得越动态,编译器就越猜不到你想干什么。小程序端不支持在 render() 之外定义 JSX、JSX 里必须用单引号这些规矩,根子都在这儿。
# 1.2 各家框架的多端支持情况
那几年多端框架扎堆出现,各自支持的端不一样,选型的时候这张对比得先看:

选型这事我的经验是,别只看支持的端多不多,要看你真正要发的那两三个端做得深不深。支持十个端但每个端都半残,不如支持三个端每个都能上线。
# 二、Taro 的原理
Taro 是一套多端统一开发框架,支持用 React 的开发方式编写一次代码,生成能运行在微信小程序、H5、React Native 等平台的应用。
它的整体架构分成编译和运行两条线,下面这几张图把这两条线拆开了:

编译这条线做的事,是把 React-like 的写法拆成小程序认识的两部分,模板结构进 wxml,状态和逻辑进 js。JSX 里的表达式、条件渲染、列表渲染都要在这一步翻译成 wx:if、wx:for 这类指令。
光有编译还不够,跑起来还需要每个端各自的一套底座。

所谓多端能力,落到实处就是两句:
- 提供相应端的
Runtime实现,具备适配API的能力 - 提供统一的基础组件,编译时替换为相应端的组件实现
前一句管 API。你调 Taro.request,在小程序端它转成 wx.request,在 H5 端它转成 fetch 或者 XMLHttpRequest,在 RN 端又是另一套。后一句管组件。你写 <View>,编译到小程序变 <view>,编译到 H5 变 <div>,编译到 RN 变 RN 的 View。
理解了这两句,后面所有的坑就都好解释了:只要某个端底下没有对应的实现,这层抽象就漏了。
# 三、Taro 项目中遇到的坑
# 3.1 那些真会绊到人的地方
1、React-like,不完全等同 React

这是最容易掉进去的一个。因为语法长得一模一样,你会下意识按 React 的习惯写,然后在编译期收到一条看不懂的报错。原因前面说过,Taro 1.x 靠静态分析改写代码,凡是它分析不出来的动态写法都会被拒绝。
2、端能力差异不可避免,某些功能在相应端上没有支持

框架能抹平的是写法,抹不平的是能力。小程序有的原生能力,浏览器里根本不存在;RN 有的原生模块,小程序里也调不到。碰到这类差异,只能自己在业务层做分支。
3、样式支持、写法上存在差异

样式是多端项目里返工最多的部分。H5 最灵活,小程序次之,RN 最弱。你按 H5 的习惯写一版,到 RN 上会发现选择器全被忽略了。
4、还没有真正支持多端的 UI 组件库能在 Taro 上使用

这条是 2019 年的现状,当时组件库生态确实很薄,多数只覆盖小程序端。后面会讲现在的情况。
5、React Native 的 view 不支持 click 事件,需要用 Touchable 组件

第一次遇到这个会很懵,代码没报错,样式也对,就是点不动。RN 里的 View 本来就不是可交互元素,要响应点击得用 Touchable 系列组件包一层。
6、React Native 不支持 text-overflow,而是提供原生支持
RN 的做法是给 Text 组件传入 numberOfLines={num} 属性来控制行数。问题在于 Taro 当时没有暴露原生 RN Text 组件的 numberOfLines 属性,所以通过 Text 基础组件没法实现多端统一的文本截断。
(原文这里的组件名被反引号截断成了两截,这里按 Text 改正了。)
H5 端的情况相对乐观,主要差异集中在两点:小程序的页面、组件样式都是独立的,H5 会受同名样式影响;小程序的组件会多出一层标签。

第一条尤其要当心。你在小程序上验证过没问题的样式,搬到 H5 上可能被别的页面的同名 class 覆盖掉,因为小程序天然是样式隔离的,H5 不是。这个差异不会报错,只会让你在某个页面上看到一个诡异的边距。
7、多端要求较高
用多端框架并不等于可以不懂各个端,实际要求反而更高:
- 对不同端的具体差异有所了解
- 样式实现较为苛刻,需兼顾多端、有所取舍
- 端能力差异可能需要自己填坑
8、多端填坑方式

我们当时摸出来的实践就四条:
- 对多端差异做好封装
- 采用 BEM 命名方式管理样式
- 采用全局样式维护基础组件
@tarojs/components的样式 - 基于
@tarojs/components自行封装组件库
(原文这两条里包名写的是 @taro/components,实际的包名是 @tarojs/components,这里改正了。)
前两条是重点。差异封装的意思是,别在业务组件里到处写 if (端 === 'weapp'),而是把这类判断收进一层工具函数或者一层适配组件,业务代码只调统一接口。BEM 则是为了兼容 RN 只支持类选择器这条硬限制,你不可能在 RN 上写嵌套选择器,那就干脆从命名上把层级表达出来。
# 3.2 记在本子上的注意事项
下面这些是当时一条条撞出来的,按类别归了一下:
命名和写法约束
- 函数需要
on+函数名来规范命名 - 在
Taro中,JS代码里必须书写单引号,特别是 JSX 中,如果出现双引号,可能会导致编译错误 - 小程序端不支持在
render()之外定义 JSX,比如在外面写renderForm() - Taro 当时还没有支持
React.Fragment语法
这几条都是编译期静态分析带来的约束,前面讲原理时提过。单引号那条最玄学,报错信息经常指不到真正的位置,排查了一下午才发现是某个属性里混了双引号。
组件与数据
- 子组件中接收的
props需要定义defaultProps,否则小程序端报错 - 状态更新一定是异步的,React 的状态更新不一定是异步
- 根据环境变量引入不同平台组件、编译到对应平台
- 页面布局拆分组件的方法,由上到下、由左到右拆分
defaultProps 那条是因为小程序的自定义组件要求属性有明确的类型和默认值,编译期需要据此生成 properties 声明,缺了它生成不出来。
样式与 RN 限制
css样式单位写PX大写会转成rem单位- 文字要包在
Text组件里面,否则不显示 position:fixed在React Native不支持Animation和transform在React Native动画不支持
环境相关
- 运行时报缺少包,需要在
.rn_temp目录里面安装
# 四、几个真正省事的技巧
坑说完了,说点好用的。Taro 处理多端差异的思路很统一,都是「同名不同后缀」,编译时按目标端挑文件。理解了这一条,下面三个技巧就是同一个套路的三种用法。
# 4.1 样式文件条件编译
假设目录中同时存在以下两个文件:
- index.scss
- index.rn.scss
当在 JS 文件中引用样式文件写 import './index.scss' 时,RN 平台会找到并引入 index.rn.scss,其他平台会引入 index.scss。
这招解决的是前面那条最难受的限制:RN 不支持组合选择器,也不支持一堆 CSS 属性。你不可能为了迁就 RN 把所有端的样式都写成扁平的类选择器,那 H5 端的表达力就废了一半。有了这个机制,公共样式留在 index.scss 里正常写,RN 那份单独降级,两边互不干扰。
# 4.2 内置环境变量 process.env.TARO_ENV
process.env.TARO_ENV 用于判断当前编译类型,当时有 weapp / swan / alipay / h5 / rn / tt 六个取值。可以通过这个变量来书写不同环境下的代码,编译时会将不属于当前编译类型的代码去掉,只保留当前编译类型下的代码。
关键在「编译时去掉」这几个字。这不是运行时的 if 判断,而是构建阶段就把另一个分支整段删掉。所以你在小程序包里不会带上 H5 的那份代码,包体积不会因为多端而膨胀。
比如想在微信小程序和 H5 端分别引用不同资源:
if (process.env.TARO_ENV === 'weapp') {
require('path/to/weapp/name')
} else if (process.env.TARO_ENV === 'h5') {
require('path/to/h5/name')
}
同时也可以在 JSX 中使用,用来决定不同端要加载的组件:
render () {
return (
<View>
{process.env.TARO_ENV === 'weapp' && <ScrollViewWeapp />}
{process.env.TARO_ENV === 'h5' && <ScrollViewH5 />}
</View>
)
}
这里有个坑要注意,判断必须写成能被静态分析出来的形式。把 process.env.TARO_ENV 先赋值给一个变量再判断,编译器就认不出来了,两个分支都会被打进包里。
新版本的 Taro 支持的端比这六个多,取值列表也扩充了,具体有哪些以你装的版本文档为准,别照抄这个列表。
# 4.3 统一接口的多端文件
这是同一套思路的完整版,适合封装差异比较大的组件和工具。