在我作为一名身处设计和代码交叉点的 Web 开发人员的角色中,我被 Web Components 的可移植性所吸引。这很有道理:自定义元素是功能齐全的 HTML 元素,可以在所有现代浏览器中使用,并且阴影 DOM 使用相当大的自定义表面积封装了正确的样式。这是一个非常不错的选择,尤其是在大型组织希望在多个框架(如 Angular、Svelte 和 Vue)中创建一致的用户体验时。
然而,根据我的经验,存在一个异常情况,许多开发人员认为自定义元素不起作用,特别是那些使用 React 的开发人员,React 可以说是目前最流行的前端库。确实,React 与 Web Components 规范的兼容性确实存在一些提升空间;但是,React 无法与 Web Components 深度集成的想法是一个误区。
在本文中,我将逐步介绍如何将 React 应用程序与 Web Components 集成,以创建(几乎)无缝的开发体验。我们将了解 React 的最佳实践及其限制,然后创建通用包装器和自定义 JSX pragma,以便更紧密地耦合我们的自定义元素和当今最流行的框架。
遵循规范
如果 React 是一本涂色书——请原谅这个比喻,我有两个喜欢涂色的孩子——那么肯定有一些方法可以遵循规范来使用自定义元素。首先,我们将编写一个非常简单的自定义元素,该元素将文本输入附加到 Shadow DOM,并在值更改时发出事件。为了简单起见,我们将使用 LitElement 作为基础,但如果您愿意,也可以 从头开始编写自己的自定义元素。
我们的 super-cool-input
元素基本上是一个带有普通 <input>
元素样式的包装器,它发出自定义事件。它有一个 reportValue
方法,用于以最显眼的方式让用户知道当前值。虽然此元素可能不是最有用的,但我们在将其插入 React 时将说明的技术将有助于处理其他自定义元素。
方法 1:使用 ref
根据 React 的 Web Components 文档,“[要访问 Web Components 的命令式 API,您需要使用 ref 来直接与 DOM 节点交互。]
这是必要的,因为 React 目前没有办法监听原生 DOM 事件(而是更喜欢使用它自己的 专有 SyntheticEvent
系统),也没有办法在不使用 ref 的情况下声明式地访问当前 DOM 元素。
我们将利用 React 的 useRef
钩子来创建对我们已定义的原生 DOM 元素的引用。我们还将使用 React 的 useEffect
和 useState
钩子来访问输入的值并将其渲染到我们的应用程序中。如果值是“rad”的变体,我们还将使用 ref 调用 super-cool-input
的 reportValue
方法。
上面示例中需要注意的一点是 React 组件的 useEffect
块。
useEffect(() => {
coolInput.current.addEventListener('custom-input', eventListener);
return () => {
coolInput.current.removeEventListener('custom-input', eventListener);
}
});
useEffect
块创建了一个副作用(添加一个 React 未管理的事件侦听器),因此我们必须小心地在组件需要更改时删除事件侦听器,以免出现任何意外的内存泄漏。
虽然上面的示例只是绑定了一个事件侦听器,但这也是一种可以用于绑定到 DOM 属性(定义为 DOM 对象上的条目,而不是 React 属性或 DOM 属性)的技术。
这还不错。我们的自定义元素在 React 中工作,我们能够绑定到我们的自定义事件,访问其中的值,以及调用自定义元素的方法。虽然这确实有效,但它很冗长,并且看起来并不像 React。
方法 2:使用包装器
我们在 React 应用程序中使用自定义元素的第二次尝试是为该元素创建一个包装器。我们的包装器只是一个 React 组件,它将 props 传递给我们的元素并创建了一个 API,用于与 React 中通常不可用的元素部分交互。
在这里,我们将复杂性转移到了自定义元素的包装器组件中。新的 CoolInput
React 组件管理创建 ref,同时为我们添加和删除事件侦听器,以便任何使用组件都可以像任何其他 React 组件一样传入 props。
function CoolInput(props) {
const ref = useRef();
const { children, onCustomInput, ...rest } = props;
function invokeCallback(event) {
if (onCustomInput) {
onCustomInput(event, ref.current);
}
}
useEffect(() => {
const { current } = ref;
current.addEventListener('custom-input', invokeCallback);
return () => {
current.removeEventListener('custom-input', invokeCallback);
}
});
return <super-cool-input ref={ref} {...rest}>{children}</super-cool-input>;
}
在此组件上,我们创建了一个 prop,onCustomInput
,当存在时,它会触发来自父组件的事件回调。与正常的事件回调不同,我们选择添加第二个参数,将 CoolInput
的内部 ref 的当前值传递过去。
使用这些相同的技术,可以为自定义元素创建通用包装器,例如 Mathieu Puech 的这个 reactifyLitElement
组件。此特定组件负责定义 React 组件并管理整个生命周期。
方法 3:使用 JSX pragma
另一种选择是使用 JSX pragma,它有点像劫持 React 的 JSX 解析器并向语言添加我们自己的功能。在下面的示例中,我们从 Skypack 导入包 jsx-native-events。此 pragma 向 React 元素添加了一种额外的 prop 类型,并且任何以 onEvent
为前缀的 prop 都会向主机添加事件侦听器。
要调用 pragma,我们需要将其导入到我们正在使用的文件中,并使用文件顶部的 /** @jsx <PRAGMA_NAME> */
注释调用它。您的 JSX 编译器通常会知道如何处理此注释(并且可以配置 Babel 使其全局化)。您可能在像 Emotion 这样的库中见过它。
带有 onEventInput={callback}
prop 的 <input>
元素将在每次调度名为 'input'
的事件时运行 callback
函数。让我们看看它在我们的 super-cool-input
中是什么样子的。
pragma 的代码在 GitHub 上可用。如果要绑定到原生属性而不是 React 属性,可以使用 react-bind-properties。让我们快速了解一下
import React from 'react'
/**
* Convert a string from camelCase to kebab-case
* @param {string} string - The base string (ostensibly camelCase)
* @return {string} - A kebab-case string
*/
const toKebabCase = string => string.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()
/** @type {Symbol} - Used to save reference to active listeners */
const listeners = Symbol('jsx-native-events/event-listeners')
const eventPattern = /^onEvent/
export default function jsx (type, props, ...children) {
// Make a copy of the props object
const newProps = { ...props }
if (typeof type === 'string') {
newProps.ref = (element) => {
// Merge existing ref prop
if (props && props.ref) {
if (typeof props.ref === 'function') {
props.ref(element)
} else if (typeof props.ref === 'object') {
props.ref.current = element
}
}
if (element) {
if (props) {
const keys = Object.keys(props)
/** Get all keys that have the `onEvent` prefix */
keys
.filter(key => key.match(eventPattern))
.map(key => ({
key,
eventName: toKebabCase(
key.replace('onEvent', '')
).replace('-', '')
})
)
.map(({ eventName, key }) => {
/** Add the listeners Map if not present */
if (!element[listeners]) {
element[listeners] = new Map()
}
/** If the listener hasn't be attached, attach it */
if (!element[listeners].has(eventName)) {
element.addEventListener(eventName, props[key])
/** Save a reference to avoid listening to the same value twice */
element[listeners].set(eventName, props[key])
}
})
}
}
}
}
return React.createElement.apply(null, [type, newProps, ...children])
}
从本质上讲,此代码会转换任何以 onEvent
为前缀的现有 props 并将其转换为事件名称,获取传递给该 prop 的值(大概是一个签名为 (e: Event) => void
的函数),并在元素实例上将其添加为事件侦听器。
展望未来
截至撰写本文时,React 最近发布了 17 版。React 团队最初计划发布与自定义元素的兼容性改进;不幸的是,这些计划似乎已被推迟到 18 版。
在那之前,使用 React 使用自定义元素提供的所有功能将需要一些额外的工作。希望 React 团队将继续改进支持,以弥合 React 和 Web 平台之间的差距。