weex-html5元件進階

手淘大法發表於2016-09-29

前言

上一篇文章《weex-html5 擴充套件開發指引》中介紹了 weex-html5 擴充套件元件、模組的基本步驟和方法。在元件擴充套件的內容裡提了幾個擴充套件元件的關鍵性的問題,這幾個問題涉及到元件的實現以及一些原理和工具。本篇將會就 weex-html5 元件的基類、管理類、元件渲染的執行流程以及一些重要的注意事項和最佳實踐展開討論。

先來回味一下前篇中提到的,在元件擴充套件過程中可能遇到的問題:

  • 在元件的 constructor 裡需要幹些什麼?
  • 在元件的其他方法中分別需要做哪些事情?
  • 有哪些可以直接呼叫的父類的原型方法?
  • 元件從註冊到渲染到頁面上的執行流程是怎樣的?

先不著急回答這幾個問題,我們先來了解一下 weex-html5 元件架構的基本原理。

元件基礎

weex-html5 的所有元件都是從一個最基礎的基類繼承而來,基類中包含基本的 渲染操作 和一些 __輔助方法__。每個元件都有一個 id 用於 jsfm (weex-jsframework) 對其進行索引。在web渲染端,管理這些索引,以及響應 jsfm 的操作指令,並做一些元件渲染週期的管理,這些事情是由元件的管理者 ComponentManager 負責的。每一個 weex 的例項都包含一個 ComponentManager 的例項。

基類 Component 和 Atomic

每個元件在定義的時候都需要實現一個 init 方法,用於 weex 對該元件進行註冊。在 weex.install(yourComponent) 的過程中會向這個方法裡注入 Weex 這個類。你可以通過 Weex.Component 獲取到這個類的建構函式,也可以通過 Weex.Atomic 獲取 Atomic 類的建構函式(其他暴露在 Weex 上的靜態屬性還包括 ComponentManager, utils 以及 config)。

Component 是 weex-html5 自定義元件的始祖,一切元件包括 Atomic 都是從這個基類繼承而來。 Atomic 是 不包含任何子元件 的元件,相比 Component 來說有更嚴格的限制,並且不需要重寫它的 createChildrenappendChildinserBefore以及 removeChild 等操作子節點的方法。簡單來說,如果你要定義一個可以有子元件的元件,那麼繼承 Component 就可以,如果你要定義一個不應當包含任何子元件的元件(比如表單元件 input),那麼需要繼承 Atomic.

Component

下面對這些方法分別進行介紹:

獲取其他關鍵資訊的方法

  • getWeexInstance 獲取當前的 weex 例項
  • getComponentManager 獲取當前 weex 例項對應的 ComponentManager 例項

用於元件索引的方法

  • getParent 獲取父元件
  • getParentScroller 向上獲取最近的 scrollable 元件(滑動元件,目前有 list、scroller 等 ),如果不在 scrollable 元件內部,則返回 null
  • getRootScroller 獲取最頂層的 scrollable 元件
  • getRootContainer 獲取當前 weex 頁面的 root 節點,一般是 document.body 下 id 為 weex 的節點
  • isScrollable 當前元件是否是 scrollable 元件
  • isInScrollable 當前元件是否是其他 scrollable 元件的子孫元件

渲染操作相關方法

渲染操作相關方法是 weex 元件渲染執行流程中的重要環節。weex 元件的執行流程可以在 Component 元件的建構函式中找到:

export default function Component (data, nodeType) {
  this.data = data
  this.node = this.create(nodeType)
  this.createChildren()
  this.updateAttrs(this.data.attr || {})
  const classStyle = this.data.classStyle
  classStyle && this.updateStyle(classStyle)
  this.updateStyle(this.data.style || {})
  this.bindEvents(this.data.event || [])
}

從程式碼裡可以看出一個元件的基本構造流程為:

繫結資料 (data) -> 建立節點 (create) -> 建立子節點 (createChildren) -> 更新屬性值 (updateAttrs) -> 更新樣式 (updateStyle) -> 繫結事件 (bindEvents)

這個元件構造完畢後需要掛載到某個頁面中已經存在的父節點中,這時候就會(通過 ComponentManager )呼叫父節點的 appendChildinsertBefore 方法,所以這兩個方法也非常重要,但是一般元件不需要重寫這兩個方法,除非需要在這裡做一些特殊的邏輯處理。

  • create 建立當前元件在 weex 頁面中所佔據的具體 dom 節點。比如 <image> 元件的 create 方法裡就建立了一個 <div> 元素,併為該元素新增了 class 類名。weex 預置了兩個通用的類名 weex-containerweex-element,用於消除 web 元件和基於css-layout庫的 native 元件之間的樣式差異,建議總是新增這兩個類名中的一個。

weex-container 和 weex-element 類的預設樣式如下:

.weex-container {
  box-sizing: border-box;
  display: -webkit-box;
  display: -webkit-flex;
  display: flex;
  -webkit-box-orient: vertical;
  -webkit-flex-direction: column;
  flex-direction: column;
  flex-shrink: 0;
  align-items: stretch;
  box-align: stretch;
  align-content: flex-start;
  position: relative;
  border: 0 solid black;
  margin: 0;
  padding: 0;
  min-width: 0;
}

.weex-element {
  box-sizing: border-box;
  position: relative;
  flex-shrink: 0;
  border: 0 solid black;
  margin: 0;
  padding: 0;
  min-width: 0;
}
  • createChildren 建立子節點。這裡僅限於建立 data.children 中的節點,如果當前節點的 append 方式append="node" 這種 weex 的預設處理方式,那麼子節點不會被塞到 data.children 裡處理,如果當前節點的 append 方式是 append="tree" 方式,此時該節點的子節點都需要父節點通過處理 data.children 來建立。如果你不知道怎麼處理 data.children 裡的每個陣列元素,可以直接丟給 ComponentManager.createElement(data.children[i]) 來處理。實際上 Component 基類裡就是這麼做的,當然你也可以自己去做一些特殊處理
  • appendChild 新增一個子節點到子節點列表的末尾
  • insertBefore 新增一個子節點到指定的位置 (指定 index)
  • removeChild 刪除一個子節點
  • updateAttrs 更新屬性值,不推薦直接重寫此方法,後面會介紹如何配置屬性的 setter
  • updateStyle 更新樣式,不推薦直接重寫此方法,後面會介紹如何配置樣式的 setter
  • bindEvents 繫結事件,不推薦直接重寫此方法,後面會介紹如何配置事件的額外引數以及 updator
  • unbindEvents 解綁事件,一般不需要重寫此方法

元件週期相關方法

  • onAppend 元件被掛載到頁面時的執行勾子,基類已經在這裡做了一些處理,不要直接重寫這個方法
  • addAppendHandler 為元件新增掛載時的執行勾子,如果想要在元件被掛載時執行一些程式碼,可以呼叫這個方法

其他重要方法

  • dispatchEvent 在當前元件的 node 上觸發一個事件(如果 DSL 開發者繫結了這個事件型別的監聽器,那麼這個監聽器會被觸發)
  • enableLazyload 為當前元件的 node 節點指定懶載入的屬性 img-src 為某個指定的 src,這樣懶載入控制器會識別當前節點為 image 並對當前節點進行懶載入控制,這個一般不會用到,開發者比較常用的是下一個方法
  • fireLazyload 用於手動觸發某個元件或者節點內部的 image 元件進行懶載入。之所以會有這個方法是由於某些特殊情況下元件進入了視口,其中的圖片節點應該進行載入時,懶載入因為某些原因卻沒有正確執行,這種情況下就可以手動呼叫此方法

配置資訊

配置資訊是方便元件對屬性、樣式和事件進行定製的一種手段。通過配置可以簡化程式碼,規範元件行為,避免不必要的程式碼冗餘,提高程式碼複用度,方便開發者進行擴充套件。

  • attr 屬性 setter 配置,基類的為空物件
  • style 基類配置兩組樣式的 setter, 一組和 positon 相關,即除了基本的 relativeabsolute,更支援了 position 的 fixed, sticky 值。另外對 flex 相關的樣式做了歸一化的處理,開發者不用去寫多套 flex 降級名稱。歸一化後的 flex 樣式及其支援的值為:
樣式名 支援的值
flex number
align-items flex-start, flex-end, stretch, center
justify-content flex-start, flex-end, center, space-between
  • event 事件配置,基類的為空物件

在後面的 元件擴充套件實踐 小節裡對如何做元件配置做了詳細解釋。

管理者componentManager

ComponentManager 是元件的大管家,不僅僅需要管理當前註冊的元件型別,管理當前 weex 例項的所有元件以及它們的 ref id,還要負責監聽元件的 appear 事件,判斷當前的渲染狀態,並串聯處理元件生命週期的各個階段。

ComponentManager

靜態方法

ComponentManager 包含幾個靜態方法,可以直接通過 ComponentManager.xxx 呼叫。

  • getInstance(id) 獲取對應 id 的 ComponentManager 例項
  • registerComponent 註冊元件,我們自定義的元件在內部就是通過這個方法註冊進來的
  • getScrollableTypes 獲取 scrollable 元件型別(比如 list, scroller 等)的陣列

例項方法

每個 ComponentManager 例項都實現了 jsfm 裡 vdom 的 Listener 的裡的方法,在 jsfm 裡的 Listener 負責建立對應虛擬 dom 操作的指令傳送給 native 端的 callNative 橋接器。而在 weex-html5 裡 ComponentManager 接管了這個介面,並將 dom 操作的指令轉換為真實的元件增添刪除以及其他操作。

這些元件操作相關的方法,在 native 平臺以及舊版的 weex-html5(< v0.3.0) 中是通過 bridge 的 callNative 接收 dom 模組的 API 呼叫實現的。在新版本的 weex-html5 (>= v0.3.0)中 ComponentManager 接管了 dom 模組的幾乎所有方法(除了 scrollToElement 這個比較特殊的方法)。

這些方法包括:

  • createBody 建立頁面根節點
  • addElement 新增一個元件
  • removeElement 移除一個元件
  • moveElement 移動一個元件
  • setAttr 更新屬性值
  • setStyle 更新樣式
  • setStyles 更新多個樣式
  • addEvent 新增事件監聽
  • removeEvent 移除事件監聽

上述方法對於我們元件開發者來說其實是透明的,一般不會被用到。另一些方法則是你可能會用到的:

  • getComponent weex 裡每個元件都有一個唯一的 id, 在 weex 裡這個 id 叫做 ref. ComponentManager 內部儲存了一個元件的 ref 和例項對映的 map. 一些針對某個元件進行的操作,比如 setAttr, setStyle, removeElement 等都會根據這個 map 去查詢對應 ref 的元件。ComponentManager 同時提供了這個專門通過 ref 獲取對應元件的方法。 在元件中可以通過 this.getComponentManager().getComponent(ref) 獲取某個元件。
  • createElement 如果你要自己實現元件的 appendChild 或者 createChildren 等方法,你得到的入參一般是元件的 data,這時呼叫 ComponentManager 的 createElement(data) 返回的就是對應 data.type 指定型別的元件的例項。這時這個方法是必須呼叫的,因為只有 ComponentManager 中有註冊的元件型別資訊。

另一些你不會直接呼叫,但是在元件裡可能會間接用到的方法:

  • rendering weex 元件基於優化的考慮會在元件做頻繁 dom 操作期間做一些提高可用性減少頁面阻塞的操作。ComponentManager 通過這個方法向 global (window) 物件註冊了兩個事件,renderbeginrenderend. 某個固定時間間隔內(預設為 800ms)沒有任何 dom 操作時會出發 renderend 事件,一旦有 dom 操作就會觸發 renderbegin. 這裡的 dom 操作特指 addElement, removeElementmoveElement 這三個操作。如果你的元件需要在`頻繁 dom 操作期間`做一些優化操作,比如關閉某些特性,可以考慮監聽這兩個事件。
  • handleAppend 在元件掛載之後呼叫 onAppend 方法。你不會直接用到這個方法,但是元件的 onAppend 的執行依賴這個方法。另外元件的 appeardisappear 事件也是在這裡繫結的,圖片的懶載入也是在這裡觸發。

頁面的構建流程

前面已經介紹了在基類 Component 的建構函式裡的執行流程。這裡跳出單個元件的構造過程,我們來看整個頁面是如何被構造並渲染出來的。這個過程涉及到 jsfm 裡如何編譯模板、繫結資料、監聽變化並構造虛擬 dom 等等,這些原理限於篇幅這裡就不多做介紹了,展開會是個很大的話題。我們把 jsfm 看作一個實體,來看 jsfm 和 render 之間的通訊過程,以及 ComponentManager 例項和各個元件之間的協作過程。

render-process

ComponentManager 做為 Listener 掛載在 jsfm 的 vdom(虛擬 dom) 裡,在 jsfm 的 Document 例項裡包含它的引用。vdom 的所有新增刪除元素的操作,都會觸發 ComponentManager 的對應方法,轉變為真實的元件操作。

在 vdom 裡有個 documentElement 的概念,類似 html 裡的 documentElement,相當於整個頁面的根標籤。在這個根標籤裡新增的節點,被稱為 body. 在 append body 的過程中會呼叫 ComponentManager 的 createBody 方法,這時 weex 頁面的根節點就是 createBody 這個方法建立出來的。

當 vdom 裡需要新增一個元素,首先觸發其父元素的 appendChild (Element.prototype.appendChild) 或者 insertBefore (Element.prototype.insertBefore) 方法,這個操作被翻譯到 Listener 中,也就是 ComponentManager 的 addElement 方法。在 ComponentManager 中會根據傳入的 index 判斷是呼叫自己的 appendChild (在末尾新增元素) 還是 insertBefore (在中間插入元素)。

在 ComponentManager 的 appendChildinsertBefore 方法中首先會根據 parentRef 找到父元件,然後呼叫父元件的對應的方法(如果沒有 override 的話就是基類 Component 的對應方法)。在這些方法中又會呼叫 ComponentManager 的 createElement 方法建立要替新增的子元件。

子元件也可能會有子元素,如果 DSL 裡指定了一個元件的 append 屬性為 append=tree ,那麼新增這個元件的時候,它的子元件的資訊都放在了 data.children 裡。這時子元件的構建過程中會呼叫 createChildren 方法建立子元件。反之如果 DSL 不指定 append 或者指定其為 append=node (預設方式),此時 data.children 一般為空,而它的子元件會通過下一次的 addElement (ComponentManager.prototype.addElement) 呼叫被建立。

整個頁面就是從 createBody 開始,接著向 body 節點裡 addElement 新增新的元件,並在這個新的元件裡 addElement 新增它自己的子元件,這樣不斷迭代構造出來的。每個元件再通過自身的構造渲染流程(create, createChildren, updateAttrs, updateStyle, bindEvents 等等),把自己按照一定的模板和樣式渲染到頁面中。

元件擴充套件實踐

基本原理是很簡單的,但是實際擴充套件元件的過程中,需要關注一些最佳實踐和注意事項。

元件的建構函式

一個元件例項化的入口總是它的建構函式。在基類 Component 和 Atomic 的建構函式裡已經做了大部分的函式呼叫,前面已經提過,它們的執行順序是:

繫結資料 (data) -> 建立節點 (create) -> 建立子節點 (createChildren) -> 更新屬性值 (updateAttrs) -> 更新樣式 (updateStyle) -> 繫結事件 (bindEvents)

在自定義元件的建構函式裡就不需要重複去做這些事情了,只需要呼叫基類的建構函式即可:

function init (Weex) {
  const Component = Weex.Component
  // ...
  // weex-hello-web 的建構函式:
  function Hello (data) {
    // 在子類的建構函式裡呼叫基類的建構函式
    Component.call(this, data)
  }
  Hello.prototype = Object.create(Component.prototype)
  // ...
}

這樣這個元件就可以跑起來。當然你可以向其中新增一些其他的邏輯,比如儲存 data 裡的屬性,做一些初始化的操作,這取決於你元件所承載的功能。建議元件內部抽象的以及資料相關的初始化邏輯放到元件的建構函式裡,而涉及到具體 Dom 構建和操作的初始化邏輯放到 create 方法裡。

配置屬性、樣式和事件

擴充套件一個元件,不僅僅要定製它的建構函式,建立節點及子節點的過程,還要定義它能接受的引數。拿weex-hello這個簡單的元件 demo 做例子,它帶有一個屬性叫做 value,可以設定的樣式包括 txt-colorbg-color,並在 click 事件的引數裡傳遞了一個 value 值(e.value)。

weex-hello-spec

配置屬性

const attr = {
  // 定義 value 這個屬性的 setter
  value (val) {
    this.value = val
    this.inner.textContent = `Hello ${val}!`
  }
}

這裡 value 是一個 setter,接受新的屬性值(val)做為引數,你所要做的事情就是定義這個 setter,每次這個屬性更新的時候這個 setter 都會被執行。在 setter 裡的 this 會繫結為當前的 component 例項。

配置樣式

const style = {
  // 定義 text-color 這個樣式的 setter
  txtColor (val) {
    this.inner.style.color = val
  },
  // 定義 bg-color 的 setter
  bgColor (val) {
    this.inner.style.backgroundColor = val
  }
}

這裡 txtColor 的 setter 的引數接受的是 txt-color 的樣式值。同理 bgColor 對應的是元件的樣式 bg-color. 你所要做的事是定義這兩個 setter 的內容。在這個例子裡僅僅是將 this.inner 這個 dom 元素的對應樣式更新為指定的值,複雜元件可能需要你做更多事情。和 attr 的 setter 一樣,函式體裡的 this 會繫結為當前的 component 例項。

配置事件

const event = {
  click: {
    // 定義 click 事件物件的額外引數
    extra () {
      return {
        value: this.inner.textContent
      }
    }
  }
}

事件配置相比屬性和樣式的配置更復雜一些。首先需要指定對哪個事件型別做配置,例子裡需要定製的事件型別只有一個 click。一個事件可以進行三種配置,分別為 extraupdatorsetter.

  • extra 配置事件引數傳遞的額外資訊,比如在上面的例子裡需要往 event 物件裡增加一個 value 值,DSL 開發者可以通過 evt.value 得到這個值:
methods: {
  // 在 click 的回撥函式裡獲取 value 值
  handleClick (evt) {
    console.log(`value is:`, evt.value)
  }
}

注意 extra 是一個函式,需要返回一個額外資料物件,這個函式的 this 也是繫結為當前 component 的例項的。

  • updator weex 目前由於自身的限制無法做到資料雙向繫結,使用者操作導致的資料變更需要 DSL 開發者在事件監聽裡獲取並進行手動更新,而手動更新資料可能導致 jsfm 傳送冗餘的更新操作訊息。updator 可以認為是 weex 的一種資料靜默更新機制,當使用者操作導致某個 attr 或者 style 的值發生變更時,會把對應的值傳給 jsfm ,這樣當 DSL 開發者手動更新資料時 jsfm 已經將該值更新過了,不會再發冗餘的訊息
  • setter 直接替換掉事件監聽函式,並不推薦使用這種方式進行事件繫結

統統繫結到 prototype

定義了 attr, style 和 event,還需要把它們繫結到 prototype 上。這裡也有一些技巧,並不是直接把 prototype 加上這些屬性就可以了。我們來看這段程式碼:

function init (Weex) {
  // ...
  const extend = Weex.utils.extend
  extend(Input.prototype, proto)
  extend(Input.prototype, { attr })
  extend(Input.prototype, {
    style: extend(Object.create(Atomic.prototype.style), style)
  })
  extend(Input.prototype, { event })
  // ...
}

這裡的 extend 就是簡化版的 Object.assign,這段程式碼很好理解。需要注意的是,style 不是直接掛載到 prototype 上面的,因為基類(這個例子是 Atomic)已經包含 style 屬性,即 position 的 setter 以及 flex 規範化的 setter. 所以需要把原來的 style 都繼承下來,再用 extend 新增新的 style 進來。

螢幕適配係數:scale

weex 是如何適配不同大小螢幕的?這個問題涉及到元件在頁面上的最終展現。如果你擴充套件的元件有自定義的屬性或者樣式,涉及到尺寸大小的,需要非常注意這一塊。每個元件在被建立之前,會由 ComponentManager 將當前螢幕的 scale 值注入元件的 data (在除了 constructor 以外的任何元件方法中都可以通過 this.data.scale 訪問到)中。那麼這個 scale 到底是什麼?

weex 中的設計尺寸是 __750px__,也就是說 weex 認為所有螢幕的寬度都是歸一化的 __750px__. 當真實螢幕不是 750px,weex 會自動將設計尺寸對映到真實尺寸中去,這個 scale 就是這種對映的比例。它的計算公式是 當前螢幕尺寸 / 750.

所以在擴充套件元件的時候,如果使用者傳入一個尺寸值,比如說 375,這個值是相對於 750 的設計尺寸來說的。你只需要將這個值乘以 scale, 就是適配當前螢幕的真實尺寸:value = 375 * this.data.scale. 它應該佔據真實螢幕一半的大小。

元件的生命週期

一個元件的生命週期包括初始化、構建、掛載以及移除等。元件開發者可以在其中各個階段進行控制。

  • __初始化__:通過元件的建構函式實現初始化的邏輯控制
  • __構建__:通過重寫元件的 create 方法實現構建階段的邏輯控制
  • __掛載__:基類 Component 在 onAppend 方法中已經做了一些處理(檢測 appear 事件的觸發條件,如果滿足條件則觸發該事件),如果需要在掛載階段做一些自己的處理,可以在初始化的邏輯裡呼叫 addAppendHandler 方法向 onAppend 中新增程式碼:
function MyComponent (data) {
  this.addAppendHandler(() => {
    // 節點掛載以後需要執行的邏輯
  })
  Component.call(this, data)
}

這個呼叫操作也可以放在元件繼承完成以後的任何時刻進行:

function init (Weex) {
  const Atomic = Weex.Atomic
  const extend = Weex.utils.extend
  function Input (data) {
    Atomic.call(this, data)
  }
  Input.prototype = Object.create(Atomic.prototype)
  extend(Input.prototype, proto)

  // 新增 onAppend handler
  Input.prototype.addAppendHandler(() => {
    // 節點掛載以後需要執行的邏輯
  })
  // ...
}
  • __移除__:在基類的程式碼裡可能找不到這個函式,因為基類沒有做額外的處理。如果你需要在元件被移除時做一些操作,比如連線斷開、資源釋放等,可以為你的元件新增一個 onRemove 方法,元件在被移除時會自動呼叫這個方法。

獲取當前 weex 例項的 id

和 scale 類似,元件在被構建之前就已經在 ComponentManager 的 createElement 方法裡,將 id 注入到元件的 data 裡。在元件的生命週期的任何時刻都可以通過 this.data.instanceId 獲取到當前 weex 例項的 id.

獲取當前 weex 例項

在元件的生命週期任何時刻都可以通過 this.getWeexInstance 獲取到當前 weex 例項。這個方法只是 this.getComponentManager().getWeexInstance() 的簡化版,一般也不太會用到。

獲取 ComponentManager

ComponentManager 包含靜態方法和例項方法。在 init 方法裡可以通過 Weex.ComponentManager 獲取到 ComponentManager 這個類,並在接下來的程式碼裡呼叫它的靜態方法。在自定義元件的方法實現中,可以通過 this.getComponentManager() 獲取到當前 ComponentManager 的例項。一般可能用到的是 createElement, getComponent 這兩個方法。

utils

utils 是 weex-html5 內部一套工具函式,不是很推薦業務上直接使用。後續可能進行相關的重構,但是有幾個使用比較頻繁的方法應該不會有大的變動:

  • extend(target, ...src) 其實就是 Object.assign,將後面傳入的物件的鍵值對拷貝給 target,相當於 mixin. 需要注意越靠後的物件的鍵值對具有越優先的覆蓋權
  • detectWep 判讀當前裝置是否支援 webp 格式圖片
  • detectSticky 判斷當前裝置是否支援原生的 position: sticky
  • throttle(func, wait) 函式限流,func 為要限流的函式,wait 為呼叫時間最小間隔,單位為 ms
  • isPlainObject 是否是 [object Object]
  • getType 返回物件的真實型別,如 string, number, object, date, 等等

可以通過 init 方法裡注入的 Weex 上掛載的 Weex.utils 獲取到 utils 的方法:

function init (Weex) {
  // 通過注入的 Weex 取得掛載的 utils 物件.
  const utils = Weex.utils
  // ...
}

小結

本文接著前篇《weex-html5 擴充套件開發指引》的話題,介紹了 weex-html5 裡的元件基類,執行流程,生命週期,螢幕適配,元件管理以及元件定義過程中的一些最佳實踐和注意事項等。通過這些介紹相信大家對文章開頭提出的幾個問題已經有了答案。對於 weex-html5 元件擴充套件還有什麼問題或建議歡迎與我交流。


相關文章