Taro Next H5 跨框架组件库实践

2022-10-08,,,,

作者:凹凸曼 - 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 指令提供了 修饰符,它可以将参数作为 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 问题

我们在自定义组件中使用了渲染函数进行渲染,因此对表单组件需要额外处理 。

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 的组件库改造历程,希望能为读者们带来一些帮助与启迪。


欢迎关注凹凸实验室博客:

或者关注凹凸实验室公众号(aotulabs),不定时推送文章:

《Taro Next H5 跨框架组件库实践.doc》

下载本文的Word格式文档,以方便收藏与打印。