做者:凹凸曼 - 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),不定时推送文章: