Taro Next H5 跨框架組件庫實踐

做者:凹凸曼 - JJ
Taro 是一款多端開發框架。開發者只需編寫一份代碼,便可生成各小程序端、H5 以及 React Native 的應用。html

Taro Next 近期已發佈 beta 版本,全面完善對小程序以及 H5 的支持,歡迎體驗!

背景

Taro Next 將支持使用多框架開發

過去的 Taro 1 與 Taro 2 只能使用 React 語法進行開發,但下一代的 Taro 框架對總體架構進行了升級,支持使用 React、Vue、Nerv 等框架開發多端應用。前端

爲了支持使用多框架進行開發,Taro 須要對自身的各端適配能力進行改造。本文將重點介紹對 Taro H5 端組件庫的改造工做。vue

Taro H5

Taro 遵循以微信小程序爲主,其餘小程序爲輔的組件與 API 規範。node

但瀏覽器並無小程序規範的組件與 API 可供使用,例如咱們不能在瀏覽器上使用小程序的 view 組件和 getSystemInfo API。所以咱們須要在 H5 端實現一套基於小程序規範的組件庫和 API 庫。react

Taro H5 架構圖

在 Taro 1 和 Taro 2 中,Taro H5 的組件庫使用了 React 語法進行開發。但若是開發者在 Taro Next 中使用 Vue 開發 H5 應用,則不能和現有的 H5 組件庫兼容。git

因此本文須要面對的核心問題就是:咱們須要在 H5 端實現 React、Vue 等框架均可以使用的組件庫github

方案選擇

咱們最早想到的是使用 Vue 再開發一套組件庫,這樣最爲穩妥,工做量也沒有特別大。web

但考慮到如下兩點,咱們遂放棄了此思路:小程序

  1. 組件庫的可維護性和拓展性不足。每當有問題須要修復或新功能須要添加,咱們須要分別對 React 和 Vue 版本的組件庫進行改造。
  2. Taro Next 的目標是支持使用任意框架開發多端應用。假若未來支持使用 Angular 等框架進行開發,那麼咱們須要再開發對應支持 Angular 等框架的組件庫。

那麼是否存在着一種方案,使得只用一份代碼構建的組件庫能兼容全部的 web 開發框架呢?微信小程序

答案就是 Web Components

但在組件庫改造爲 Web Components 的過程並非一路順風的,咱們也遇到了很多的問題,故藉此文向你們娓娓道來。

Web Components 簡介

Web Components 由一系列的技術規範所組成,它讓開發者能夠開發出瀏覽器原生支持的組件。

技術規範

Web Components 的主要技術規範爲:

  • Custom Elements
  • Shadow DOM
  • HTML Template

Custom Elements 讓開發者能夠自定義帶有特定行爲的 HTML 標籤。

Shadow DOM 對標籤內的結構和樣式進行一層包裝。

<template> 標籤爲 Web Components 提供複用性,還能夠配合 <slot> 標籤提供靈活性。

示例

定義模板:

<template id="template">
  <h1>Hello World!</h1>
</template>

構造 Custom Element:

class App extends HTMLElement {
  constructor () {
    super(...arguments)

    // 開啓 Shadow DOM
    const shadowRoot = this.attachShadow({ mode: 'open' })

    // 複用 <template> 定義好的結構
    const template = document.querySelector('#template')
    const node = template.content.cloneNode(true)
    shadowRoot.appendChild(node)
  }
}
window.customElements.define('my-app', App)

使用:

<my-app></my-app>

Stencil

使用原生語法去編寫 Web Components 至關繁瑣,所以咱們須要一個框架幫助咱們提升開發效率和開發體驗。

業界已經有不少成熟的 Web Components 框架,一番比較後咱們最終選擇了 Stencil,緣由有二:

  1. Stencil 由 Ionic 團隊打造,被用於構建 Ionic 的組件庫,證實經受過業界考驗。
  2. Stencil 支持 JSX,能減小現有組件庫的遷移成本。

Stencil 是一個能夠生成 Web Components 的編譯器。它糅合了業界前端框架的一些優秀概念,如支持 Typescript、JSX、虛擬 DOM 等。

示例:

建立 Stencil Component:

import { Component, Prop, State, h } from '@stencil/core'

@Component({
  tag: 'my-component'
})
export class MyComponent {
  @Prop() first = ''
  @State() last = 'JS'

  componentDidLoad () {
    console.log('load')
  }

  render () {
    return (
      <div>
        Hello, my name is {this.first} {this.last}
      </div>
    )
  }
}

使用組件:

<my-component first='Taro' />

在 React 與 Vue 中使用 Stencil

到目前爲止一切都那麼美好:使用 Stencil 編寫出 Web Components,便可以在 React 和 Vue 中直接使用它們。

但實際使用上卻會出現一些問題,Custom Elements Everywhere 經過一系列的測試用例,羅列出業界前端框架對 Web Components 的兼容問題及相關 issues。下面將簡單介紹 Taro H5 組件庫分別對 React 和 Vue 的兼容工做。

兼容 React

1. Props

1.1 問題

React 使用 setAttribute 的形式給 Web Components 傳遞參數。當參數爲原始類型時是能夠運行的,可是若是參數爲對象或數組時,因爲 HTML 元素的 attribute 值只能爲字符串或 null,最終給 WebComponents 設置的 attribute 會是 attr="[object Object]"

attribute 與 property 區別
1.2 解決方案

採用 DOM Property 的方法傳參。

咱們能夠把 Web Components 包裝一層高階組件,把高階組件上的 props 設置爲 Web Components 的 property:

const reactifyWebComponent = WC => {
  return class extends React.Component {
    ref = React.createRef()

    update () {
      Object.entries(this.props).forEach(([prop, val]) => {
        if (prop === 'children' || prop === 'dangerouslySetInnerHTML') {
          return
        }
        if (prop === 'style' && val && typeof val === 'object') {
          for (const key in val) {
            this.ref.current.style[key] = val[key]
          }
          return
        }
        this.ref.current[prop] = val
      })
    }

    componentDidUpdate () {
      this.update()
    }

    componentDidMount () {
      this.update()
    }

    render () {
      const { children, dangerouslySetInnerHTML } = this.props
      return React.createElement(WC, {
        ref: this.ref,
        dangerouslySetInnerHTML
      }, children)
    }
  }
}

const MyComponent = reactifyWebComponent('my-component')

注意:

  • children、dangerouslySetInnerHTML 屬性須要透傳。
  • React 中 style 屬性值能夠接受對象形式,這裏須要額外處理。

2. Events

2.1 問題

由於 React 有一套合成事件系統,因此它不能監聽到 Web Components 發出的自定義事件。

如下 Web Component 的 onLongPress 回調不會被觸發:

<my-view onLongPress={onLongPress}>view</my-view>
2.2 解決方案

經過 ref 取得 Web Component 元素,手動 addEventListener 綁定事件。

改造上述的高階組件:

const reactifyWebComponent = WC => {
  return class Index extends React.Component {
    ref = React.createRef()
    eventHandlers = []

    update () {
      this.clearEventHandlers()

      Object.entries(this.props).forEach(([prop, val]) => {
        if (typeof val === 'function' && prop.match(/^on[A-Z]/)) {
          const event = prop.substr(2).toLowerCase()
          this.eventHandlers.push([event, val])
          return this.ref.current.addEventListener(event, val)
        }

        ...
      })
    }

    clearEventHandlers () {
      this.eventHandlers.forEach(([event, handler]) => {
        this.ref.current.removeEventListener(event, handler)
      })
      this.eventHandlers = []
    }

    componentWillUnmount () {
      this.clearEventHandlers()
    }

    ...
  }
}

3. Ref

3.1 問題

咱們爲了解決 Props 和 Events 的問題,引入了高階組件。那麼當開發者向高階組件傳入 ref 時,獲取到的實際上是高階組件,但咱們但願開發者能獲取到對應的 Web Component。

domRef 會獲取到 MyComponent,而不是 <my-component></my-component>

<MyComponent ref={domRef} />
3.2 解決方案

使用 forwardRef 傳遞 ref。

改造上述的高階組件爲 forwardRef 形式:

const reactifyWebComponent = WC => {
  class Index extends React.Component {
    ...

    render () {
      const { children, forwardRef } = this.props
      return React.createElement(WC, {
        ref: forwardRef
      }, children)
    }
  }
  return React.forwardRef((props, ref) => (
    React.createElement(Index, { ...props, forwardRef: ref })
  ))
}

4. Host's className

4.1 問題

在 Stencil 裏咱們可使用 Host 組件爲 host element 添加類名。

import { Component, Host, h } from '@stencil/core';

@Component({
  tag: 'todo-list'
})
export class TodoList {
  render () {
    return (
      <Host class='todo-list'>
        <div>todo</div>
      </Host>
    )
  }
}

而後在使用 <todo-list> 元素時會展現咱們內置的類名 「todo-list」 和 Stencil 自動加入的類名 「hydrated」:

但若是咱們在使用時設置了動態類名,如: <todo-list class={this.state.cls}>。那麼在動態類名更新時,則會把內置的類名 「todo-list」 和 「hydrated」 抹除掉。

關於類名 「hydrated」:

Stencil 會爲全部 Web Components 加上 visibility: hidden; 的樣式。而後在各 Web Component 初始化完成後加入類名 「hydrated」,將 visibility 改成 inherit。若是 「hydrated」 被抹除掉,Web Components 將不可見。

所以咱們須要保證在類名更新時不會覆蓋 Web Components 的內置類名。

4.2 解決方案

高階組件在使用 ref 爲 Web Component 設置 className 屬性時,對內置 class 進行合併。

改造上述的高階組件:

const reactifyWebComponent = WC => {
  class Index extends React.Component {
    update (prevProps) {
      Object.entries(this.props).forEach(([prop, val]) => {
        if (prop.toLowerCase() === 'classname') {
          this.ref.current.className = prevProps
            // getClassName 在保留內置類名的狀況下,返回最新的類名
            ? getClassName(this.ref.current, prevProps, this.props)
            : val
          return
        }

        ...
      })
    }

    componentDidUpdate (prevProps) {
      this.update(prevProps)
    }

    componentDidMount () {
      this.update()
    }

    ...
  }
  return React.forwardRef((props, ref) => (
    React.createElement(Index, { ...props, forwardRef: ref })
  ))
}

兼容 Vue

不一樣於 React,雖然 Vue 在傳遞參數給 Web Components 時也是採用 setAttribute 的方式,但 v-bind 指令提供了 .prop 修飾符,它能夠將參數做爲 DOM property 來綁定。另外 Vue 也能監聽 Web Components 發出的自定義事件。

所以 Vue 在 Props 和 Events 兩個問題上都不須要額外處理,但在與 Stencil 的配合上仍是有一些兼容問題,接下來將列出主要的三點。

1. Host's className

1.1 問題

同上文兼容 React 第四部分,在 Vue 中更新 host element 的 class,也會覆蓋內置 class。

1.2 解決方案

一樣的思路,須要在 Web Components 上包裝一層 Vue 的自定義組件。

function createComponent (name, classNames = []) {
  return {
    name,
    computed: {
      listeners () {
        return { ...this.$listeners }
      }
    },
    render (createElement) {
      return createElement(name, {
        class: ['hydrated', ...classNames],
        on: this.listeners
      }, this.$slots.default)
    }
  }
}

Vue.component('todo-list', createComponent('todo-list', ['todo-list']))

注意:

  • 咱們在自定義組件中重複聲明瞭 Web Component 該有的內置類名。後續開發者爲自定義組件設置類名時,Vue 將會自動對類名進行合併
  • 須要把自定義組件上綁定的事件經過 &dollar;listeners 透傳給 Web Component。

2. Ref

2.1 問題

爲了解決問題 1,咱們給 Vue 中的 Web Components 都包裝了一層自定義組件。一樣地,開發者在使用 ref 時取到的是自定義組件,而不是 Web Component。

2.2 解決方案

Vue 並無 forwardRef 的概念,只可簡單粗暴地修改 this.$parent.$refs

爲自定義組件增長一個 mixin:

export const refs = {
  mounted () {
    if (Object.keys(this.$parent.$refs).length) {
      const refs = this.$parent.$refs

      for (const key in refs) {
        if (refs[key] === this) {
          refs[key] = this.$el
          break
        }
      }
    }
  },
  beforeDestroy () {
    if (Object.keys(this.$parent.$refs).length) {
      const refs = this.$parent.$refs

      for (const key in refs) {
        if (refs[key] === this.$el) {
          refs[key] = null
          break
        }
      }
    }
  }
}

注意:

  • 上述代碼沒有處理循環 ref,循環 ref 還須要另外判斷和處理。

3. v-model

3.1 問題

咱們在自定義組件中使用了渲染函數進行渲染,所以對錶單組件須要額外處理 v-model

3.2 解決方案

使用自定義組件上的 model 選項,定製組件使用 v-model 時的 prop 和 event。

改造上述的自定義組件:

export default function createFormsComponent (name, event, modelValue = 'value', classNames = []) {
  return {
    name,
    computed: {
      listeners () {
        return { ...this.$listeners }
      }
    },
    model: {
      prop: modelValue,
      event: 'model'
    },
    methods: {
      input (e) {
        this.$emit('input', e)
        this.$emit('model', e.target.value)
      },
      change (e) {
        this.$emit('change', e)
        this.$emit('model', e.target.value)
      }
    },
    render (createElement) {
      return createElement(name, {
        class: ['hydrated', ...classNames],
        on: {
          ...this.listeners,
          [event]: this[event]
        }
      }, this.$slots.default)
    }
  }
}

const Input = createFormsComponent('taro-input', 'input')
const Switch = createFormsComponent('taro-switch', 'change', 'checked')
Vue.component('taro-input', Input)
Vue.component('taro-switch', Switch)

總結

當咱們但願建立一些不拘泥於框架的組件時,Web Components 會是一個不錯的選擇。好比跨團隊協做,雙方的技術棧不一樣,但又須要公用部分組件時。

本次對 React 語法組件庫進行 Web Components 化改造,工做量不下於從新搭建一個 Vue 組件庫。但往後當 Taro 支持使用其餘框架編寫多端應用時,只須要針對對應框架與 Web Components 和 Stencil 的兼容問題編寫一個膠水層便可,整體來看仍是值得的。

關於膠水層,業界兼容 React 的方案頗多,只是兼容 Web Components 可使用 reactify-wc,配合 Stencil 則可使用官方提供的插件 Stencil DS Plugin。假若 Vue 須要兼容 Stencil,或須要提升兼容時的靈活性,仍是建議手工編寫一個膠水層。

本文簡單介紹了 Taro Next、Web Components、Stencil 以及基於 Stencil 的組件庫改造歷程,但願能爲讀者們帶來一些幫助與啓迪。


歡迎關注凹凸實驗室博客:aotu.io

或者關注凹凸實驗室公衆號(AOTULabs),不定時推送文章:

歡迎關注凹凸實驗室公衆號

相關文章
相關標籤/搜索