作者:凹凸曼 - 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 = 'https://www.cnblogs.com/o2team/p/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),不定時推送文章:

轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/123510.html
標籤:JavaScript
