Taro實踐 - 深度開發實踐體驗及總結

凹凸實驗室發表於2018-11-30

前言

凹凸實驗室的 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 作為補充,但是使用體驗還是非常糟糕
  • 缺乏測試套件,無法編寫測試程式碼來保證專案質量,也就不能進行持續整合,自動化打包

原生的開發方式不友好,自然就想要有更高效的替代方案。所以我們將目光投向了市面上流行的三大前端框架ReactVueAngularAngular在國內的流行程度不高,我們首先排除了這種語法規範。而類 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萬行程式碼,數十個頁面,就是按上述目錄的方式組織程式碼的。比較重要的資料夾主要是pagescomponentsactions

  • 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 中的用法大同小異,先建立 storereducers,再編寫 actions;然後通過@tarojs/redux,使用Providerconnect,將 store 和 actions 繫結到元件上。基礎的用法大家都懂,下面我給大家介紹下如何更好地使用 redux。

資料預處理

相信大家都遇到過這種時候,介面返回的資料和頁面顯示的資料並不是完全對應的,往往需要再做一層預處理。那麼這個業務邏輯應該在哪裡管理,是元件內部,還是redux的流程裡?

舉個例子:

Taro實踐 - 深度開發實踐體驗及總結

例如上圖的購物車模組,介面返回的資料是

{
	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 就只是根據樣板程式碼複製一下,改改元件各自的storeactions。實際上,我們可以讓它可以做更多的事情,例如:

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 操作。要解決問題該就在頁面銷燬或是隱藏時進行銷燬定時器操作即可。

列表渲染優化

在我們開發的一個商品列表頁面中,是需要有無限下拉的功能。

Taro實踐 - 深度開發實踐體驗及總結

因此會存在一個問題,當載入的商品資料越來越多時,就會報錯,invokeWebviewMethod 資料傳輸長度為 1227297 已經超過最大長度 1048576。原因就是我們上面所說的,小程式在 setData 的時候會將該部分資料在邏輯層與檢視層之間傳遞,當資料量過大時就會超出限制。

為了解決這個問題,我們採用了一個大分頁思想的方法。就是在下拉選單中記錄當前分頁,達到 10 頁的時候,就以 10 頁為分割點,將當前 this.state 裡的 list 取分割點後面的資料,判斷滾動向前滾動就將前面資料 setState 進去,流程圖如下:

Taro實踐 - 深度開發實踐體驗及總結

可以見到,我們先把商品所有的原始資料放在this.allList中,然後判斷根據頁面的滾動高度,在頁面滾動事件中判斷當前的頁碼。頁碼小於10,取 this.allList.slice 的前十項,大於等於10,則取後十項,最後再呼叫 this.setState 進行列表渲染。這裡的核心思想就是,把看得見的資料才渲染出來,從而避免資料量過大而導致的報錯。

同時為了提前渲染,我們會預設一個500的閾值,使整個渲染切換的流程更加順暢。

多端相容

儘管 Taro 編譯可以適配多端,但有些情況或者有些 API 在不同端的表現差異是十分巨大的,這時候 Taro 沒辦法幫我們適配,需要我們手動適配。

process.env.TARO_ENV

使用process.env.TARO_ENV可以幫助我們判斷當前的編譯環境,從而做一些特殊處理,目前它的取值有 weappswanalipayh5rn 五個。可以通過這個變數來書寫對應一些不同環境下的程式碼,在編譯時會將不屬於當前編譯型別的程式碼去掉,只保留當前編譯型別下的程式碼,從而達到相容的目的。例如想在微信小程式和 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 的深度開發實踐體驗。整體而言,都是一些較為深入的,偏實踐類的內容,如有什麼觀點或異議,歡迎加入開發交流群,一起參與討論。

相關文章