做者:凹凸曼 - JJ
Taro 是一款多端開發框架。開發者只需編寫一份代碼,便可生成各小程序端、H5 以及 React Native 的應用。html
Taro Next 近期已發佈 beta 版本,全面完善對小程序以及 H5 的支持,歡迎體驗!
過去的 Taro 1 與 Taro 2 只能使用 React 語法進行開發,但下一代的 Taro 框架對總體架構進行了升級,支持使用 React、Vue、Nerv 等框架開發多端應用。前端
爲了支持使用多框架進行開發,Taro 須要對自身的各端適配能力進行改造。本文將重點介紹對 Taro H5 端組件庫的改造工做。vue
Taro 遵循以微信小程序爲主,其餘小程序爲輔的組件與 API 規範。node
但瀏覽器並無小程序規範的組件與 API 可供使用,例如咱們不能在瀏覽器上使用小程序的 view
組件和 getSystemInfo
API。所以咱們須要在 H5 端實現一套基於小程序規範的組件庫和 API 庫。react
在 Taro 1 和 Taro 2 中,Taro H5 的組件庫使用了 React 語法進行開發。但若是開發者在 Taro Next 中使用 Vue 開發 H5 應用,則不能和現有的 H5 組件庫兼容。git
因此本文須要面對的核心問題就是:咱們須要在 H5 端實現 React、Vue 等框架均可以使用的組件庫。github
咱們最早想到的是使用 Vue 再開發一套組件庫,這樣最爲穩妥,工做量也沒有特別大。web
但考慮到如下兩點,咱們遂放棄了此思路:小程序
那麼是否存在着一種方案,使得只用一份代碼構建的組件庫能兼容全部的 web 開發框架呢?微信小程序
答案就是 Web Components。
但在組件庫改造爲 Web Components 的過程並非一路順風的,咱們也遇到了很多的問題,故藉此文向你們娓娓道來。
Web Components 由一系列的技術規範所組成,它讓開發者能夠開發出瀏覽器原生支持的組件。
Web Components 的主要技術規範爲:
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>
使用原生語法去編寫 Web Components 至關繁瑣,所以咱們須要一個框架幫助咱們提升開發效率和開發體驗。
業界已經有不少成熟的 Web Components 框架,一番比較後咱們最終選擇了 Stencil,緣由有二:
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' />
到目前爲止一切都那麼美好:使用 Stencil 編寫出 Web Components,便可以在 React 和 Vue 中直接使用它們。
但實際使用上卻會出現一些問題,Custom Elements Everywhere 經過一系列的測試用例,羅列出業界前端框架對 Web Components 的兼容問題及相關 issues。下面將簡單介紹 Taro H5 組件庫分別對 React 和 Vue 的兼容工做。
React 使用 setAttribute
的形式給 Web Components 傳遞參數。當參數爲原始類型時是能夠運行的,可是若是參數爲對象或數組時,因爲 HTML 元素的 attribute 值只能爲字符串或 null,最終給 WebComponents 設置的 attribute 會是 attr="[object Object]"
。
attribute 與 property 區別
採用 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')
注意:
由於 React 有一套合成事件系統,因此它不能監聽到 Web Components 發出的自定義事件。
如下 Web Component 的 onLongPress 回調不會被觸發:
<my-view onLongPress={onLongPress}>view</my-view>
經過 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() } ... } }
咱們爲了解決 Props 和 Events 的問題,引入了高階組件。那麼當開發者向高階組件傳入 ref 時,獲取到的實際上是高階組件,但咱們但願開發者能獲取到對應的 Web Component。
domRef 會獲取到 MyComponent
,而不是 <my-component></my-component>
<MyComponent ref={domRef} />
使用 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 }) )) }
在 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 的內置類名。
高階組件在使用 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 }) )) }
不一樣於 React,雖然 Vue 在傳遞參數給 Web Components 時也是採用 setAttribute
的方式,但 v-bind 指令提供了 .prop 修飾符,它能夠將參數做爲 DOM property 來綁定。另外 Vue 也能監聽 Web Components 發出的自定義事件。
所以 Vue 在 Props 和 Events 兩個問題上都不須要額外處理,但在與 Stencil 的配合上仍是有一些兼容問題,接下來將列出主要的三點。
同上文兼容 React 第四部分,在 Vue 中更新 host element 的 class,也會覆蓋內置 class。
一樣的思路,須要在 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']))
注意:
爲了解決問題 1,咱們給 Vue 中的 Web Components 都包裝了一層自定義組件。一樣地,開發者在使用 ref 時取到的是自定義組件,而不是 Web Component。
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 } } } } }
注意:
咱們在自定義組件中使用了渲染函數進行渲染,所以對錶單組件須要額外處理 v-model。
使用自定義組件上的 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),不定時推送文章: