Web Components系列文章(二) - 与现有JS框架集成

1,535 阅读4分钟

这是Web Components系列介绍的第二篇,会介绍与现有的JS框架例如React、Vue的集成。

以下是三篇文章的链接:

由于Web Components更多还是用来开发UI组件库,那么最终可能还是要应用到流行的前端框架开发的项目中。在custom-elements-everywhere给出了各种框架中Web Components的集成度,跑了一些测试用例,这些测试用例主要针对如何进行数据传递和监听事件的,这也是我们在开发组件的时候主要关注的两个点。并且进行了打分。Vue(2.X)和Angular都全部通过了测试用例,而React的得分比较低。

下面分别介绍在几个框架中的使用方法。

Vue

Vue官方文档上有这么一段描述:

You may have noticed that Vue components are very similar to Custom Elements, which are part of the Web Components Spec. That's because Vue's component syntax is loosely modeled after the spec.

大概意思就是Vue组件遵循了部分自定义组件规范的,因此对Web Components的集成度是很高的。贴一下测试结果:

解释一下:

如何传递数据

对于字符串类型的数据没什么特殊的,但是对其他类型的数据,Vue2.X版本提供了.prop修饰符,将传递的数据从attribute方式转成通过property。

<my-comp :list.prop="list"></my-comp>

但是我在Vue3中试了,这个修饰符似乎是失效了。这时可以通过ref拿到自定义元素,然后进行设置:

<my-comp ref="myComp"></my-comp>
this.$refs.myComp.list = []

如何监听事件

由于Vue内部采用built-in browser事件,因此custom elements内部抛出的事件能被监听到。和其他Vue组件没什么区别,还是用v-on或者简写的@进行监听。

其他

在Vue中使用Web Components还需要进行一项配置,让Vue忽略自定义元素,别把它当做Vue组件,否则会提示是未注册就使用的组件。在入口文件例如main.js中进行配置:

// main.js
// Vue2.X
Vue.config.ignoredElements = [/^my-/] // 假设自定义元素都以my-开头

// Vue3
app.config.isCustomElement = tag => tag.startsWith('my-')

在上一篇基础概念介绍的文章中,给了一个demo my-web-component,其中包含MyButton、MyDropdown和MyDialog三个自定义组件,在Vue2.X中的使用如下:

<template>
  <div>
    <my-button :label="buttonLabel" @clicked="clickBtnHandler"></my-button>
    <my-dialog
      :dialogTitle.prop="dialogTitle"
      :visible.prop="showDialog"
      @close="closeHandler"
      @confirm="confirmHandler">
      Hello Vue!
    </my-dialog>
    <my-dropdown placeholder="请选择" :data.prop="hobbies" :defaultValue.prop="defaultValue"></my-dropdown>
  </div>
</template>

<script>
/* eslint-disable*/
export default {
  name: 'App',
  data() {
    return {
      buttonLabel: 'Open Dialog',
      dialogTitle: 'Welcome to Vue',
      showDialog: false,
      hobbies: [
        {
          label: '乒乓球',
          value: 'table tennis'
        },
        {
          label: '阅读',
          value: 'reading'
        }
      ],
      defaultValue: 'reading'
    }
  },
  methods: {
    closeHandler() {
      this.showDialog = false
    },
    confirmHandler() {
      console.log('confirm')
    },
    clickBtnHandler() {
      this.showDialog = true
    }
  }
}
</script>

React

集成问题分析

在React中使用Web Components比较麻烦,主要是因为一下两个因素:

  • 通过自定义标签(非react component)attributes传递数据时,React会全部转字符串,数组会变成,连接的字符串,对象类型会直接变成[object Object],导致非字符串类型的数据传递失败。不像Vue提供了.prop这种方式转成property传递。
  • React实现了自己的一套事件系统,因此无法监听从自定义组件内部抛出的事件(属于浏览器built-in event)。

下面是评测结果,可见集成度不太好:

但是不是没有办法,只是相对有些繁琐。

解决办法

创建一个React组件,将Web Component组件包一层,其他React组件只和这个wrapper组件通信,然后由Wrapper组件接收props再将数据传递给Web component。主要方式是通过ref拿到的Web Component实例,之后在适合的生命周期将props上的数据传递给Web Component实例的property,另外也能在Web Component实例上监听内部抛出的事件。

下面是一个简单的例子,分别给出React class component和使用hooks的方法:

  • Class Component
// MyButtonWrapper.js

import React, { Component } from 'react';
import './MyButton';

export default class MyButtonWrapper extends Component {
    constructor(props) {
        super(props)
        this.buttonRef = React.createRef()
    }

    componentDidMount() {
        this.buttonRef.current.label = this.props.label;

        if (this.props.onClicked) {
            this.buttonRef.current.addEventListener('clicked', (e) => {
                this.props.onClicked(e)
            })
        }
    }

    render() {
        return(
            <my-button ref={this.buttonRef}></my-button>
        )
    }
}
// MyDialogWrapper.js

import React, { Component } from 'react';
import './MyDialog';

export default class MyDialogWrapper extends Component {
    constructor(props) {
        super(props);
        this.dialogRef = React.createRef();
    }

    componentDidMount() {
        const {dialogTitle, visible} = this.props
        this.dialogRef.current.dialogTitle = dialogTitle
        this.dialogRef.current.visible = visible

        if (this.props.onConfirm) {
            this.dialogRef.current.addEventListener('confirm', (e) => {
                this.props.onConfirm(e)
            })
        }

        if (this.props.onCancel) {
            this.dialogRef.current.addEventListener('cancel', (e) => {
                this.props.onCancel(e)
            })
        }

        if (this.props.onClose) {
            this.dialogRef.current.addEventListener('close', (e) => {
                this.props.onClose(e)
            })
        }
    }

    componentDidUpdate(prevProps, prevState) {
        if (this.props.dialogTitle !== prevProps.dialogTitle) {
            this.dialogRef.current.dialogTitle = this.props.dialogTitle
        }

        if (this.props.visible !== prevProps.visible) {
            this.dialogRef.current.visible = this.props.visible
        }
    }

    render() {
        return (
            <my-dialog ref={this.dialogRef}>{this.props.children}</my-dialog>
        )
    }
}

在App.js中引用这两个Wrapper组件:

import React, { Component } from 'react';
import MyButtonWrapper from './web-components/MyButtonWrapper';
import MyDialogWrapper from './web-components/MyDialogWrapper';

export default class App extends Component {
  constructor(props) {
    super(props)
    this.state = {
      label: 'Open Dialog',
      title: 'Welcome',
      visible: false
    }

  }
  clickButtonHandler() {
    this.setState({
      visible: true
    })
  }

  confirmHandler() {
    console.log('Dialog confirm')
  }

  cancelHandler() {
    console.log('Dialog cancel')
  }
  
  closeHandler() {
    this.setState({
      visible: false
    })
  }

  render() {
    const {label, title, visible} = this.state
    return (
      <div className="App">
        <MyButtonWrapper label={label} onClicked={this.clickButtonHandler.bind(this)}></MyButtonWrapper>
        <MyDialogWrapper
          dialogTitle={title}
          visible={visible}
          onConfirm={this.confirmHandler.bind(this)}
          onCancel={this.cancelHandler.bind(this)}
          onClose={this.closeHandler.bind(this)}>
          Hello Webcomponents!
        </MyDialogWrapper>
      </div>
    );
  }
}
  • Hooks
// MyButtonWrapperHook.js
import {useRef, useEffect} from 'react';
import './MyButton';

export default function MyButtonWrapperHook(props) {
    const buttonRef = useRef(null)

    function clickButtonHandler(e) {
        props.onClicked(e)
    }
    useEffect(() => {
        buttonRef.current.label = props.label
        if (props.onClicked) {
            buttonRef.current.addEventListener('clicked', clickButtonHandler)
        }

        return () => {
            buttonRef.current.removeEventListener('clicked', clickButtonHandler)
        }
    })

    return (
        <my-button ref={buttonRef}></my-button>
    )
}
// MyDialogWrapperHook.js
import {useRef, useEffect} from 'react';
import './MyDialog';

export default function MyDialogWrapperHook(props) {
    const dialogRef = useRef(null)

    function onConfirmHandler(e) {
        props.onConfirm(e)
    }

    function onCancelHandler(e) {
        props.onCancel(e)
    }

    function onCloseHandler(e) {
        props.onClose(e)
    }

    useEffect(() => {
        dialogRef.current.dialogTitle = props.dialogTitle
        dialogRef.current.visible = props.visible
        if (props.onConfirm) {
            dialogRef.current.addEventListener('confirm', onConfirmHandler)
        }
        if (props.onCancel) {
            dialogRef.current.addEventListener('cancel', onCancelHandler)
        }
        if (props.onClose) {
            dialogRef.current.addEventListener('close', onCloseHandler)
        }

        return () => {
            dialogRef.current.removeEventListener('confirm', onConfirmHandler)
            dialogRef.current.removeEventListener('cancel', onCancelHandler)
            dialogRef.current.removeEventListener('close', onCloseHandler)
        }
    })

    return (
        <my-dialog ref={dialogRef}>{props.children}</my-dialog>
    )
}

使用这两个wrapper组件:

import { useState } from 'react';
import MyButtonWrapperHook from './web-components/MyButtonWrapperHook';
import MyDialogWrapperHook from './web-components/MyDialogWrapperHook';

export default function App() {
    const [label, setLabel] = useState('Open Dialog!')
    const [title, setTitle] = useState('Welcome')
    const [visible, setVisible] = useState(false)
    const [list, setList] = useState([
        {
            label: '阅读',
            value: 'reading'
        },
        {
            label: '王者荣耀',
            value: 'nongyao'
        }
    ])
    
    return (
        <div>
            <MyButtonWrapperHook label={label} onClicked={() => setVisible(true)}></MyButtonWrapperHook>
            <MyDialogWrapperHook
                dialogTitle={title}
                visible={visible}
                onClose={() => setVisible(false)}>
                Hello web components in React with hooks
            </MyDialogWrapperHook>
        </div>
    )
}

Angular

Angular的集成度也很高,通过了全部的测试用例,由于我对Angular不太熟悉,在此不在做详细说明。

参考