基于 Web Component 跨框架组件解决方案

725 阅读6分钟

基于 Web Component 跨框架组件解决方案

Web Component 基本介绍

Web Components 允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的 web 应用中使用它们。

兼容性如下图所示:

兼容性.png

Web Component Api

CustomElementRegistry.define()

customElements.define(name, constructor, options)

注册一个 custom element,该方法接受以下参数:

  • name: 元素名称
  • constructor: 自定义元素构造器。
  • options: 可选参数,控制元素如何定义。目前有一个选项支持:
    • extends. 指定继承的已创建的元素。被用于创建自定义元素。

Element.attachShadow()

var shadowroot = element.attachShadow(shadowRootInit)

给指定的元素挂载一个 Shadow DOM,并且返回对 ShadowRoot 的引用:

  • shadowRootInit,包括下列字段:

    • mode 模式

      • open: 可以从 js 外部访问 shadow root 节点

        element.shadowRoot // 返回一个 ShadowRoot 对象
        
      • closed: 拒绝从 js 外部访问关闭的 shadow root 节点

        element.shadowRoot // 返回 null
        
    • delegatesFocus 焦点委托 一个布尔值,当设置为 true 时,指定减轻自定义元素的聚焦性能问题行为。 当 shadow DOM 中不可聚焦的部分被点击时,让第一个可聚焦的部分成为焦点,并且 shadow host(影子主机)将提供所有可用的 :focus 样式。

生命周期

  • connectedCallback:当 custom element 首次被插入文档 DOM 时,被调用。
  • disconnectedCallback:当 custom element 从文档 DOM 中删除时,被调用。
  • adoptedCallback:当 custom element 被移动到新的文档时,被调用。
  • attributeChangedCallback: 当 custom element 增加、删除、修改自身属性时,被调用。

更多

Web Component 示例代码

示例一

web-component-demo1.gif

<form>
  <div>
    <label for="cvc"
      >Enter your CVC
      <popup-info
        img="img/alt.png"
        data-text="Your card validation code (CVC) is an extra security feature — it is the last 3 or 4 numbers on the back of your card."
      ></popup-info
    ></label>
    <input type="text" id="cvc" />
  </div>
</form>
// Create a class for the element
class PopUpInfo extends HTMLElement {
  constructor() {
    // Always call super first in constructor
    super()

    // Create a shadow root
    const shadow = this.attachShadow({ mode: 'open' })

    // Create spans
    const wrapper = document.createElement('span')
    wrapper.setAttribute('class', 'wrapper')

    const icon = document.createElement('span')
    icon.setAttribute('class', 'icon')
    icon.setAttribute('tabindex', 0)

    const info = document.createElement('span')
    info.setAttribute('class', 'info')

    // Take attribute content and put it inside the info span
    const text = this.getAttribute('data-text')
    info.textContent = text

    // Insert icon
    let imgUrl
    if (this.hasAttribute('img')) {
      imgUrl = this.getAttribute('img')
    } else {
      imgUrl = 'img/default.png'
    }

    const img = document.createElement('img')
    img.src = imgUrl
    icon.appendChild(img)

    // Create some CSS to apply to the shadow dom
    const style = document.createElement('style')
    console.log(style.isConnected)

    style.textContent = `
      .wrapper {
        position: relative;
      }

      .info {
        font-size: 0.8rem;
        width: 200px;
        display: inline-block;
        border: 1px solid black;
        padding: 10px;
        background: white;
        border-radius: 10px;
        opacity: 0;
        transition: 0.6s all;
        position: absolute;
        bottom: 20px;
        left: 10px;
        z-index: 3;
      }

      img {
        width: 1.2rem;
      }

      .icon:hover + .info, .icon:focus + .info {
        opacity: 1;
      }
    `

    // Attach the created elements to the shadow dom
    shadow.appendChild(style)
    console.log(style.isConnected)
    shadow.appendChild(wrapper)
    wrapper.appendChild(icon)
    wrapper.appendChild(info)
  }
}

// Define the new element
customElements.define('popup-info', PopUpInfo)

示例二

web-component-demo2.gif

<div>
  <button class="add">Add custom-square to DOM</button>
  <button class="update">Update attributes</button>
  <button class="remove">Remove custom-square from DOM</button>
</div>
// Create a class for the element
class Square extends HTMLElement {
  // Specify observed attributes so that
  // attributeChangedCallback will work
  static get observedAttributes() {
    return ['c', 'l']
  }

  constructor() {
    // Always call super first in constructor
    super()

    const shadow = this.attachShadow({ mode: 'open' })

    const div = document.createElement('div')
    const style = document.createElement('style')
    shadow.appendChild(style)
    shadow.appendChild(div)
  }

  connectedCallback() {
    console.log('Custom square element added to page.')
    updateStyle(this)
  }

  disconnectedCallback() {
    console.log('Custom square element removed from page.')
  }

  adoptedCallback() {
    console.log('Custom square element moved to new page.')
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log('Custom square element attributes changed.')
    updateStyle(this)
  }
}

customElements.define('custom-square', Square)

function updateStyle(elem) {
  const shadow = elem.shadowRoot
  shadow.querySelector('style').textContent = `
    div {
      width: ${elem.getAttribute('l')}px;
      height: ${elem.getAttribute('l')}px;
      background-color: ${elem.getAttribute('c')};
    }
  `
}

const add = document.querySelector('.add')
const update = document.querySelector('.update')
const remove = document.querySelector('.remove')
let square

update.disabled = true
remove.disabled = true

function random(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min)
}

add.onclick = function () {
  // Create a custom square element
  square = document.createElement('custom-square')
  square.setAttribute('l', '100')
  square.setAttribute('c', 'red')
  document.body.appendChild(square)

  update.disabled = false
  remove.disabled = false
  add.disabled = true
}

update.onclick = function () {
  // Randomly update square's attributes
  square.setAttribute('l', random(50, 200))
  square.setAttribute(
    'c',
    `rgb(${random(0, 255)}, ${random(0, 255)}, ${random(0, 255)})`
  )
}

remove.onclick = function () {
  // Remove the square
  document.body.removeChild(square)

  update.disabled = true
  remove.disabled = true
  add.disabled = false
}

更多示例

Stencil 基本介绍

Stencil.js 是一个基于 Web Components 设计的框架,由它创建的组件库,可以轻松地在可以引入 Web Component 的浏览器和框架上运行

Stencil Api

修饰器

  • @Component(opts: ComponentOptions) 声明一个 web component

    export interface ComponentOptions {
      /**
       * Tag name of the web component. Ideally, the tag name must be globally unique,
       * so it's recommended to choose a unique prefix for all your components within the same collection.
       *
       * In addition, tag name must contain a '-'
       */
      tag: string
    
      /**
       * If `true`, the component will use scoped stylesheets. Similar to shadow-dom,
       * but without native isolation. Defaults to `false`.
       */
      scoped?: boolean
    
      /**
       * If `true`, the component will use native shadow-dom encapsulation, it will fallback to `scoped` if the browser
       * does not support shadow-dom natively. Defaults to `false`.
       *
       * If an object literal containing `delegatesFocus` is provided, the component will use native shadow-dom
       * encapsulation. When `delegatesFocus` is set to `true`, the component will have `delegatesFocus: true` added to its
       * shadow DOM. When `delegatesFocus` is `true` and a non-focusable part of the component is clicked:
       * - the first focusable part of the component is given focus
       * - the component receives any available `focus` styling
       * Setting `delegatesFocus` to `false` will not add the `delegatesFocus` property to the shadow DOM and therefore
       * will have the focusing behavior described for `shadow: true`.
       */
      shadow?: boolean | { delegatesFocus: boolean }
    
      /**
       * Relative URL to some external stylesheet file. It should be a `.css` file unless some
       * external plugin is installed like `@stencil/sass`.
       */
      styleUrl?: string
    
      /**
       * Similar as `styleUrl` but allows one to specify different stylesheets for different modes.
       */
      styleUrls?: string[] | d.ModeStyles
    
      /**
       * String that contains inlined CSS instead of using an external stylesheet.
       * The performance characteristics of this feature are the same as using an external stylesheet.
       *
       * Notice, you can't use sass, or less, only `css` is allowed using `styles`, use `styleUrl` if you need more advanced features.
       */
      styles?: string
    
      /**
       * Array of relative links to folders of assets required by the component.
       */
      assetsDirs?: string[]
    
      /**
       * @deprecated Use `assetsDirs` instead
       */
      assetsDir?: string
    }
    
  • @Prop(options: PropOptions) 声明暴露的 property/attribute

    export interface PropOptions {
      /**
       * The name of the associated DOM attribute.
       * Stencil uses different heuristics to determine the default name of the attribute,
       * but using this property, you can override the default behaviour.
       */
      attribute?: string | null
      /**
       * A Prop is _by default_ immutable from inside the component logic.
       * Once a value is set by a user, the component cannot update it internally.
       * However, it's possible to explicitly allow a Prop to be mutated from inside the component,
       * by setting this `mutable` option to `true`.
       */
      mutable?: boolean
      /**
       * In some cases it may be useful to keep a Prop in sync with an attribute.
       * In this case you can set the `reflect` option to `true`, since it defaults to `false`:
       */
      reflect?: boolean
    }
    
  • @State() 生命组件内部状态

  • @Watch(propName: string) 监听器

  • @Element() 声明 host element 的引用

  • @Method() 声明暴露的方法

  • @Event(opts?: EventOptions) 声明事件发射器

    export interface EventOptions {
      /**
       * A string custom event name to override the default.
       */
      eventName?: string
      /**
       * A Boolean indicating whether the event bubbles up through the DOM or not.
       */
      bubbles?: boolean
      /**
       * A Boolean indicating whether the event is cancelable.
       */
      cancelable?: boolean
      /**
       * A Boolean value indicating whether or not the event can bubble across the boundary between the shadow DOM and the regular DOM.
       */
      composed?: boolean
    }
    
  • @Listen(eventName: string, opts?: ListenOptions) listens for DOM events 监听 DOM 事件

    export interface ListenOptions {
      /**
       * Handlers can also be registered for an event other than the host itself.
       * The `target` option can be used to change where the event listener is attached,
       * this is useful for listening to application-wide events.
       */
      target?: ListenTargetOptions
      /**
       * Event listener attached with `@Listen` does not "capture" by default,
       * When a event listener is set to "capture", means the event will be dispatched
       * during the "capture phase". Please see
       * https://www.quirksmode.org/js/events_order.html for further information.
       */
      capture?: boolean
      /**
       * By default, Stencil uses several heuristics to determine if
       * it must attach a `passive` event listener or not.
       *
       * Using the `passive` option can be used to change the default behaviour.
       * Please see https://developers.google.com/web/updates/2016/06/passive-event-listeners for further information.
       */
      passive?: boolean
    }
    

生命周期

  • connectedCallback()
  • disconnectedCallback()
  • componentWillLoad()
  • componentDidLoad()
  • componentShouldUpdate(newValue, oldValue, propName): boolean
  • componentWillRender()
  • componentDidRender()
  • componentWillUpdate()
  • componentDidUpdate()
  • render()

lifecycle.png

Stencil 示例代码

目录结构

dirs.png

stencil-library 代码

生成初始化项目

lerna init
mkdir packages
cd packages/
npm init stencil components stencil-library
cd stencil-library
# Install dependencies
npm install

应用打包

stencil.config.ts

import { Config } from '@stencil/core'
import { vueOutputTarget } from '@stencil/vue-output-target'
import { reactOutputTarget } from '@stencil/react-output-target'

export const config: Config = {
  namespace: 'stencil-library',
  outputTargets: [
    reactOutputTarget({
      componentCorePackage: 'stencil-library',
      proxiesFile: '../react-app/src/components.ts',
      includeDefineCustomElements: true,
    }),
    vueOutputTarget({
      componentCorePackage: 'stencil-library', // i.e.: stencil-library
      proxiesFile: '../vue-app/src/components.ts',
    }),
    {
      type: 'dist',
      esmLoaderPath: '../loader',
    },
    {
      type: 'dist-custom-elements',
    },
    {
      type: 'docs-readme',
    },
    {
      type: 'www',
      serviceWorker: null, // disable service workers
    },
  ],
}

vue 项目引用

初始化项目

# From your top-most-directory/
lerna create vue-app
# or if you are using other monorepo tools, create a new Vue library
# Follow the prompts and confirm
cd packages/vue-app
# Install Vue dependency
npm install vue@3 --save-dev
# or if you are using yarn
yarn add vue@3 --dev
# Add the stencil-library dependency
lerna add stencil-library

引用

<script setup lang="ts">
import { MyComponent } from './components'
</script>

<template>
  <div>
    <MyComponent first="first" middle="middle" last="last"></MyComponent>
  </div>
</template>

react 项目引用

初始化项目

# From your top-most-directory/
lerna create react-app
# or if you are using other monorepo tools, create a new Vue library
# Follow the prompts and confirm
cd packages/react-app
# Install Vue dependency
npx create-react-app

引用

import { MyComponent } from './components'

function App() {
  return (
    <div className="App">
      <MyComponent></MyComponent>
    </div>
  )
}

export default App

jquery 项目引用

初始化项目

lerna create js-app
cd packages/js-app
npm i -D vite

index.html

<my-component id="my-component"></my-component>
<script type="module">
  import { defineCustomElements } from 'stencil-library/loader'
  defineCustomElements()
</script>