做者:凹凸曼 - JJhtml
Taro 是一款多端開發框架。開發者只需編寫一份代碼,便可生成各小程序端、H5 以及 React Native 的應用。前端
Taro Next 近期已發佈 beta 版本,全面完善對小程序以及 H5 的支持,歡迎體驗!vue
過去的 Taro 1 與 Taro 2 只能使用 React 語法進行開發,但下一代的 Taro 框架對總體架構進行了升級,支持使用 React、Vue、Nerv 等框架開發多端應用。node
爲了支持使用多框架進行開發,Taro 須要對自身的各端適配能力進行改造。本文將重點介紹對 Taro H5 端組件庫的改造工做。react
Taro 遵循以微信小程序爲主,其餘小程序爲輔的組件與 API 規範。git
但瀏覽器並無小程序規範的組件與 API 可供使用,例如咱們不能在瀏覽器上使用小程序的 view
組件和 getSystemInfo
API。所以咱們須要在 H5 端實現一套基於小程序規範的組件庫和 API 庫。github
在 Taro 1 和 Taro 2 中,Taro H5 的組件庫使用了 React 語法進行開發。但若是開發者在 Taro Next 中使用 Vue 開發 H5 應用,則不能和現有的 H5 組件庫兼容。web
因此本文須要面對的核心問題就是:咱們須要在 H5 端實現 React、Vue 等框架均可以使用的組件庫。小程序
咱們最早想到的是使用 Vue 再開發一套組件庫,這樣最爲穩妥,工做量也沒有特別大。微信小程序
但考慮到如下兩點,咱們遂放棄了此思路:
那麼是否存在着一種方案,使得只用一份代碼構建的組件庫能兼容全部的 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),不定時推送文章: