文章同步於 Github/Blog
前言
Taro 是由凹凸實驗室打造的一套遵循 React 語法規範的多端統一開發框架。
使用 Taro,我們可以只書寫一套程式碼,再通過 Taro 的編譯工具,將原始碼分別編譯出可以在不同端(微信小程式、H5、App 端等)執行的程式碼。實現 一次編寫,多端執行。 關於 Taro 的更多詳細的資訊可以看官方的介紹文章 Taro - 多端開發框架 ,或者直接前往 GitHub 倉庫 NervJS/taro 檢視 Taro 文件及相關資料。
Taro 專案實現的功能強大,專案複雜而龐大,涉及到的方方面面(多端程式碼轉換、元件、路由、狀態管理、生命週期、端能力的實現與相容等等)多,對於大多數人來說,想要深入理解其實現機制及原理,還是比較困難的。
Taro 技術揭祕
系列文章將為你逐步揭開 Taro 強大的功能之後的神祕面紗,帶領你深入 Taro 內部,瞭解 Taro 是怎樣一步一步實現 一次編寫,多端執行 的巨集偉目標,同時也希望藉此機會拋磚引玉,促進前端圈湧現出更多的,能夠解決大家痛點的開源專案。
首先,我們將從負責 Taro 腳手架初始化和專案構建的的命令列工具,也就是 Taro 的入口:@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 在nodejs的fs基礎上增加了一些新的方法,更好用,還可以拷貝模板。
- chalk/chalk 可以用於控制終端輸出字串的樣式。
- SBoudrias/Inquirer.js NodeJs 命令列互動工具,通用的命令列使用者介面集合,用於和使用者進行互動。
- sindresorhus/ora 載入中狀態表示的時候一個loading怎麼夠,再在前面加個小圈圈轉起來,成功了console一個success怎麼夠,前面還可以給他加個小鉤鉤,ora就是做這個的。
- SBoudrias/mem-fs-editor 提供一系列API,方便操作模板檔案。
- shelljs/shelljs ShellJS 是Node.js 擴充套件,用於實現Unix shell 命令執行。
- Node.js child_process 模組 用於新建子程式。子程式的執行結果儲存在系統快取之中(最大200KB),等到子程式執行結束以後,主程式再用回撥函式讀取子程式的執行結果。
taro init
taro init 命令主要的流程如下:
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)
command方法
用法:.command('init <path>', 'description')
command的 用法稍微複雜,原則上他可以接受三個引數,第一個為命令定義,第二個命令描述,第三個為命令輔助修飾物件。
- 第一個引數中可以使用 <> 或者 [] 修飾命令引數
-
第二個引數可選。
- 當沒有第二個引數時,commander.js 將返回 Command 物件,若有第二個引數,將返回原型物件。
- 當帶有第二個引數,並且沒有顯示呼叫 action(fn) 時,則將會使用子命令模式。
- 所謂子命令模式即,
./pm
,./pm-install
,./pm-search
等。這些子命令跟主命令在不同的檔案中。
- 第三個引數一般不用,它可以設定是否顯示的使用子命令模式。
注意第一行#!/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()
獲取到這個答案物件。so easy!
藉此,新專案的名稱、版本號、描述等資訊可以直接通過終端互動插入到專案模板中,完善互動流程。
當然,互動的問題不僅限於此,可以根據自己專案的情況,新增更多的互動問題。inquirer.js強大的地方在於,支援很多種互動型別,除了簡單的input
,還有confirm
、list
、password
、checkbox
等,具體可以參見專案的工程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等)程式碼。一般來說,將一種結構化語言的程式碼編譯成另一種類似的結構化語言的程式碼包括以下幾個步驟:
首先是 parse,將程式碼 解析(Parse)
成 抽象語法樹(Abstract Syntex Tree)
,然後對 AST 進行 遍歷(traverse)
和 替換(replace)
(這對於前端來說其實並不陌生,可以類比 DOM 樹的操作),最後是 生成(generate)
,根據新的 AST 生成編譯後的程式碼。
Babel 模組
Babel 是一個通用的多功能的 JavaScript 編譯器
,更確切地說是原始碼到原始碼的編譯器,通常也叫做 轉換編譯器(transpiler)
。 意思是說你為 Babel 提供一些 JavaScript 程式碼,Babel 更改這些程式碼,然後返回給你新生成的程式碼。
此外它還擁有眾多模組可用於不同形式的 靜態分析
。
靜態分析是在不需要執行程式碼的前提下對程式碼進行分析的處理過程 (執行程式碼的同時進行程式碼分析即是動態分析)。 靜態分析的目的是多種多樣的, 它可用於語法檢查,編譯,程式碼高亮,程式碼轉換,優化,壓縮等等場景。
Babel 實際上是一組模組的集合,擁有龐大的生態。Taro 專案的程式碼編譯部分就是基於 Babel 的以下模組實現的:
- babylon Babylon 是 Babel 的解析器。最初是 從Acorn專案fork出來的。Acorn非常快,易於使用,並且針對非標準特性(以及那些未來的標準特性) 設計了一個基於外掛的架構。
- babel-traverse Babel Traverse(遍歷)模組維護了整棵樹的狀態,並且負責替換、移除和新增節點。
- babel-types Babel Types模組是一個用於 AST 節點的 Lodash 式工具庫, 它包含了構造、驗證以及變換 AST 節點的方法。 該工具庫包含考慮周到的工具方法,對編寫處理AST邏輯非常有用。
- babel-generator Babel Generator模組是 Babel 的程式碼生成器,它讀取AST並將其轉換為程式碼和原始碼對映(sourcemaps)。
- babel-template 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-cli
的主要目錄結構,命令呼叫,專案初始化方式等基本都捋完了,有興趣的同學可以結合著工程的原始碼自己跟一遍,應該不會太費勁。
taro-cli
目前是將模版放在工程裡面的,每次更新模版都要同步更新腳手架。而 vue-cli 是將專案模板放在 git 上,執行的時候再根據使用者互動下載不同的模板,經過模板引擎渲染出來,生成專案。這樣將模板和腳手架分離,就可以各自維護,即使模板有變動,只需要上傳最新的模板即可,而不需要使用者去更新腳手架就可以生成最新的專案。 這個後期可以納入優化的範疇。
下一篇文章,我們將一起進入 Taro 程式碼編譯的世界。