為何我們要用 React 來寫小程式 - Taro 誕生記

凹凸實驗室發表於2018-06-25

在網際網路不斷髮展的今天,前端程式設計師們也不斷面臨著新的挑戰,在這個變化多端、不斷革新自己的領域,每一年都有新的美好事物在發生。從去年微信小程式的誕生,到今年的逐漸火熱,以及異軍突起的輕應用、百度小程式等的出現,前端可以延伸的領域已經越來越廣,當然也意味著業務在不斷擴大。這時候,如何通過技術手段來提升開發效率,應對不斷增長的業務,就是一個值得探索的話題。本文將對 Taro 誕生的故事,進行深入淺出地介紹,記錄下這個忙碌的春夏之交發生的故事。

讓人又愛又恨的微信小程式

2017-1-9 微信小程式(以下簡稱小程式)誕生以來,就伴隨著讚譽與爭議不斷。從釋出上線時的不被大多數人看好,到如今的逐漸火熱,甚至說是如日中天也不為過,小程式用時間與實踐證明了自己的價值。同時於開發者來說,小程式的生態不斷在完善,許多的坑已被踩平,雖然還是存在一些令人詬病的問題,但已經足見微信的誠意了。這個時候要是還沒有上手把玩過小程式,就顯得非常OUT了。

小程式對於前端程式設計師來說應該算得上是福音了,用前端相關的技術,獲得絲般順滑的 Native 體驗,前端們又可以在產品小姐姐面前硬氣一把了。可以說小程式給前端程式設計師開啟了一扇新的大門,大家都應該感謝微信,但是從開發的角度來說,小程式的開發體驗就非常值得商榷了,不僅語法上顯得有些不倫不類,而且有些莫名其妙的坑也經常讓人不經意間感嘆一下和諧社會,從市面上層出不窮的小程式開發框架就可見一斑。以下就盤點部分小程式開發的痛點。

程式碼組織與語法

在小程式中,一個頁面 page 可能擁有 page.jspage.wxsspage.wxmlpage.json 四個檔案

為何我們要用 React 來寫小程式 - Taro 誕生記

這樣在開發的時候就需要來回進行檔案切換,尤其是在同時開發模板和邏輯的時候,切來切去會顯得尤其麻煩,影響開發效率,但小程式原生只支援這麼寫,就顯得比較尷尬了。

而在語法上,小程式的語法可以說既像 React ,又像 Vue,不能說顯得有點不倫不類吧,但在使用上總是感覺有些彆扭,對於開發者來說,等於又要學習一套新的語法,提升了學習成本。而且,小程式的模板由於沒有編輯器外掛的支援,書寫的時候也沒有智慧提示與 lint 檢查,書寫起來顯得有些麻煩。

命名規範

在小程式中到處可見規範不統一的情況

例如元件的屬性,以最簡單的 <button /> 元件為例,在小程式官方文件中,該元件的屬性部分截圖如下,大家可以感受下

為何我們要用 React 來寫小程式 - Taro 誕生記

<button /> 元件屬性名既有以中劃線分割多個單詞的情況 session-form,也有多個單詞連寫的情況 bindgetphonenumber。當然這也不是最嚴重的,你可以說事件繫結的規範就是 bind + 事件名 ,而其他屬性的規範就是中劃線分割單詞,我一度以為小程式就是這個作為標準,直到我看到了 <progress /> 元件

為何我們要用 React 來寫小程式 - Taro 誕生記

這和說好的不一樣啊喂!

為何我們要用 React 來寫小程式 - Taro 誕生記

同樣的情況也出現在 頁面元件 的生命週期方法中,頁面 的生命週期方法有 onLoadonReadyonUnload 等,但到了 元件 中則是 createdattachedready 等,這樣規範又不統一了,為啥 頁面 的生命週期方法是 on+Xxx 的格式,但到了 元件 裡缺不一樣了呢,有點費解。

開發方式

小程式官方提供了 微信開發工具 作為開發編譯工具,而對於程式碼本身沒有提供一個類似 webpack 的工程化開發工具,來解決開發中的一些問題,所以小程式原生的開發方式顯得不那麼現代化,這也是很多小程式開發框架致力於解決的問題。例如,在小程式開發中

  • 不能使用 npm 管理依賴,在小程式中需要手動把第三方程式碼檔案下載到本地,然後再 reuqire 進行使用,顯得不那麼優雅
  • 不能使用 Sass 等 CSS 前處理器,由於沒有預編譯的概念,小程式開發中無法使用市面上流行的 CSS 前處理器,這樣會使得樣式程式碼難以管理
  • 不完整的 ES Next 語法支援,小程式預設只能支援極少一部分 ES6 規範的語法,而 ES 是不斷往前發展的,一些非常優秀的新語法特性就不能使用了
  • 手動的檔案處理,像圖片壓縮、程式碼壓縮等等的一些檔案操作,必須手工來處理,顯得有些繁瑣

以上就是從開發者的角度看到的一些小程式的開發問題,不過縱然有千般困難,我們總要面對,作為新時代的前端開發工程師,我們不能一味忍受問題,要保持技術的頭腦,以技術作為武器,用技術手段去提升的我們開發體驗。

突發奇想:我能不能用React來寫小程式

目前前端界言及前端框架,必離不開依然保持著統治地位的 ReactVue,這兩個都是非常優秀的前端 UI 框架,而且在網上也經常能看到兩個框架的粉絲之間熱情交流,碰撞出一些思想火花,顯得社群異常活躍。

而我們團隊也在去年勇敢地拋棄了歷史包袱,非常榮幸地引入了 React 開發方式,讓我們團隊丟掉了煤油燈,開始通上了電。而且也研發出了一款優秀的類 React 框架 Nerv ,讓我們和 React 開發思想結合得更深。

與小程式的開發方式相比,React 明顯顯得更加現代化、規範化,而且 React 天生元件化更適合我們的業務開發,JSX 也比字串模板有更強的表現力。那麼這時候我們就在思考,我們能不能用 React 來寫小程式?

理性地探索

類比

通過對比體驗 小程式和 React ,我們還是能發現兩者之間相似的地方

生命週期

小程式的生命週期和 React 的生命週期,在很大程度上是類似的,我們甚至能找到他們之間的對應關係

app 及頁面的生命週期

小程式 React
onLaunch componentWillMount
onLoad componentWillMount
onReady componentDidMount
onShow 不支援,需要特殊處理
onHide 不支援,需要特殊處理
onUnload componentWillUnmount

可以看出,對於 app頁面 來說,除了 onShowonHide 兩個方法,其他方法都能在 React 中找到對應。

資料更新方式

React 中,元件的內部資料是用 state 來進行管理的,而在小程式中元件的內部資料都是用 data 來進行管理,兩者具有一定相似性。而同時在 React 中,我們更新資料使用的是 setState 方法,傳入新的資料或者生成新資料的函式,從而更新相應檢視。在小程式中,則對應的有 setData 方法,傳入新的資料,從而更新檢視。

兩者都是以資料驅動檢視的方式進行更新,而且 api 神似。

事件繫結

小程式中繫結事件使用的是 bind + 事件名 的方式,例如點選事件,小程式中是 bindtap

<view bindtap="handlClick">1</view>
複製程式碼

而在 React 裡,則是 on + 事件名 的方式,例如點選事件, React web 中是 onClick

<View onClick={this.handlClick}>1</View>
複製程式碼

雖然看上去不一樣,但其實是可以類比的,我們只需要在編譯時將 on + 事件名 的形式編譯成 bind + 事件名 的形式就可以了。

如此看來,兩者之間有些相似,用 React 來寫小程式貌似是可行的,但接下來我們就發現了巨大的差異。

巨大的差異

React 與小程式之間最大的差異就是他們的模板了,在 React 中,是使用 JSX 來作為元件的模板的,而小程式則與 Vue 一樣,是使用字串模板的。這樣兩者之間就有著巨大的差異了。

JSX

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

小程式模板

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

眾所周知,JSX 其實本質上就是 JS,我們可以在裡面寫任意的邏輯程式碼,這樣一來就比字串模板的表現力與操作性要強多了,況且,小程式的字串模板功能比較羸弱,只有一些比較基本的功能。那這樣的話,要如何來實現用 JSX 來寫小程式模板呢。

編譯原理的力量

我們可以仔細來分析我們的需求,我們期望使用 JSX 來書寫小程式模板,但小程式顯然是不支援執行 JSX 程式碼的(要是支援的話,Taro 應該也就不存在了吧),我們也不能期望微信能給我們開個後門來跑 JSX。那麼這個時候我們就想,我們要是能夠將 JSX 編譯成小程式模板就好了。

事實上在我們平時的開發中,這種編譯的操作到處可見,babel 就是我們最常用的 JS 程式碼編譯器,一般瀏覽器是不能支援一些非常新的語法特性的,但我們又想使用它們,這個時候就可以藉助 babel 來將我們的高版本的 ES 程式碼,編譯成瀏覽器可以執行的 ES 程式碼。而我們像要將 JSX編譯成小程式模板,也是同樣的道理。我們首先來了解一下 Babel 的執行機制。

Babel 作為一個 程式碼編譯器 ,能夠將 ES6/7/8 的程式碼編譯成 ES5 的程式碼,其核心利用的就是計算中非常基礎的編譯原理知識,將輸入語言程式碼,通過編譯器執行,輸出目標語言的程式碼。編譯原理的一般過程就是,輸入源程式,經過詞法分析、語法分析,構造出語法樹,再經過語義分析,理解程式正確與否,再對語法樹做出需要的操作與優化,最終生成目的碼。

為何我們要用 React 來寫小程式 - Taro 誕生記

Babel 的編譯過程亦是如此,主要包含三個階段

  • 解析過程,在這個過程中進行詞法、語法分析,以及語義分析,生成符合 ESTree 標準 虛擬語法樹(AST)
  • 轉換過程,針對 AST 做出已定義好的操作,babel 的配置檔案 .babelrc 中定義的 presetplugin 就是在這一步中執行並改變 AST 的
  • 生成過程,將前一步轉換好的 AST 生成目的碼的字串

為了更好地理解這些過程,大家可以利用 Ast Explorer 這個網站接一下自己的程式碼,感受一下每一部分程式碼所對應的 AST 結構。

為何我們要用 React 來寫小程式 - Taro 誕生記

可以看到,一份原始碼經過編譯器解析後,會變成類似如下的結構

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

其中,body 裡包含的就是我們示例程式碼的語法樹結構,第一個 VariableDeclaration 對應的是 const a = 1,第三個 FunctionDeclaration 對應的則是 function sum (a, b) { },分別就是 JS 中的變數定義與函式定義,每一個樹節點裡都會包含許多子節點,這樣就形成了一個樹形結構,更多的節點型別,請參考 babel types

當然我們在這兒只是簡單介紹下編譯原理與 babel,編譯原理是一門非常深奧的課程, babel 也是一個非常優秀的工具,希望在後續的文章中能和大家再詳細探討這一部分內容。

再次回到我們的需求,將 JSX 編譯成小程式模板,非常幸運的是 babel 的核心編譯器 babylon 是支援對 JSX 語法的解析的,我們可以直接利用它來幫我們構造 AST,而我們需要專注的核心就是如何對 AST 進行轉換操作,得出我們需要的新 AST,再將新 AST 進行遞迴遍歷,生成小程式的模板。

JSX 程式碼

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

編譯生成小程式模板

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

這時候,聰明的你應該就能發現問題的難點所在了,要知道小程式的模板只是字串,而 JSX 則是真正的 JS 程式碼擴充套件,其語法之豐富,顯然不是字串模板所能比,在這一步中,我們要做的操作,包括但不僅限於如下

  • 理解三目運算子與邏輯表示式,例如三目運算子 abc ? : <View>1</View> : <View>2</View> 需要編譯成 <view wx:if="{{abc}}">1</view><view wx:else>2</view>
  • 理解陣列 map 語法,例如 map 的使用 abc.map(item => <View>item</View>) 需要編譯成 <view wx:for="{{abc}}" wx:for-item="item">item</view>
  • 等等

以上僅僅是我們轉換規則的冰山一角,JSX 的寫法極其靈活多變,我們只能通過窮舉的方式,將常用的、React 官方推薦的寫法作為轉換規則加以支援,而一些比較生僻的,或者是不那麼推薦的寫的寫法則不做支援,轉而以 eslint 外掛的方式,提示使用者進行修改。目前我們支援的 JSX 轉換規則,大致能覆蓋到 JSX 80% 的寫法操作。

關於 JSX 轉小程式模板這一部分,我們將在後續的技術原理分析系列文章中,詳細為大家介紹。

還能不能幹點別的

經過我們一次次的探索,以及一波波猛如虎的操作,我們已經可以將類 React 程式碼轉成小程式可以跑的程式碼了,也就是說我們已經可以正式以 React 的方式來寫小程式的程式碼了。喜大普奔!但是我們激動之餘,冷靜下來繼續思考,我們還能不能幹點別的有意思的事情呢。

分析一下需求

我們發現,在平常的工作中,我們業務通常有一些多端的需求,就是要求小程式要有,H5 要有,甚至 RN 也能有就最好了,我猜產品經理還看不上快應用,不然肯定要求我們快應用也上一套吧,反正你們不是經常號稱程式碼優秀、高度可複用麼。這個時候,你就會發現,差不多的介面和邏輯,你可能需要重複寫上好幾輪,這時候要是有個多端程式碼生成工具就好了,只寫一份程式碼,可以多端執行。Write once, run anywhere,相信是所有工程師的夢想。

依然編譯原理的力量

這時候我們回憶一下前文的內容,將一份程式碼編譯成多端程式碼,這不正是編譯原理乾的事麼,我們可以輸入一份原始碼,針對不同的端設定好對應的轉換規則,再一鍵轉換出對應端的程式碼。而且由於我們已經遵循 React 語法了,那我們再轉成 H5 端(使用 Nerv)與 RN 端(使用 React)也就有了天然的優勢。

為何我們要用 React 來寫小程式 - Taro 誕生記

設計思路補完

但是仔細思考我們又會發現,僅僅將程式碼按照對應語法規則轉換過去後,還遠遠不夠,因為不同端會有自己的原生元件,端能力 API 等等,程式碼直接轉換過去後,可能不能直接執行。例如,小程式中普通的容器元件用的是 <view />,而在 H5 中則是 <div />;小程式中提供了豐富的端能力 API,例如網路請求、檔案下載、資料快取等,而在 H5 中對應功能的 API 則不一致。

所以,為了彌補不同端的差異,我們需要訂製好一個統一的元件庫標準,以及統一的 API 標準,在不同的端依靠它們的語法與能力去實現這個元件庫與 API,同時還要為不同的端編寫相應的執行時框架,負責初始化等等操作。通過以上這些操作,我們就能實現一份一鍵生成多端的需求了。在 Taro 最初的設計中,我們元件庫與 API 的標準就是源自小程式的,因為我們覺得既然已經有定義好的元件庫與 API 標準,那為啥不直接拿來使用呢,這樣不僅省去了定製標準的冥思苦想,同時也省去了為小程式開發元件庫與 API 的麻煩,只需要讓其他端來向小程式靠齊就好。

可能有些人會有疑問,既然是為不同的端實現了對應的元件庫與端能力 API (小程式除外,因為元件庫和 API 的標準都是源自小程式),那麼是怎麼能夠只寫一份程式碼就夠了呢?因為我們有編譯的操作,在書寫程式碼的時候,只需要引入標準元件庫 @tarojs/components 與執行時框架 @tarojs/taro ,程式碼經過編譯之後,會變成對應端所需要的庫。

為何我們要用 React 來寫小程式 - Taro 誕生記

既然元件庫以及端能力都是依靠不同的端做不同實現來抹平差異,那麼同樣的,如果我們想為 Taro 引入更多的功能支援的話,有時候也需要按照這個套路來。例如,為了提升開發便利性,我們為 Taro 加入了 Redux 支援,我們的做法就是,在小程式端,我們實現了 @tarojs/redux 這個庫來作為小程式的 Redux 輔助庫,並且以他作為基準庫,它具有和 react-redux 一致的 API,在書寫程式碼的時候,引用的都是 @tarojs/redux ,經過編譯後,在 H5 端會替換成 nerv-reduxNervRedux 輔助庫),在 RN 端會替換成 react-redux。這樣就實現了 Redux 在 Taro 中的多端支援。

為何我們要用 React 來寫小程式 - Taro 誕生記

以上就是 Taro 的整體設計思路,裡面還有很多細節沒有展開去闡述,可能大家會覺得有些意猶未盡,後續我們將會產出一系列的文章來闡述 Taro 的技術細節,例如 《Taro 開發工具原理分析》、《Taro 程式碼編譯的背後》、《深入淺出 JSX 轉小程式模板》等等。

最後的最後

Taro 從立項之初到現在已經差不多有了三個月左右的時間,從最初的激烈討論方案,各種思想的碰撞,到方案逐漸成型,進入火熱的開發迭代,再到現在的小程式端和 H5 端順利支援,從而決定走向開源。這一路走來,收穫頗豐,既有跟團隊小夥伴一起創造的激動,也有無數個日夜加班的苦思。Taro 是凹凸實驗室的誠意之作,我們也將會一直維護下去,希望 Taro 能越來越好,幫助更多人創造更多價值。

專案官網:taro.aotu.io/

專案 GitHub:github.com/NervJS/taro

相關文章