摘要:所以本文需要面對的核心問題就是: 我們需要在 H5 端實現 React、Vue 等框架都可以使用的組件庫。高階組件在使用 ref 爲 Web Component 設置 className 屬性時,對內置 class 進行合併。

作者:凹凸曼 - JJ

Taro 是一款多端開發框架。開發者只需編寫一份代碼,即可生成各小程序端、H5 以及 React Native 的應用。

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

背景

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

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

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

Taro H5

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

但瀏覽器並沒有小程序規範的組件與 API 可供使用,例如我們不能在瀏覽器上使用小程序的 view 組件和 getSystemInfo API。因此我們需要在 H5 端實現一套基於小程序規範的組件庫和 API 庫。

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

所以本文需要面對的核心問題就是: 我們需要在 H5 端實現 React、Vue 等框架都可以使用的組件庫

方案選擇

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

但考慮到以下兩點,我們遂放棄了此思路:

  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 將會 自動對類名進行合併
  • 需要把自定義組件上綁定的事件通過 $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),不定時推送文章:

相關文章