作者:凹凸曼 JJ "Taro" 是一款多端開發框架。開發者只需編寫一份代碼,即可生成各小程式端、H5 以及 React Native 的應用。 "Taro Next" 近期已發佈 beta 版本,全面完善對小程式以及 H5 的支持,歡迎體驗! 背景 Taro Next 將支持使用多框架開發 過去的 ...
作者:凹凸曼 - 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 再開發一套組件庫,這樣最為穩妥,工作量也沒有特別大。
但考慮到以下兩點,我們遂放棄了此思路:
- 組件庫的可維護性和拓展性不足。每當有問題需要修複或新功能需要添加,我們需要分別對 React 和 Vue 版本的組件庫進行改造。
- 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,原因有二:
- Stencil 由 Ionic 團隊打造,被用於構建 Ionic 的組件庫,證明經受過業界考驗。
- 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),不定時推送文章: