前言
凹凸實驗室的 Taro 是遵循 React 語法規範的多端開發方案,Taro 目前已對外開源一段時間,受到了前端開發者的廣泛歡迎和關注。截止目前 star 數已經突破11.7k,還在開啟的 Issues 有 200多個,已經關閉700多個,可見使用並參與討論的開發者是非常多的。Taro 目前已經支援微信小程式、H5、RN、支付寶小程式、百度小程式,持續迭代中的 Taro,也正在相容更多的端以及增加一些新特性的支援。
迴歸正題,本篇文章主要講的是 Taro 深度開發實踐,綜合我們在實際專案中使用 Taro 的一些經驗和總結,首先會談談 Taro 為什麼選擇使用React語法,然後再從Taro專案的程式碼組織、資料狀態管理、效能優化以及多端相容等幾個方面來闡述 Taro 的深度開發實踐體驗。
為什麼選擇使用React語法
這個要從兩個方面來說,一是小程式原生的開發方式不夠友好,或者說不夠工程化,在開發一些大型專案時就會顯得很吃力,主要體現在以下幾點:
- 一個小程式頁面或元件,需要同時包含 4 個檔案,以至開發一個功能模組時,需要多個檔案間來回切換
- 沒有自定義檔案預處理,無法直接使用 Sass、Less 以及較新的 ES Next 語法
- 字串模板太過孱弱,小程式的字串模板仿的是 Vue,但是沒有提供 Vue 那麼多的語法糖,當實現一些比較複雜的處理時,寫起來就非常麻煩,雖然提供了
wxs
作為補充,但是使用體驗還是非常糟糕 - 缺乏測試套件,無法編寫測試程式碼來保證專案質量,也就不能進行持續整合,自動化打包
原生的開發方式不友好,自然就想要有更高效的替代方案。所以我們將目光投向了市面上流行的三大前端框架React、Vue、Angular 。Angular在國內的流行程度不高,我們首先排除了這種語法規範。而類 Vue 的小程式開發框架市面上已經有一些優秀的開源專案,同時我們部門內的技術棧主要是 React,那麼 React 語法規範 也自然成為了我們的第一選擇。除此之外,我們還有以下幾點的考慮:
- React 一門非常流行的框架,也有廣大的受眾,使用它也能降低小程式開發的學習成本
- 小程式的資料驅動模板更新的思想與實現機制,與 React 類似
- React 採用 JSX 作為自身模板,JSX 相比字串模板來說更加自由,更自然,更具表現力,不需要依賴字串模板的各種語法糖,也能完成複雜的處理
- React 本身有跨端的實現方案 ReactNative,並且非常成熟,社群活躍,對於 Taro 來說有更多的多端開發可能性
綜上所述,Taro 最終採用了 React 語法 來作為自己的語法標準,配合前端工程化的思想,為小程式開發打造了更加優雅的開發體驗。
Taro專案的程式碼組織
要進行 Taro 的專案開發,首先自然要安裝 taro-cli,具體的安裝方法可參照文件,這裡不做過多介紹了,預設你已經裝好了 taro-cli 並能執行命令。
然後我們用 cli 新建一個專案,得到的專案模板如下:
├── dist 編譯結果目錄
├── config 配置目錄
| ├── dev.js 開發時配置
| ├── index.js 預設配置
| └── prod.js 打包時配置
├── src 原始碼目錄
| ├── pages 頁面檔案目錄
| | ├── index index頁面目錄
| | | ├── index.js index頁面邏輯
| | | └── index.css index頁面樣式
| ├── app.css 專案總通用樣式
| └── app.js 專案入口檔案
└── package.json
複製程式碼
如果是十分簡單的專案,用這樣的模板便可以滿足需求,在 index.js 檔案中編寫頁面所需要的邏輯
假如專案引入了 redux,例如我們之前開發的專案,目錄則是這樣的:
├── dist 編譯結果目錄
├── config 配置目錄
| ├── dev.js 開發時配置
| ├── index.js 預設配置
| └── prod.js 打包時配置
├── src 原始碼目錄
| ├── actions redux裡的actions
| ├── asset 圖片等靜態資源
| ├── components 元件檔案目錄
| ├── constants 存放常量的地方,例如api、一些配置項
| ├── reducers redux裡的reducers
| ├── store redux裡的store
| ├── utils 存放工具類函式
| ├── pages 頁面檔案目錄
| | ├── index index頁面目錄
| | | ├── index.js index頁面邏輯
| | | └── index.css index頁面樣式
| ├── app.css 專案總通用樣式
| └── app.js 專案入口檔案
└── package.json
複製程式碼
我們之前開發的一個電商小程式,整個專案大概3萬行程式碼,數十個頁面,就是按上述目錄的方式組織程式碼的。比較重要的資料夾主要是pages
、components
和actions
。
-
pages裡面是各個頁面的入口檔案,簡單的頁面就直接一個入口檔案可以了,倘若頁面比較複雜那麼入口檔案就會作為元件的聚合檔案,
redux
的繫結一般也是此頁面裡進行。 -
元件都放在components裡面。裡面的目錄是這樣的,假如有個
coupon
優惠券頁面,在pages
自然先有個coupon
,作為頁面入口,然後它的元件就會存放在components/coupon
裡面,就是components裡面也會按照頁面分模組,公共的元件可以建一個components/public
資料夾,進行復用。這樣的好處是頁面之間互相獨立,互不影響。所以我們幾個開發人員,也是按照頁面的維度來進行分工,互不干擾,大大提高了我們的開發效率。
-
actions這個資料夾也是比較重要,這裡處理的是拉取資料,資料再處理的邏輯。可以說,資料處理得好,流動清晰,整個專案就成功了一半,具體可以看下面***資料狀態管理***的部分。如上,假如是
coupon
頁面的actions
,那麼就會放在actions/coupon
裡面,可以再一次見到,所有的模組都是以頁面的維度來區分的。
除此之外,asset檔案用來存放的靜態資源,如一些icon類的圖片,但建議不要存放太多,畢竟程式包有限制。而constants則是一些存放常量的地方,例如api
域名,配置等等。
專案搭建完畢後,在根目錄下執行命令列 npm run build:weapp
或者 taro build --type weapp --watch
編譯成小程式,然後就可以開啟小程式開發工具進行預覽開發了。編譯成其他端的話,只需指定 type 即可(如編譯 H5 :taro build --type h5 --watch
)。
使用 Taro 開發專案時,程式碼組織好,遵循規範和約定,便成功了一半,至少會讓開發變得更有效率。
資料狀態管理
上面說到,會用 redux 進行資料狀態管理。
說到 redux,相信大家早已耳熟能詳了。在 Taro 中,它的用法和平時在 React 中的用法大同小異,先建立 store
、reducers
,再編寫 actions
;然後通過@tarojs/redux
,使用Provider
和 connect
,將 store 和 actions 繫結到元件上。基礎的用法大家都懂,下面我給大家介紹下如何更好地使用 redux。
資料預處理
相信大家都遇到過這種時候,介面返回的資料和頁面顯示的資料並不是完全對應的,往往需要再做一層預處理。那麼這個業務邏輯應該在哪裡管理,是元件內部,還是redux
的流程裡?
舉個例子:
例如上圖的購物車模組,介面返回的資料是
{
code: 0,
data: {
shopMap: {...}, // 存放購物車裡商品的店鋪資訊的map
goods: {...}, // 購物車裡的商品資訊
...
}
...
}
複製程式碼
對的,購車裡的商品店鋪和商品是放在兩個物件裡面的,但檢視要求它們要顯示在一起。這時候,如果直接將返回的資料存到store
,然後在元件內部render
的時候東拼西湊,將兩者資訊匹配,再做顯示的話,會顯得元件內部的邏輯十分的混亂,不夠純粹。
所以,我個人比較推薦的做法是,在介面返回資料之後,直接將其處理為與頁面顯示對應的資料,然後再dispatch
處理後的資料,相當於做了一層攔截,像下面這樣:
const data = result.data // result為介面返回的資料
const cartData = handleCartData(data) // handleCartData為處理資料的函式
dispatch({type: 'RECEIVE_CART', payload: cartData}) // dispatch處理過後的函式
...
// handleCartData處理後的資料
{
commoditys: [{
shop: {...}, // 商品店鋪的資訊
goods: {...}, // 對應商品資訊
}, ...]
}
複製程式碼
可以見到,處理資料的流程在render前被攔截處理了,將對應的商品店鋪和商品放在了一個物件了.
這樣做有如下幾個好處:
- 一個是元件的渲染更純粹,在元件內部不用再關心如何將資料修改而滿足檢視要求,只需關心元件本身的邏輯,例如點選事件,使用者互動等
- 二是資料的流動更可控,後臺資料 ——> 攔截處理 ——> 期望的資料結構 ——> 元件,假如後臺返回的資料有變動,我們要做的只是改變
handleCartData
函式裡面的邏輯,不用改動元件內部的邏輯。
實際上,不只是後臺資料返回的時候,其它資料結構需要變動的時候都可以做一層資料攔截,攔截的時機也可以根據業務邏輯調整,重點是要讓元件內部本身不關心資料與檢視是否對應,只專注於內部互動的邏輯,這也很符合 React
本身的初衷,資料驅動檢視。
用Connect實現計算屬性
計算屬性?這不是響應式檢視庫才會有的麼,其實也不是真正的計算屬性,只是通過一些處理達到模擬的效果而已。因為很多時候我們使用 redux 就只是根據樣板程式碼複製一下,改改元件各自的store
、actions
。實際上,我們可以讓它可以做更多的事情,例如:
export default connect(({
cart,
}) => ({
couponData: cart.couponData,
commoditys: cart.commoditys,
editSkuData: cart.editSkuData
}), (dispatch) => ({
// ...actions繫結
}))(Cart)
// 元件裡
render () {
const isShowCoupon = this.props.couponData.length !== 0
return isShowCoupon && <Coupon />
}
複製程式碼
上面是很普通的一種connect
寫法,然後render
函式根據couponData
裡是否資料來渲染。這時候,我們可以把this.props.couponData.length !== 0
這個判斷丟到connect
裡,達成一種computed
的效果,如下:
export default connect(({
cart,
}) => {
const { couponData, commoditys, editSkuData } = cart
const isShowCoupon = couponData.length !== 0
return {
isShowCoupon,
couponData,
commoditys,
editSkuData
}}, (dispatch) => ({
// ...actions繫結
}))(Cart)
// 元件裡
render () {
return this.props.isShowCoupon && <Coupon />
}
複製程式碼
可以見到,在connect
裡定義了isShowCoupon
變數,實現了根據couponData
來進行computed
的效果。
實際上,這也是一種資料攔截處理。除了computed
,還可以實現其它的功能,具體就由各位看官自由發揮了。
效能優化
關於資料狀態處理,我們提到了兩點,主要都是關於 redux 的用法。接下我們聊一下關於效能優化的。
setState的使用
其實在小程式的開發中,最大可能的會遇到的效能問題,大多數出現在setData
(具體到 Taro 中就是呼叫 setState
函式)上。這是由小程式的設計機制所導致的,每呼叫一次 setData
,小程式內部都會將該部分資料在邏輯層(執行環境 JSCore
)進行類似序列化的操作,將資料轉換成字串形式傳遞給檢視層(執行環境 WebView
),檢視層通過反序列化拿到資料後再進行頁面渲染,這個過程下來有一定效能開銷。
所以關於setState
的使用,有以下幾個原則:
- 避免一次性更新巨大的資料。這個更多的是元件設計的問題,在平衡好開發效率的情況下儘可能地細分元件。
- 避免頻繁地呼叫
setState
。實際上在 Taro 中 setState 是非同步的,並且在編譯過程中會幫你做了這層優化,例如一個函式裡呼叫了兩次 setState,最後 Taro 會在下一個事件迴圈中將兩者合併,並剔除重複資料。 - 避免後臺態頁面進行
setState
。這個更有可能是因為在定時器等非同步操作中使用了 setState,導致後臺態頁面進行了 setState 操作。要解決問題該就在頁面銷燬或是隱藏時進行銷燬定時器操作即可。
列表渲染優化
在我們開發的一個商品列表頁面中,是需要有無限下拉的功能。
因此會存在一個問題,當載入的商品資料越來越多時,就會報錯,invokeWebviewMethod 資料傳輸長度為 1227297 已經超過最大長度 1048576
。原因就是我們上面所說的,小程式在 setData 的時候會將該部分資料在邏輯層與檢視層之間傳遞,當資料量過大時就會超出限制。
為了解決這個問題,我們採用了一個大分頁思想的方法。就是在下拉選單中記錄當前分頁,達到 10 頁的時候,就以 10 頁為分割點,將當前 this.state
裡的 list
取分割點後面的資料,判斷滾動向前滾動就將前面資料 setState 進去,流程圖如下:
可以見到,我們先把商品所有的原始資料放在this.allList
中,然後判斷根據頁面的滾動高度,在頁面滾動事件中判斷當前的頁碼。頁碼小於10,取 this.allList.slice 的前十項,大於等於10,則取後十項,最後再呼叫 this.setState
進行列表渲染。這裡的核心思想就是,把看得見的資料才渲染出來,從而避免資料量過大而導致的報錯。
同時為了提前渲染,我們會預設一個500的閾值,使整個渲染切換的流程更加順暢。
多端相容
儘管 Taro 編譯可以適配多端,但有些情況或者有些 API 在不同端的表現差異是十分巨大的,這時候 Taro 沒辦法幫我們適配,需要我們手動適配。
process.env.TARO_ENV
使用process.env.TARO_ENV
可以幫助我們判斷當前的編譯環境,從而做一些特殊處理,目前它的取值有 weapp
、swan
、 alipay
、 h5
、 rn
五個。可以通過這個變數來書寫對應一些不同環境下的程式碼,在編譯時會將不屬於當前編譯型別的程式碼去掉,只保留當前編譯型別下的程式碼,從而達到相容的目的。例如想在微信小程式和 H5 端分別引用不同資源:
if (process.env.TARO_ENV === 'weapp') {
require('path/to/weapp/name')
} else if (process.env.TARO_ENV === 'h5') {
require('path/to/h5/name')
}
複製程式碼
我們知道了這個變數的用法後,就可以進行一些多端相容了,下面舉兩個例子來詳細闡述
滾動事件相容
在小程式中,監聽頁面滾動需要在頁面中的onPageScroll
事件裡進行,而在 H5 中則是需要手動呼叫window.addEventListener
來進行事件繫結,所以具體的相容我們可以這樣處理:
class Demo extends Component {
constructor() {
super(...arguments)
this.state = {
}
this.pageScrollFn = throttle(this.scrollFn, 200, this)
}
scrollFn = (scrollTop) => {
// do something
}
// 在H5或者其它端中,這個函式會被忽略
onPageScroll (e) {
this.pageScrollFn(e.scrollTop)
}
componentDidMount () {
// 只有編譯為h5時下面程式碼才會被編譯
if (process.env.TARO_ENV === 'h5') {
window.addEventListener('scroll', this.pageScrollFn)
}
}
}
複製程式碼
可以見到,我們先定義了頁面滾動時所需執行的函式,同時外面做了一層節流的處理(不瞭解函式節流的可以看這裡)。然後,在 onPageScroll
函式中,我們將該函式執行。同時的,在 componentDidMount
中,進行環境判斷,如果是 h5
環境就將其繫結到 window
的滾動事件上。
通過這樣的處理,在小程式中,頁面滾動時就會執行 onPageScroll
函式(在其它端該函式會被忽略);在 h5 端,則直接將滾動事件繫結到window
上。因此我們就達成小程式,h5端的滾動事件的繫結相容(其它端的處理也是類似的)。
canvas相容
假如要同時在小程式和 H5 中使用 canvas
,同樣是需要進行一些相容處理。canvas
在小程式和 H5 中的 API 基本都是一致的,但有幾點不同:
- canvas 上下文的獲取方式不同,h5 中是直接從 dom 中獲取;而小程式裡要通過呼叫 Taro.createCanvasContext 來手動建立
- 繪製時,小程式裡還需在手動呼叫 CanvasContext.draw 來進行繪製
所以做相容處理時就圍繞這兩個點來進行相容
componentDidMount () {
// 只有編譯為h5下面程式碼才會被編譯
if (process.env.TARO_ENV === 'h5') {
this.context = document.getElementById('canvas-id').getContext('2d')
// 只有編譯為小程式下面程式碼才會被編譯
} else if (process.env.TARO_ENV === 'weapp') {
this.context = Taro.createCanvasContext('canvas-id', this.$scope)
}
}
// 繪製的函式
draw () {
// 進行一些繪製操作
// .....
// 相容小程式端的繪製
typeof this.context.draw === 'function' && this.context.draw(true)
}
render () {
// 同時標記上id和canvas-id
return <Canvas id='canvas-id' canvas-id='canvas-id'/>
}
複製程式碼
可以見到,先是在 componentDidMount 生命週期中,分別針對不同的端的方法而取得 CanvasContext 上下文,在小程式端是直接通過Taro.createCanvasContext
進行建立,同時需要在第二個引數傳入this.$scope
;在 H5 端則是通過 document.getElementById(id).getContext('2d')
來獲得 CanvasContext 上下文。
獲得上下文後,繪製的過程是一致的,因為兩端的 API 基本一樣,而只需在繪製到最後時判讀上下文是否有 draw 函式,有的話就執行一遍來相容小程式端,將其繪製出來。
我們內部用 Canvas 寫了一個彈幕掛件,正是用這種方法來進行兩端的相容。
上述兩個具體例子總結起來,就是先根據 Taro 內建的 process.env.TARO_ENV
環境變數來判斷當前環境,然後再對某些端進行單獨適配。因此具體的程式碼層級的相容方式會多種多樣,完全取決於你的需求,希望上面的例子能對你有所啟發。
總結
本文先談了 Taro 為什麼選擇使用React語法,然後再從Taro專案的程式碼組織、資料狀態管理、效能優化以及多端相容這幾個方面來闡述了 Taro 的深度開發實踐體驗。整體而言,都是一些較為深入的,偏實踐類的內容,如有什麼觀點或異議,歡迎加入開發交流群,一起參與討論。