🔥 在React中使用WebComponents组件的最佳实践

3,437 阅读4分钟

写完本篇文章之后我曾在掘金上搜过类似的文章。感觉我的文章和别人写的没啥两样。所以我就删掉从不同的角度重写了一遍,解决了一个很重要的问题,怎么才能像使用React组件那样使用WebComponents组件?

为什么很重要呢?举个例子:

如果使用普通的WebComponents组件,应该怎样添加事件呢?就像这样:

...
counterDOM.addEventListener('myChange',function(e){
    console.log("App -> e", e.detail.value);
})
return (<my-counter max={10} min={-10} ref={counterDOM} ></my-counter>)
...

说句实话,大家不觉得这样的写法很丑陋吗? 那这样的组件还有谁会去用呢?

那如果是这样呢?是不是就好多了。

<Counter
    onMyChange={(e) => {
       console.log("App -> e", e.detail.value);
     }}
     max={10}
     min={-10}
></Counter>

下面的动图展示了实际的效果:

2021-09-21 16.25.34.gif

接下来我们就实现这个组件。

my-counter 的实现

我没有使用原生的WebComponents的写法,而是使用了Lit 这个WebComponents库。Lit 建立在 Web Components 标准之上,压缩后的大小只有5KB,并且渲染速度非常快,因为 Lit 在更新时只接触 UI 的动态部分——无需重建虚拟树并将其与 DOM 进行比较。而且每个 Lit 组件都是原生 Web 组件。

下载Lit:

npm i lit

接下来书写我们的 my-counter 组件:

import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { emit } from "./tool";
import styles from "./counter.style";

@customElement("my-counter")
export default class MyCounter extends LitElement {
  static styles = styles;

  @property({ type: Number })
  value = 0;

  @property({ type: Number })
  min: number = -Infinity;

  @property({ type: Number })
  max: number = Infinity;

  handleIncrease() {
    this.value = Math.min(this.value + 1, this.max);
    this.requestUpdate();
    emit(this, "myChange", {
      detail: {
        value: this.value,
      },
    });
  }
  handleReduce() {
    this.value = Math.max(this.value - 1, this.min);
    this.requestUpdate();
    emit(this, "myChange", {
      detail: {
        value: this.value,
      },
    });
  }

  render() {
    return html`
      <div class="counter-container">
        <button class="button-left" @click=${this.handleReduce}>-</button>
        <p class="value-show">${this.value}</p>
        <button class="button-right" @click=${this.handleIncrease}>+</button>
      </div>
    `;
  }
}

declare global {
  namespace JSX {
    interface IntrinsicElements {
      "my-counter": any;
    }
  }
}

编写样式文件:

import { css } from "lit";

export default css`
  .counter-container {
    box-sizing: border-box;
    width: 120px;
    height: 32px;
    opacity: 1;
    background: #ffffff;
    border: 1px solid rgba(0, 0, 0, 0.15);
    border-radius: 4px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    overflow: hidden;
  }
  .button-left,
  .button-right {
    outline: none;
    border: 0;
    width: 31px;
    height: 30px;
    cursor: pointer;
    background-color: white;
  }
  .button-left {
    border-right: 1px solid #ccc;
  }
  .button-right {
    border-left: 1px solid #ccc;
  }
  .value-show {
    margin: 0;
    width: 50px;
    text-align: center;
  }
`;

现在我们已经写完了一个WebComponents了,现在可以使用它了:

const App = ()=>(<my-counter></my-counter>)

接下来我们就看看怎样像使用React组件一样使用它。

转换为React组件

我们先写一个转换函数:

// 保存事件函数的MAP
const listenedEvents = new WeakMap();
const addOrUpdateEventListener = (node, event, listener) => {
  // 根据DOM获取事件对象
  let events = listenedEvents.get(node);
  // 如果没有则设置新的
  if (events === undefined) {
    listenedEvents.set(node, (events = new Map()));
  }
  // 根据传递进来的对象获取函数
  let handler = events.get(event);
  if (listener !== undefined) {
    // 有传递函数
    if (handler === undefined) {
      // 没有获取到函数 添加新的函数
      events.set(event, (handler = { handleEvent: listener }));
      node.addEventListener(event, handler);
    } else {
      // 更新函数
      handler.handleEvent = listener;
    }
  } else if (handler !== undefined) {
    // 删除
    events.delete(event);
    node.removeEventListener(event, handler);
  }
};
const setRef = (ref, value) => {
  if (typeof ref === "function") {
    ref(value);
  } else {
    ref.current = value;
  }
};
const setProperty = (node, name, value, old, events) => {
  // 获取事件函数
  const event =
    events === null || events === undefined ? undefined : events[name];
  if (event !== undefined) {
    // 函数不一样添加监听
    if (value !== old) {
      addOrUpdateEventListener(node, event, value);
    }
  } else {
    node[name] = value;
  }
};
const conversionComponents = (React, tagName, elementClass, events) => {
  const { Component, createElement } = React;

  // 获取传递给WebComponents的Props,包括事件
  const classProps = new Set(
    Object.keys(events !== null && events !== void 0 ? events : {})
  );
  // 获取传递给WebComponents的Props,包括事件
  for (const p in elementClass.prototype) {
    if (!(p in HTMLElement.prototype)) {
      classProps.add(p);
    }
  }
  class ReactComponent extends Component {
    constructor(props) {
      super(props);
      // 保存DOM的引用
      this.DOM = null;
    }
    _updateElement(oldProps) {
      if (this.DOM === null) {
        return;
      }
      // 将新的Props值传递给自定义元素,并且设置事件的监听
      for (const prop in this.DOMProps) {
        setProperty(
          this.DOM,
          prop,
          this.props[prop],
          oldProps ? oldProps[prop] : undefined,
          events
        );
      }
    }
    componentDidMount() {
      this._updateElement();
    }
    componentDidUpdate(old) {
      this._updateElement(old);
    }
    render() {
      // 获取WebComponents组件的DOM
      const userRef = this.props.__forwardedRef;
      if (this._ref === undefined || this._userRef !== userRef) {
        this._ref = (value) => {
          if (this.DOM === null) {
            this.DOM = value;
          }
          if (userRef !== null) {
            setRef(userRef, value);
          }
          this._userRef = userRef;
        };
      }
      // 设置最新的Props
      const props = { ref: this._ref };
      this.DOMProps = {};
      for (const [k, v] of Object.entries(this.props)) {
        if (classProps.has(k)) {
          this.DOMProps[k] = v;
        }
      }
      return createElement(tagName, props);
    }
  }
  // 转发Ref
  return React.forwardRef((props, ref) =>
    createElement(
      ReactComponent,
      { ...props, __forwardedRef: ref },
      props === null || props === undefined ? undefined : props.children
    )
  );
};

export default conversionComponents;

这转换函数主要的思路就是将我们自定元素经过createElement函数转化为React元素,然后将给这个元素添加的Props进行转换编辑,如果传递的是事件函数,那我们就将这函数添加到自定义元素的DOM上即可。这样就不用写之前那也丑陋的写法了。

接下来就是转换我们的WebComponents组件:

import React, { FC } from "react";
import conversionComponents from "./conversionComponents";
import MyCounter from "./counter";

interface PropCounter {
  max?: number;
  min?: number;
  onMyChange?: (e: { detail: { value: number } }) => void;
}
const Counter: FC<PropCounter> = conversionComponents(
  React,
  "my-counter",
  MyCounter,
  {
    onMyChange: "myChange",
  }
);

export { Counter };

使用:

import React, { useState } from "react";
import { Counter } from "./components";

function App() {
  const [num, setNum] = useState<number>(0);
  return (
    <div>
      <h1>Destiny__ {num}</h1>
      <Counter
        onMyChange={(e) => {
          console.log("App -> e", e.detail.value);
          setNum(e.detail.value);
        }}
        max={10}
        min={-10}
      ></Counter>
    </div>
  );
}

export default App;

经过这个转换函数之后,我们就可以像使用正常的组件一样使用WebComponents组件了!!

感兴趣的同学可是尝试一下。遇到问题欢迎下方留言!!随时解答。 另外如果你不想使用Lit这个库,原生也是可以实现这个功能的!只不过要改写转换函数而已!!

🔥 在线尝试

🔥 GitHub 地址

往期精彩