作者:凹凸曼 - 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 再开发一套组件库,这样最为稳妥,工作量也没有特别大。

但考虑到以下两点,我们遂放弃了此思路:

  1. 组件库的可维护性和拓展性不足。每当有问题需要修复或新功能需要添加,我们需要分别对 React 和 Vue 版本的组件库进行改造。
  2. 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,原因有二:

  1. Stencil 由 Ionic 团队打造,被用于构建 Ionic 的组件库,证明经受过业界考验。
  2. 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),不定时推送文章:

Taro Next H5 跨框架组件库实践的更多相关文章

  1. 小程序-文章:微信小程序常见的UI框架/组件库总结

    ylbtech-小程序-文章:微信小程序常见的UI框架/组件库总结 1.返回顶部 1. 想要开发出一套高质量的小程序,运用框架,组件库是省时省力省心必不可少一部分,随着小程序日渐火爆,各种不同类型的小 ...

  2. 微信小程序常见的UI框架/组件库总结

    想要开发出一套高质量的小程序,运用框架,组件库是省时省力省心必不可少一部分,随着小程序日渐火爆,各种不同类型的小程序也渐渐更新,其中不乏一些优秀好用的框架/组件库. 1:WeUI 小程序–使用教程 h ...

  3. React Hooks Typescript 开发的一款 H5 移动端 组件库

    CP design 使用 React hooks Typescript 开发的一个 H5 移动端 组件库 English | 简体中文 badge button icon CP Design Mobi ...

  4. 加薪攻略之UI组件库实践—storybook

    目录 加薪攻略之UI组件库实践-storybook 一.业务背景 二.选用方案 三.引入分析 项目结构 项目效果 四.实现步骤 1.添加依赖 2.添加npm执行脚本 3.添加配置文件 4.添加必要的w ...

  5. Svelte入门——Web Components实现跨框架组件复用(二)

    在上节中,我们一起了解了如何使用Svelte封装Web Component,从而实现在不同页面间使用电子表格组件. Svelte封装组件跨框架复用,带来的好处也十分明显: 1.使用框架开发,更容易维护 ...

  6. Svelte入门——Web Components实现跨框架组件复用

    Svelte 是构建 Web 应用程序的一种新方法,推出后一直不温不火,没有继Angular.React和VUE成为第四大框架,但也没有失去热度,无人问津.造成这种情况很重要的一个原因是,Svelte ...

  7. "五号标题"组件:<h5> —— 快应用组件库H-UI

     <import name="h5" src="../Common/ui/h-ui/text/c_h5"></import> < ...

  8. Taro 3 正式版发布:开放式跨端跨框架解决方案

    作者:凹凸曼 - yuche 从 Taro 第一个版本发布到现在,Taro 已经接受了来自于开源社区两年多的考验.今天我们很高兴地在党的生日发布 Taro 3(Taro Next)正式版,希望 Tar ...

  9. 如何用 Blazor 实现 Ant Design 组件库

    本文主要分享我创建 Ant Design of Blazor 项目的心路历程,已经文末有一个 Blazor 线上分享预告. Blazor WebAssembly 来了! Blazor 这个新推出的前端 ...

随机推荐

  1. 《52讲轻松搞定网络爬虫》读书笔记 —— HTTP基本原理

    URI 和 URL URI :Uniform Resource Identifier,即统一资源标志符, URL :Universal Resource Locator,即统一资源定位符. 举栗子,加 ...

  2. QT使用信号量QSemaphore处理大量数据

    实现如下:

  3. Shell 截取字符串方法

    原文链接 方法1 "${varible##*string}" 从左向右截取最后一个string后的字符串 e.g. exampleString="abc//888//ab ...

  4. [转]【maven】解决Missing artifact jdk.tools:jdk.tools:jar:1.6

    解决在pom.xml文件中出现的Missing artifact jdk.tools:jdk.tools:jar:1.6问题 <dependency> <groupId>jdk ...

  5. CAS无锁模式

    一.java内存模型:JMM 在内存模型当中定义一个主内存,所有声明的实例变量都存在于主内存当中,主内存的数据会共享给所有线程,每一个线程有一个块工作内存,工作内存当中主内存数据的副本当更新数据时,会 ...

  6. 108. Convert Sorted Array to Binary Search [Python]

    108. Convert Sorted Array to Binary Search Given an array where elements are sorted in ascending ord ...

  7. 强智教务系统验证码识别 Tensorflow CNN

    强智教务系统验证码识别 Tensorflow CNN 一直都是使用API取得数据,但是API提供的数据较少,且为了防止API关闭,先把验证码问题解决 使用Tensorflow训练模型,强智教务系统的验 ...

  8. Postman从入门到入门

    1.Postman简介 Postman是一个接口测试工具,在做接口测试的时候, Postman相当于一个客户端,可以发送请求到服务器端.Postman分为Chrome插件版本(不再更新维护)和Nati ...

  9. Centos 非root用户安装rabbitmq

    RabbitMQ是一个开源消息队列服务器,您可以使用它来构建您的消息传递应用程序.简单地说,您可以将来自一个应用程序的消息放入队列,并从来自相同应用程序或不同应用程序的队列中检索消息.您可以使用多种编 ...

  10. Reverse Subarray To Maximize Array Value

    2020-02-03 20:43:46 问题描述: 问题求解: public boolean canTransform(String start, String end) { int n = star ...