Taro Next H5 跨框架组件库实践
作者:凹凸曼 - 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),不定时推送文章:

Taro Next H5 跨框架组件库实践的更多相关文章
- 小程序-文章:微信小程序常见的UI框架/组件库总结
		ylbtech-小程序-文章:微信小程序常见的UI框架/组件库总结 1.返回顶部 1. 想要开发出一套高质量的小程序,运用框架,组件库是省时省力省心必不可少一部分,随着小程序日渐火爆,各种不同类型的小 ... 
- 微信小程序常见的UI框架/组件库总结
		想要开发出一套高质量的小程序,运用框架,组件库是省时省力省心必不可少一部分,随着小程序日渐火爆,各种不同类型的小程序也渐渐更新,其中不乏一些优秀好用的框架/组件库. 1:WeUI 小程序–使用教程 h ... 
- React Hooks Typescript 开发的一款 H5 移动端 组件库
		CP design 使用 React hooks Typescript 开发的一个 H5 移动端 组件库 English | 简体中文 badge button icon CP Design Mobi ... 
- 加薪攻略之UI组件库实践—storybook
		目录 加薪攻略之UI组件库实践-storybook 一.业务背景 二.选用方案 三.引入分析 项目结构 项目效果 四.实现步骤 1.添加依赖 2.添加npm执行脚本 3.添加配置文件 4.添加必要的w ... 
- Svelte入门——Web Components实现跨框架组件复用(二)
		在上节中,我们一起了解了如何使用Svelte封装Web Component,从而实现在不同页面间使用电子表格组件. Svelte封装组件跨框架复用,带来的好处也十分明显: 1.使用框架开发,更容易维护 ... 
- Svelte入门——Web Components实现跨框架组件复用
		Svelte 是构建 Web 应用程序的一种新方法,推出后一直不温不火,没有继Angular.React和VUE成为第四大框架,但也没有失去热度,无人问津.造成这种情况很重要的一个原因是,Svelte ... 
- "五号标题"组件:<h5> —— 快应用组件库H-UI
		 <import name="h5" src="../Common/ui/h-ui/text/c_h5"></import> < ... 
- Taro 3 正式版发布:开放式跨端跨框架解决方案
		作者:凹凸曼 - yuche 从 Taro 第一个版本发布到现在,Taro 已经接受了来自于开源社区两年多的考验.今天我们很高兴地在党的生日发布 Taro 3(Taro Next)正式版,希望 Tar ... 
- 如何用 Blazor 实现 Ant Design 组件库
		本文主要分享我创建 Ant Design of Blazor 项目的心路历程,已经文末有一个 Blazor 线上分享预告. Blazor WebAssembly 来了! Blazor 这个新推出的前端 ... 
随机推荐
- mycli初体验
			一.安装 pip install mycli 二.使用 mycli --help 三.特点 语法不全,高亮等 
- react 新创建项目
			1,先创建一个文件夹用于存放项目 2,运行cmd,路径选择到你创建的文件夹内 3, npm install -g create-react-app create-react-app ... 
- 使用JDBC工具类模拟登陆验证-Java(新手)
			模拟登陆验证: package JdbcDome; import java.sql.Connection; import java.sql.PreparedStatement; import java ... 
- [C++]那些年被虐的STL
			首先很感谢**P1135奇怪的电梯 **[2.14补充:此题已被AC!然后将被我花式虐[From语]哈哈哈哈哈哈哈哈哈哈好嗨哟感觉人生已经到达了巅峰感觉人生已经到达了高潮]这道题了!在做这道题的我大致 ... 
- docker:一文学基础使用
			目录 docker介绍 安装与镜像源配置 CentOS7 安装 设置镜像源 补充: 简单使用例子 基础概念 四个概念 镜像概念补充: 容器概念补充: 常用命令: 查看docker信息 镜像操作 容器操 ... 
- Linux中更换为国内镜像源
			推荐使用清华镜像:https://mirrors.tuna.tsinghua.edu.cn/help/ubuntu/ 将下列文本添加到/etc/apt/sources.list文件里 # 默认注释了源 ... 
- Python-hashlib、OS、Random、sys、zipfile模块
			# print(sys.version) #python 版本 # print(sys.path) # print(sys.platform) #当前什么系统 # print(sys.argv) #当 ... 
- jvm:内存结构(堆、方法区、程序计数器、本地方法栈、虚拟机栈)
			1.jvm内存结构 静态编译:把java源文件编译成字节码文件class,这个时候class文件以静态方式存在. 类加载器:把java字节码文件加载到内存中 方法区:将字节码放到方法区作为元数据(简单 ... 
- 曹工说Spring Boot源码(27)-- Spring的component-scan,光是include-filter属性的各种配置方式,就够玩半天了.md
			写在前面的话 相关背景及资源: 曹工说Spring Boot源码(1)-- Bean Definition到底是什么,附spring思维导图分享 曹工说Spring Boot源码(2)-- Bean ... 
- MYSQL_批量更新
			UPDATE categories SET display_order = CASE id WHEN 1 THEN 3 WHEN 2 THEN 4 WHEN 3 THEN 5 END, title = ... 
