$ npm install -g @tarojs/cli
taro -V
1.2 使用
使用命令建立模板專案
$ taro init myApp
1.2.1 微信小程式
選擇微信小程式模式,需要自行下載並開啟微信開發者工具,然後選擇專案根目錄進行預覽
微信小程式編譯預覽及打包
# npm script
$ npm run dev:weapp
$ npm run build:weapp
1.2.2 百度小程式
選擇百度小程式模式,需要自行下載並開啟 百度開發者工具 ,然後在專案編譯完後選擇專案根目錄下 dist 目錄進行預覽
百度小程式編譯預覽及打包
# npm script
$ npm run dev:swan
$ npm run build:swan
1.2.3 支付寶小程式
選擇支付寶小程式模式,需要自行下載並開啟 支付寶小程式開發者工具 ,然後在專案編譯完後選擇專案根目錄下 dist 目錄進行預覽
支付寶小程式編譯預覽及打包:
# npm script
$ npm run dev:alipay
$ npm run build:alipay
1.2.4 H5
H5 編譯預覽及打包:
# npm script
$ npm run dev:h5
# 僅限全域性安裝
$ taro build --type h5 --watch
1.2.5 React Native
React Native 端執行需執行如下命令, React Native 端相關的執行說明請參見 React Native 教程
# npm script
$ npm run dev:rn
1.3 更新 Taro
Taro 提供了更新命令來更新 CLI 工具自身和專案中 Taro 相關的依賴。
更新 taro-cli 工具
# taro
$ taro update self
# npm
更新專案中 Taro 相關的依賴,這個需要在你的專案下執行
$ taro update project
二、Taro 開發說明與注意事項
2.1 微信小程式開發工具的配置
由於 Taro 編譯後的程式碼已經經過了轉義和壓縮,因此還需要注意微信開發者工具的專案設定
- 設定關閉
ES6轉ES5功能 - 設定關閉上傳程式碼時樣式自動補全
- 設定關閉程式碼壓縮上傳
2.2 Taro 與 React 的差異
由於微信小程式的限制, React 中某些寫法和特性在 Taro 中還未能實現,後續將會逐漸完善。 截止到本小冊釋出前, Taro 的最新版本為 1.1 ,因此以下講解預設版本為 1.1
2.2.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>
)
}
}...
2.2.2 不能在包含 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 />
})...
2.2.3 不能使用 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 />
})...
2.2.4 不能在 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)} />
2.2.5 不能在 JSX 引數中使用物件展開符
微信小程式元件要求每一個傳入元件的引數都必須預先設定好,而物件展開符則是動態傳入不固定數量的引數。所以 Taro 沒有辦法支援該功能
無效情況
<View {...this.props} />
<View {...props} />
<Custom {...props} />
解決方案
開發者自行賦值:
render () {
const { id, title } = obj
return <View id={id} title={title} />
}...
2.2.6 不允許在 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 來巢狀子元件
2.2.7 不支援無狀態元件(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 />
)
}
}
2.3 命名規範
Taro 函式命名使用駝峰命名法,如 onClick ,由於微信小程式的 WXML 不支援傳遞函式,函式名編譯後會以字串的形式繫結在 WXML 上,囿於 WXML 的限制,函式名有三項限制
請遵守以上規則,否則編譯後的程式碼在微信小程式中會報以下錯誤
2.4 推薦安裝 ESLint 編輯器外掛
Taro 有些寫法跟 React 有些差異,可以通過安裝 ESLint 相關的編輯器外掛來獲得人性化的提示。由於不同編輯器安裝的外掛有所不同,具體安裝方法請自行搜尋,這裡不再贅述。 如下圖,就是安裝外掛後獲得的提示
2.5 最佳編碼方式
元件傳遞函式屬性名以 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 />標籤,所以它在小程式端屬於語法糖的存在,請不要在元件中列印它…
元件 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 之後取路由引數的頁面則沒有任何影響…
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.navigateTo 、 Taro.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
...
}
}...
三、Taro 設計思想及架構
在 Taro 中採用的是編譯原理的思想,所謂編譯原理,就是一個對輸入的原始碼進行語法分析,語法樹構建,隨後對語法樹進行轉換操作再解析生成目的碼的過程。
3.1 抹平多端差異
基於編譯原理,我們已經可以將 Taro 原始碼編譯成不同端上可以執行的程式碼了,但是這對於實現多端開發還是遠遠不夠。因為不同的平臺都有自己的特性,每一個平臺都不盡相同,這些差異主要體現在不同的元件標準與不同的 API 標準以及不同的執行機制上
以小程式和 Web 端為例
- 可以看出小程式和 Web 端上元件標準與 API 標準有很大差異,這些差異僅僅通過程式碼編譯手段是無法抹平的,例如你不能直接在編譯時將小程式的
<view />直接編譯成<div />,因為他們雖然看上去有些類似,但是他們的元件屬性有很大不同的,僅僅依靠程式碼編譯,無法做到一致,同理,眾多API也面臨一樣的情況。針對這樣的情況,Taro採用了定製一套執行時標準來抹平不同平臺之間的差異。 - 這一套標準主要以三個部分組成,包括標準執行時框架、標準基礎元件庫、標準端能力 API,其中執行時框架和 API 對應
@taro/taro,元件庫對應@tarojs/components,通過在不同端實現這些標準,從而達到去差異化的目的…
四、CLI 原理及不同端的執行機制
4.1 taro-cli 包
4.1.1 Taro 命令
taro-cli 包位於 Taro 工程的 Packages 目錄下,通過 npm install -g @tarojs/cli 全域性安裝後,將會生成一個 Taro 命令。主要負責專案初始化、編譯、構建等。直接在命令列輸入 Taro ,會看到如下提示…
➜ taro
:alien: 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 所有命令用法及作用。
4.1.2 包管理與釋出
- 首先,我們需要了解
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
Packages 目錄下十幾個包中,最常用的專案初始化與構建的命令列工具 Taro CLI 就是其中一個。在 Taro 工程根目錄執行 lerna publish 命令之後, lerna.json 裡面配置好的所有的包會被髮布到 NPM 上
4.1.3 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
│ ├── gitignor...
通過上面的目錄樹可以發現, taro-cli 工程的檔案並不算多,主要目錄有: /bin 、 /src 、 /template
4.2 用到的核心庫
- tj/commander.js Node.js - 命令列介面全面的解決方案
- 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 命令執行。
4.3 Taro Init
當我們全域性安裝 taro-cli 包之後,我們的命令列裡就有了 Taro 命令
- 那麼
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...
4.3.1 命令關聯與引數解析
這裡就不得不提到一個有用的包: tj/commander.js , Node.js 命令列介面全面的解決方案,靈感來自於 Ruby’s commander。可以自動的解析命令和引數,合併多選項,處理短參等等,功能強大,上手簡單
更主要的, 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)...
通過上面程式碼可以發現, init , build , update 等命令都是通過 .command(name, description) 方法定義的,然後通過 .parse(arg) 方法解析引數
4.3.2 引數解析及與使用者互動
-
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,還有confirm、list、password、checkbox等,具體可以參見專案的工程 README。
此外,你在執行非同步操作的過程中,還可以使用sindresorhus/ora來新增一下Loading效果。使用chalk/chalk給終端的輸出新增各種樣式…
4.3.3 模版檔案操作
最後就是模版檔案操作了,主要分為兩大塊:
- 將輸入的內容插入到模板中
- 根據命令建立對應目錄結構,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}
);...
4.4 Taro Build
-
taro build命令是整個Taro專案的靈魂和核心,主要負責多端程式碼編譯(H5,小程式,React Native等)。 -
Taro命令的關聯,引數解析等和taro init其實是一模一樣的,那麼最關鍵的程式碼轉換部分是怎樣實現的呢?…
4.4.1 編譯工作流與抽象語法樹(AST)
Taro 的核心部分就是將程式碼編譯成其他端(H5、小程式、React Native 等)程式碼。一般來說,將一種結構化語言的程式碼編譯成另一種類似的結構化語言的程式碼包括以下幾個步驟
首先是 Parse ,將程式碼解析( Parse )成抽象語法樹(Abstract Syntex Tree),然後對 AST 進行遍歷( traverse )和替換( replace )(這對於前端來說其實並不陌生,可以類比 DOM 樹的操作),最後是生成( generate ),根據新的 AST 生成編譯後的程式碼…
4.4.2 Babel 模組
Babel 是一個通用的多功能的 JavaScript編譯器,更確切地說是原始碼到原始碼的編譯器,通常也叫做轉換編譯器(transpiler)。 意思是說你為 Babel 提供一些 JavaScript 程式碼,Babel 更改這些程式碼,然後返回給你新生成的程式碼…
4.4.3 解析頁面 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))
...
五、Taro 元件庫及 API 的設計與適配
5.1 多端差異
5.1.1 元件差異
小程式、H5 以及快應用都可以劃分為 XML 類,React Native 歸為 JSX 類,兩種語言風牛馬不相及,給適配設定了非常大的障礙。XML 類有個明顯的特點是關注點分離(Separation of Concerns),即語義層(XML)、視覺層(CSS)、互動層(JavaScript)三者分離的鬆耦合形式,JSX 類則要把三者混為一體,用指令碼來包攬三者的工作…
不同端的元件的差異還體現在定製程度上
- H5 標籤(元件)提供最基礎的功能——佈局、表單、媒體、圖形等等;
- 小程式元件相對 H5 有了一定程度的定製,我們可以把小程式元件看作一套類似於 H5 的 UI 元件庫;
- React Native 端元件也同樣如此,而且基本是專“組”專用的,比如要觸發點選事件就得用 Touchable 或者 Text 元件,要渲染文字就得用 Text 元件(雖然小程式也提供了 Text 元件,但它的文字仍然可以直接放到 view 之類的元件裡)…
5.1.2 API 差異
各端 API 的差異具有定製化、介面不一、能力限制的特點
- 定製化:各端所提供的 API 都是經過量身打造的,比如小程式的開放介面類 API,完全是針對小程式所處的微信環境打造的,其提供的功能以及外在表現都已由框架提供實現,使用者上手可用,毋須關心內部實現。
- 介面不一:相同的功能,在不同端下的呼叫方式以及呼叫引數等也不一樣,比如
socket,小程式中用wx.connectSocket來連線,H5則用new WebSocket()來連線,這樣的例子我們可以找到很多個。 - 能力限制:各端之間的差異可以進行定製適配,然而並不是所有的
API(此處特指小程式API,因為多端適配是向小程式看齊的)在各個端都能通過定製適配來實現,因為不同端所能提供的端能力“大異小同”,這是在適配過程中不可抗拒、不可抹平的差異…
5.2 多端適配
5.2.1 樣式處理
H5 端使用官方提供的 WEUI 進行適配,React Native 端則在元件內新增樣式,並通過指令碼來控制一些狀態類的樣式,框架核心在編譯的時候把原始碼的 class 所指向的樣式通過 css-to-react-native 進行轉譯,所得 StyleSheet 樣式傳入元件的 style 引數,元件內部會對樣式進行二次處理,得到最終的樣式…
為什麼需要對樣式進行二次處理?
部分元件是直接把傳入 style 的樣式賦給最外層的 React Native 原生元件,但部分經過層層封裝的元件則不然,我們要把容器樣式、內部樣式和文字樣式離析。為了方便解釋,我們把這類元件簡化為以下的形式:
<View style={wrapperStyle}>
<View style={containerStyle}>
<Text style={textStyle}>Hello World</Text>
</View>
</View>
假設元件有樣式 margin-top 、 background-color 和 font-size ,轉譯傳入元件後,就要把分別把它們傳到 wrapperStyle 、 containerStyle 和 textStyle ,可參考 ScrollView 的 style 和 contentContainerStyle …
5.2.2 元件封裝
元件的封裝則是一個“仿製”的過程,利用端提供的原材料,加工成通用的元件,暴露相對統一的呼叫方式。我們用 <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)
}
六、JSX 轉換微信小程式模板的實現
6.1 程式碼的本質
不管是任意語言的程式碼,其實它們都有兩個共同點
- 它們都是由字串構成的文字
- 它們都要遵循自己的語言規範
第一點很好理解,既然程式碼是字串構成的,我們要修改/編譯程式碼的最簡單的方法就是使用字串的各種正則表示式。例如我們要將 JSON 中一個鍵名 foo 改為 bar ,只要寫一個簡單的正則表示式就能做到:
jsonStr.replace(/(?<=")foo(?="\s*:)/i, 'bar')...
編譯就是把一段字串改成另外一段字串
6.2 Babel
JavaScript 社群其實有非常多 parser 實現,比如 Acorn 、 Esprima 、 Recast 、 Traceur 、 Cherow 等等。但我們還是選擇使用 Babel ,主要有以下幾個原因
-
Babel可以解析還沒有進入 ECMAScript 規範的語法。例如裝飾器這樣的提案,雖然現在沒有進入標準但是已經廣泛使用有一段時間了; -
Babel提供外掛機制解析TypeScript、Flow、JSX這樣的JavaScript超集,不必單獨處理這些語言; -
Babel擁有龐大的生態,有非常多的文件和樣例程式碼可供參考;
除去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 會解析成這樣的資料結構:
你也可以使用 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 的方法還能提供型別校驗的功能,能有效地提高我們轉換程式碼的健壯性和可靠性…
6.3 實踐例子
以一個簡單 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>
)
}
}...
6.3.1 設計思路
- Taro 的結構主要分兩個方面:執行時和編譯時。執行時負責把編譯後到程式碼執行在本不能執行的對應環境中,你可以把 Taro 執行時理解為前端開發當中
polyfill。舉例來說,小程式新建一個頁面是使用Page方法傳入一個字面量物件,並不支援使用類。如果全部依賴編譯時的話,那麼我們要做到事情大概就是把類轉化成物件,把state變為data,把生命週期例如 componentDidMount 轉化成onReady,把事件由可能的類函式(Class method)和類屬性函式(Class property function) 轉化成字面量物件方法(Objectproperty function)等等。 - 但這顯然會讓我們的編譯時工作變得非常繁重,在一個類異常複雜時出錯的概率也會變高。但我們有更好的辦法:實現一個
createPage方法,接受一個類作為引數,返回一個小程式Page方法所需要的字面量物件。這樣不僅簡化了編譯時的工作,我們還可以在createPage對編譯時產出的類做各種操作和優化。通過執行時把工作分離了之後,再編譯時我們只需要在檔案底部加上一行程式碼Page(createPage(componentName))即可…
- 回到一開始那段程式碼,我們定義了一個類屬性
config,config是一個物件表示式(Object Expression),這個物件表示式只接受鍵值為識別符號(Identifier)或字串,而鍵名只能是基本型別。這樣簡單的情況我們只需要把這個物件表示式轉換為JSON即可。另外一個類屬性state在Page當中有點像是小程式的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;
}...
6.3.2 WXML 和 JSX
在 Taro 裡 render 的所有 JSX 元素都會在 JavaScript 檔案中被移除,它們最終將會編譯成小程式的 WXML 。每個 WXML 元素和 HTML 元素一樣,我們可以把它定義為三種類型: Element 、 Text 、 Comment 。其中 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 和 JSXExpressionContainer , className 是簡單的 StringLiteral 處理起來很方便, scrollTop 處理起來稍微麻煩點,我們需要用兩個花括號 {} 把內容包起來…
接下來我們再思考一下每一個 JSXElement 出現的位置,你可以發現其實它的父元素只有幾種可能性:return、迴圈、條件(邏輯)表示式。而在上一篇文章中我們提到,babel-traverse 遍歷的 AST 型別是響應式的——也就是說只要我們按照 JSXElement 父元素型別的順序窮舉處理這幾種可能性,把各種可能性大結果應用到 JSX 元素之後刪除掉原來的表示式,最後就可以把一個複雜的 JSX 表示式轉換為一個簡單的 WXML 資料結構。…
我們先看第一個迴圈:
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 就非常簡單了
再來看第二個迴圈表示式:
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>
七、小程式執行時
為了使 Taro 元件轉換成小程式元件並執行在小程式環境下, Taro 主要做了兩個方面的工作:編譯以及執行時適配。編譯過程會做很多工作,例如:將 JSX 轉換成小程式 .wxml 模板,生成小程式的配置檔案、頁面及元件的程式碼等等。編譯生成好的程式碼仍然不能直接執行在小程式環境裡,那執行時又是如何與之協同工作的呢?…
7.1 註冊程式、頁面以及自定義元件
在小程式中會區分程式、頁面以及元件,通過呼叫對應的函式,並傳入包含生命週期回撥、事件處理函式等配置內容的 object 引數來進行註冊:
Component({
data: {},
methods: {
handleClick () {}
}
})
而在 Taro 裡,它們都是一個元件類:
class CustomComponent extends Component {
state = { }
handleClick () { }
}...
Taro
customComponent
Component(createComponent(customComponent))
-
createComponent方法是整個執行時的入口,在執行的時候,會根據傳入的元件類,返回一個元件的配置物件
在小程式裡,程式的功能及配置與頁面和元件差異較大,因此執行時提供了兩個方法 createApp 和 createComponent 來分別建立程式和元件(頁面)。 createApp 的實現非常簡單
createComponent 方法主要做了這樣幾件事情:
- 將元件的
state轉換成小程式元件配置物件的data - 將元件的生命週期對應到小程式元件的生命週期
- 將元件的事件處理函式對應到小程式的事件處理函式
7.2 元件 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 來獲取最新資料…
7.3 將元件的生命週期對應到小程式元件的生命週期
初始化過程裡的生命週期對應很簡單,在小程式的生命週期回撥函式裡呼叫 Taro 元件裡對應的生命週期函式即可,例如:小程式元件 ready 的回撥函式裡會呼叫 Taro 元件的 componentDidMount 方法。它們的執行過程和對應關係如下圖…
小程式頁面的 componentWillMount 有一點特殊,會有兩種初始化方式。由於小程式的頁面需要等到 onLoad 之後才可以獲取到頁面的路由引數,因此如果是啟動頁面,會等到 onLoad 時才會觸發。而對於小程式內部通過 navigateTo 等 API 跳轉的頁面,Taro 做了一個相容,呼叫 navigateTo 時將頁面引數儲存在一個全域性物件中,在頁面
attached 的時候從全域性物件裡取到,這樣就不用等到頁面 onLoad 即可獲取到路由引數,觸發 componentWillMount 生命週期…
狀態更新
- 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 方法來進行元件的更新
7.4 事件處理函式對應
在小程式的元件裡,事件響應函式需要配置在 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 元件終於可以執行在小程式環境裡了…
7.5 對 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
})...
八、H5 執行時
8.1 H5 執行時解析
首先,我們選用 Nerv 作為 Web 端的執行時框架。你可能會有問題:同樣是類 React 框架,為何我們不直接用 React ,而是用 Nerv 呢?
為了更快更穩。開發過程中前端框架本身有可能會出現問題。如果是第三方框架,很有可能無法得到及時的修復,導致整個專案的進度受影響。Nerv就不一樣。作為團隊自研的產品,出現任何問題我們都可以在團隊內部快速得到解決。與此同時,Nerv也具有與React相同的 API,同樣使用 Virtual DOM 技術進行優化,正常使用與React並沒有區別,完全可以滿足我們的需要。
使用Taro之後,我們書寫的是類似於下圖的程式碼…
我們注意到,就算是轉換過的程式碼,也依然存在著 view 、 button 等在 Web 開發中並不存在的元件。如何在 Web 端正常使用這些元件?這是我們碰到的第一個問題
8.1.1 元件實現
作為開發者,你第一反應或許會嘗試在編譯階段下功夫,嘗試直接使用效果類似的 Web 元件替代:用 div 替代 view ,用 img 替代 image ,以此類推。
費勁心機搞定標籤轉換之後,上面這個差異似乎是解決了。但很快你就會碰到一些更加棘手的問題: hover-start-time 、 hover-stay-time 等等這些常規 Web 開發中並不存在的屬性要如何處理?
回顧一下:在前面講到多端轉換的時候,我們說到了 babel 。在Taro中,我們使用 babylon 生成 AST , babel-traverse 去修改和移動 AST 中的節點。但babel所做的工作遠遠不止這些。
我們不妨去 babel 的 playground 看一看程式碼在轉譯前後的對比:在使用了 @babel/preset-env 的 BUILT-INS 之後,簡單的一句原始碼 new Map() ,在 babel 編譯後卻變成了好幾行程式碼…
注意看這幾個檔案: core-js/modules/web.dom.iterable , core-js/modules/es6.array.iterator , core-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 元件接受的屬性名將會是駝峰命名法: hoverStartTime 。 hoverStartTime 引數決定我們將在 View 元件觸發 touch 事件多久後改變元件的樣式…
// 示例程式碼
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 的實現甚至比上面的程式碼還要簡單得多。但這並不說明元件的實現之路上就沒有障礙。複雜如 swiper , scroll-view , tabbar ,我們需要花費大量的精力分析小程式原生元件的 API ,互動行為,極端值處理,接受的屬性等等,再通過 Web 技術實現。…
8.2 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。…
而在 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' });
}...
看起來很完美,但開發的道路不會如此平坦。我們還需要處理其餘的入參:success、fail和complete。success回撥會在操作成功完成時呼叫,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' });
}...
把這個 API 實現掛載到Taro模組之後,我們就可以通過 Taro.setStorage 來呼叫這個 API 了。
當然,也有一些 API 是 Web 端無論如何無法實現的,比如 wx.login ,又或者 wx.scanCode 。我們維護了一個 API 實現情況的列表,在實際的多端專案開發中應該儘可能避免使用它們…
8.3 路由
作為小程式的一大能力,小程式框架中以棧的形式維護了當前所有的頁面,由框架統一管理。使用者只需要呼叫 wx.navigateTo , wx.navigateBack , wx.redirectTo 等官方 API,就可以實現頁面的跳轉、回退、重定向,而不需要關心頁面棧的細節。但是作為多端專案,當我們…
小程式的路由比較輕量。使用時,我們先通過 app.json 為小程式配置頁面列表:
{
"pages": [
"pages/index/index",
"pages/logs/logs"
],
// ...
}
在執行時,小程式內維護了一個頁面棧,始終展示棧頂的頁面( Page 物件)。當用戶進行跳轉、後退等操作時,相應的會使頁面棧進行入棧、出棧等操作
同時,在頁面棧發生路由變化時,還會觸發相應頁面的生命週期
對於 Web 端單頁應用路由,我們則以react-router為例進行說明
- 首先,
react-router開始通過history工具監聽頁面路徑的變化。 - 在頁面路徑發生變化時,
react-router會根據新的location物件,觸發 UI 層的更新。 - 至於 UI 層如何更新,則是取決於我們在Route元件中對頁面路徑和元件的繫結,甚至可以實現巢狀路由。
- 可以說,
react-router的路由方案是元件級別的。 - 具體到
Taro,為了保持跟小程式的行為一致,我們不需要細緻到元件級別的路由方案,但需要為每次路由儲存完整的頁面棧。 - 實現形式上,我們參考
react-router:監聽頁面路徑變化,再觸發UI更新。這是React的精髓之一,單向資料流…
@tarojs/router 包中包含了一個輕量的 history 實現。 history 中維護了一個棧,用來記錄頁面歷史的變化。對歷史記錄的監聽,依賴兩個事件: hashchange 和 popstate 。
/* 示例程式碼 */
window.addEventListener('hashchange', () => {});
window.addEventListener('popstate', () => {})
- 對於使用
Hash模式的頁面路由,每次頁面跳轉都會依次觸發popstate和hashchange事件。由於在popstate的回撥中可以取到當前頁面的state,我們選擇它作為主要跳轉邏輯的容器。 - 作為 UI 層,
@tarojs/router包提供了一個Router元件,維護頁面棧。與小程式類似,使用者不需要手動呼叫Router元件,而是由Taro自動處理。 - 對於歷史棧來說,無非就是三種操作:
push,pop,還有replace。在歷史棧變動時觸發Router的回撥,就可以讓Router也同步變化。這就是Taro中路由的基本原理…
8.4 Redux 處理
@tarojs/redux
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的實現:我們繼承,並且改寫componentDidMount。 - 但是對於使用Redux的頁面來說,我們繼承的類,是經過
@connect修飾過的一個高階元件。 - 問題就出在這裡:這個高階元件的簽名裡並沒有
componentDidShow這一個函式。所以我們的componentDidMount內,理所當然是取不到componentDidShow的。 - 為了解決這個問題,我們對
react-redux程式碼進行了一些小改裝,這就是@taro/redux-h5的由來…