Taro多端开发实现原理与项目实战(一)

Taro 多端开发实现原理与项目实战

 

前端多端统一开发背景与趋势介绍

 

背景

「大前端」不仅会成为移动开发与 Web 前端的发展趋势,也会是未来的显示设备终端的开发技术趋势?

越来越多的业内人士对此表示肯定,因为终端碎片化和 Serverless 让这一切看起来更加可信。终端碎片化顾名思义就是指终端越来越多样,比如 Apple Watch 手表、智能 TV、VR 眼镜等等。这些终端就和智能手机一样,支持第三方应用的嵌入。Serverless 字面意思是无服务架构,实际就是指用新的架构去代替传统服务器。

当然,聊背景聊趋势,脱离了时间维度都是耍流氓。在 2018 年这个时间节点,前端界基本被 React、Angular、Vue 这三大框架统治,我们很少会在新项目中使用 jQuery 和 SeaJS 。而且来自 Facebook 公司的 React Native 项目可以让前端工程师在不了解原生开发的情况下,初步涉猎 Android、iOS 端的客户端开发。

在这样的大背景下,前端工程师不仅需要掌握 PC 端和 H5 相关的开发能力,还需要对 Android、iOS 等各种终端的开发具有一定的了解。

 

 

 

时间回到 2017 年,微信小程序横空出世。小程序为业务提供了一种新的展现形态。在拥有近似 Native APP 体验的同时,依托于微信便利的入口,小程序能够让用户很方便地访问我们的业务,大大降低了业务产品的传播成本。很显然,小程序带来的业务上的好处是不言而喻的。但对于前端开发者来说,这意味着开发者需要掌握的工具/框架又多了一套。

总体来看,我们认为前端框架、技术标准在不停演变,新的业务形态、用户终端也在不断涌现,这对于一个行业的发展来说或许是件好事;但对于开发者和团队来说,这意味着居高不下的学习和试错成本。伴随着支付宝小程序、快应用、百度智能小程序等终端的陆续入局,由于成本和效率的问题,业务产品也不太可能为每个终端都单独的进行开发工作。

随着团队不断深入小程序的开发,我们发现小程序的开发模式并没有融合目前主流的工程化开发思想,例如不完整的 ES.Next 语法,较为封闭的开发工具等等,这使得很多业界开发模式与工具不能在小程序开发中得到相应体现,像是从前端工业时代回退到了刀耕火种的年代。

深耕小程序开发一段时间后,我们认为,如果构建一个抹平小程序端开发差异的解决方案,让开发者可以使用熟悉的技术栈完成多端的开发,这对于开发者和团队将会是一件好事。怀揣着同样的愿望,业界已经出现了来自美团点评的基于 Vue 语法的 mpvue 框架,还有腾讯团队的 WePY。

 

 

 

由于我们团队的开发更偏向 React 技术栈,所以我们考虑基于 React 语法打造一套解决方案。团队中的科幻迷为这个解决方案起了个代号,叫 Alien(异形)。

在实际项目中,我们还面临着同时开发多端的需求。也就是说,同一个项目需要分别在小程序端,H5 端,甚至 React Native 端有相同的表现。重写一份意味着加倍的工作量,所以我们考虑让这套解决方案支持生成可用于多个终端的代码。在 Web 端的代码中使用团队自研的类 React 组件框架 Nerv 作为基础框架;CLI 工具的设计上,我们则借鉴了团队打包工具 Athena 的代码;仓库包管理方面,则选择了 Lerna。站在这些优秀项目的肩膀上,我们快速完成了多端转换功能的开发。最后,我们团队为这个方案起了个更响亮更好记的名字 —— Taro(泰罗奥特曼,宇宙警备队总教官)。

 

本小册的简介

 

学习一门技术的最好方法,就是在实践中使用它。

Taro 也是如此。所以这本小册主要介绍从 0 到 1 构建一个电商平台的实战过程。这更多的是将新技术付诸实践的一个试验品。它距离真正投入电商平台,难免会有些不严谨或是不优雅的地方。但通过这样一个从前端到后台的完整实践,我们可以经历 React 语法的学习过程,了解 Taro 的编码规范,品味在 React 中状态管理的艺术,领略多端适配的神秘魔法,还可以了解 Serverless 架构的一些应用。

下面,我们将从 React 语法开始进行介绍,如果你对 React、JSX 等概念不熟悉,可以细心品味;如果你已经是 React 的老手,也可以一目十行复习一下。希望这本小册对各位小白或是老手都能有所启发。

假如觉得有点复杂了,可以先从实战篇入手。当有了 Taro 的开发经验后再回来重读进阶篇,相信会有更大的收获。

本小册的内容将按入门篇、基础篇、进阶篇、实战篇、总结篇进行编排。本章作为入门篇,主要介绍了前端多端统一开发背景与趋势,以及说明了 Taro 的由来。

基础篇为第 2-7 章,主要是介绍了开发 Taro 开发电商平台所需要的 React 知识和小程序开发入门知识,Taro 的安装使用和开发说明及注意事项,最后我们实现一个简单的 Todo 项目,并通过它的升级版了解如何在 Taro 中整合 Redux。

进阶篇为第 8-15 章,本篇主要介绍 Taro 的技术原理与细节,首先是多端统一开发设计思想及架构,而后介绍 CLI 原理及不同端的运行机制、文件转换处理、组件库及 API 的设计与适配,最后介绍如何实现 JSX 转换微信小程序模板。

实战篇为第 16-26 章,这一部分我们将以一个电商平台为例,挑选出黄金购物流程来和大家一一讲解,其中会涉及到授权、商品列表页、商品详情页、购物车、结算页、以及小程序云的介绍与使用,最后介绍多端的打包与发布。

总结篇为第 27 章,总结 Taro,本电商平台项目以及本小册重要知识点。

 

React 核心语法初识

 

上篇文章提到 React 、 Angular 、 Vue 三大框架大行其道,前端开发也变得越来越规范化,工程化,我们常说的前端,也不仅仅局限于 H5 和网页端,而是延伸到了小程序、移动端、桌面端等。

在整个前端体系发展的过程中,React 语法体系起到了至关重要的作用,Taro 也正是基于 React 语法来进行开发。本章我们逐一介绍一下 React 语法体系。

注:目前 Taro 所支持的 React 语法版本是以 React15 为基础的,所以本篇文章说的生命周期, API 等都是基于 React15 的版本。而想了解更多关于 React16 版本的变更情况,可到官网查阅。

** JSX 语法 **

JSX 是 React 的核心组成部分,React 认为组件化才是正确的代码分离方式,它要比模板与组件逻辑分开的方式更好,所以就有了 JSX 语法。它把 HTML 模板直接嵌入到 JS 代码里面,这样就做到了模板和组件关联。JSX 允许在 JS 中直接使用 XML 标记的方式来声明界面,初次看起来,可能会有点糅杂的感觉,不太适应。但习惯了之后,也没有那么别扭,而且开发起来还蛮方便的。

举个 Taro 里的例子:

// 只看 render 函数,绝大多数情况下 render 函数里都会有 XML 标记
render () {
  return (
    <View className='index'>
      <View className='title'>{this.state.title}</View>
      <View className='content'>
        {this.state.list.map(item => {
          return (
            <View className='item'>{item}</View>
          )
        })}
        <Button className='add' onClick={this.add}>添加</Button>
      </View>
    </View>
  )
}

可以见到,render 函数返回了一些用括号包住的 XML 结构的界面描述,这其实就是该组件的界面描述。里面的写法和 HTML 并没有多大的差别。不同的地方主要是可以在里面进行事件绑定,表达变量,实现简单 JS 逻辑等,即在 JS 里写 HTML。而变量、简单 JS 逻辑都是需要用 {} 包裹起来。另外 HTML 的 class 属性因为是 Javascript 的保留字,所以需要写成 className

实际上,上述 JSX 代码,最终都会通过 JSX 编译器(现在主要都是 babel),转换为 JS 代码。例如:

// JSX 语法
const num = 1
const hello = <div className='test'>{num}</div>

// 编译后的 JS
const num = 1
const hello = React.createElement('div', {className:'test'}, num)

这里用的是 React 的例子(React.createElement 是 React 里的一个核心函数,用来生成虚拟 DOM ),Taro 中也会有类似的处理。简言之,JSX 其实是一种语法糖,最终会转化为生成虚拟 DOM 的 JS 代码。之所以有这种语法,我个人的理解是可以让你更自由,更开放,用 JS 的思维来处理界面与数据之间的关系,将界面抽象出来,不再受那些 HTML 的束缚。

然而,在 JSX 里使用 JS 是有限制的,只能使用一些表达式,不能定义变量,使用 if/else 等,你可以用提前定义变量;用三元表达式来达到同样的效果。

列表渲染,一般是用数组的 map 函数。正如上面的例子,把需要列表渲染的数据使用 map 函数,返回所需要的 JSX 代码。而在事件绑定上,使用 on + 事件名称,这点和我们理解的并无大异。

** React 组件 **

组件化,是 React 的另一大特点。顾名思义,组件化就是把整个页面分成大大小小的各个组件(component),然后像插入普通 HTML 标签一样,在页面中插入这个组件。组件之间可能互相关联,也可能互相独立,这取决于业务逻辑。这样做主要还是为了方便管理,把页面的维度降到组件的维度,维度越小,管理起来就越精确,同时,经过抽象之后,相同的组件还可以复用,提高了工作效率。

class Demo extends Component {
  // ...
  render () {
    return <View className='test'>{this.state.num}</View>
  }
}

这里需要注意,组件类的第一个字母必须大写,否则会报错,比如这里的 Demo 不能写成 demo。另外,组件类只能包含一个顶层标签,否则也会报错。

每个 React 组件都会有一个 render 函数,用于返回该组件的 JSX 片段,代表着该组件的界面结构。同时,也可以在 render 函数里返回另一个组件,即组件之间的相互嵌套,如下面所示,Demo 组件的 render 函数返回了一个 Nav 组件和一些 JSX 片段。

import Nav from './nav.js'
class Demo extends React.Component {
  // ...
  render () {
    return (
      <div className='test'>
        <Nav />
        <div className='bar'>{this.state.num}</div>
      </div>
    )
  }
}

** Props 与 State**

props 与 state 是 React 组件中最为重要的两个状态。

** props **

父组件传给子组件的数据,会挂载在子组件的 this.props 上,如:

// 子组件
class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}
// 父组件
class Demo extends React.Component {
  render () {
    return <Welcome name='aotu,taro!' />
  }
}
// 最终页面上会渲染出 <h1>Hello, aotu,taro!</h1>

可以见到,Demo 组件在调用 Welcome 组件时,传入了 name='aotu,taro!' 的 props,所以子组件里就能访问到 this.props.name 属性。比较重要的一点是,组件中如果收到了新的 props,就会重新执行一次 render 函数,也就是重新渲染一遍

上述只是关于 props 的最基础的用法,除此之外,还有 this.props.children、高阶组件等比较高级的用法,在这里就不一一叙述了。

** state **

state 与 props 不同,是属于组件自己内部的数据状态,一般在 constructor 构造函数里初始化定义 state,例如:

class Welcome extends React.Component {
  constructor(props) {
    super(props);
    this.state = {name: 'aotu,taro!'};
  }
  render() {
    return <h1>Hello, {this.state.name}</h1>;
  }
}

可以见到,在构造函数里初始化一个 state 对象,然后在 render 函数里渲染出来。

值得注意的是,当 state 需要变化时,是不允许随便更改的,需要调用 this.setState 来进行更改,否则视图没法进行更新。

// 错误
this.state.name = 'hey!hey!hey!'

// 正确
this.setState({name: 'hey!hey!hey!'})

这与 Vue 等一些可以在数据变更时自动响应视图变化的框架不同,React 的数据及视图变化是需要主动调用 setState 函数来触发的,具体原因与框架实现的原理有关,这里就不细说了。当调用 setState 函数时,最终组件会执行 render 函数,重新再渲染一遍。

所以一般来说,比较好的做法是只把跟组件内部视图有关联的数据,变量放在 state 里面,以此避免不必要的渲染。

** 组件的生命周期 **

组件的生命周期,指的是一个 React 组件从挂载,更新,销毁过程中会执行的生命钩子函数。

class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  componentWillMount() {}

  componentDidMount() {}

  componentWillUpdate(nextProps, nextState) {}

  componentWillReceiveProps(nextProps) {}  

  componentDidUpdate(prevProps, prevState) {}

  shouldComponentUpdate(nextProps, nextState) {}

  componentWillUnmount() {}

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

上述组件中基本把所有生命周期都列出来了,下面简单一一叙述下

  • constructor,顾名思义,组件的构造函数。一般会在这里进行 state 的初始化,事件的绑定等等

  • componentWillMount,是当组件在进行挂载操作前,执行的函数,一般紧跟着 constructor 函数后执行

  • componentDidMount,是当组件挂载在 dom 节点后执行。一般会在这里执行一些异步数据的拉取等动作

  • shouldComponentUpdate,返回 false 时,组件将不会进行更新,可用于渲染优化

  • componentWillReceiveProps,当组件收到新的 props 时会执行的函数,传入的参数就是 nextProps ,你可以在这里根据新的 props 来执行一些相关的操作,例如某些功能初始化等

  • componentWillUpdate,当组件在进行更新之前,会执行的函数

  • componentDidUpdate,当组件完成更新时,会执行的函数,传入两个参数是 prevProps 、prevState

  • componentWillUnmount,当组件准备销毁时执行。在这里一般可以执行一些回收的工作,例如 clearInterval(this.timer) 这种对定时器的回收操作

** 小结 **

本文从 JSX 语法React 组件Props 与 State生命周期这四个方面简单介绍了 React 语法体系的核心部分,整体而言都是一些比较基础的部分。而更详细,更多具体的开发技巧,随着小册的深入我们在后续涉及到的章节会继续介绍。

需要说明的是,由于微信小程序的限制,React 中某些写法和特性在 Taro 中还未能实现,后续将会逐渐完善。差异的部分可以在《基础篇 4:Taro 开发说明与注意事项》章节里查看。

** 参考资料 **

 

微信小程序开发入门与技术选型

 

微信小程序是以微信为运行环境的一种应用,其实质是 Hybrid 技术的应用,Hybrid App 即混合模式移动应用,因此与 H5 类似,但又比 H5 拥有很多原生的能力,例如调用位置信息和摄像头等。

小程序的开发方式与 H5 十分相似,用的也是 JavaScriptHTMLCSS 语言。

** 微信小程序开发入门 **

** 申请小程序账号 **

申请小程序账号需要一个未申请过公众号和小程序账号的邮箱,然后在小程序介绍页的底部点击 「前往注册」 按钮,前往注册页根据指引填写信息。

file

填完信息,点击「注册」进入邮箱激活页面(如下图),再去邮箱激活账号即可。

 

更详细的流程可以参考官方文档

** 安装开发者工具 **

微信开发者工具可以帮助开发者简单和高效地开发和调试微信小程序,集成了公众号网页调试和小程序调试两种开发模式。它可以实时查看页面和功能效果,还能在开发者工具中进行 Debug。它使用 NW.js (previously known as node-webkit) 编写,在调试时和 Chrome 几乎无差别,很容易上手。

前往开发者工具下载页面 ,根据自己的操作系统下载对应的安装包进行安装。

打开微信开发者工具,用微信扫码登录开发者工具,确认后会进入选择「小程序项目」或「公众号网友项目」的界面,这里我们选择「小程序项目」,出现如下界面:

 

在这个页面,项目目录选择 hello 文件夹(此文件夹为笔者新建的空白文件夹),然后在 AppId 填入刚才申请好的账号里的 AppId (在小程序后台:设置 - 开发设置 - 开发者 ID - AppID ),项目名称 笔者填的是 hello

 

点击「确定」后,出现一个示例项目的预览,点击顶部菜单「编译」就可以在微信开发者工具中预览你的第一个小程序。

 

 

** 代码构成 **

看一下我们生成的 hello 项目的目录:

├── pages                  页面目录
|   ├── index              首页
|   |   ├── index.js       首页js
|   |   ├── index.wxss     首页样式文件
|   |   └── index.wxml     首页模板文件
├── utils                  工具函数
|   ├── utils.js
├── app.js                 app入口文件
├── app.json               app配置文件
├── app.wxss               app样式文件
└── project.config.json    项目配置文件

如上面的目录,一个小程序必须要有一个 app.js 入口文件,app.json 配置文件。除此之外,还有一个叫 project.config.json 的工具配置文件,是方便你在不同的电脑上开发时,开发者工具能拥有相同的设置。

每个页面上,同样会有 page.json 、page.js 、 page.wxml 、 page.wxss 这四种文件。分别是页面配置,页面逻辑,页面模板和页面样式文件。除去页面配置文件 page.json, 后三者和我们 HTML 、 JavaScript 、 CSS 三剑客十分相像,只不过换了个后缀而已。

WXML 模板文件里面,提供了数据绑定列表渲染条件渲染模板事件引用等功能,有点类似Vue的语法。

WXSS 样式文件和 CSS 别无大异,能支持绝大多数的 CSS 、 CSS3 的语法。除此之外,还支持样式引入,单位转换的功能,小程序在 WXSS 上做了一些扩充和修改,新增了 rpx 尺寸单位,不需要再人工地使用类似rem的适配方案来适配移动端的各种机型,给开发者提供了便利。

** app.json **

这个文件是当前小程序的全局配置,包括了小程序的所有页面路径、界面表现、网络超时时间、底部 Tab 等。

// app.json
{
  "pages":[
    "pages/index/index"
  ],
  "window":{
    "backgroundTextStyle":"light",
    "navigationBarBackgroundColor": "#fff",
    "navigationBarTitleText": "WeChat",
    "navigationBarTextStyle":"black"
  }
}

所有的页面都需要在app.jsonpages里面增加入口,这样才能让页面被加载。

** index.js **

//page/index/index.js
//获取应用实例
const app = getApp()

Page({
  data: {
    motto: 'Hello World',
    userInfo: {},
    hasUserInfo: false,
    canIUse: wx.canIUse('button.open-type.getUserInfo')
  },
  //事件处理函数
  bindViewTap: function() {
    wx.navigateTo({
      url: '../logs/logs'
    })
  },
  onLoad: function () {
  // ...
  },
  getUserInfo: function(e) {
    // ...
  }
})

可以见到,页面中有一个 Page 包裹着一个对象,页面的 data、一些生命周期、一些方法,都挂载在该对象上。而小程序正是通过这样的方式进行初始化的。

** index.wxml **

<!--index.wxml-->
<view class="container">
  <view class="userinfo">
    <button wx:if="{{!hasUserInfo && canIUse}}" open-type="getUserInfo" bindgetuserinfo="getUserInfo"> 获取头像昵称 </button>
    <block wx:else>
      <image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
      <text class="userinfo-nickname">{{userInfo.nickName}}</text>
    </block>
  </view>
  <view class="usermotto">
    <text class="user-motto">{{motto}}</text>
  </view>
</view>

在 WXML 模板中,渲染了一些在 index.js 里定义的页面变量,绑定了一个事件 bindtap="bindViewTap",还有一些条件判断等等。

** index.wxss **

/** index.wxss **/
.userinfo {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.userinfo-avatar {
  width: 128rpx;
  height: 128rpx;
  margin: 20rpx;
  border-radius: 50%;
}

.userinfo-nickname {
  color: #aaa;
}

.usermotto {
  margin-top: 200px;
}

我们可以看到 WXSS 里的内容和 CSS 没有什么差异,这个小程序的示例里没有使用 rpx 尺寸单位而是直接使用 px ,大概是小程序官方的疏忽吧。你在开发的时,记得使用小程序的新单位 rpx

** 生命周期 **

小程序的生命周期分为页面的生命周期整个应用的生命周期

应用的生命周期主要有onLaunchonShowonHide

  • onLaunch 是当小程序初始化完成时,会触发 onLaunch(全局只触发一次);
  • onShow 是当小程序启动,或从后台进入前台显示,会触发 onShow;
  • onHide 是当小程序从前台进入后台,会触发 onHide;

页面的生命周期会比较多一些,有onLoadonReadyonShowonHideonUnload

  • onLoad 是监听页面加载的函数
  • onReady 是监听页面初次渲染完成的函数
  • onShow 是监听页面显示的函数
  • onHide 是监听页面隐藏的函数
  • onUnload 是监听页面卸载的函数

整体来说,原生的小程序开发与 Vue (指它的 template 指令风格) 的语法比较类似,但其实开发起来还是会有所差异。譬如莫名其妙的 Page 、没有明确的路径引用、挂载了一大堆东西的初始化对象等等,这些有点反直觉;加之小程序缺乏 CSS 的工业化抽象(PostCSS,Sass, Less),NPM 支持(这个小程序已经准备要支持了),ES6/ES7 语法糖,TypeScript 的强类型约束,Vue/React 的组件开发,Vuex/Redux 的状态管理,在已经进入 “工业化”的前端时代里,让广大开发者难以忍受,于是各种转换小程序的方案如雨后春笋般涌现:mpvue、 MINA、Wept、WePY、 mpvue- wxParse、ANU、 Taro,这些工具框架各具特色,拥有自己的支持者。

** 流行的小程序开发框架 **

目前比较流行的小程序开发框架主要有 WePYmpvueTaro,我们简单对比下。

  • WePY 应该是比较早的小程序开发框架了,而且也是腾讯内部开源的一款框架。它主要解决了小程序开发较为松散,不能用 NPM 包,自定义组件开发不友好等问题。相比于原生的开发方式,已经是大大地增强了开发体验,提高了开发效率。
  • mpvue 是美团点评技术团队开源的一款小程序开发框架,相较于 WePY,mpvue 则是完全用 Vue 的开发方式来开发小程序,开发体验较 WePY 相比有了进一步的提升。
  • Taro 则是我们京东凹凸实验室团队开源的一款小程序开发框架,与 mpvue 相反,Taro 用的是 React 的开发方式来开发小程序,可以说又是另一个派别了。

具体看下面表格:

 原生开发WePYmpvueTaro
开发方式JS,JSON,WXML,WXSS类 Vue 开发,wpy 文件Vue 开发方式React 开发方式
是否支持 NPM 包非常规支持支持支持支持
ES6+ 特性支持开发者工具支持支持支持支持
CSS 预编译器支持不支持支持支持支持
状态管理ReduxVuexRedux
生命周期小程序生命周期小程序生命周期Vue 的生命周期React 的生命周期
流行程度-14.9k个 Star14.1k个 Star10.5k个 Star

更具体的区别与对比要深入使用过才有发言权,在这里就不多说了,这里只是给大家一个大概的介绍,以便对当前小程序流行的开发方式有一定的了解。

** 技术选型 **

讲了那么多关于小程序的开发方式,我们应该如何选型?

首先,还是不太推荐小程序原生的开发方式。2018 年了,它还缺乏很多的现代前端开发所需的东西,例如 NPM 包,CSS 预编译,状态管理等,如果要开发大型项目,自然会需要做非常多的适配方法,简言之就是原生方式还是比较费时费力。不过据说新的版本会加入 NPM 包支持等新功能,在这里就小小期待一下吧。

** WePY **

假如你需要开发中小型的小程序,同时也想体验原生小程序的诸多语法和特性,WePY 是你很好的选择。它保留了小程序诸多的语法特性,例如模板绑定、生命周期、API 调用等,同时在小程序原生开发的基础上,优化整合了现代前端开发利器,很好地提升了开发体验。而且由于开源的时间很早,网上也有了很多关于此框架 Bug 解决方法的文章,也不怕会遇到什么困难而影响后续开发。

缺点可能就是有一定的学习成本,需要适应。

** mpvue **

假如你是 Vue 开发者,同时也不想做太多的折腾,继续沿用 Vue 的开发方式来开发小程序,那么用 mpvue 将会是你的最明智决定。除了有一些因为环境原因不能在小程序中使用的特性之外,几乎就和用 Vue 差不多,可以说真的是入门只需 5分钟。同时也支持一些第三方 UI 库,这给一些需要讲求速度的小程序开发者提供了便利。

** Taro **

在上面描述 mpvue 的话语中,把 Vue 替换成 React ,就是对应 Taro 的描述了。作为一款新兴的框架(6月开源),第一款用 React 语法写小程序的框架,Taro 一直在不断的迭代和快速的成长,还给开发者提供了 Taro-UI 。对于 React 爱好者,Taro 将是你开发小程序的不二选择。

目前 Taro 1.0.0 的正式版已经发布,在稳定性和可用性上都有了很大的提高;除此之外,Taro 的多端转换功能也是其特色之一,可以将一套代码转换到小程序、H5 和 React Native 三端中使用,之后还有快应用等多端的支持计划。

Taro 支持主流的 React 开发方式,但由于实现原理,小程序限制等诸多原因导致它对一些较为特殊,不太常用的写法还不能 100% 支持,好在官网为此提供了最佳实践的文档。

** 小结 **

本章我们介绍了微信小程序开发入门,其中包括小程序账号的申请,微信开发者工具的简单使用以及新建了示例小程序;然后我们了解了小程序项目里都有哪些文件以及功能作用;最后我们对比了几个主流的开发框架。

总的来说,开发方式并没有什么优劣之分,适合自己的才是最好的。技术最终还是为业务服务,技术选型也是对症下药的过程,上面所说的几种框架各有优劣,哪种更适合团队,更能提高开发效率,自然就选择哪一种框架。

 

多端统一开发框架 Taro 的安装与使用

** 安装 **

** Node 环境 **

Taro 是一个基于 NodeJS 的多端统一开发框架,在安装使用 Taro 之前需要确保已安装好 Node 环境。

你可以直接从 NodeJS 官网下载 NodeJS 安装包来安装 Node 环境,但我们更推荐使用 Node 版本管理工具 nvm 来安装管理 Node,这样不仅可以在不同版本的 Node 之间自由切换,而且在全局安装的时候也不再需要加 sudo 了。

** NPM 与 Yarn **

安装好 Node 之后你就可以直接使用 NPM 来安装 Taro 的开发工具了,当然你还有一个选择就是使用 Yarn,Yarn 是由 Facebook、Google、Exponent 和 Tilde 联合推出了一个新 Node 包管理工具,相比于 NPM,它具有速度更快安装版本统一更加清爽简洁等特点。

你可以从 Yarn 官网获得相关的安装信息。

查看版本号测试是否已经成功安装:

file

** taro-cli **

安装好 NPM 或 Yarn 后,就可以全局安装 Taro 开发工具 @tarojs/cli 了。

如果你是使用 NPM 安装,使用如下命令:

$ npm install -g @tarojs/cli

如果你是使用 Yarn 安装,使用如下命令:

$ yarn global add @tarojs/cli

安装完毕,测试一下是否安装成功:

file

如你所见,版本号打印出来,说明已经安装好了。

** 使用 **

到目前我们已经完成了 Taro 的安装,下面我们看看怎么使用。我们将建一个叫做 myApp 的项目。

使用命令创建模板项目:

$ taro init myApp

NPM 5.2+ 也可在不全局安装的情况下使用 npx 创建模板项目:

$ npx @tarojs/cli init myApp

命令行上会提示 Taro 即将创建一个新项目!,接着 Taro 会提示你输入项目介绍,我们这里输入 我的第一个 Taro 项目;而后让你选择是否使用 TypeScript,笔者不使用 TypeScript ,输入 n ; 接着提供 CSS 预处理器选择,有 Sass、Less、Seyless,笔者选择了 Sass;然后是选择模板,笔者选择默认模版,完成后,Taro 开始创建项目,自动安装依赖,这里可能需要等待一会。

过程如下图:

file

看到提示 请进入项目目录 myApp 开始工作吧 后,进入项目目录开始开发,目前 Taro 已经支持微信/百度/支付宝小程序、H5 以及 ReactNative 等端的代码转换,针对不同端的启动以及预览、打包方式并不一致。

** 微信小程序 **

选择微信小程序模式,需要自行下载并打开微信开发者工具,然后选择项目根目录进行预览。具体安装的方法和使用请参考第 3 章《微信小程序开发入门与技术选型》里的 「微信小程序开发入门」。

微信小程序编译预览及打包:

# npm script
$ npm run dev:weapp
$ npm run build:weapp
# 仅限全局安装
$ taro build --type weapp --watch
$ taro build --type weapp
# npx 用户也可以使用
$ npx taro build --type weapp --watch
$ npx taro build --type weapp

** 百度小程序 **

选择百度小程序模式,需要自行下载并打开百度开发者工具,然后在项目编译完后选择项目根目录下 dist 目录进行预览。

百度小程序编译预览及打包:

# npm script
$ npm run dev:swan
$ npm run build:swan
# 仅限全局安装
$ taro build --type swan --watch
$ taro build --type swan
# npx 用户也可以使用
$ npx taro build --type swan --watch
$ npx taro build --type swan

** 支付宝小程序 **

选择支付宝小程序模式,需要自行下载并打开支付宝小程序开发者工具,然后在项目编译完后选择项目根目录下 dist 目录进行预览。

支付宝小程序编译预览及打包:

# npm script
$ npm run dev:alipay
$ npm run build:alipay
# 仅限全局安装
$ taro build --type alipay --watch
$ taro build --type alipay
# npx 用户也可以使用
$ npx taro build --type alipay --watch
$ npx taro build --type alipay

** H5 **

H5 模式,无需特定的开发者工具,在执行完下述命令之后即可通过浏览器进行预览。

H5 编译预览及打包:

# npm script
$ npm run dev:h5
# 仅限全局安装
$ taro build --type h5 --watch
# npx 用户也可以使用
$ npx taro build --type h5 --watch

** React Native **

React Native 端运行需执行如下命令,React Native 端相关的运行说明请参见 React Native 教程

# npm script
$ npm run dev:rn
# 仅限全局安装
$ taro build --type rn --watch
# npx 用户也可以使用
$ npx taro build --type rn --watch

** 更新 Taro **

Taro 提供了更新命令来更新 CLI 工具自身和项目中 Taro 相关的依赖。

更新 taro-cli 工具:

# taro
$ taro update self
# npm 
npm i -g @tarojs/cli@latest 
# yarn 
yarn global add @tarojs/cli@latest

更新项目中 Taro 相关的依赖,这个需要在你的项目下执行。

$ taro update project

 

Taro 开发说明与注意事项

在成功安装 Taro 后准备开发前,我们有必要了解一下 Taro 的一些注意事项避免踩坑。

** 微信小程序开发工具的配置 **

若使用微信小程序预览模式 ,则需下载并使用微信开发者工具(申请账号和开发者工具安装及建项目的方法可以参考前面第 3 章《微信小程序开发入门与技术选型》的开发入门内容),添加项目进行预览,添加的路径为项目根目录下的 dist 文件夹。此外,由于 Taro 编译后的代码已经经过了转义和压缩,因此还需要注意微信开发者工具的项目设置:

  • 设置关闭 ES6 转 ES5 功能
  • 设置关闭上传代码时样式自动补全
  • 设置关闭代码压缩上传

即在设置-项目设置里面,建议将设置配置如下:

** Taro 与 React 的差异 **

由于微信小程序的限制,React 中某些写法和特性在 Taro 中还未能实现,后续将会逐渐完善。 截止到本小册发布前,Taro 的最新版本为 1.1,因此以下讲解默认版本为 1.1。

** 暂不支持在 render() 之外的方法定义 JSX **

由于微信小程序的 template 不能动态传值和传入函数,Taro 暂时也没办法支持在类方法中定义 JSX。

** 无效情况 **

class App extends Component {
  _render() {
    return <View />
  }
}

class App extends Component {
  renderHeader(showHeader) {
    return showHeader && <Header />
  }
}

class App extends Component {
  renderHeader = (showHeader) => {
    return showHeader& & <Header />
  }
}

** 解决方案 **

在 render 方法中定义。

class App extends Component {

  render () {
    const { showHeader, showMain } = this.state
    const header = showHeader && <Header />
    const main = showMain && <Main />
    return (
      <View>
        {header}
        {main}
      </View>
    )
  }
}

** 不能在包含 JSX 元素的 map 循环中使用 if 表达式 **

** 无效情况 **

numbers.map((number) => {
  let element = null
  const isOdd = number % 2
  if (isOdd) {
    element = <Custom />
  }
  return element
})

numbers.map((number) => {
  let isOdd = false
  if (number % 2) {
    isOdd = true
  }
  return isOdd && <Custom />
})

** 解决方案 **

尽量在 map 循环中使用条件表达式或逻辑表达式。

numbers.map((number) => {
  const isOdd = number % 2
  return isOdd ? <Custom /> : null
})

numbers.map((number) => {
  const isOdd = number % 2
  return isOdd && <Custom />
})

** 不能使用 Array.map 之外的方法操作 JSX 数组 **

Taro 在小程序端实际上把 JSX 转换成了字符串模板,而一个原生 JSX 表达式实际上是一个 React/Nerv 元素(react - element)的构造器,因此在原生 JSX 中你可以对任何一组 React 元素进行操作。但在 Taro 中你只能使用 map 方法,Taro 转换成小程序中 wx:for

** 无效情况 **

test.push(<View />)

numbers.forEach(numbers => {
  if (someCase) {
    a = <View />
  }
})

test.shift(<View />)

components.find(component => {
  return component === <View />
})

components.some(component => component.constructor.__proto__ === <View />.constructor)

numbers.filter(Boolean).map((number) => {
  const element = <View />
  return <View />
})

** 解决方案 **

先处理好需要遍历的数组,然后再用处理好的数组调用 map 方法。

numbers.filter(isOdd).map((number) => <View />)

for (let index = 0; index < array.length; index++) {
  // do you thing with array
}

const element = array.map(item => {
  return <View />
})

** 不能在 JSX 参数中使用匿名函数 **

** 无效情况 **

<View onClick={() => this.handleClick()} />

<View onClick={(e) => this.handleClick(e)} />

<View onClick={() => ({})} />

<View onClick={function () {}} />

<View onClick={function (e) {this.handleClick(e)}} />

** 解决方案 **

使用 bind 或 类参数绑定函数。

<View onClick={this.props.hanldeClick.bind(this)} />

** 不能在 JSX 参数中使用对象展开符 **

微信小程序组件要求每一个传入组件的参数都必须预先设定好,而对象展开符则是动态传入不固定数量的参数。所以 Taro 没有办法支持该功能。

** 无效情况 **

<View {...this.props} />

<View {...props} />

<Custom {...props} />

** 解决方案 **

开发者自行赋值:

render () {
    const { id, title } = obj
    return <View id={id} title={title} />
}

** 不允许在 JSX 参数(props)中传入 JSX 元素 **

由于微信小程序内置的组件化的系统不能通过属性(props) 传函数,而 props 传递函数可以说是 React 体系的根基之一,我们只能自己实现一套组件化系统。而自制的组件化系统不能使用内置组件化的 slot 功能。两权相害取其轻,我们暂时只能不支持该功能。

** 无效情况 **

<Custom child={<View />} />

<Custom child={() => <View />} />

<Custom child={function () { <View /> }} />

<Custom child={ary.map(a => <View />)} />

** 解决方案 **

通过 props 传值在 JSX 模板中预先判定显示内容,或通过 props.children 来嵌套子组件。

** 不支持无状态组件(Stateless Component) **

由于微信的 template 能力有限,不支持动态传值和函数,Taro 暂时只支持一个文件只定义一个组件。为了避免开发者疑惑,暂时不支持定义 Stateless Component。

** 无效情况 **

function Test () {
  return <View />
}

function Test (ary) {
  return ary.map(() => <View />)
}

const Test = () => {
  return <View />
}

const Test = function () {
  return <View />
}

** 解决方案 **

使用 class 定义组件。

class App extends Component {
  render () {
    return (
      <View />
    )
  }
}

** 命名规范 **

Taro 函数命名使用驼峰命名法,如onClick,由于微信小程序的 WXML 不支持传递函数,函数名编译后会以字符串的形式绑定在 WXML 上,囿于 WXML 的限制,函数名有三项限制:

  • 方法名不能含有数字
  • 方法名不能以下划线开头或结尾
  • 方法名的长度不能大于 20

请遵守以上规则,否则编译后的代码在微信小程序中会报以下错误:

file

** 推荐安装 ESLint 编辑器插件 **

Taro 有些写法跟 React 有些差异,可以通过安装 ESLint 相关的编辑器插件来获得人性化的提示。由于不同编辑器安装的插件有所不同,具体安装方法请自行搜索,这里不再赘述。 如下图,就是安装插件后获得的提示:

file

file

** 最佳编码方式 **

经过较长时间的探索与验证,目前 Taro 在微信小程序端是采用依托于小程序原生自定义组件系统来设计实现 Taro 组件化的,所以目前小程序端的组件化会受到小程序原生组件系统的限制,而同时为了实现以 React 方式编写代码的目标,Taro 本身做了一些编译时以及运行时的处理,这样也带来了一些值得注意的约束,所以有必要阐述一下 Taro 编码上的最佳实践。

** 组件样式说明 **

微信小程序的自定义组件样式默认是不能受外部样式影响的,例如在页面中引用了一个自定义组件,在页面样式中直接写自定义组件元素的样式是无法生效的。这一点,在 Taro 中也是一样,而这也是与大家认知的传统 Web 开发不太一样。

** 给组件设置 defaultProps **

在微信小程序端的自定义组件中,只有在 properties 中指定的属性,才能从父组件传入并接收

Component({
  properties: {
    myProperty: { // 属性名
      type: String, // 类型(必填),目前接受的类型包括:String, Number, Boolean, Object, Array, null(表示任意类型)
      value: '', // 属性初始值(可选),如果未指定则会根据类型选择一个
      observer: function (newVal, oldVal, changedPath) {
         // 属性被改变时执行的函数(可选),也可以写成在 methods 段中定义的方法名字符串, 如:'_propertyChange'
         // 通常 newVal 就是新设置的数据, oldVal 是旧数据
      }
    },
    myProperty2: String // 简化的定义方式
  }
  ...
})

而在 Taro 中,对于在组件代码中使用到的来自 props 的属性,会在编译时被识别并加入到编译后的 properties 中,暂时支持到了以下写法

this.props.property

const { property } = this.props

const property = this.props.property

但是一千个人心中有一千个哈姆雷特,不同人的代码写法肯定也不尽相同,所以 Taro 的编译肯定不能覆盖到所有的写法,而同时可能会有某一属性没有使用而是直接传递给子组件的情况,这种情况是编译时无论如何也处理不到的,这时候就需要大家在编码时给组件设置 defaultProps 来解决了。

组件设置的 defaultProps 会在运行时用来弥补编译时处理不到的情况,里面所有的属性都会被设置到 properties 中初始化组件,正确设置 defaultProps 可以避免很多异常的情况的出现。

** 组件传递函数属性名以 on 开头 **

在 Taro 中,父组件要往子组件传递函数,属性名必须以 on 开头

// 调用 Custom 组件,传入 handleEvent 函数,属性名为 `onTrigger`
class Parent extends Component {

  handleEvent () {

  }

  render () {
    return (
      <Custom onTrigger={this.handleEvent}></Custom>
    )
  }
}

这是因为,微信小程序端组件化是不能直接传递函数类型给子组件的,在 Taro 中是借助组件的事件机制来实现这一特性,而小程序中传入事件的时候属性名写法为 bindmyevent 或者 bind:myevent

<!-- 当自定义组件触发“myevent”事件时,调用“onMyEvent”方法 -->
<component-tag-name bindmyevent="onMyEvent" />
<!-- 或者可以写成 -->
<component-tag-name bind:myevent="onMyEvent" />

所以 Taro 中约定组件传递函数属性名以 on 开头,同时这也和内置组件的事件绑定写法保持一致了。

** 小程序端不要在组件中打印传入的函数 **

前面已经提到小程序端的组件传入函数的原理,所以在小程序端不要在组件中打印传入的函数,因为拿不到结果,但是 this.props.onXxx && this.props.onXxx() 这种判断函数是否传入来进行调用的写法是完全支持的。

** 小程序端不要将在模板中用到的数据设置为 undefined **

由于小程序不支持将 data 中任何一项的 value 设为 undefined ,在 setState 的时候也请避免这么用。你可以使用 null 来替代。

** 小程序端不要在组件中打印 this.props.children **

在微信小程序端是通过 <slot /> 来实现往自定义组件中传入元素的,而 Taro 利用 this.props.children 在编译时实现了这一功能, this.props.children 会直接被编译成 <slot /> 标签,所以它在小程序端属于语法糖的存在,请不要在组件中打印它。

** 组件属性传递注意 **

不要以 idclassstyle 作为自定义组件的属性与内部 state 的名称,因为这些属性名在微信小程序中会丢失。

** 组件 state 与 props 里字段重名的问题 **

不要在 state 与 props 上用同名的字段,因为这些被字段在微信小程序中都会挂在 data 上。

** 小程序中页面生命周期 componentWillMount 不一致问题 **

由于微信小程序里页面在 onLoad 时才能拿到页面的路由参数,而页面 onLoad 前组件都已经 attached 了。因此页面的 componentWillMount 可能会与预期不太一致。例如:

// 错误写法
render () {
  // 在 willMount 之前无法拿到路由参数
  const abc = this.$router.params.abc
  return <Custom adc={abc} />
}

// 正确写法
componentWillMount () {
  const abc = this.$router.params.abc
  this.setState({
    abc
  })
}
render () {
  // 增加一个兼容判断
  return this.state.abc && <Custom adc={abc} />
}

对于不需要等到页面 willMount 之后取路由参数的页面则没有任何影响。

** 组件的 constructor 与 render 提前调用 **

很多细心的开发者应该已经注意到了,在 Taro 编译到小程序端后,组件的 constructor 与 render 默认会多调用一次,表现得与 React 不太一致。

这是因为,Taro 的组件编译后就是小程序的自定义组件,而小程序的自定义组件的初始化时是可以指定 data 来让组件拥有初始化数据的。开发者一般会在组件的 constructor 中设置一些初始化的 state,同时也可能会在 render 中处理 state 与 props 产生新的数据,在 Taro 中多出的这一次提前调用,就是为了收集组件的初始化数据,给自定义组件提前生成 data ,以保证组件初始化时能带有数据,让组件初次渲染正常。

所以,在编码时,需要在处理数据的时候做一些容错处理,这样可以避免在 constructor 与 render 提前调用时出现由于没有数据导致出错的情况。

** JS 编码必须用单引号 **

在 Taro 中,JS 代码里必须书写单引号,特别是 JSX 中,如果出现双引号,可能会导致编译错误。

** 环境变量 process.env 的使用 **

不要以解构的方式来获取通过 env 配置的 process.env 环境变量,请直接以完整书写的方式 process.env.NODE_ENV 来进行使用

// 错误写法,不支持
const { NODE_ENV = 'development' } = process.env
if (NODE_ENV === 'development') {
  ...
}

// 正确写法
if (process.env.NODE_ENV === 'development') {

}

** 预加载 **

微信小程序中,从调用 Taro.navigateToTaro.redirectTo 或 Taro.switchTab 后,到页面触发 componentWillMount 会有一定延时。因此一些网络请求可以提前到发起跳转前一刻去请求。

Taro 提供了 componentWillPreload 钩子,它接收页面跳转的参数作为参数。可以把需要预加载的内容通过 return 返回,然后在页面触发 componentWillMount 后即可通过 this.$preloadData 获取到预加载的内容。

class Index extends Component {
  componentWillMount () {
    console.log('isFetching: ', this.isFetching)
    this.$preloadData
      .then(res => {
        console.log('res: ', res)
        this.isFetching = false
      })
  }

  componentWillPreload (params) {
    return this.fetchData(params.url)
  }

  fetchData () {
    this.isFetching = true
    ...
  }
}

** 小结 **

由于 JSX 中的写法千变万化,我们不能支持到所有的 JSX 写法,同时由于微信小程序端的限制,也有部分 JSX 的优秀用法暂时不能得到很好地支持。这些不支持的写法都可以通过其他写法来规避,同时 ESLint 相关插件都能很好地提醒用户避免踩坑。了解 Taro 这些注意事项后,接下来我们就来动手实现一个简单的 Todo 项目。

用 Taro 实现一个简单的 Todo 项目

按前面章节的介绍操作,想必已经安装好了 Taro 了,现在我们来实现一个简单的 Todo 项目进行实战。

** 创建项目 **

Todo 项目包含两个简单的功能:

  • 输入事项:点击「添加」按钮增加一个事项;
  • 删除事项:点击事项列表里事项后面的「删除」按钮将删除该事项。

首先进入你想存放本项目的目录里,使用 Taro 创建一个 Todolist 项目,即输入命令taro init todoList(Todolist 为项目名称,你也可指定其他名称),过程如下图:

 

创建项目成功后,我们先来看看当前的项目结构。

 

src 文件夹存放我们开发时的文件,包括一个根目录的全局配置文件 app.js,全局样式 app.scss,入口 HTML 文件 index.htmlindex.html 里内置了 rem 转换代码(这个不需要关注)。而 src 目录 pages 下则对应的是项目的每一个页面,这里默认只有一个 index 页面,每一个页面下又包含 JS 文件和样式文件两个文件,这里即 index.js 和 index.scss

如果需要创建组件,我们可以在 src 创建 component 目录来统一存放组件。

了解了目录以后,接下来找到在 src 目录的 pages 下的 index.js (我们前面了解到这个就是默认的页面)。

import Taro, { Component } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import './index.scss'

export default class Index extends Component {

  config = {
    navigationBarTitleText: '首页'
  }

  componentWillMount () { }

  componentDidMount () { }

  componentWillUnmount () { }

  componentDidShow () { }

  componentDidHide () { }

  render () {
    return (
      <View className='index'>
        <Text>Hello world!</Text>
      </View>
    )
  }
}

文件开始第 1 行默认引入 Taro 的 Component 组件,它主要用来做组件化,我们这里暂时不用了解它;接着第 2 行是引入要用到的 View 和 Text, 我们下文要用到的 Input 组件也会是在这里引入。接下来第 3 行是引入样式对应的样式文件。

在我们正式开始写代码前先看看默认页面的效果吧。可以选择小程序的方式在微信开发者工具里查看(第 3 章《微信小程序开发入门与技术选型》里有相关的介绍)或 H5 的方式在浏览器里查看。在命令行进行项目构建:

# 小程序构建
$ taro build --type weapp --watch
# H5构建
$ taro build --type h5 --watch

得到小程序的效果:

file

接下来我们就正式开始编写 Todolist 了,我们把事项列表存在 state 里且给它默认几个事项,最后把它渲染出来。

// ...
export default class Index extends Component {
  config = {
    navigationBarTitleText: '首页'
  }

  constructor (props) {
    super (props)
    this.state = {
      // 创建一个初始的 Todolist
      list: [
        'get up',
        'coding',
        'sleep',
      ],
      inputVal: ''
    }
  }

  render () {
    let { list, inputVal } = this.state

    return (
      <View className='index'>
        <View className='list_wrap'>
          <Text>Todo list</Text>
          {
            list.map((item, index) => {
              return <View>
                <Text>{index + 1}.{item}</Text>
              </View>
            })
          }
        </View>
      </View>
    )
  }
}

要想实现添加事项的功能,我们需要引入 Input 组件来输入事项,当我们输入的时候触发 onInput 事件,把输入的值保存在 this.inputVal;同时使用 Text 组件把「添加」按钮展示出来,点击的时候触发 onClick 事件,当点击发生时,我们可以查看 this.inputVal 是否为空,如果不为空则可以添加到事项列表里,即在 list 列表里添加事项且更新 state 。具体的代码如下:

// 这里多引入了 Input 组件
import { View, Text, Input } from '@tarojs/components'

export default class Index extends Component {

  // ... 生命周期函数,暂时不需要关注
  // 添加按钮 onClick 时,添加事项,然后更新 list
  addItem () {
    let { list } = this.state
    const inputVal = this.inputVal
    // 如果输入框的值为空,则返回,否则添加到事项列表里
    if (inputVal == '') return
    else {
      list.push(inputVal)
    }
    this.setState({
      list,
      inputVal: ''
    })
  }

  // 输入框 onInput 的时候,它的值暂存起来
  inputHandler (e) {
    this.inputVal = e.target.value
  }

  render () {
    let { list, inputVal } = this.state

    return (
      <View className='index'>
        <Input className='input' type='text' value={inputVal} onInput={this.inputHandler.bind(this)} />
        <Text className='add' onClick={this.addItem.bind(this)}>添加</Text>
        <View className='list_wrap'>
          <Text>Todo list</Text>
          {
            list.map((item, index) => {
              return <View>
                <Text>{index + 1}.{item}</Text>
              </View>
            })
          }
        </View>
      </View>
    )
  }
}

上面我们完成了添加事项的功能,下面我们开发删除功能。删除功能就是点击事项后的「删除」按钮删除对应的事项,即从事项列表中移除该事项。首先是在展示事项的列表后添加「删除」按钮并且把该事项的索引传入点击事件里,发生点击的时候就可以根据索引删除对应的事件。

export default class Index extends Component {
  config = {
    navigationBarTitleText: '首页'
  }

  // ... 其他代码已略
  // 根据索引删除事项,然后更新 list
  delItem (index) {
    let { list } = this.state
    list.splice(index, 1)
    this.setState({
      list
    })
  }

  render () {
    let { list, inputVal } = this.state

    return (
      <View className='index'>
        <Input className='input' type='text' value={inputVal} onInput={this.inputHandler.bind(this)} />
        <Text className='add' onClick={this.addItem.bind(this)}>添加</Text>
        <View className='list_wrap'>
          <Text>Todo list</Text>
          {
            list.map((item, index) => {
              return <View>
                <Text>{index + 1}.{item}</Text>
                <Text className='del' onClick={this.delItem.bind(this, index)}>删除</Text>
              </View>
            })
          }
        </View>
      </View>
    )
  }
}

到这里我们实现了一个简单的可添加可删除的 Todolist,因为我们之前使用的是 Taro 的 taro build --type weapp --watch 或 taro build --type h5 --watch 命令,所以在编写代码的同时可以不停地查看我们编写的效果。我们再补充一些样式,样式写在 index 页面下的样式文件里,这里即 index.scss

.input {
    display: inline-block;
    margin: 20px;
    border: 1px solid #666;
    width: 500px;
    vertical-align: middle;
}
.list_wrap {
    padding: 50px 20px;
}
.list {
    margin: 20px 0;
}
.add,
.del {
    display: inline-block;
    width: 120px;
    height: 60px;
    margin: 0 10px;
    padding: 0 10px;
    color: #333;
    font-size: 22px;
    line-height: 60px;
    text-align: center;
    border-radius: 10px;
    border: 1px solid #C5D9E8;
    box-sizing: border-box;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    justify-content: center;
    vertical-align: middle;
}
.add {
    background-color: #5c89e4;
    color: #fff;
    border: 1px solid #5c89e4;
}
.del {
    background-color: #fff;
    color: #5c89e4;
    border: 1px solid #5c89e4;
    margin-left: 100px;
}

为了方便大家实践,且篇幅不算长,这里把 index.js 全部代码展示一下:


import Taro, { Component } from '@tarojs/taro'
import { View, Text, Input } from '@tarojs/components'
import './index.scss'

export default class Index extends Component {
  config = {
    navigationBarTitleText: '首页'
  }

  constructor (props) {
    super (props)
    this.state = {
      // 创建一个初始的 Todolist
      list: [
        'get up',
        'coding',
        'sleep',
      ],
      inputVal: ''
    }
  }

  // ... 生命周期函数,暂时不需要关注

  addItem () {
    let { list } = this.state
    const inputVal = this.inputVal
    if (inputVal == '') return
    else{
      list.push(inputVal)
    }
    this.setState({
      list,
      inputVal: ''
    })
  }

  delItem (index) {
    let { list } = this.state
    list.splice(index, 1)
    this.setState({
      list
    })
  }

  inputHandler (e) {
    // 不参与渲染的变量可不使用state储存,提高性能
    this.inputVal = e.target.value
  }

  render () {
    let { list, inputVal } = this.state

    return (
      <View className='index'>
        <Input className='input' type='text' value={inputVal} onInput={this.inputHandler.bind(this)} />
        <Text className='add' onClick={this.addItem.bind(this)}>添加</Text>
        <View className='list_wrap'>
          <Text>Todo list</Text>
          {
            list.map((item, index) => {
              return <View className='list'>
                <Text>{index + 1}.{item}</Text>
                <Text className='del' onClick={this.delItem.bind(this, index)}>删除</Text>
              </View>
            })
          }
        </View>
      </View>
    )
  }
}

** 实际效果 **

最后来看一眼实现的效果:

file

** 小结 ** 本文主要介绍了如何使用 Taro 创建一个应用目录,并快速进行开发,以及目录中的一些配置内容。Taro 给前端同学们带来了全新的编码体验,想必熟悉 React 的同行们都能快速上手,(如果从未使用 React,则可以看本小册的第 2 章《React 核心语法初识》)这里就不再赘述了。

下一章将为大家讲解如何在应用中添加以及使用 Redux 来管理我们的数据。

 

在 Taro 中使用 Redux

** 前言 **

读了上篇文章,相信大家已经掌握了初步的 Taro 开发技能了,本文将带领大家结合 Redux 完善一个 Todolist。 首先,我们得对 Redux 有个初步的了解。Redux 是 JavaScript 状态容器,提供可预测的状态管理。一般来说,规模比较大的小程序,其页面状态和数据缓存等都需要管理很多的东西,这时候引入 Redux 可以方便的管理这些状态,同一数据,一次请求,应用全局共享

而 Taro 也非常友好地为开发者提供了可移植的 Redux 。

** 依赖 **

为了更方便地使用 Redux,Taro 提供了与 react-redux API 几乎一致的包 @tarojs/redux 来让开发人员获得更加良好的开发体验。

开发前需要安装 redux 和 @tarojs/redux ,开发者可自行选择安装 Redux 中间件,本文以如下中间件为例:

$ yarn add redux @tarojs/redux redux-logger
# 或者使用 npm
$ npm install --save redux @tarojs/redux redux-logger

** 示例 **

下面通过丰富上一篇文章的 Todolist 快速上手 Redux 。

** 目录结构 **

首先通过目录划分我们的store/reducers/actions

file

分别在三个文件夹里创建index.js,作为三个模块的入口文件。首先来看看store/index.js里面的内容。reducersactions里面的内容我们需要规划好功能之后再来处理。

// store/index.js

import { createStore, applyMiddleware } from 'redux'

// 引入需要的中间件
import thunkMiddleware from 'redux-thunk'
import { createLogger } from 'redux-logger'

// 引入根reducers
import rootReducer from '../reducers'

const middlewares = [
  thunkMiddleware,
  createLogger()
]

// 创建 store
export default function configStore () {
  const store = createStore(rootReducer, applyMiddleware(...middlewares))
  return store
}

** 编写 Todos **

首先,定义好store,然后在app.js中引入。使用@tarojs/redux中提供的Provider组件将前面写好的store接入应用中,这样一来,被Provider包裹的页面都能访问到应用的store

Provider 组件使组件层级中的 connect() 方法都能够获得 Redux store。

import Taro, { Component } from '@tarojs/taro'
import { Provider } from '@tarojs/redux'

import configStore from './store'
import Index from './pages/index'

import './app.scss'

const store = configStore()

class App extends Component {
  ...
  render () {
    return (
      <Provider store={store}>
        <Index />
      </Provider>  
    )
  }
}

接下来我们正式开始规划 Todos 应用的主要功能。

首先我们可以新建constants文件夹来定义一系列所需的action type常量。例如 Todos 我们可以先增加ADDDELETE两个action type来区分新增和删除 Todo 指令。

// src/constants/todos.js

export const ADD = 'ADD'
export const DELETE = 'DELETE'

然后开始创建处理这两个指令的reducer

// src/reducers/index.js

import { combineReducers } from 'redux'
import { ADD, DELETE } from '../constants/todos'

// 定义初始状态
const INITIAL_STATE = {
  todos: [
    {id: 0, text: '第一条todo'}
  ]
}

function todos (state = INITIAL_STATE, action) {
  // 获取当前todos条数,用以id自增
  const todoNum = state.todos.length

  switch (action.type) {  
    // 根据指令处理todos
    case ADD:      
      return {
        ...state,
        todos: state.todos.concat({
          id: todoNum,
          text: action.data
        })
      }
    case DELETE:
      let newTodos = state.todos.filter(item => {
        return item.id !== action.id
      })

      return {
        ...state,
        todos: newTodos
      }
    default:
      return state
  }
}

export default combineReducers({
  todos
})

接着在action中定义函数对应的指令。

// src/actions/index.js

import { ADD, DELETE } from '../constants/todos'

export const add = (data) => {
  return {
    data,
    type: ADD
  }
}

export const del = (id) => {
  return {
    id,
    type: DELETE
  }
}

完成上述三步之后,我们就可以在 Todos 应用的主页使用相应action修改并取得新的store数据了。来看一眼 Todos 的index.js

// src/pages/index/index.js

import Taro, { Component } from '@tarojs/taro'
import { View, Input, Text } from '@tarojs/components'
import { connect } from '@tarojs/redux'
import './index.scss'

import { add, del } from '../../actions/index'

class Index extends Component {
  config = {
    navigationBarTitleText: '首页'
  }

  constructor () {
    super ()

    this.state = {
      newTodo: ''
    }
  }

  saveNewTodo (e) {
    let { newTodo } = this.state
    if (!e.detail.value || e.detail.value === newTodo) return

    this.setState({
      newTodo: e.detail.value
    })
  }

  addTodo () {
    let { newTodo } = this.state
    let { add } = this.props

    if (!newTodo) return

    add(newTodo)
    this.setState({
      newTodo: ''
    })
  }

  delTodo (id) {
    let { del } = this.props
    del(id)
  }

  render () {
    // 获取未经处理的todos并展示
    let { newTodo } = this.state
    let { todos, add, del } = this.props  

    const todosJsx = todos.map(todo => {
      return (
        <View className='todos_item'><Text>{todo.text}</Text><View className='del' onClick={this.delTodo.bind(this, todo.id)}>-</View></View>
      )
    })

    return (
      <View className='index todos'>
        <View className='add_wrap'>
          <Input placeholder="填写新的todo" onBlur={this.saveNewTodo.bind(this)} value={newTodo} />
          <View className='add' onClick={this.addTodo.bind(this)}>+</View>
        </View>
        <View>{ todosJsx }</View>  
      </View>
    )
  }
}

export default connect (({ todos }) => ({
  todos: todos.todos
}), (dispatch) => ({
  add (data) {
    dispatch(add(data))
  },
  del (id) {
    dispatch(del(id))
  }
}))(Index)

最后来看一眼实现的效果:

 

** 小结 ** 本章我们结合 Redux 丰富了一个 Todolist,通过梳理文件目录结构,规划 Todolist 功能,再细化到每一个文件的具体代码,让读者们深入浅出地了解到如何在 Taro 内结合 Redux 开发应用。

诚然,该文只是提供一种选型建议,是否需要在应用中使用状态管理框架,是否选用 Redux 作为应用的状态管理框架,还需要具体问题具体分析。如果你是在搭建类似商城这样的大型应用,我们非常建议你采用 Redux 管理数据状态,而譬如开发单页应用这类小型的站点,使用 Redux 则有可能会增加你的工作量哦。

 

Taro 设计思想及架构

Taro 诞生之初是为了解决微信小程序开发的一系列痛点,那么它是如何从一个小程序开发框架慢慢演变成一个多端统一开发框架的呢?本章节将带你了解 Taro 的整体设计思想与架构。

** 使用 React 语法来写小程序 **

** 谈一谈小程序开发 **

微信小程序为我们的业务提供了一种新的展现形态,但对于开发者来说,开发体验则显得并不那么友好。

首先,从文件组织上看,一个小程序页面或组件,需要同时包含 4 个文件:脚本逻辑、样式、模板以及配置文件,在开发一个功能模块时,就需要在 4 个文件之间切换,而当功能模块多的话,就需要在多个文件之间切换,这样显然非常浪费时间。

其次,从开发方式上看,在前端工程化思想深入人心的今天,小程序的种种开发方式显得有些落后了,主要体现在以下几个方面:

  • 没有自定义文件预处理,无法直接使用 Sass、Less 以及较新的 ES.Next 语法;
  • 字符串模板太过孱弱,小程序的字符串模板仿的是 Vue,但是没有提供 Vue 那么多的语法糖,当实现一些比较复杂的处理时,写起来就非常麻烦,虽然提供了 wxs 作为补充,但是使用体验还是非常糟糕;
  • 缺乏测试套件,无法编写测试代码来保证项目质量,也就不能进行持续集成,自动化打包。

所以,从开发方式上看,小程序开发没有融入目前主流的工程化开发思想,很多业界开发模式与工具没有在小程序开发中得到相应体现,像是从前端工业时代回退到了刀耕火种的年代。

最后,从代码规范上看,小程序的规范有很多不统一的地方,例如内置组件的属性名,有时候是全小写,有时候是 CamelCase 格式,有时候又是中划线分割的形式,这样就导致编码的时候得不时查阅文档才能确定写法。

** 如何更优雅地开发小程序 **

在 Taro 的设计之初,我们的想法就是希望能够以一种更加优雅的方式来开发小程序,解决小程序开发上的种种痛点,首先我们希望能使用前端工程化的方式来进行开发,同时在语法上,我们希望能抛弃小程序的四不像语法,遵循一套我们熟悉的框架语法来进行开发,这样不仅能更好地保证开发质量、提升开发体验,同时也能大大降低开发者开发小程序的成本。

于是,在开发方式上,Taro 打造了一套完善编译工具,引入了前置编译的机制,可以自动化地对源文件进行一系列的处理,最终输出小程序上的可执行文件,包括代码的编译转换处理,加入文件预处理功能,支持 NPM 包管理等等,这一部分的原理,将会在后续章节中为大家介绍。而语法标准上,我们把目光投向了市面上流行的三大前端框架。

file

ReactVueAngular 是目前前端框架三巨头,他们各有各的风格,关于他们的优劣,在业界也是一直争论不休,这本身也是智者见智仁者见仁的事,所以在本文中就不再评述。Taro 最终采用的是 React 语法来作为自己的语法标准,主要有以下几点考虑:

  • React 是一个非常流行的框架,也有广大的受众,使用它也能降低小程序开发的学习成本;
  • 小程序的数据驱动模板更新的思想与实现机制,与 React 类似;
  • React 采用 JSX 作为自身模板,JSX 相比字符串模板来说更自由,更自然,更具表现力,不需要依赖字符串模板的各种语法糖,也能完成复杂的处理
  • React 本身有跨端的实现方案 - React Native,并且非常成熟,社区活跃,对于 Taro 来说有更多的多端开发可能性。

最终,Taro 采用了 React 语法来作为自己的语法标准,配合前端工程化的思想,为小程序开发打造了更加优雅的开发体验。

** 如何实现优雅 **

那么如何实现使用 React 来开发小程序呢?在 Taro 中采用的是编译原理的思想,所谓编译原理,就是一个对输入的源代码进行语法分析,语法树构建,随后对语法树进行转换操作再解析生成目标代码的过程。

file

在后续章节中,我们将会为大家详细讲述,如何基于编译原理思想来实现使用 React 来开发小程序,揭开其背后的种种开发秘辛。

** 探索多端可能性 **

多端统一开发一直是所有开发人员的共同追求。在终端碎片化的大背景下,前有 Hybrid 模式拉开序幕,后有 React Native、Weex 风起云涌,再到如今 Flutter 横空出世,种种这些都是为了能够 Write once, run anywhere 。给每一种终端单独进行开发的成本是昂贵的,所以一个能够尽可能抹平多端开发差异的开发解决方案就显得极为重要。

** 多端转换原理 **

开发时我们遵循 React 语法标准,结合编译原理的思想,对代码文件进行一系列转换操作,最终获得可以在小程序运行的代码。而 React 最开始就是为了解决 Web 开发而生的,所以对代码稍加改动,也可以直接生成在 Web 端运行的代码,而同属 React 语法体系下的 React Native,也能够很便捷地提供支持。同理其他平台,如快应用、百度小程序等,将源码进行编译转换操作,也能获得该平台下的对应语法代码。

file

** 抹平多端差异 **

基于编译原理,我们已经可以将 Taro 源码编译成不同端上可以运行的代码了,但是这对于实现多端开发还是远远不够。因为不同的平台都有自己的特性,每一个平台都不尽相同,这些差异主要体现在不同的组件标准与不同的 API 标准以及不同的运行机制上。

以小程序和 Web 端为例。

 

file

file

可以看出小程序和 Web 端上组件标准与 API 标准有很大差异,这些差异仅仅通过代码编译手段是无法抹平的,例如你不能直接在编译时将小程序的 直接编译成

,因为他们虽然看上去有些类似,但是他们的组件属性有很大不同的,仅仅依靠代码编译,无法做到一致,同理,众多 API 也面临一样的情况。针对这样的情况,Taro 采用了定制一套运行时标准来抹平不同平台之间的差异。

 

这一套标准主要以三个部分组成,包括标准运行时框架、标准基础组件库、标准端能力 API,其中运行时框架和 API 对应 @taro/taro,组件库对应 @tarojs/components,通过在不同端实现这些标准,从而达到去差异化的目的。

 

 

 

而在标准的定制上,起初我们想重新定制一套标准规范,但是发现在所有端都得实现这套标准的成本太高,所以我们就思考为什么不以一个端的组件库、API 为标准呢?这样不仅省去了标准定制的时间,而且在这个端上我们可以不用去实现这套标准了。最终在所有端中,我们挑选了微信小程序的组件库和 API 来作为 Taro 的运行时标准,因为微信小程序的文档非常完善,而且组件与 API 也是非常丰富,同时最重要的是,百度小程序以及支付宝小程序都是遵循的微信小程序的标准,这样一来,Taro 在实现这两个平台的转换上成本就大大降低了。

file

** 小结 **

本文主要介绍了 Taro 在实现多端统一开发上的架构原理与思想,带大家了解 Taro 背后的设计原理,帮助大家对 Taro 有更深刻的理解。Taro 主要借助编译原理的思想来实现多端统一开发,在接下来的章节中将带领大家更深入地了解编译原理在 Taro 中的应用,为大家打开解决问题的新思路。

 

CLI 原理及不同端的运行机制

taro-cli 负责 Taro 脚手架初始化和项目构建的的命令行工具,NPM 包的链接在这里:@tarojs/cli 。

** taro-cli 包 **

** Taro 命令 **

taro-cli 包位于 Taro 工程的 Packages 目录下,通过 npm install -g @tarojs/cli 全局安装后,将会生成一个 Taro 命令。主要负责项目初始化、编译、构建等。直接在命令行输入 Taro ,会看到如下提示:

➜ taro
👽 Taro v0.0.63


  Usage: taro <command> [options]

  Options:

    -V, --version       output the version number
    -h, --help          output usage information

  Commands:

    init [projectName]  Init a project with default templete
    build               Build a project with options
    update              Update packages of taro
    help [cmd]          display help for [cmd]

里面包含了 Taro 所有命令用法及作用。

** 包管理与发布 **

首先,我们需要了解 taro-cli 包与 Taro 工程的关系。

将 Taro 工程 Clone 之后,可以看到工程的目录结构如下,整体结构还是比较清晰的:

.
├── CHANGELOG.md
├── LICENSE
├── README.md
├── build
├── docs
├── lerna-debug.log
├── lerna.json        // Lerna 配置文件
├── package.json
├── packages
│   ├── eslint-config-taro
│   ├── eslint-plugin-taro
│   ├── postcss-plugin-constparse
│   ├── postcss-pxtransform
│   ├── taro
│   ├── taro-async-await
│   ├── taro-cli
│   ├── taro-components
│   ├── taro-components-rn
│   ├── taro-h5
│   ├── taro-plugin-babel
│   ├── taro-plugin-csso
│   ├── taro-plugin-sass
│   ├── taro-plugin-uglifyjs
│   ├── taro-redux
│   ├── taro-redux-h5
│   ├── taro-rn
│   ├── taro-rn-runner
│   ├── taro-router
│   ├── taro-transformer-wx
│   ├── taro-weapp
│   └── taro-webpack-runner
└── yarn.lock

Taro 项目主要是由一系列 NPM 包组成,位于工程的 Packages 目录下。它的包管理方式和 Babel 项目一样,将整个项目作为一个 monorepo 来进行管理,并且同样使用了包管理工具 Lerna

Lerna 是一个用来优化托管在 Git/NPM 上的多 package 代码库的工作流的一个管理工具,可以让你在主项目下管理多个子项目,从而解决了多个包互相依赖,且发布时需要手动维护多个包的问题。

关于 Lerna 的更多介绍可以看官方文档 Lerna:A tool for managing JavaScript projects with multiple packages

Packages 目录下十几个包中,最常用的项目初始化与构建的命令行工具 Taro CLI 就是其中一个。在 Taro 工程根目录运行 lerna publish 命令之后,lerna.json 里面配置好的所有的包会被发布到 NPM 上。

** 目录结构 **

taro-cli 包的目录结构如下:

./
├── bin        // 命令行
│   ├── taro              // taro 命令
│   ├── taro-build        // taro build 命令
│   ├── taro-update       // taro update 命令
│   └── taro-init         // taro init 命令
├── package.json
├── node_modules
├── src
│   ├── build.js        // taro build 命令调用,根据 type 类型调用不同的脚本
│   ├── config
│   │   ├── babel.js        // Babel 配置
│   │   ├── babylon.js      // JavaScript 解析器 babylon 配置
│   │   ├── browser_list.js // autoprefixer browsers 配置
│   │   ├── index.js        // 目录名及入口文件名相关配置
│   │   └── uglify.js
│   ├── creator.js
│   ├── h5.js       // 构建h5 平台代码
│   ├── project.js  // taro init 命令调用,初始化项目
│   ├── rn.js       // 构建React Native 平台代码
│   ├── util        // 一系列工具函数
│   │   ├── index.js
│   │   ├── npm.js
│   │   └── resolve_npm_files.js
│   └── weapp.js        // 构建小程序代码转换
├── templates           // 脚手架模版
│   └── default
│       ├── appjs
│       ├── config
│       │   ├── dev
│       │   ├── index
│       │   └── prod
│       ├── editorconfig
│       ├── eslintrc
│       ├── gitignore
│       ├── index.js    // 初始化文件及目录,copy模版等
│       ├── indexhtml
│       ├── npmrc
│       ├── pagejs
│       ├── pkg
│       └── scss
└── yarn-error.log

其中关键文件的作用已添加注释说明,大家可以先大概看看,有个初步印象。

通过上面的目录树可以发现,taro-cli 工程的文件并不算多,主要目录有:/bin/src/template,笔者已经在上面详细标注了主要的目录和文件的作用,至于具体的流程,咱们接下来再分析。

** 用到的核心库 **

  • tj/commander.js Node.js - 命令行接口全面的解决方案,灵感来自于 Ruby's commander。可以自动的解析命令和参数,合并多选项,处理短参等等,功能强大,上手简单。
  • jprichardson/node-fs-extra - 在 Node.js 的 fs 基础上增加了一些新的方法,更好用,还可以拷贝模板。
  • chalk/chalk - 可以用于控制终端输出字符串的样式。
  • SBoudrias/Inquirer.js - Node.js 命令行交互工具,通用的命令行用户界面集合,可以和用户进行交互。
  • sindresorhus/ora - 实现加载中的状态是一个 Loading 加前面转起来的小圈圈,成功了是一个 Success 加前面一个小钩钩。
  • SBoudrias/mem-fs-editor - 提供一系列 API,方便操作模板文件。
  • shelljs/shelljs - ShellJS 是 Node.js 扩展,用于实现 Unix shell 命令执行。
  • Node.js child_process - 模块用于新建子进程。子进程的运行结果储存在系统缓存之中(最大 200KB),等到子进程运行结束以后,主进程再用回调函数读取子进程的运行结果。

** Taro Init **

Taro Init 命令主要的流程如下:

file

** Taro 命令入口 **

当我们全局安装 taro-cli 包之后,我们的命令行里就有了 Taro 命令。

$ npm install -g @tarojs/cli

那么 Taro 命令是怎样添加进去的呢?其原因在于 package.json 里面的 bin 字段:

"bin": {
    "taro": "bin/taro"
  },

上面代码指定,Taro 命令对应的可执行文件为 bin/taro 。NPM 会寻找这个文件,在 [prefix]/bin 目录下建立符号链接。在上面的例子中,Taro 会建立符号链接 [prefix]/bin/taro。由于 [prefix]/bin 目录会在运行时加入系统的 PATH 变量,因此在运行 NPM 时,就可以不带路径,直接通过命令来调用这些脚本。

关于prefix,可以通过npm config get prefix获取。

$ npm config get prefix
/usr/local

通过下列命令可以更加清晰的看到它们之间的符号链接:

$ ls -al `which taro`
lrwxr-xr-x  1 chengshuai  admin  40  6 15 10:51 /usr/local/bin/taro -> ../lib/node_modules/@tarojs/cli/bin/taro

** Taro 子命令 **

上面我们已经知道 taro-cli 包安装之后,Taro 命令是怎么和 /bin/taro 文件相关联起来的, 那 Taro Init 和 Taro Build 又是怎样和对应的文件关联起来的呢?

** 命令关联与参数解析 **

这里就不得不提到一个有用的包:tj/commander.js ,Node.js 命令行接口全面的解决方案,灵感来自于 Ruby's commander。可以自动的解析命令和参数,合并多选项,处理短参等等,功能强大,上手简单。具体的使用方法可以参见项目的 README

更主要的,commander 支持 Git 风格的子命令处理,可以根据子命令自动引导到以特定格式命名的命令执行文件,文件名的格式是 [command]-[subcommand],例如:

taro init => taro-init
taro build => taro-build

/bin/taro 文件内容不多,核心代码也就那几行 .command() 命令:

#! /usr/bin/env node

const program = require('commander')
const {getPkgVersion} = require('../src/util')

program
  .version(getPkgVersion())
  .usage('<command> [options]')
  .command('init [projectName]', 'Init a project with default templete')
  .command('build', 'Build a project with options')
  .command('update', 'Update packages of taro')
  .parse(process.argv)

通过上面代码可以发现,initbuild ,update等命令都是通过.command(name, description)方法定义的,然后通过 .parse(arg) 方法解析参数。具体可以查看 Commander.js API 文档

注意第一行#!/usr/bin/env node,有个关键词叫 Shebang,不了解的可以去搜搜看。

** 参数解析及与用户交互 **

前面提到过,commander 包可以自动解析命令和参数,在配置好命令之后,还能够自动生成 help(帮助)命令和 version(版本查看) 命令。并且通过program.args便可以获取命令行的参数,然后再根据参数来调用不同的脚本。

但当我们运行 taro init 命令后,如下所示的命令行交互又是怎么实现的呢?

$ taro init taroDemo
Taro 即将创建一个新项目!
Need help? Go and open issue: https://github.com/NervJS/taro/issues/new

Taro v0.0.50

? 请输入项目介绍!
? 请选择模板 默认模板

这里使用的是 SBoudrias/Inquirer.js 来处理命令行交互。

用法其实很简单:

const inquirer = require('inquirer')  // npm i inquirer -D

if (typeof conf.description !== 'string') {
      prompts.push({
        type: 'input',
        name: 'description',
        message: '请输入项目介绍!'
      })
}

prompt()接受一个问题对象的数据,在用户与终端交互过程中,将用户的输入存放在一个答案对象中,然后返回一个Promise,通过then()获取到这个答案对象。

借此,新项目的名称、版本号、描述等信息可以直接通过终端交互插入到项目模板中,完善交互流程。

当然,交互的问题不仅限于此,可以根据自己项目的情况,添加更多的交互问题。inquirer.js 强大的地方在于,支持很多种交互类型,除了简单的input,还有confirmlistpasswordcheckbox等,具体可以参见项目的工程 README

此外,你在执行异步操作的过程中,还可以使用 sindresorhus/ora 来添加一下 Loading 效果。使用 chalk/chalk 给终端的输出添加各种样式。

** 模版文件操作 **

最后就是模版文件操作了,主要分为两大块:

  • 将输入的内容插入到模板中
  • 根据命令创建对应目录结构,copy 文件
  • 更新已存在文件内容

这些操作基本都是在 /template/index.js 文件里。

这里还用到了 shelljs/shelljs 执行 shell 脚本,如初始化 Git: git init,项目初始化之后安装依赖 npm install等。

** 拷贝模板文件 **

拷贝模版文件主要是使用 jprichardson/node-fs-extra 的 copyTpl()方法,此方法使用 ejs 模板语法,可以将输入的内容插入到模版的对应位置:

this.fs.copyTpl(
  project,
  path.join(projectPath, 'project.config.json'),
  {description, projectName}
);

** 更新已经存在的文件内容 **

更新已经存在的文件内容是很复杂的工作,最可靠的方法是把文件解析为AST,然后再编辑。一些流行的 AST parser 包括:

  • Cheerio:解析HTML
  • Babylon:解析JavaScript
  • 对于JSON文件,使用原生的JSON对象方法。

使用 Regex 解析一个代码文件是「邪道」,不要这么干,不要心存侥幸。

** Taro Build **

taro build 命令是整个 Taro 项目的灵魂和核心,主要负责多端代码编译(H5,小程序,React Native 等)。

Taro 命令的关联,参数解析等和 taro init 其实是一模一样的,那么最关键的代码转换部分是怎样实现的呢?

这一部分内容过于庞大,需要单独拉出来一篇讲。不过这里可以先简单提一下。

** 编译工作流与抽象语法树(AST) **

Taro 的核心部分就是将代码编译成其他端(H5、小程序、React Native 等)代码。一般来说,将一种结构化语言的代码编译成另一种类似的结构化语言的代码包括以下几个步骤:

file

首先是 Parse,将代码解析(Parse)成抽象语法树(Abstract Syntex Tree),然后对 AST 进行遍历(traverse)和替换(replace)(这对于前端来说其实并不陌生,可以类比 DOM 树的操作),最后是生成(generate),根据新的 AST 生成编译后的代码。

** Babel 模块 **

Babel 是一个通用的多功能的 JavaScript编译器,更确切地说是源码到源码的编译器,通常也叫做转换编译器(transpiler)。 意思是说你为 Babel 提供一些 JavaScript 代码,Babel 更改这些代码,然后返回给你新生成的代码。

此外它还拥有众多模块可用于不同形式的静态分析。

静态分析是在不需要执行代码的前提下对代码进行分析的处理过程(执行代码的同时进行代码分析即是动态分析)。 静态分析的目的是多种多样的, 它可用于语法检查、编译、代码高亮、代码转换、优化和压缩等等场景。

Babel 实际上是一组模块的集合,拥有庞大的生态。Taro 项目的代码编译部分就是基于 Babel 的以下模块实现的:

  • Babylon - Babel 的解析器。最初是从 Acorn 项目 fork 出来的。Acorn 非常快,易于使用,并且针对非标准特性(以及那些未来的标准特性) 设计了一个基于插件的架构。
  • Babel-traverse - 负责维护整棵树的状态,并且负责替换、移除和添加节点。
  • Babel-types - 一个用于 AST 节点的 Lodash 式工具库, 它包含了构造、验证以及变换 AST 节点的方法。 该工具库包含考虑周到的工具方法,对编写处理 AST 逻辑非常有用。
  • Babel-generator - Babel 的代码生成器,它读取 AST 并将其转换为代码和源码映射(sourcemaps)。
  • Babel-template - 另一个虽然很小但却非常有用的模块。 它能让你编写字符串形式且带有占位符的代码来代替手动编码, 尤其是生成大规模 AST 的时候。 在计算机科学中,这种能力被称为准引用(quasiquotes)。

** 解析页面 Config 配置 **

在业务代码编译成小程序的代码过程中,有一步是将页面入口 JS 的 Config 属性解析出来,并写入 *.json 文件,供小程序使用。那么这一步是怎么实现的呢?这里将这部分功能的关键代码抽取出来:

// 1. babel-traverse方法, 遍历和更新节点
traverse(ast, {  
  ClassProperty(astPath) { // 遍历类的属性声明
    const node = astPath.node
    if (node.key.name === 'config') { // 类的属性名为 config
      configObj = traverseObjectNode(node)
      astPath.remove() // 将该方法移除掉
    }
  }
})

// 2. 遍历,解析为 JSON 对象
function traverseObjectNode(node, obj) {
  if (node.type === 'ClassProperty' || node.type === 'ObjectProperty') {
    const properties = node.value.properties
      obj = {}
      properties.forEach((p, index) => {
        obj[p.key.name] = traverseObjectNode(p.value)
      })
      return obj
  }
  if (node.type === 'ObjectExpression') {
    const properties = node.properties
    obj = {}
    properties.forEach((p, index) => {
      // const t = require('babel-types')  AST 节点的 Lodash 式工具库
      const key = t.isIdentifier(p.key) ? p.key.name : p.key.value
      obj[key] = traverseObjectNode(p.value)
    })
    return obj
  }
  if (node.type === 'ArrayExpression') {
    return node.elements.map(item => traverseObjectNode(item))
  }
  if (node.type === 'NullLiteral') {
    return null
  }
  return node.value
}

// 3. 写入对应目录的 *.json 文件
fs.writeFileSync(outputPageJSONPath, JSON.stringify(configObj, null, 2))

从以上代码的注释中,可以清晰的看到,通过以上三步,就可以将工程里面的 Config 配置转换成小程序对应的 JSON 配置文件。

但是,哪怕仅仅是这一小块功能点,真正实现起来也没那么简单,你还需要考虑大量的真实业务场景和极端情况:

  • 应用入口 app.js 和页面入口 index.js 的 Config 是否单独处理?
  • TabBar 配置怎样转换且保证功能及交互一致?
  • 用户的配置信息有误怎么处理?

想了解更多 Taro 编译的相关实现,可以查阅第 11-12 章《Taro 实现 JSX 转换小程序模板》。

** Taro Update **

taro update 命令主要是为了方便大家更新 taro-cli 及 Taro 相关的依赖包,在命令行直接输入taro update 的命令,便可以得到如下的提示:

taro update
👽 Taro v0.0.70

命令错误:
taro update self 更新 Taro 开发工具 taro-cli 到最新版本
taro update project 更新项目所有 Taro 相关依赖到最新版本...

通过上面的提示可以看到,taro update 现在仅支持两个子命令,原理和 taro init 类似,第一个命令其实等同于如下命令:

 npm i -g @tarojs/cli@latest
 或
 yarn global add @tarojs/cli@latest

第二个命令稍微复杂点,不过代码也十分易懂,无非就是如下三步:

  1. 获取 Taro 的最新版本
  2. 更新项目 package.json 里面的 Taro 依赖信息到最新版本
  3. 运行 npm install

** 小结 **

到此,taro-cli 的主要目录结构、命令调用和项目初始化方式等基本都捋完了,有兴趣的同学可以结合着工程的源代码自己捋一遍,应该不会太费劲。

 

Taro 组件库及 API 的设计与适配

前面我们提到,框架核心已经实现了源代码到目标代码的转换,这一步骤繁琐且复杂,但距离实现多端目标,仍有一步之遥,那就是组件和 API —— 如果把框架核心比作材料加工机,那么组件和 API 就是模具,机器处理原材料,模具把材料塑造成型,不管用的是哪种模具,最终得到的形状都一样,多端组件和 API 存在的意义就是:无论你用哪套组件,最终产出的效果都是一致的。

目前 Taro 已支持三端——小程序、H5、React Native。本节将会讲述组件库和 API 设计适配中的哲学。

** 多端差异 **

在开始讲述实现之前,先了解一下各端之间的差异,这也是我们实际操作中绕不过的坎。

** 组件差异 **

小程序、H5 以及快应用都可以划分为 XML 类,React Native 归为 JSX 类,两种语言风牛马不相及,给适配设置了非常大的障碍。XML 类有个明显的特点是关注点分离(Separation of Concerns),即语义层(XML)、视觉层(CSS)、交互层(JavaScript)三者分离的松耦合形式,JSX 类则要把三者混为一体,用脚本来包揽三者的工作。

不同端的组件的差异还体现在定制程度上:

  • H5 标签(组件)提供最基础的功能——布局、表单、媒体、图形等等;
  • 小程序组件相对 H5 有了一定程度的定制,我们可以把小程序组件看作一套类似于 H5 的 UI 组件库;
  • React Native 端组件也同样如此,而且基本是专“组”专用的,比如要触发点击事件就得用 Touchable 或者 Text 组件,要渲染文本就得用 Text 组件(虽然小程序也提供了 Text 组件,但它的文本仍然可以直接放到 view 之类的组件里)。

对于 React Native 的样式,我们可以姑且把它当作 CSS 的子集,但相比于 CSS,又有非常大的差别,首先是单位不一致,你必须根据屏幕的尺寸来精细地控制元素的尺寸和相关数值,然后是以对象的形式存在,不作用于全局,没有选择器的概念,你完全可以把它看做是一种 Inline Style,对于写惯了 XML 类的朋友,可能不太适应这种“另类”的写法,于是林林总总的第三方库就冒出来了,这类库统称为 CSS in JS,至于他们存在的意义就见仁见智了。

API 差异

各端 API 的差异具有定制化、接口不一、能力限制的特点:

  1. 定制化:各端所提供的 API 都是经过量身打造的,比如小程序的开放接口类 API,完全是针对小程序所处的微信环境打造的,其提供的功能以及外在表现都已由框架提供实现,用户上手可用,毋须关心内部实现。
  2. 接口不一:相同的功能,在不同端下的调用方式以及调用参数等也不一样,比如 socket,小程序中用 wx.connectSocket 来连接,H5 则用 new WebSocket() 来连接,这样的例子我们可以找到很多个。
  3. 能力限制:各端之间的差异可以进行定制适配,然而并不是所有的 API(此处特指小程序 API,因为多端适配是向小程序看齐的)在各个端都能通过定制适配来实现,因为不同端所能提供的端能力“大异小同”,这是在适配过程中不可抗拒、不可抹平的差异。

设计思路

由多端差异我们了解到进行多端适配的困难,那应该如何去设计组件和 API 呢?

由于组件和 API 定制程度的不同,相同功能的组件和 API 提供的能力不完全相同,在设计的时候,对于端差异较小的不影响主要功能的,我们直接使用相应端对应的组件 API 来实现,并申明特性的支持程度,对于端差异较大的且影响了主要功能的,则通过封装的形式来完成,并申明特性的支持程度,绝大部分的组件 API 都是通过这种形式来实现的

这里特别提到样式的设计,前面提到 React Native 的 Inline Style,不支持全局样式,不支持标签样式,不支持部分的 CSS 属性,flex 布局等等,这些可能会在交付开发者使用过程中人为产生的问题,我们会在规范中提到:如果你要兼容 React Native,不要使用全局样式,不要用标签样式,不能写这个样式等等。

多端适配

样式处理

H5 端使用官方提供的 WEUI 进行适配,React Native 端则在组件内添加样式,并通过脚本来控制一些状态类的样式,框架核心在编译的时候把源代码的 class 所指向的样式通过 css-to-react-native 进行转译,所得 StyleSheet 样式传入组件的 style 参数,组件内部会对样式进行二次处理,得到最终的样式。

file

为什么需要对样式进行二次处理?

部分组件是直接把传入 style 的样式赋给最外层的 React Native 原生组件,但部分经过层层封装的组件则不然,我们要把容器样式、内部样式和文本样式离析。为了方便解释,我们把这类组件简化为以下的形式:

<View style={wrapperStyle}>
  <View style={containerStyle}>
    <Text style={textStyle}>Hello World</Text>
  </View>
</View>

假设组件有样式 margin-topbackground-color 和 font-size,转译传入组件后,就要把分别把它们传到 wrapperStylecontainerStyle 和 textStyle,可参考 ScrollView 的 style 和 contentContainerStyle

组件封装

组件的封装则是一个“仿制”的过程,利用端提供的原材料,加工成通用的组件,暴露相对统一的调用方式。我们用 <Button /> 这个组件来举例,在小程序端它也许是长这样子的:

<button size="mini" plain={{plain}} loading={{loading}} hover-class="you-hover-me"></button>

如果要实现 H5 端这么一个按钮,大概会像下面这样,在组件内部把小程序的按钮特性实现一遍,然后暴露跟小程序一致的调用方式,就完成了 H5 端一个组件的设计。

<button
  {...omit(this.props, ['hoverClass', 'onTouchStart', 'onTouchEnd'])}
  className={cls}
  style={style}
  onClick={onClick}
  disabled={disabled}
  onTouchStart={_onTouchStart}
  onTouchEnd={_onTouchEnd}
>
  {loading && <i class='weui-loading' />}
  {children}
</button>

其他端的组件适配相对 H5 端来说会更曲折复杂一些,因为 H5 跟小程序的语言较为相似,而其他端需要整合特定端的各种组件,以及利用端组件的特性来实现,比如在 React Native 中实现这个按钮,则需要用到 <Touchable* /><View /><Text />,要实现动画则需要用上 <Animated.View />,还有就是相对于 H5 和小程序比较容易实现的 touch 事件,在 React Native 中则需要用上 PanResponder 来进行“仿真”,总之就是,因“端”制宜,一切为了最后只需一行代码通行多端!

除了属性支持外,事件回调的参数也需要进行统一,为此,需要在内部进行处理,比如 Input 的 onInput 事件,需要给它造一个类似小程序相同事件的回调参数,比如 { target: { value: text }, detail: { value: text } },这样,开发者们就可以像下面这样处理回调事件,无需关心中间发生了什么。

function onInputHandler ({ target, detail }) {
  console.log(target.value, detail.value)
}

当然,因“端”制宜也并不能支持所有的特性,换句话说实现完全支持会特别困难,比如 <Input /> 的 type 属性,下面是 React Native 实现中的类型对应,可以看到 idcard 类型转为了 default 类型,因为 React Native 本身不支持:

const keyboardTypeMap = {
  text: 'default',
  number: 'numeric',
  idcard: 'default',
  digit: Platform.select({
    ios: 'decimal-pad',
    android: 'numeric'
  })
}

还有就是组件规范方面,由于 React Native 是 flex 型布局的,这点跟 H5 和小程序还是有蛮大区别的,所以就得在开发规范中约束用户要注意这些,比如用户要兼容 React Native 就要采用 flex 布局的写法。

质量把关

代码质量重于泰山,凹凸实验室始终把代码质量看作重中之重,通过两个强力手腕来保证,一是代码规范,二是测试。

代码规范

在日常业务中也需遵循代码规范,日常 Code Review 也会把代码规范作为检查的一方面,统一的规范对于代码交接,业务检查等方面有重要作用,在 Taro 组件库和 API 的相应库代码都严格遵循这个规范,既保证团队开发者协作的顺畅,又利于优秀的开源合作者们贡献代码。总之,代码规范既体现个人的代码素养,也侧面体现团队的综合能力。

测试

作为 Taro 中的重要一环,组件和 API 功能的稳定性尤为重要,于是引入了单元测试,细心的读者可以翻阅框架代码、组件和 API 的库都带有 JEST 测试。当然,不管在任何框架,写测试是一个优秀开发者必做的工作。

小结

Taro 的语法遵循小程序的语法,所以小程序端使用固有的官方组件以及 API 即可,不用单独设计,而 H5、React Native、多应用等端则需要设计一套组件和 API 进行适配,抹平各端之间的差异,力图让开发者达到无感适配。

 

JSX 转换微信小程序模板的实现(上)

《Taro 设计思想及架构》中我们提到过 Taro 的设计思路:

在一个优秀且严格的规范限制下,从更高抽象的视角(语法树)来看,每个人写的代码都差不多。

也就是说,对于微信小程序这样不开放不开源的端,我们可以先把 React 代码想象成一颗抽象语法树,根据这颗树生成小程序支持的模板代码,再做一个小程序运行时框架处理事件和生命周期与之兼容,然后把业务代码跑在运行时框架就完成了小程序端的适配。

这里其实我们是将 React 的 API 和 JSX 语法当成一种 DSL(Domain-specific language),只要将源代码编译成个各平台的对应语法就能达到跨平台的目的。而微信小程序和 React 的 API 和 JSX 语法差距巨大,Taro 是怎么编译的呢?这就要从代码是什么这个问题开始讲起。

代码的本质

不管是任意语言的代码,其实它们都有两个共同点:

  1. 它们都是由字符串构成的文本
  2. 它们都要遵循自己的语言规范

第一点很好理解,既然代码是字符串构成的,我们要修改/编译代码的最简单的方法就是使用字符串的各种正则表达式。例如我们要将 JSON 中一个键名 foo 改为 bar,只要写一个简单的正则表达式就能做到:

jsonStr.replace(/(?<=")foo(?="\s*:)/i, 'bar')

而这句代码就是我们的编译器——你看到这里可能觉得被骗了:“说好了讲一些编译原理高大上的东西呢?”但实际上这是理解编译器万里长征的第零步(也可能是最重要的一步):编译就是把一段字符串改成另外一段字符串。很多同学觉得做编译一定是高大上的,但当我们把它拉下神坛,就可以发现它其实就是(艰难地)操作字符串而已。

我们再来看这个正则表达式,由于 JSON 规定了它的键名必须由双引号包裹且包裹键名的第二个双引号的下一个非空字符串一定是冒号,所以我们的正则一定能匹配到对应的键值。这就是我们之前提到的凡是语言一定有一个规范, JavaScript 作为 JSON 的超集也和 JSON 别无二致,也就是说不管是 JSON 还是 JavaScript 它们的代码都是结构化的,我们可以通过任意一个结构化的数据结构(Schema)把它们的对应语法描述出来。

对于我们的目标而言,我们打算用 JavaScript 去编译 JavaScript。其实要做的事情就是把一段 JavaScript 代码解析成一个让 JavaScript 易于操作的对象,然后我们操作这个对象用它来生成另外一段目标字符串,而那个易于操作的对象我们把它称之为抽象语法树(Abstract Syntax Tree,以下简称为 AST)。生成 AST 的解析器(parser)在一个完整编译器当中属于前端部分,这部分代码可以说是比较无聊、复杂又繁琐的部分,由于 ECMAScript 本身也在不断进化,新的规范在不断添加,parser 也变得越来越复杂。最新的 ECMAScript 规范(ECMA-262)已经是八百页的 PDF 文件,如果我们先把这八百页看完再从头去实现一个 parser 将会消耗掉大量的时间(可能就没有 Taro 也没有这篇文章了)。但好在社区已经有了非常好的 parser 可以供我们直接使用。

Babel

JavaScript 社区其实有非常多 parser 实现,比如 Acorn、Esprima、Recast、Traceur、Cherow 等等。但我们还是选择使用 Babel,主要有以下几个原因:

  1. Babel 可以解析还没有进入 ECMAScript 规范的语法。例如装饰器这样的提案,虽然现在没有进入标准但是已经广泛使用有一段时间了;
  2. Babel 提供插件机制解析 TypeScript、Flow、JSX 这样的 JavaScript 超集,不必单独处理这些语言;
  3. Babel 拥有庞大的生态,有非常多的文档和样例代码可供参考;
  4. 除去 parser 本身,Babel 还提供各种方便的工具库可以优化、生成、调试代码。

Babylon( @babel/parser

Babylon 就是 Babel 的 parser。它可以把一段符合规范的 JavaScript 代码输出成一个符合 Esprima 规范的 AST。 大部分 parser 生成的 AST 数据结构都遵循 Esprima 规范,包括 ESLint 的 parser ESTree。这就意味着我们熟悉了 Esprima 规范的 AST 数据结构还能去写 ESLint 插件。

我们可以尝试解析 n * n 这句简单的表达式:

import * as babylon from "babylon";

const code = `n * n`;

babylon.parse(code);

最终 Babylon 会解析成这样的数据结构:

file

你也可以使用 ASTExploroer 快速地查看代码的 AST。

Babel-traverse (@babel/traverse)

babel-traverse 可以遍历由 Babylon 生成的抽象语法树,并把抽象语法树的各个节点从拓扑数据结构转化成一颗路径(Path)树,Path 表示两个节点之间连接的响应式(Reactive)对象,它拥有添加、删除、替换节点等方法。当你调用这些修改树的方法之后,路径信息也会被更新。除此之外,Path 还提供了一些操作作用域(Scope) 和标识符绑定(Identifier Binding) 的方法可以去做处理一些更精细复杂的需求。可以说 babel-traverse 是使用 Babel 作为编译器最核心的模块。

让我们尝试一下把一段代码中的 n * n 变为 x * x

import * as babylon from "@babel/parser";
import traverse from "babel-traverse";

const code = `function square(n) {
  return n * n;
}`;

const ast = babylon.parse(code);

traverse(ast, {
  enter(path) {
    if (
      path.node.type === "Identifier" &&
      path.node.name === "n"
    ) {
      path.node.name = "x";
    }
  }
});

Babel-types(@babel/types

babel-types 是一个用于 AST 节点的 Lodash 式工具库,它包含了构造、验证以及变换 AST 节点的方法。 该工具库包含考虑周到的工具方法,对编写处理 AST 逻辑非常有用。例如我们之前在 babel-traverse 中改变标识符 n 的代码可以简写为:

import traverse from "babel-traverse";
import * as t from "babel-types";

traverse(ast, {
  enter(path) {
    if (t.isIdentifier(path.node, { name: "n" })) {
      path.node.name = "x";
    }
  }
});

可以发现使用 babel-types 能提高我们转换代码的可读性,在配合 TypeScript 这样的静态类型语言后,babel-types 的方法还能提供类型校验的功能,能有效地提高我们转换代码的健壮性和可靠性。

小结

在本章我们探索了代码究竟是什么:一段结构化的文本,本质是一种叫抽象语法树的复杂拓扑数据结构。也就是说只要我们在简单的情况把代码当做字符串处理,在复杂的情况把代码当做数据处理,这样几乎就可以把一段代码转译成任意的字符串(或数据结构)。我们还介绍了 Babel 一些重要的包以及它们的使用方法,我们了解到 Babel 是使用 JavaScript 处理 JavaScript 代码最成熟的技术方案。

现在,我们已经拥有了写一个转译器的理论基础和技术储备。下一章,我们将讨论 Taro 如何具体地将一段 React (JSX) 代码转译到小程序代码。

参考资料

  1. 《前端要以正确的姿势学习编译原理(上篇)》

深入浅出地介绍了编译原理的基础知识,同时也给出大量的参考资料提供给后续的学习。

  1. 《对 Parser 的误解》

PL 届大神王垠的作品,他一针见血地指出了代码的本质,并对写 Parser 提供了一些建议。

  1. Babel plugin handbook

目前网上能找到最为详细的 Babel 教程,作者是 Babel 的维护者之一,同时也是 TC39 委员会的 Member。但也没有详细讲解 babel-traverse 中 Path 的核心方法。

  1. 《Parsing techniques》

相对于著名的龙书虎书鲸书来说,《Parsing techniques》专注在编译器的前端(也就是 Parser)部分。作者在行文的时候充分考虑了人文学科读者的阅读体验,将复杂繁复的技术讲得尽量易懂

 

JSX 转换微信小程序模板的实现(下)

在 《JSX 转换微信小程序模板的实现(上)》我们已经了解了 Taro 编译器的理论基础。本章我们将一步步地探究 Taro 是如何将一个 JSX 文件转换成 JavaScript 文件、CSS 文件以及 JSON 文件。以一个简单 Page 页面为例:

import Taro, { Component } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'

class Home extends Component {

  config = {
    navigationBarTitleText: '首页'
  }

  state = {
    numbers: [1, 2, 3, 4, 5]
  }

  handleClick = () => {
    this.props.onTest()
  }

  render () {
    const oddNumbers = this.state.numbers.filter(number => number & 2)
    return (
      <ScrollView className='home' scrollTop={false}>
        奇数:
        {
          oddNumbers.map(number => <Text onClick={this.handleClick}>{number}</Text>)
        }
        偶数:
        {
          numbers.map(number => number % 2 === 0 && <Text onClick={this.handleClick}>{number}</Text>)
        }
      </ScrollView>
    )
  }
}

设计思路

Taro 的结构主要分两个方面:运行时和编译时。运行时负责把编译后到代码运行在本不能运行的对应环境中,你可以把 Taro 运行时理解为前端开发当中 polyfill。举例来说,小程序新建一个页面是使用 Page 方法传入一个字面量对象,并不支持使用类。如果全部依赖编译时的话,那么我们要做到事情大概就是把类转化成对象,把 state 变为 data,把生命周期例如 componentDidMount 转化成 onReady,把事件由可能的类函数(Class method)和类属性函数(Class property function) 转化成字面量对象方法(Object property function)等等。

但这显然会让我们的编译时工作变得非常繁重,在一个类异常复杂时出错的概率也会变高。但我们有更好的办法:实现一个 createPage 方法,接受一个类作为参数,返回一个小程序 Page 方法所需要的字面量对象。这样不仅简化了编译时的工作,我们还可以在 createPage 对编译时产出的类做各种操作和优化。通过运行时把工作分离了之后,再编译时我们只需要在文件底部加上一行代码 Page(createPage(componentName)) 即可。

如果你是从 Taro CLI 的 dist 文件夹看编译后的代码会发现它相当复杂,那是因为代码会再经过 babel 编译为 ES5。

除了 Page 类型之外,小程序还有 Component 类型,所以 Taro 其实还有 createComponent 方法。由于 Component 在小程序里是全局变量,因此我们还得把 import { Component } from '@tarojs/taro' 的 Component 重命名。

file

回到一开始那段代码,我们定义了一个类属性 configconfig 是一个对象表达式(Object Expression),这个对象表达式只接受键值为标识符(Identifier)或字符串,而键名只能是基本类型。这样简单的情况我们只需要把这个对象表达式转换为 JSON 即可。另外一个类属性 state 在 Page 当中有点像是小程序的 data,但它在多数情况不是完整的 data(下文会继续讨论data)。这里我们不用做过多的操作,babel的插件 transform-class-proerties 会把它编译到类的构造器中。函数 handleClick 我们交给运行时处理,有兴趣的同学可以跳到 Taro 运行时原理查看具体技术细节。

再来看我们的 render() 函数,它的第一行代码通过 filter 把数字数组的所有偶数项都过滤掉,真正用来循环的是 oddNumbers,而 oddNumbers 并没有在 this.state 中,所以我们必须手动把它加入到 this.state。和 React 一样,Taro 每次更新都会调用 render 函数,但和 React 不同的是,React 的 render 是一个创建虚拟 DOM 的方法,而 Taro 的 render 会被重命名为 _createData,它是一个创建数据的方法:在 JSX 使用过的数据都在这里被创建最后放到小程序 Page 或 Component 工厂方法中的 data 。最终我们的 render 方法会被编译为:

_createData() {
  this.__state = arguments[0] || this.state || {};
  this.__props = arguments[1] || this.props || {};

  const oddNumbers = this.__state.numbers.filter(number => number & 2);
  Object.assign(this.__state, {
    oddNumbers: oddNumbers
  });
  return this.__state;
}

WXML 和 JSX

在 Taro 里 render 的所有 JSX 元素都会在 JavaScript 文件中被移除,它们最终将会编译成小程序的 WXML。每个 WXML 元素和 HTML 元素一样,我们可以把它定义为三种类型:ElementTextComment。其中 Text 只有一个属性: 内容(content),它对应的 AST 类型是 JSXText,我们只需要将前文源码中对应字符串的奇数和偶数转换成 Text 即可。而对于 Comment 而言我们可以将它们全部清除,不参与 WXML 的编译。Element 类型有它的名字(tagName)、children、属性(attributes),其中 children 可能是任意 WXML 类型,属性是一个对象,键值和键名都是字符串。我们将把重点放在如何转换成为 WXML 的 Element 类型。

首先我们可以先看 <View className='home'>,它在 AST 中是一个 JSXElement,它的结构和我们定义 Element 类型差不多。我们先将 JSXElement 的 ScrollView 从驼峰式的 JSX 命名转化为短横线(kebab case)风格,className 和 scrollTop 的值分别代表了 JSXAttribute 值的两种类型:StringLiteral 和 JSXExpressionContainerclassName 是简单的 StringLiteral 处理起来很方便,scrollTop 处理起来稍微麻烦点,我们需要用两个花括号 {} 把内容包起来。

JSXExpressionContainer 其实可以包含任何合法的 JavaScript 表达式,本例中我们只传入了一个字面量的布尔值,直接用双括号包裹在 WXML 是合法的。但 WXML 的模板支持的表达式是有限的,当表达式包含函数时 Taro 将生成一个匿名的 state 放在当前表达式作用域的前一行,并处理作用域命名的问题。

接下来我们再思考一下每一个 JSXElement 出现的位置,你可以发现其实它的父元素只有几种可能性:return、循环、条件(逻辑)表达式。而在上一篇文章中我们提到,babel-traverse 遍历的 AST 类型是响应式的——也就是说只要我们按照 JSXElement 父元素类型的顺序穷举处理这几种可能性,把各种可能性大结果应用到 JSX 元素之后删除掉原来的表达式,最后就可以把一个复杂的 JSX 表达式转换为一个简单的 WXML 数据结构。

JSXElement 的父元素其实可能有很多种情况,例如父元素可能是一个 JSXAttribute,这类情况 Taro 还不支持,我们用 ESLint 插件规避了这样的写法。还有一些情况,例如赋值表达式和 If 表达式处理起来较为复杂,本文不过多赘述。

我们先看第一个循环:

oddNumbers.map(number => <Text onClick={this.handleClick}>{number}</Text>)

Text 的父元素是一个 map 函数(CallExpression),我们可以把函数的 callee: oddNumbers 作为 wx:for 的值,并把它放到 state 中,匿名函数的第一个参数是 wx:for-item的值,函数的第二个参数应该是 wx:for-index 的值,但代码中没有传所以我们可以不管它。然后我们把这两个 wx: 开头的参数作为 attribute 传入 Text 元素就完成了循环的处理。而对于 onClick 而言,在 Taro 中 on 开头的元素参数都是事件,所以我们只要把 this. 去掉即可。Text 元素的 children 是一个 JSXExpressionContainer,我们按照之前的处理方式处理即可。最后这行我们生成出来的数据结构应该是这样:

{
  type: 'element',
  tagName: 'text',
  attributes: [
    { bindtap: 'handleClick' },
    { 'wx:for': '{{oddNumbers}}' },
    { 'wx:for-item': 'number' }
  ],
  children: [
    { type: 'text', content: '{{number}}' }
  ]
}

有了这个数据结构生成一段 WXML 就非常简单了,你可以参考 himalaya 的代码。

再来看第二个循环表达式:

numbers.map(number => number % 2 === 0 && <Text onClick={this.handleClick}>{number}</Text>)

它比第一个循环表达式多了一个逻辑表达式(Logical Operators),我们知道 expr1 && expr2 意味着如果 expr1 能转换成 true 则返回 expr2,也就是说我们只要把 number % 2 === 0 作为值生成一个键名 wx:if 的 JSXAttribute 即可。但由于 wx:if 和 wx:for 同时作用于一个元素可能会出现问题,所以我们应该生成一个 block 元素,把 wx:if 挂载到 block 元素,原元素则全部作为 children 传入 block 元素中。这时 babel-traverse 会检测到新的元素 block,它的父元素是一个 map 循环函数,因此我们可以按照第一个循环表达式的处理方法来处理这个表达式。

这里我们可以思考一下 this.props.text || this.props.children 的解决方案。当用户在 JSX 中使用 || 作为逻辑表达式时很可能是 this.props.text 和 this.props.children 都有可能作为结果返回。这里 Taro 将它编译成了 this.props.text ? this.props.text: this.props.children,按照条件表达式(三元表达式)的逻辑,也就是说会生成两个 block,一个 wx:if 和一个 wx:else

<block wx:if="{{text}}">{{text}}</block>
<block wx:else>
    <slot></slot>
</block>

条件表达式(Conditional Expression)的处理比逻辑表达式稍微复杂一些,因为表达式返回的结果可以是任意类型。但万变不离其宗,我们只要一直处理 JSX 元素的父元素,如果支持不了就用 ESLint 警告,如果能够支持就把表达式转换成对应的属性挂载在到 JSX 元素中再把表达式删除,直到我们能将这个 JSX 元素移除为止。

小结

本章节我们介绍 Taro 转换 JSX 的运行机制和一些基本的转换操作。由于篇幅所限我们也没法面面俱到,读者朋友们不妨思考一些更复杂的情况:例如在循环中有 wxml 不支持的复杂表达式,wxml 的 wx:for 的值是否有变化?复杂的表达式在 wxml 里应该用什么来替代?如果循环中有 if 表达式怎么办?

思考之后就不难发现,当处理简单的 JSX 转换时是比较容易的,但情况复杂起来转换的复杂度会相应地大幅增加。开源几个月以来,不管从运行时机制还是转换机制来讲 Taro 都是「摸着石头过河」,远远称不上完美。但目前 Taro 还是在没有前人探索过的道路上披荆斩棘走了一段路,未来的路,我们还需要在你和社区伙伴的帮助下一起走下去。

 

运行时揭秘 - 小程序运行时

为了使 Taro 组件转换成小程序组件并运行在小程序环境下, Taro 主要做了两个方面的工作:编译以及运行时适配。编译过程会做很多工作,例如:将 JSX 转换成小程序 .wxml 模板,生成小程序的配置文件、页面及组件的代码等等。编译生成好的代码仍然不能直接运行在小程序环境里,那运行时又是如何与之协同工作的呢?

注册程序、页面以及自定义组件

在小程序中会区分程序、页面以及组件,通过调用对应的函数,并传入包含生命周期回调、事件处理函数等配置内容的 object 参数来进行注册:

Component({
  data: {},
  methods: {
    handleClick () {}
  }
})

而在 Taro 里,它们都是一个组件类:

class CustomComponent extends Component {
  state = { }
  handleClick () { }
}

那么 Taro 的组件类是如何转换成小程序的程序、页面或组件的呢?

例如,有一个组件:customComponent,编译过程会在组件底部添加一行这样的代码(此处代码作示例用,与实际项目生成的代码不尽相同):

Component(createComponent(customComponent))

createComponent 方法是整个运行时的入口,在运行的时候,会根据传入的组件类,返回一个组件的配置对象。

在小程序里,程序的功能及配置与页面和组件差异较大,因此运行时提供了两个方法 createApp 和 createComponent 来分别创建程序和组件(页面)。createApp 的实现非常简单,本章我们主要介绍 createComponent 做的工作。

createComponent 方法主要做了这样几件事情:

  1. 将组件的 state 转换成小程序组件配置对象的 data
  2. 将组件的生命周期对应到小程序组件的生命周期
  3. 将组件的事件处理函数对应到小程序的事件处理函数

接下来将分别讲解以上三个部分。

组件 state 转换

其实在 Taro(React) 组件里,除了组件的 state,JSX 里还可以访问 props 、render 函数里定义的值、以及任何作用域上的成员。而在小程序中,与模板绑定的数据均来自对应页面(或组件)的 data 。因此 JSX 模板里访问到的数据都会对应到小程序组件的 data 上。接下来我们通过列表渲染的例子来说明 state 和 data 是如何对应的:

在 JSX 里访问 state

在小程序的组件上使用 wx:for 绑定一个数组,就可以实现循环渲染。例如,在 Taro 里你可能会这么写:

{ 
  state = {
    list: [1, 2, 3]
  }
  render () {
    return (
      <View>
        {this.state.list.map(item => <View>{item}</View>)}
      </View>
    )
  }
}

编译后的小程序组件模板:

<view>
  <view wx:for="{{list}}" wx:for-item="item">{{item}}</view> 
</view>

其中 state.list 只需直接对应到小程序(页面)组件的 data.list 上即可。

在 render 里生成了新的变量

然而事情通常没有那么简单,在 Taro 里也可以这么用:

{
  state = {
    list = [1, 2, 3]
  }
  render () {
    return (
      <View>
        {this.state.list.map(item => ++item).map(item => <View>{item}</View>)}
      </View>
    )
  }
}

编译后的小程序组件模板是这样的:

<view>
  <view wx:for="{{$anonymousCallee__1}}" wx:for-item="item">{{item}}</view> 
</view>

在编译时会给 Taro 组件创建一个 _createData 的方法,里面会生成 $anonymousCallee__1 这个变量, $anonymousCallee__1 是由编译器生成的,对 this.state.list 进行相关操作后的变量。 $anonymousCallee__1 最终会被放到组件的 data 中给模板调用:

var $anonymousCallee__1 = this.state.list.map(function (item) {
  return ++item;
});

render 里 return 之前的所有定义变量或者对 props、state 计算产生新变量的操作,都会被编译到 _createData 方法里执行,这一点在前面 JSX 编译成小程序模板的相关文章中已经提到。每当 Taro 调用 this.setState API 来更新数据时,都会调用生成的 _createData 来获取最新数据。

将组件的生命周期对应到小程序组件的生命周期

生命周期的对应工作主要包含两个部分:初始化过程和状态更新过程。

初始化过程里的生命周期对应很简单,在小程序的生命周期回调函数里调用 Taro 组件里对应的生命周期函数即可,例如:小程序组件 ready 的回调函数里会调用 Taro 组件的 componentDidMount 方法。它们的执行过程和对应关系如下图:

file

小程序的页面除了渲染过程的生命周期外,还有一些类似于 onPullDownRefresh 、 onReachBottom 等功能性的回调方法也放到了生命周期回调函数里。这些功能性的回调函数,Taro 未做处理,直接保留了下来。

小程序页面的 componentWillMount 有一点特殊,会有两种初始化方式。由于小程序的页面需要等到 onLoad 之后才可以获取到页面的路由参数,因此如果是启动页面,会等到 onLoad 时才会触发。而对于小程序内部通过 navigateTo 等 API 跳转的页面,Taro 做了一个兼容,调用 navigateTo 时将页面参数存储在一个全局对象中,在页面 attached 的时候从全局对象里取到,这样就不用等到页面 onLoad 即可获取到路由参数,触发 componentWillMount 生命周期。

状态更新:

file

Taro 组件的 setState 行为最终会对应到小程序的 setData。Taro 引入了如 nextTick ,编译时识别模板中用到的数据,在 setData 前进行数据差异比较等方式来提高 setState 的性能。

如上图,组件调用 setState 方法之后,并不会立刻执行组件更新逻辑,而是会将最新的 state 暂存入一个数组中,等 nextTick 回调时才会计算最新的 state 进行组件更新。这样即使连续多次的调用 setState 并不会触发多次的视图更新。在小程序中 nextTick 是这么实现的:

const nextTick = (fn, ...args) => {
  fn = typeof fn === 'function' ? fn.bind(null, ...args) : fn
  const timerFunc = wx.nextTick ? wx.nextTick : setTimeout
  timerFunc(fn)
}

除了计算出最新的组件 state ,在组件状态更新过程里还会调用前面提到过的 _createData 方法,得到最终小程序组件的 data,并调用小程序的 setData 方法来进行组件的更新。

组件更新如何触发子组件的更新呢?

这里用到了小程序组件的 properties 的 observer 特性,给子组件传入一个 prop 并在子组件里监听 prop 的更改,这个 prop 更新就会触发子组件的状态更新逻辑。细心的 Taro 开发者可能会发现,编译后的代码里会给每个自定义的组件传入一个 __triggerObserer 的值,它的作用正是用于触发子组件的更新逻辑。

由于小程序在调用 setData 之后,会将数据使用 JSON.stringify 进行序列化,再拼接成脚本,然后再传给视图层渲染,这样的话,当数据量非常大的时候,小程序就会变得异常卡顿,性能很差。Taro 在框架级别帮助开发者进行了优化。

  • 首先,在编译的过程中会找到所有在模板中用到到字段 ,并存储到组件的 $usedState 字段中。例如,编译后的小程序模板:
<view>{{a}}<view>

那么在编译后的组件类里就会多这样一个字段:

{
  $usedState = ['a']
}

在计算完小程序的 data 之后,会遍历 $usedState 字段,将多余的内容过滤掉,只保留模板用到的数据。例如,即使原本组件的状态包含:

{
  state = {
    a: 1,
    b: 2,
    c: 3
  }
}

最终 setData 的数据也只会包含 $usedState 里存在的字段:

{
  a: 1
}
  • 其次在 setData 之前进行了一次数据 Diff,找到数据的最小更新路径,然后再使用此路径来进行更新。例如:
// 初始 state
this.state = {
  a: [0],
  b: {
    x: {
      y: 1
    }
  }
}

// 调用 this.setState

this.setState({
  a: [1, 2],
  b: {
    x: {
      y: 10
    }
  }
})

在优化之前,会直接将 this.setState 的数据传给 setData,即:

this.$scope.setData({
  a: [1, 2],
  b: {
    x: {
      y: 10
    }
  }
})

而在优化之后的数据更新则变成了:

this.$scope.setData({
  'a[0]': 1,
  'a[1]': 2,
  'b.x.y': 10
})

这样的优化对于小程序来说意义非常重大,可以避免因为数据更新导致的性能问题。

事件处理函数对应

在小程序的组件里,事件响应函数需要配置在 methods 字段里。而在 JSX 里,事件是这样绑定的:

<View onClick={this.handleClick}></View>

编译的过程会将 JSX 转换成小程序模板:

<view bindclick="handleClick"></view>

在 createComponent 方法里,会将事件响应函数 handleClick 添加到 methods 字段中,并且在响应函数里调用真正的 this.handleClick 方法。

在编译过程中,会提取模板中绑定过的方法,并存到组件的 $events 字段里,这样在运行时就可以只将用到的事件响应函数配置到小程序组件的 methods 字段中。

在运行时通过 processEvent 这个方法来处理事件的对应,省略掉处理过程,就是这样的:

function processEvent (eventHandlerName, obj) {
  obj[eventHandlerName] = function (event) {
    // ...
    scope[eventHandlerName].apply(callScope, realArgs)
  }
}

这个方法的核心作用就是解析出事件响应函数执行时真正的作用域 callScope 以及传入的参数。在 JSX 里,我们可以像下面这样通过 bind 传入参数:

<View onClick={this.handleClick.bind(this, arga, argb)}></View>

小程序不支持通过 bind 的方式传入参数,但是小程序可以用 data 开头的方式,将数据传递到 event.currentTarget.dataset 中。编译过程会将 bind 方式传递的参数对应到 dataset 中,processEvent 函数会从 dataset 里取到传入的参数传给真正的事件响应函数。

至此,经过编译之后的 Taro 组件终于可以运行在小程序环境里了。为了方便用户的使用,小程序运行时还提供了更多的特性,接下来会举一个例子来说明。

对 API 进行 Promise 化的处理 Taro 对小程序的所有 API 进行了一个分类整理,将其中的异步 API 做了一层 Promise 化的封装。例如,wx.getStorage经过下面的处理对应到Taro.getStorage(此处代码作示例用,与实际源代码不尽相同):

Taro['getStorage'] = options => {
  let obj = Object.assign({}, options)
  const p = new Promise((resolve, reject) => {
    ['fail', 'success', 'complete'].forEach((k) => {
      obj[k] = (res) => {
        options[k] && options[k](res)
        if (k === 'success') {
          resolve(res)
        } else if (k === 'fail') {
          reject(res)
        }
      }
    })
    wx['getStorage'](obj)
  })
  return p
}

就可以这么调用了:

// 小程序的调用方式
Taro.getStorage({
  key: 'test',
  success() {

  }
})
// 在 Taro 里也可以这样调用
Taro.getStorage({
  key: 'test'
}).then(() => {
  // success
})

百度/支付宝小程序运行时

Taro 在支持转换到 微信小程序 的同时,已经支持转换到 百度/支付宝小程序 了,这两家小程序的使用方式与微信小程序相似程度非常高,所以其运行时机制也与微信小程序基本一致,读者朋友可以自行类比,或通过源码一探究竟。

小结

本章节主要讲解了两个方面的内容:

  • Taro 小程序运行时是如何配合编译过程,抹平了状态、事件绑定以及生命周期的差异,使得 Taro 组件运行在小程序环境中。
  • 通过运行时对原生 API 进行扩展,实现了诸如事件绑定时通过 bind 传递参数、通过 Promise 的方式调用原生 API 等特性。
  • 本章节参考 Taro 源码

 

运行时揭秘 - H5 运行时

通过前面的文章《JSX 转换微信小程序模板的实现》,我们对Taro所做的编译转换工作有了一定的了解。Taro将 JS 代码转换为 AST 后,进行了诸如将data换成state,把componentDidMount改写成onReady等等的操作,再把修改后的 AST 转换成适合小程序执行的源码。

但上面这些工作,距离生成一个开箱即用的 H5 项目,距离我们的最终目标Write once, run anywhere还远远不够。要达成这个大目标,我们在《Taro 多端统一开发设计思路及架构》一文中提到过:因为各平台不尽相同的运行时框架、组件标准、API 标准和运行机制,除了在编译时进行多端转换,我们还需要在运行时抹平多端的差异。这篇文章将会对这部分运行时的工作进行阐述。

H5 运行时解析

首先,我们选用Nerv作为 Web 端的运行时框架。你可能会有问题:同样是类React框架,为何我们不直接用React,而是用Nerv呢?

为了更快更稳。开发过程中前端框架本身有可能会出现问题。如果是第三方框架,很有可能无法得到及时的修复,导致整个项目的进度受影响。Nerv就不一样。作为团队自研的产品,出现任何问题我们都可以在团队内部快速得到解决。与此同时,Nerv也具有与React相同的 API,同样使用 Virtual DOM 技术进行优化,正常使用与React并没有区别,完全可以满足我们的需要。

使用Taro之后,我们书写的是类似于下图的代码:

 

 

 

我们注意到,就算是转换过的代码,也依然存在着viewbutton等在 Web 开发中并不存在的组件。如何在 Web 端正常使用这些组件?这是我们碰到的第一个问题。

 

组件实现

我们不妨捋一捋小程序和 Web 开发在这些组件上的差异:

 

 

作为开发者,你第一反应或许会尝试在编译阶段下功夫,尝试直接使用效果类似的 Web 组件替代:用div替代view,用img替代image,以此类推。

费劲心机搞定标签转换之后,上面这个差异似乎是解决了。但很快你就会碰到一些更加棘手的问题:hover-start-timehover-stay-time等等这些常规 Web 开发中并不存在的属性要如何处理?

回顾一下:在前面讲到多端转换的时候,我们说到了babel。在Taro中,我们使用babylon生成 AST,babel-traverse去修改和移动 AST 中的节点。但babel所做的工作远远不止这些。

我们不妨去babel的 playground 看一看代码在转译前后的对比:在使用了@babel/preset-envBUILT-INS之后,简单的一句源码new Map(),在babel编译后却变成了好几行代码:

file

注意看这几个文件:core-js/modules/web.dom.iterablecore-js/modules/es6.array.iteratorcore-js/modules/es6.map。我们可以在core-js的 Git 仓库找到他们的真身。很明显,这几个模块就是对应的 es 特性运行时的实现。

从某种角度上讲,我们要做的事情和babel非常像。babel把基于新版 ECMAScript 规范的代码转换为基于旧 ECMAScript 规范的代码,而Taro希望把基于React语法的代码转换为小程序的语法。我们从babel受到了启发:既然babel可以通过运行时框架来实现新特性,那我们也同样可以通过运行时代码,实现上面这些 Web 开发中不存在的功能。

举个例子。对于view组件,首先它是个普通的类 React 组件,它把它的子组件如实展示出来:

import Nerv, { Component } from 'nervjs';

class View extends Component {
  render() {
    return (
      <div>{this.props.children}</div>
    );
  }
}

这太简单。接下来,我们需要对hover-start-time做处理。与Taro其他地方的命名规范一致,我们这个View组件接受的属性名将会是驼峰命名法:hoverStartTimehoverStartTime参数决定我们将在View组件触发touch事件多久后改变组件的样式。hover-stay-time属性的处理也十分类似,就不再赘述。这些属性的实现比起前面的代码会稍微复杂一点点,但绝对没有超纲。

// 示例代码
render() {
  const {
    hoverStartTime = 50,
    onTouchStart
  } = this.props;

  const _onTouchStart = e => {
    setTimeout(() => {
      // @TODO 触发touch样式改变
    }, hoverStartTime);
    onTouchStart && onTouchStart(e);
  }
  return (
    <div onTouchStart={_onTouchStart}>
      {this.props.children}
    </div>
  );
}

再稍加修饰,我们就能得到一个功能完整的 Web 版 View 组件 。

view可以说是小程序最简单的组件之一了。text的实现甚至比上面的代码还要简单得多。但这并不说明组件的实现之路上就没有障碍。复杂如swiperscroll-viewtabbar,我们需要花费大量的精力分析小程序原生组件的 API,交互行为,极端值处理,接受的属性等等,再通过 Web 技术实现。

API 适配

除了组件,小程序下有一些 API 也是 Web 开发中所不具备的。比如小程序框架内置的wx.request/wx.getStorage等 API;但在 Web 开发中,我们使用的是fetch/localStorage等内置的函数

小程序的 API 实现是个巨大的黑盒,我们仅仅知道如何使用它,使用它会得到什么结果,但对它内部的实现一无所知。

如何让 Web 端也能使用小程序框架中提供的这些功能?既然已经知道这个黑盒的入参出参情况,那我们自己打造一个黑盒就好了。

换句话说,我们依然通过运行时框架来实现这些 Web 端不存在的能力。

具体说来,我们同样需要分析小程序原生 API,最后通过 Web 技术实现。有兴趣可以在 Git 仓库中看到这些原生 API 的实现。下面以wx.setStorage为例进行简单解析。

wx.setStorage是一个异步接口,可以把key: value数据存储在本地缓存。很容易联想到,在 Web 开发中也有类似的数据存储概念,这就是localStorage。到这里,我们的目标已经十分明确:我们需要借助于localStorage,实现一个与wx.setStorage相同的 API。

我们首先查阅官方文档了解这个 API 的具体入参出参:

参数类型必填说明
keyString本地缓存中的指定的 key
dataObject/String需要存储的内容
successFunction接口调用成功的回调函数
failFunction接口调用失败的回调函数
completeFunction接口调用结束的回调函数(调用成功、失败都会执行)

而在 Web 中,如果我们需要往本地存储写入数据,使用的 API 是localStorage.setItem(key, value)。我们很容易就可以构思出这个函数的雏形:

/* 示例代码 */
function setStorage({ key, value }) {
  localStorage.setItem(key, value);
}

我们顺手做点优化,把基于异步回调的 API 都给做了一层 Promise 包装,这可以让代码的流程处理更加方便。所以这段代码看起来会像下面这样:

/* 示例代码 */
function setStorage({ key, value }) {
  localStorage.setItem(key, value);
  return Promise.resolve({ errMsg: 'setStorage:ok' });
}

看起来很完美,但开发的道路不会如此平坦。我们还需要处理其余的入参:successfailcompletesuccess回调会在操作成功完成时调用,fail会在操作失败的时候执行,complete则无论如何都会执行。setStorage函数只会在key值是String类型时有正确的行为,所以我们为这个函数添加了一个简单的类型判断,并在异常情况下执行fail回调。经过这轮变动,这段代码看起来会像下面这样:

/* 示例代码 */
function setStorage({ key, value, success, fail, complete }) {
  let res = { errMsg: 'setStorage:ok' }
  if (typeof key === 'string') {
    localStorage.setItem(key, value);
    success && success(res);
  } else {
    fail && fail(res);
    return Promise.reject(res);
  }
  complete && complete(res);
  return Promise.resolve({ errMsg: 'setStorage:ok' });
}

这个函数的最终版本可以在 Taro 仓库中找到。

把这个 API 实现挂载到Taro模块之后,我们就可以通过Taro.setStorage来调用这个 API 了。

当然,也有一些 API 是 Web 端无论如何无法实现的,比如wx.login,又或者wx.scanCode。我们维护了一个 API 实现情况的列表,在实际的多端项目开发中应该尽可能避免使用它们。

路由

作为小程序的一大能力,小程序框架中以栈的形式维护了当前所有的页面,由框架统一管理。用户只需要调用wx.navigateTo,wx.navigateBack,wx.redirectTo等官方 API,就可以实现页面的跳转、回退、重定向,而不需要关心页面栈的细节。但是作为多端项目,当我们尝试在 Web 端实现路由功能的时候,就需要对小程序和 Web 端单页应用的路由原理有一定的了解。

小程序的路由比较轻量。使用时,我们先通过app.json为小程序配置页面列表:

{
  "pages": [
    "pages/index/index",
    "pages/logs/logs"
  ],
  // ...
}

在运行时,小程序内维护了一个页面栈,始终展示栈顶的页面(Page对象)。当用户进行跳转、后退等操作时,相应的会使页面栈进行入栈、出栈等操作。 | 路由方式 | 页面栈表现 | | ---------- | --------------------------------- | | 初始化 | 新页面入栈(push) | | 打开新页面 | 新页面入栈(push) | | 页面重定向 | 当前页面出栈,新页面入栈(pop, push) | | 页面返回 | 页面不断出栈,直到目标返回页(pop) | | Tab 切换 | 页面全部出栈,只留下新的 Tab 页面 | | 重加载 | 页面全部出栈,只留下新的页面 |

同时,在页面栈发生路由变化时,还会触发相应页面的生命周期:

路由方式触发时机路由前页面路由后页面
初始化小程序打开的第一个页面 onLoad, onShow
打开新页面调用 API wx.navigateTo 或使用组件 navigatoronHideonLoad, onShow
页面重定向调用 API wx.redirectTo 或使用组件 navigatoronUnloadonLoad, onShow
页面返回调用 API wx.navigateBack 或使用组件 navigator 或用户按左上角返回按钮onUnloadonShow
重启动调用 API wx.reLaunch 或使用组件 navigatoronUnloadonLoad, onShow

对于 Web 端单页应用路由,我们则以react-router为例进行说明:

首先,react-router开始通过history工具监听页面路径的变化。

在页面路径发生变化时,react-router会根据新的location对象,触发 UI 层的更新。

至于 UI 层如何更新,则是取决于我们在Route组件中对页面路径和组件的绑定,甚至可以实现嵌套路由。

可以说,react-router的路由方案是组件级别的。

具体到Taro,为了保持跟小程序的行为一致,我们不需要细致到组件级别的路由方案,但需要为每次路由保存完整的页面栈。

实现形式上,我们参考react-router:监听页面路径变化,再触发 UI 更新。这是React的精髓之一,单向数据

file

@tarojs/router包中包含了一个轻量的history实现。history中维护了一个栈,用来记录页面历史的变化。对历史记录的监听,依赖两个事件:hashchangepopstate

/* 示例代码 */
window.addEventListener('hashchange', () => {});
window.addEventListener('popstate', () => {})

对于使用 Hash 模式的页面路由,每次页面跳转都会依次触发popstatehashchange事件。由于在popstate的回调中可以取到当前页面的 state,我们选择它作为主要跳转逻辑的容器。

作为 UI 层,@tarojs/router包提供了一个Router组件,维护页面栈。与小程序类似,用户不需要手动调用Router组件,而是由Taro自动处理。

对于历史栈来说,无非就是三种操作:pushpop,还有replace。在历史栈变动时触发Router的回调,就可以让Router也同步变化。这就是Taro中路由的基本原理。

只有三种操作,说起来很简单,但实际操作中有一个难点。设想你正处在一个历史栈的中间:(...、a、b、你、b,c),c 是栈顶。 这时候,你通过hashchange事件得知页面 Hash 变化了,肯定是页面发生跳转了。不过很遗憾,跳转后的页面 Hash 是 b。这时候,你能知道这次路由变动到底是前进还是后退吗?

我们在hashchange回调中,通过history.replaceState API,在 state 中记录了页面的跳转次数。从而可以在popstate中推断导致跳转的具体行为。具体可以在这里看到相关实现。

@tarojs/router实现中还有一些小细节需要处理。比如如何加入compomentDidShow之类原本不存在的生命周期? 我们选择在运行时进行这个操作。对于在入口config中注册的页面文件,我们继承了页面类并对componentDidMount做了改写,简单粗暴地插入了componentDidShow的调用。

Redux 处理

每当提到React的数据流,我们就不得不提到Redux。通过合并ReducerRedux可以让大型应用中的数据流更加规则、可预测。

我们在Taro中加入了Redux的支持,通过导入@tarojs/redux,即可在小程序端使用Redux的功能。

对于 Web 端,我们尝试直接使用nerv-redux包提供支持,但这会带来一些问题。

我们使用与下面类似的代码:

import Nerv from 'nervjs'
import { connect } from 'nerv-redux'

@connect(() => {})
class Index extends Nerv.Componnet {
  componentDidShow() { console.log('didShow') }
  componentDidMount() { console.log('didMount') }
  render() { return '' }
}

但这个componentDidShow并没有执行。为什么?

回想一下前面讲的componentDidShow的实现:我们继承,并且改写 componentDidMount

但是对于使用Redux的页面来说,我们继承的类,是经过@connect修饰过的一个高阶组件。

问题就出在这里:这个高阶组件的签名里并没有componentDidShow这一个函数。所以我们的 componentDidMount 内,理所当然是取不到componentDidShow的。

为了解决这个问题,我们对react-redux代码进行了一些小改装,这就是@taro/redux-h5的由来。

小结

这个章节对 H5 端的运行时环境进行了解析,包括组件库的原理和实现,还有端能力 API 的实现。

看完这篇文章,你可能就对Taro解决问题的两个方式非常熟悉了,无非就是编译时运行时

说起来可能非常简单,但这并不意味着实现起来也很简单。需要对小程序原生 API 功能、交互等进行透彻的分析和细心的实现。无论这其中有多少坑多少工作量,只要是为了提升开发体验,我们认为都是值得的。

 

运行时揭秘 - RN 运行时

Expo is a set of tools, libraries and services which let you build native iOS and Android apps by writing JavaScript.

Taro RN 端的开发基于开源项目 Expo。Expo 是一组工具、库和服务,基于 React Native 可让你通过编写 JavaScript 来构建原生 iOS 和 Android 应用程序。

为什么选择 Expo? 从某种程度上而言,目前为止 RN 只是给拥有 Mac 电脑的开发者提供了跨平台开发的能力, 因为现在还不能使用 Windows 创建 iOS 的 RN 应用。还有一个比较普遍的问题是,有一些 iOS 程序员不会配置 Android 的编译环境,而一些 Android 程序员又搞不懂 XCode。而且,Taro 的使用者基本都是前端工程师,面对 iOS 和 Android 原生的库或者文件可能会不知所措。

我们希望 Taro 的使用者,即使完全没有 RN 开发经验,也能够 5分钟实现 Hello Wolrd 的编写,并且只需要专注于基于 Taro 实现功能,不用再去配置烦人的 iOS、Android 编译环境,还可以用 Windows 开发 iOS 版的 RN 应用。

本质上,Expo 相当于一个壳,你只需关注 JS 层面的开发即可。这点类似于 Electron 或者小程序。

 

开发流程 当你运行 taro build 的 RN 端编译命令之后,Taro 命令开始编译工程。编译完成后,Taro 工程的代码将会被编译成到 .rn_temp目录下。

接下来,你可以直接在终端输入对应的字母,来进行对应的操作,如果你选择使用模拟器,模拟器及其安装的 Expo 客户端将自动启动(如果已成功安装),然后加载应用;如果你使用真机,你只需要使用 Expo 应用扫描二维码就可以打开你编写的 RN 应用了。并且只要应用在 Expo 中打开过一次,就会在 App 中保留一个入口。在这里,Expo 客户端相当于一个浏览器,可以访问到你开发的应用。

因此,开发流程其实主要分为两步:项目编译启动 Expo 服务。下面我们详细讲解这两步。

详细的开发流程,可以查看 Taro 官方文档 React Native 开发教程

项目编译 下面是一个最简单的 index 页面的转换:

 

我们可以看到,由于 Taro 工程的代码转 RN 工程代码,在 JS 编译范畴,本质上还是 JSX -> JSX,所以并没有编译成小程序那么复杂。我们主要得考虑的是:

  1. View ,Text 等组件,怎样和小程序的用法及特性保持一致?
  2. 样式由 CSS -> StyleSheet ,怎样实现?

这些问题我们将在接下来的部分会一一解答。

启动 Expo 服务 在编译完之后,如果你选择使用模拟器来进行开发调试,Taro 会调用 @tarojs/rn-runner 包启动 packager@tarojs/rn-runner 基于 create-react-native-app 进行了定制,主要是实现以下功能:

  1. 启动 iOS / Android 模拟器及模拟器中安装的 Expo 客户端
  2. 启动本地服务器,供模拟器中的 Expo 客户端访问,加载应用资源
  3. Building JavaScript bundle,也就是所谓的打包

有兴趣的可以直接看一下 @tarojs/rn-runner 包,代码量不多,结构还是挺清晰的。

这里可能有人会有疑问:在代码编译完成,生成工程目录 .rn_temp后,Expo 是怎样与入口文件 App.js 结合起来的呢?

答案就在 bin/crna_entry.js 里面,你可以在 .rn_temp/package.json 里面看到如下配置:

"main": "./bin/crna-entry.js",

也就是说,crna_entry.js 是整个工程的入口文件。该文件的代码不多,只有短短几行,大家一看便知:

import Expo from 'expo';
import App from '../../../../App';
import React, { Component } from 'react';
import { View } from 'react-native';

if (process.env.NODE_ENV === 'development') {
  Expo.KeepAwake.activate();
}

Expo.registerRootComponent(App);

就这样,Expo 获取到了工程的入口,既可以开始愉快的 Building JavaScript bundle 了。

组件 Taro 以 微信小程序组件库 为标准,结合 JSX 语法规范,定制了一套自己的组件库规范。

幸运的是,在 React Native 端,也有自己的组件库,而且命名、功能和小程序的组件都极其类似。因此,我们只需要按照小程序的规范,基于 React Native 的组件库实现一套中间层组件库,实现功能,抹平其中的差异即可。

通过上面的代码可以看到,代码转换后,组件的引入变成:import { View, Text } from "@tarojs/components-rn";。感兴趣的可以查看一下 taro-components-rn 代码,还是比较易懂的。

但是尽管如此,由于小程序和 React Native 组件的差异,还是有一些特性无法完全实现,比如 View 组件的 hover-start-timehover-stay-time。在组件的详细文档中列出了组件在不同端的支持程度,以及基本的使用示例。 部分未列出示例的,标明仅在小程序端支持的组件的用法可以直接参考小程序组件文档

样式 React Native 的样式基于开源的跨平台布局引擎 Yaga ,样式基本上是实现了 CSS 的一个子集,但是属性名不完全一致,具体的内容及相关差异可以查看文档 React Native Layout Props。Taro 在 React Native 端样式文件的处理,主要可以分为以下几步:

file

上图的所有步骤中,最复杂的一步就是 CSS to StyleSheet,此外,我们还要将 JSX 语法中的 className 转换为 React Native 的 style ,因此,Taro React Native 样式支持的关键的步骤如下:

  1. CSS to StyleSheet:将 Scss/Less/CSS 文件转换为 React Native StyleSheet 的 JS 文件
  2. className to style:将 JSX 语法中的 className 转换为 React Native 的 style

CSS to StyleSheet 我们首先使用于处理器将 Scss/Less 样式文件转换为 CSS, 这里我们借助于 css-to-react-native-transform 实现。css-to-react-native-transform 是 css-to-react-native 的一个轻量级的封装,用于将 CSS 样式转换为 React Native Stylesheet objects

下面的样式代码:

.myClass {
  font-size: 18px;
  line-height: 24px;
  color: red;
}

.other {
  padding: 1rem;
}

将被转换为:

{
  myClass: {
    fontSize: 18,
    lineHeight: 24,
    color: "red"
  },
  other: {
    paddingBottom: 16,
    paddingLeft: 16,
    paddingRight: 16,
    paddingTop: 16
  }
}

同时,为了保证样式开发的友好度,我们还实现了 StyleSheet 的错误校验,如果你写的样式 RN 不支持,会在编译时在终端报错。

className to style 这一步的实现可不仅仅是单纯的将 JSX 里面的 className 直接替换为 style,还要将引入的 Scss/Less/CSS 替换 Stylesheet 的 JS 文件。此外,className 可不仅仅支持字符串的写法,还支持各种复杂的表达式(如三目运算符、classnames( ) 等),这些情况统统都要考虑到。因此,我们专门实现了一个 Babel 插件来处理这一块的转换:babel-plugin-transform-jsx-to-stylesheet,有兴趣的可以看一下源码。

路由 路由是应用的核心之一,承担着重要的功能,同时对于用户体验至关重要。

在 React Native 端编译中,我们需要读取页面入口组件中 config 里面的 pages 配置(路由配置),然后在 React Native 端实现路由功能。由于路由功能的实现十分繁琐复杂,需要考虑的细节很多,甚至还涉及到不同端的交互差异,所以自己实现一套成熟的路由功能相当困难。因此,我们需要基于一个成熟稳定的 React Native 路由库来实现这一系列的功能。在经过层层技术选型后,我们最终还是选择了 React Navigation 。

为什么选择 React Navigation

Learn once, navigate anywhere.

React Navigation 的诞生,源于 React Native 社区对基于 Javascript 可扩展且使用简单的导航解决方案的需求。

React Navigation 是 Facebook,Expo 和 React 社区的开发者们合作的结果:它取代并改进了 React Native 生态系统中的多个导航库,其中包括 Ex-Navigation,React Native 的 Navigator 和 NavigationExperimental 组件。

我们最终选择 React Navigation ,主要是基于以下考虑:

  • 功能齐全:以上几点功能全部都能良好实现,并且比小程序现有的功能更加丰富,细节考虑更加全面,方便以后的拓展。
  • 用法类似:和小程序一样,路由及相关功能基本都是通过配置实现,业务耦合度低,代码编译方面工作量少。
  • 方案成熟:充分考虑到 Android 和 iOS 的差异,体验流畅,交互良好,文档全面。
  • 社区强大:GitHub Star 1.3W+ ,强大的社区背书,快速的迭代。

路由配置转换 在 Taro 工程中,配置方式与规则和小程序中保持一致,只不过 pages 配置放到了 App.js 的 config 属性中。

{
  "pages":[
    "pages/index/index",
    "pages/logs/logs"
  ]
}

我们需要读取 config 配置的内容,然后转换成 React Navigation 的配置。如果是直接通过编译来转换的话,工作量太大,因此在编译阶段,主要是解析 pages 配置内容,获取每个页面的 URL 及对应的入口文件,然后传入 @tarojs/taro-router-rn 中的 Taro.initRouter 方法中处理。

pages 配置内容:

config = {
    pages: [
      'pages/index/index',
      'pages/hello/hello'
      ]
    }  

路由解析并转换后,主要结构如下:

import pagesHelloHello from './pages/hello/hello';
import pagesIndexIndex from './pages/index/index';

...
  render() {
    return <RootStack />;
  }
...

const RootStack = TaroRouter.initRouter([['pages/index/index', pagesIndexIndex], ['pages/hello/hello', pagesHelloHello], Taro, App.config);

那么 Taro.initRouter 方法里面做了什么呢?主要是调用 React Navigation 的 createStackNavigator 方法,然后返回对应的 RootStack。感兴趣的,可以查看 createStackNavigator API 文档

function getRootStack ({pageList, Taro, navigationOptions}) {
  let RouteConfigs = {}
  pageList.forEach(v => {
    const pageKey = v[0]
    const Screen = v[1]
    RouteConfigs[pageKey] = getWrappedScreen(Screen, Taro, {enablePullDownRefresh: navigationOptions.enablePullDownRefresh})
  })
  return createStackNavigator(RouteConfigs, {
    navigationOptions: Object.assign({}, defaultNavigationOptions, navigationOptions)
  })
}

Taro 路由方法的挂载 在 React Navigation 中,路由的方法与小程序基本一一对应:

  • Taro.navigateTo(OBJECT) => this.props.navigation.push()
  • Taro.redirectTo(OBJECT) => this.props.navigation.replace()
  • Taro.switchTab(OBJECT) => this.props.navigation.navigate()
  • Taro.navigateBack(OBJECT) => this.props.navigation.goBack()

只需要在参数方面做一些小小的转换,就可以实现和小程序一致的功能。不过,关键是怎样在合适的时机优雅的挂载到 Taro 上去。

这里主要是在 getRootStack 方法中,通过 getWrappedScreen() 方法实现。getWrappedScreen 是一个 React 高阶组件,将页面的入口作为参数传进去,该方法会进行一系列的包裹和处理,将路由方法挂载到 Taro 上,有兴趣的可以自行查看源码。

NavigationBar NavigationBar,也就是导航栏。在小程序中,导航栏既可以在 app.json 的 Window 字段中进行全局配置,也可以在每一个小程序页面中使用 .json 文件来表现进行配置。其中页面的配置只能设置 app.json 中部分 Window 配置项的内容,页面中配置项会覆盖 app.json 的 Window 中相同的配置项。

在 React Navigation 中,NavigationBar 的实现非常简单,只需在 createStackNavigator 方法中配置 navigationOptions 属性即可。

    navigationOptions: {
      "headerStyle": {
        "backgroundColor": "black"
      },
      "title": "WeChat",
      "headerTintColor": "white"
    }

这样看着是不是感觉和小程序的导航栏配置很类似?因此,只需要在编译过程中,提取 config 里面 NavigationBar 相关的配置,然后转换成 React Navigation 规范,最后传入 createStackNavigator 方法就行了。

核心代码如下,其中 defaultNavigationOptions 表示默认的头部样式。

// 页面默认头部样式
const defaultNavigationOptions = {
  headerStyle: {
    backgroundColor: 'grey'
  },
  headerTintColor: 'black'
}

....

  return createStackNavigator(RouteConfigs, {
    navigationOptions: Object.assign({}, defaultNavigationOptions, navigationOptions)
  })

当然,仅仅是考虑这些是远远不够的,你还需要考虑:app.json 里面配置全局的 NavigationBar ;页面入口里面的配置与全局配置优先级问题等。

TabBar TabBar 表示底部 Tab 栏的表现。如果小程序是一个多 Tab 应用(客户端窗口的底部或顶部有 Tab 栏可以切换页面),可以通过 TabBar 配置项指定 Tab 栏的表现,以及 Tab 切换时显示的对应页面。

React Navigation 提供了 createBottomTabNavigator 方法来实现 TabBar 功能,其中 BottomTabNavigatorConfig 配置极其丰富,可以进行各种拓展。

createBottomTabNavigator(RouteConfigs, BottomTabNavigatorConfig);

因此,只需要在编译过程中,提取 config 里面 TabBar 相关的配置,然后转换成 React Navigation 规范,最后传入 createBottomTabNavigator 方法就行了。

核心代码如下:

    return createBottomTabNavigator(RouteConfigs, {
      navigationOptions: ({navigation}) => ({
        tabBarIcon: ({focused, tintColor}) => {
          const {routeName} = navigation.state
          const iconConfig = tabBar.list.find(item => item.pagePath === routeName)
          return (
            <Image
              style={{width: 30, height: 30}}
              source={focused ? iconConfig.selectedIconPath : iconConfig.iconPath}
            />
          )
        },
        tabBarLabel: tabBar.list.find(item => item.pagePath === navigation.state.routeName).text,
        tabBarVisible: navigation.state.index === 0 // 第一级不显示 tabBar
      }),
      tabBarOptions: {
        backBehavior: 'none',
        activeTintColor: tabBar.selectedColor || '#3cc51f',
        inactiveTintColor: tabBar.color || '#7A7E83',
        activeBackgroundColor: tabBar.backgroundColor || '#ffffff',
        inactiveBackgroundColor: tabBar.backgroundColor || '#ffffff',
        style: {
          borderColor: tabBar.borderTopColor || '#c6c6c6'
        }
      }
    })

小结  通过上面的内容,相信大家都已经清楚的了解 Taro React Native 端运行时的原理及组件、样式、路由等核心部分的实现,有兴趣的同学,可以对照着文章捋一遍,相信会有更深刻的理解。

 

推荐阅读: Taro多端开发实现原理与项目实战(一)

推荐阅读:Taro多端开发实现原理与项目实战(二)

  • 2
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值