[React]利用Webcomponent封装React组件

为什么这么做

我个人认为,最重要的点是可以很方便地跨框架挂载和卸载wc元素(至少我在项目里是这么玩的),此外,基于wc的css沙箱以及它的shadowRoot机制,可以提供一套隔离机制,保证每个渲染组件的边界分明。

利用AI总结罗列了一下都有啥优点…

  1. 封装性:Web Components提供了一种封装UI组件的方法,使得组件可以在不同的框架或无框架环境中重用。
  2. 可重用性:封装为Web Components的React组件可以在任何支持Web Components的环境中使用,不限于React应用。
  3. 封装的样式和行为:Web Components允许你封装组件的HTML结构、样式和行为,确保样式和行为不会泄露到父组件或全局作用域。
  4. 独立性:Web Components封装的组件具有独立性,它们拥有自己的DOM树和作用域,不会影响外部环境。
  5. 易于集成:Web Components提供了一种标准化的集成方式,可以更容易地将React组件集成到其他Web应用中。
  6. 更好的性能:Web Components的自定义元素可以在不影响主线程的情况下进行升级和渲染,这有助于提高应用性能。
  7. 标准化:Web Components基于W3C标准,这意味着它们在不同的浏览器和环境中具有更好的一致性和兼容性。
  8. 易于维护:由于Web Components封装的组件具有清晰的接口和封装性,维护和更新组件变得更加容易。
  9. 样式隔离:Web Components的Shadow DOM技术可以确保组件的样式不会受到外部样式的影响,同时也防止组件内部样式泄露到外部。
  10. 生命周期管理:Web Components允许你定义组件的生命周期钩子,如connectedCallbackdisconnectedCallback等,这与React组件的生命周期方法类似。
  11. 跨框架使用:封装为Web Components的React组件可以被其他前端框架或库使用,例如Vue、Angular或原生JavaScript。
  12. 自定义元素:Web Components允许开发者定义自定义HTML元素,这些元素可以像标准HTML元素一样使用。
  13. 易于测试:Web Components的封装性使得测试组件变得更加简单,因为你可以独立于其他组件来测试它们。
  14. 更好的封装和抽象:Web Components提供了一种封装和抽象UI组件的方式,使得组件的实现细节对使用者是透明的。

Webcomponent入门

先来简单地过一下webcomponent的基础

官方文档:https://developer.mozilla.org/zh-CN/docs/Web/API/Web_components

示例

下面是一个最简单的示例,自定义了一种名为”simple-component“的元素,并且它没有shadowRoot(意味着它并没有与外界隔离样式)。

class SimpleComponent extends HTMLElement {
  constructor() {
    super();
    this.innerHTML = `<p>Hello, World!</p>`;
  }
}

customElements.define('simple-component', SimpleComponent);

下面是一个内容更丰富一些的示例,有基础的大概过一眼也知道大概了。

//  1.自定义标签都是用class 的形式去继承
class myDiv extends HTMLElement {
  // 监听
  static get observedAttributes() {
    return ['option']
  }
  constructor() {
    super()
    // 这样我们才能够去追加元素
    this.attachShadow({ mode: 'open' })
  }
  // 重要:生命周期方法 开始
  connectedCallback() {
    console.log('connectedCallback生命周期')

    this.render({
      option: this.getAttribute('option'),
    })

    // 获取元素
    console.log(this.shadowRoot.querySelector('.content'))
    console.log('this.shadowRoot: ', this.shadowRoot)

    document.addEventListener('click', e => {
      // 重要:冒泡的顺序,通过这个可以判断有没有在鼠标内部进行点击
      if (e.composedPath().includes(this)) {
        console.log('点击了里面')
      }
    })

    this.shadowRoot.querySelector('.content').addEventListener('click', e => {
      console.log('e: ', e)
      // window.dispatchEvent
    })
  }
  // 重要:生命周期方法 重新渲染 .甚至还是第一次进行渲染,比connect还快
  // 会重新渲染 connectCallback
  attributeChangedCallback(attr, oldValue, newValue) {
    if (oldValue) {
      switch (attr) {
        case 'option':
          this.shadowRoot.querySelector('.title').textContent = newValue
      }
    }
    console.log('attributeChangeCallback', attr, oldValue, newValue)
  }

  borderAdd() {
    console.log('borderadd')
    this.shadowRoot.querySelector('.content').style.border = '3px solid green'
  }
  render(data) {
    let { option } = data

    // console.log()
    let nodeTemplate = document.createElement('template')
    nodeTemplate.innerHTML = `
      <div class="content" >
        <div class="title">${option} </div> 
        <slot name="container"></slot>
      </div>
  `
    let nodeStyles = document.createElement('style')

    // shadow dom 的样式绝对隔离
    // 重要: :host选择器可以选中根也就是my-div的样式。外面的选择器样式要高于这个
    nodeStyles.innerHTML = `
      :host(.active) .content{
        margin-top:20px;
        background:rgba(0,0,0,30%);
      }
      
      :host{
        display:block
      }
      
      .content{
        width:100px;
        height:100px;
        background:rgba(0,0,0,20%)
      }
        
      ::slotted([slot="container"]){
          display:none
      }

      ::slotted(.active){
        display:block
      }
 `

    this.shadowRoot.appendChild(nodeTemplate.content)
    this.shadowRoot.appendChild(nodeStyles)

    setTimeout(() => {
      this.borderAdd()
    }, 3000)
  }
}

// 名字必须小写 驼峰必须要转成横线
customElements.define('my-div', myDiv)

shadowRoot

一个Web组件可以有且仅有一个shadowRootshadowRoot是与该组件关联的影子DOM的根节点。当使用attachShadow方法创建影子DOM时,它会返回一个shadowRoot对象,这个对象是唯一的,并且与创建它的元素关联。

例如:

class MyComponent extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: "open" });
    this.shadow.innerHTML = `<p>I am in the shadow DOM!</p>`;
  }
}

customElements.define('my-component', MyComponent);

在这个例子中:

  • MyComponent类扩展了HTMLElement,定义了一个Web组件。
  • 在构造函数中,通过调用this.attachShadow({ mode: "open" })创建了一个shadowRoot,并将其存储在this.shadow变量中。
  • 这个shadowRoot是唯一的,并且与MyComponent实例关联。

关键点:

  • 唯一性:每个Web组件实例只能有一个shadowRoot
  • 关联性shadowRoot与创建它的Web组件实例是紧密关联的,不能被其他组件实例访问。

因此,尽管可以在shadowRoot内创建多个子元素和结构,但每个Web组件实例只能有一个shadowRoot。这有助于保持组件的封装性和独立性。

生命周期

  1. connectedCallback
    • 当自定义元素被插入到文档DOM树中时调用此方法。这类似于React中的componentDidMount
  2. disconnectedCallback
    • 当自定义元素从DOM树中移除时调用此方法。类似于React中的componentWillUnmount
  3. attributeChangedCallback
    • 当自定义元素的属性被更改时调用此方法。它接收三个参数:属性名称、旧值和新值。这可以用于响应属性的变化,类似于React中的componentDidUpdate,但是它是针对属性而不是状态。
  4. adoptedCallback
    • 当自定义元素被移动到新的文档时调用此方法。这在Web Components中是特有的,因为自定义元素可以跨文档使用。

主要属性

  1. 观察者模式(Observed attributes)
    • 通过在自定义元素类中定义一个静态的observedAttributes属性数组,可以指定哪些属性的更改应该触发attributeChangedCallback
  2. connecteddisconnected 属性
    • 这些属性可以用于检查自定义元素是否已经连接到文档的DOM树中。
  3. shadowRoot 属性
    • 每个自定义元素都有一个shadowRoot属性,它是一个Shadow DOM树的根。可以在这个属性上使用生命周期回调来管理Shadow DOM的创建和更新。
  4. constructor
    • 虽然不是Web Components的生命周期回调,但是自定义元素的构造函数是定义元素属性和方法的地方,并且在元素实例化时调用。

Lit框架入门

一般知道了上面的基础,就可以写wc组件了,但实际开发中,肯定还是需要借助一些已有的开发框架来辅助开发,而Lit就是目前最成熟且使用量最高的。

原理介绍

Web组件的更新并不是每次都进行全量更新。Web组件的更新机制非常灵活,能够根据组件的状态和属性的变化来决定是否需要更新。以下是一些关键点:

  1. 属性变化触发更新

    • Web组件的更新通常是由属性的变化触发的。当组件的属性发生变化时,浏览器会调用attributeChangedCallback方法来处理这些变化。
  2. 状态变化触发更新

    • 组件的内部状态变化也可能导致更新。例如,在LitElement中,当使用@property装饰器定义的属性发生变化时,会触发更新。
  3. 生命周期方法

    • 组件的生命周期方法,如connectedCallback, disconnectedCallback, adoptedCallback, firstUpdated, updated等,都可以在特定时机触发更新。
  4. 选择性更新

    • 更新机制可以是选择性的。例如,在LitElement中,可以通过使用requestUpdate方法来请求更新,而不必每次都进行全量更新。
  5. 虚拟DOM

    • 一些Web组件框架(如LitElement)使用虚拟DOM技术来优化更新过程。虚拟DOM可以比较组件的新旧状态,并只更新那些实际发生变化的部分。
  6. 优化性能

    • 为了避免不必要的全量更新,Web组件通常会使用一些优化技术,例如节流(throttle)和防抖(debounce)来减少更新次数。
  7. 自定义渲染逻辑

    • 开发者可以通过自定义渲染逻辑来控制组件的更新过程。例如,可以在render方法中手动决定哪些部分需要重新渲染。
  8. 条件渲染

    • 组件可以通过条件渲染来决定是否需要更新某些部分。例如,只有当特定条件满足时才重新渲染某些元素。

示例

以下是一个使用LitElement的示例,展示了如何控制组件的更新:

import { LitElement, html, css, property } from 'lit';

class MyComponent extends LitElement {
  @property({ type: String })
  message = '';

  render() {
    return html`
      <div>
        <p>${this.message}</p>
      </div>
    `;
  }

  updated(changedProperties) {
    super.updated(changedProperties);
    if (changedProperties.has('message')) {
      console.log('Message updated:', this.message);
    }
  }
}

customElements.define('my-component', MyComponent);

在这个示例中:

  • message属性使用@property装饰器定义,当其值发生变化时,会触发组件的更新。
  • render方法定义了组件的渲染逻辑,只有当message属性发生变化时,相关的部分才会重新渲染。
  • updated方法在组件更新后被调用,可以在这里处理更新后的逻辑。

通过这种方式,Web组件可以有效地控制更新过程,避免不必要的全量更新,从而提高性能。

增加的生命周期和内置属性

Lit 相对于传统 Web 组件规范增加的一些生命周期钩子和特性:

  1. render 方法

    • 这是 Lit 的核心特性之一。render 方法是一个返回组件模板的函数,Lit 会根据这个方法的内容来渲染组件的 UI。
  2. update 方法

    • 这个方法在组件的属性或状态发生变化时被调用。Lit 会调用这个方法来决定是否需要重新渲染组件。
  3. shouldUpdate 方法

    • 这个方法允许开发者自定义更新逻辑,决定是否需要进行更新。如果返回 false,则跳过更新。
  4. willUpdate 方法

    • 在组件更新之前被调用,可以用于执行更新前的准备工作。
  5. updated 方法

    • 在组件更新之后被调用,可以用于执行更新后的逻辑处理。
  6. firstUpdated 方法

    • 在组件首次更新后被调用。这与 Web 组件的 connectedCallback 有些相似,但专门用于处理首次渲染后的逻辑。
  7. connectedCallback

    • 这是 Web 组件规范中的方法,Lit 也支持。当组件被插入到文档中时调用。
  8. disconnectedCallback

    • 这是 Web 组件规范中的方法,Lit 也支持。当组件从文档中移除时调用。
  9. attributeChangedCallback

    • 这是 Web 组件规范中的方法,Lit 也支持。当组件的属性发生变化时调用。
  10. adoptedCallback

    • 这是 Web 组件规范中的方法,Lit 也支持。当组件被移动到新文档时调用。
  11. requestUpdate 方法

    • 这个方法可以被开发者调用,以请求更新组件的属性。Lit 会安排在下一个微任务中处理这些更新。
  12. updateComplete Promise

    • 一个 Promise,当组件的更新完成后会解析。这可以用于在更新完成后执行异步操作。
  13. 样式管理

    • Lit 提供了 CSSResultunsafeCSS 等 API,用于更安全和方便地管理组件的样式。
  14. 属性装饰器

    • 使用 @property 装饰器定义的属性会触发更新,并且可以指定属性的类型和是否同步到 DOM 属性。
  15. 状态管理

    • Lit 通过 state 方法和 reactive 装饰器,提供了一种声明式的方式来管理组件的状态。

核心:结合Lit框架实现React组件封装

那么基于以上,我们可以很容易地就实现利用Lit框架创造出一个webcomponent容器,然后用来包裹React组件。

Base基础类

import { LitElement, ReactiveElement, adoptStyles, unsafeCSS, PropertyValues } from 'lit'
import { property } from 'lit/decorators.js'


type ThrottleFn = (...args: any[]) => void
type DelayFn = (fn: ThrottleFn) => void

const throttleWith = <T extends ThrottleFn>(
  fn: T,
  delayFn: DelayFn,
  leading = false
): T => {
  let lastArgs: Parameters<T>, lastThis: unknown, isWaiting = false

  const throttledFn = (...args: Parameters<T>) => {
    lastArgs = args

    // eslint-disable-next-line
    lastThis = this

    if (!isWaiting) {
      if (leading) {
        fn.apply(lastThis, lastArgs)
      }

      isWaiting = true

      delayFn(() => {
        fn.apply(lastThis, lastArgs)
        isWaiting = false
      })
    }
  }

  return throttledFn as T
}

export default class Base extends LitElement {
  private _wcStyle?: string

  @property({ attribute: 'wc-style' })
  get wcStyle() {
    return this._wcStyle
  }

  set wcStyle(val: string | undefined) {
    this._wcStyle = val
    this.adoptStyles()
  }

  /**
   * 使事件不能跨越ShadowDOM边界传播
   */
  @property({ type: Boolean, attribute: 'prevent-compose' })
  protected preventCompose = false

  /**
   * 使事件不冒泡
   */
  @property({ type: Boolean, attribute: 'prevent-bubbles' })
  protected preventBubbles = false

  // 应用样式
  protected adoptStyles = throttleWith(
    () => {
      const apply = () => {
        if (this.renderRoot instanceof ShadowRoot) {
          const styles = (this.constructor as typeof ReactiveElement).elementStyles.slice() // 获取原有样式
          this.wcStyle && styles.push(unsafeCSS(this.wcStyle))
          adoptStyles(this.renderRoot, styles) // 重新应用样式
        }
      }

      this.renderRoot ? apply() : this.updateComplete.then(apply)
    },
    (fn: any) => Promise.resolve().then(fn)
  )

  // 派发事件
  emit(eventName: string, detail?: any, options?: CustomEventInit) {
    let event = new CustomEvent(eventName, {
      detail,
      composed: !this.preventCompose,
      bubbles: !this.preventBubbles,
      cancelable: false,
      ...options,
    })
    this.dispatchEvent(event)
    return event
  }


  // 判断 slot 是否传入内容
  hasSlot(name?: string) {
    if (name && name !== 'default') {
      return !![...this.childNodes].find(
        node => node.nodeType === node.ELEMENT_NODE && (node as Element).getAttribute('slot') === name
      )
    }

    return [...this.childNodes].some(node => {
      if (node.nodeType === node.TEXT_NODE && !!node.textContent?.trim()) {
        return true
      }

      if (node.nodeType === node.ELEMENT_NODE) {
        const el = node as HTMLElement
        if (!el.hasAttribute('slot')) {
          return true
        }
      }

      return false
    })
  }

  // 各个生命周期
  // 挂载时
  connectedCallback() {
    super.connectedCallback()

    console.log('Custom element added to page.')

    // 第一次被插入文档时执行,跳过节点删除后又重新插入的情形
    if (!this.hasUpdated) {
      this.setAttribute('wc-component', '')
      this.setAttribute('wc-pending', '')
    }
  }

  // 卸载时
  disconnectedCallback() {
    super.disconnectedCallback()
    console.log('Custom element removed from page.')
  }


  // 移动到另一个文档的时候
  adoptedCallback() {
    console.log('Custom element moved.')
  }

  // 元素的属性被添加、删除或修改时调用
  attributeChangedCallback(name: string, oldValue: any, newValue: any) {
    super.attributeChangedCallback(name, oldValue, newValue)
    console.log(`Attribute ${name} has changed.`)
  }

  // 或使用静态属性代替get方法
  static get observedAttributes() {
    // 指定要监听的元素的属性数组
    // 对应的attr改变后,会触发attributeChangedCallback
    // return ['name', 'date']

    return []
  }

  // 是否应该更新
  protected shouldUpdate(_changedProperties: PropertyValues): boolean {
    return true
  }

  // 即将更新
  protected willUpdate(_changedProperties: PropertyValues): void {
    super.willUpdate(_changedProperties)
    console.log('willUpdate')
  }

  // 首次更新元素时调用。实现在更新后对元素执行一次性工作
  protected firstUpdated(changedProperties: PropertyValues) {
    super.firstUpdated(changedProperties)

    console.log('this.hasUpdated: ', this.hasUpdated)
    // this.requestUpdate()

    // 两帧数后执行
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        this.removeAttribute('wc-pending')
      })
    })
  }

  protected updated(_changedProperties: PropertyValues): void {
    super.updated(_changedProperties)

    this.updateComplete.then((res) => {
      console.log('updateComplete', res)
    })
  }
}

withProperties封装

import type { LitElement, PropertyValues } from 'lit'

type Constructor<T> = new (...args: any[]) => T

export default <T extends Constructor<LitElement>>(superClass: T) => {
  class WithPropertiesElement extends superClass {
    props: Record<string, any> = {}

    willUpdate(changedProperties: PropertyValues) {
      const obj = [...changedProperties.entries()].reduce<any>(
        (obj, [key]) => ((obj[key] = (this as any)[key]), obj),
        {}
      )
      this.props = { ...this.props, ...obj }
      super.willUpdate(changedProperties)
    }
  }

  return WithPropertiesElement as Constructor<{
    props: Record<string, any>
  }> & T
}

这段代码定义了一个高阶组件(Higher-Order Component,HOC),用于增强 LitElement 组件的功能。具体来说,它的作用是:

  1. 创建一个带有额外属性管理功能的组件类

    • 通过扩展传入的基类(比如 LitElement 或其子类),添加一个 props 属性来存储组件的属性值。
  2. 在组件更新前处理属性变化

    • 重写 willUpdate 生命周期方法,这个方法在组件的属性发生变化并且组件即将更新之前被调用。
  3. 收集并存储属性变化

    • 使用 changedProperties 对象(一个 Map 类型的对象,包含属性名和属性变化的信息)来收集属性的变化。
    • 将变化的属性存储到 this.props 对象中,这样可以通过 props 属性访问组件的所有属性值。
  4. 保持基类的 willUpdate 方法的调用

    • 调用 super.willUpdate(changedProperties) 以确保基类的 willUpdate 方法也能正常执行。

代码详解

  • 定义了一个默认导出的函数,它接受一个构造函数 superClass(应该是 LitElement 或其子类的构造函数)。
  • 创建一个新类 WithPropertiesElement,继承自 superClass
  • WithPropertiesElement 类中定义了一个 props 属性,用于存储属性值。
  • 重写 willUpdate 方法,在组件更新前处理属性变化,并将变化的属性存储到 this.props 中。
  • 返回 WithPropertiesElement 类,并通过类型断言确保它具有额外的 props 属性。

使用示例

假设你有一个基础的 LitElement 组件:

import { LitElement, html } from 'lit';

class MyElement extends LitElement {
  count = 0;

  render() {
    return html`<p>Count: ${this.count}</p>`;
  }
}

customElements.define('my-element', MyElement);

你可以使用这个高阶组件来增强它:

import { WithPropertiesElement } from './WithPropertiesElement';
import { LitElement, html } from 'lit';

const EnhancedElement = WithPropertiesElement(MyElement);

customElements.define('enhanced-element', EnhancedElement);

const element = new EnhancedElement();
document.body.appendChild(element);

console.log(element.props); // { count: 0 }

在这个示例中,EnhancedElement 继承自 MyElement 并添加了属性管理功能。可以通过 element.props 访问组件的所有属性值。

这种模式在需要在组件中统一管理属性或在组件更新前进行额外处理时非常有用。

存放React组件的webcomponent基类

重头戏来了

import { ChildPart, html, PropertyValues } from 'lit'
import { query } from 'lit/decorators.js'
import { Fragment, createElement as h } from 'react'
import ReactDOM from 'react-dom'
import withProperties from '../mixin/withProperties'
import LightBase from './Base'

type H = typeof h

const Root: React.FC<any> = props => {
  return h(Fragment, {
    ...props,
  })
}

const omit = (obj: Record<string, any>, filter: string[] = []) =>
  Object.fromEntries(Object.entries(obj).filter(([key]) => !filter.includes(key)))

// React组件基类
export default class extends withProperties(LightBase) {
  // 子类要重写这个方法来渲染自己的组件
  protected renderReact(h: H): React.ReactNode {
    return null
  }

  protected customContainer(): Element | undefined {
    return this.$reactRoot
  }

  protected getReactProps(props: Record<string, any>) {
    return omit(props, ['preventCompose', 'preventBubbles', 'localeMessages'])
  }

  protected extraStyle = ''

  @query('.react-root')
  $reactRoot?: HTMLElement

  updated(changed: PropertyValues) {
    super.updated(changed)
    this.doRender()
  }

  connectedCallback() {
    super.connectedCallback()
    // 节点删除后重新插入的情形
    if (this.hasUpdated) {
      this.doRender()
    }
  }

  disconnectedCallback() {
    super.disconnectedCallback()
    this.doUnmount()
  }

  private container?: Element
  private doRender() {
    const container = this.customContainer()
    if (!container) {
      this.doUnmount() // 卸载前一次渲染的内容
    } else {
      this.container = container
      ReactDOM.render(h(Root, {}, this.renderReact(h)), container, () => {
        // hack for error: https://github.com/lit/lit/blob/f8ee010bc515e4bb319e98408d38ef3d971cc08b/packages/lit-html/src/lit-html.ts#L1122
        // 在React中使用此组件且非首次更新时会报错,因为lit默认会在组件下创建一个注释节点,更新时会对这个节点进行操作,而React渲染时把这个注释节点干掉了,这里要把他加回去
        const childPart = (this as any).__childPart as ChildPart | undefined
        childPart?.startNode && this.appendChild(childPart.startNode)
      })
    }
  }

  private doUnmount() {
    if (this.container) {
      ReactDOM.unmountComponentAtNode(this.container)
    }
  }

  render() {
    return html` <div class="react-root"></div> `
  }
}

使用Demo

import { unsafeCSS } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import ReactBase from './ReactBase'

// 自己的React组件
import Component from './Component'

import style from './index.less?inline'

@customElement('my-diy-react-wc')
export default class DataReport extends ReactBase {
  static get styles() {
    return unsafeCSS([style])
  }

  /**
   * 自定义属性
   */
  @property()
  language: string = 'zh-CN'


  // ReactBase中用来渲染React,不要删除
  renderReact() {
    return <Component language={this.language} />
  }
}

参考文章

https://juejin.cn/post/7296850940404580364?searchId=2024071620331848BC966F0D2051B9C533#heading-9

lit官网:https://lit.dev/docs/components/styles/

webcomponent文档:https://developer.mozilla.org/en-US/docs/Web/API/Web_components

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部