前言
大家好,我是馋嘴的猫,今天来跟大家一起探讨下 Web Components 的 SSR 实现。
Web Components,作为一种逐渐流行的组件开发的方式,已被越来越多的前端开发者所青睐。
但是,在 Declarative Shadow DOM 诞生之前,原生的 Web Components 是无法支持 SSR(服务端渲染) 的,只可以通过CSR(客户端渲染) 的方式渲染。
因此,请随本文的脚步,我们一起来探讨下,为什么 Web Components 会有这样的限制,而我们又应该如何在 SSR 的场景下,顺利使用上 Web Components。
简介
在这篇文章里,我们会以当前最新的 Swiper 11 为例,来跟大家介绍:
- Web Components 在 CSR 场景下的表现
- 尝试在 SSR 环境下使用 Web Components
- 解析为什么现有环境下 Web Components 无法支持 SSR
- 尝试使用最新的 Declarable Shadow Dom,来实现 Web Components 的 SSR 渲染
Web Components 在 CSR 下的表现
我们在这个章节,将会使用 Next.js 框架来验证 Swiper Element 的 CSR。
为了加快速度,这里提供了一个 Next.js + Swiper Element + CSR 的示例,点此查看。
现在请跟着我,一起来查看该示例的实现~
- 查看
app/swiper.tsx
, 在这个页面里,我们引入了Swiper 组件并使用。
Swiper 在 V9 版本开始提供 Web Components 组件,并命名为 Swiper Element,满足我们这次的需求。
注意:我们不能直接如同 Swiper React般引入 Swiper 组件即可使用。
在使用其提供的swiper-container
和swiper-slide
这两个 Custom Elements 前,还需要调用其提供的register
方法,来注册对应的 Custom Elements,方可使用。
typescript
// swiper.tsx
import {register} from 'swiper/element/bundle';
register();
register
的函数实现可以查看 Swiper 的源码。它通过window.customElements.define
方法,实现了将swiper-container
和swiper-slide
注册为 Custom Elements。
javascript
// swiper-element.mjs
const register = () => {
if (typeof window === 'undefined') return;
if (!window.customElements.get('swiper-container'))
window.customElements.define('swiper-container', SwiperContainer);
if (!window.customElements.get('swiper-slide'))
window.customElements.define('swiper-slide', SwiperSlide);
};
- 从 Next.js 13 起,如果需要配置组件为CSR渲染模式,需要手动指定其为 Client Component。做法很简单,只需要在页面开头加上
"use client";
即可,如下所示
typescript
// swiper.tsx
"use client";
// ... 省略其它代码
- 此时,我们可以再查看stackblitz 右边的预览区域,正常体验 Web Components 版本的 Swiper 组件了。
- 至此,全流程完结,也验证了 Swiper Element 在 CSR 模式下是能正常运行的。
Web Components 在 SSR 下的表现
我们在刚刚的步骤里,通过指定 swiper.tsx
为 Client Component
,成功将 Swiper 组件成功引入并运行起来。接下来,我们将尝试,在 SSR 场景下,Swiper Element 是否也能正常运行?
- 定位到
app/swiper.tsx
,注释代码一开头的"use client";
typescript
// swiper.tsx
// "use client";
// ... 省略其它代码
- 此时访问预览网址,提示以下错误:
text
Error: Event handlers cannot be passed to Client Component props.
<swiper-container slides-per-view={1} space-between=... centered-slides=...
pagination=... onSwiperprogress={function} onSwiperslidechange=... children=...>
很明显,这是因为 Next.js 限制了事件处理回调函数的使用场景,只能在 Client Component
使用。
因此,我们也把回调函数给注释掉。
typescript
// swiper.tsx
// 注释掉事件处理回调函数
// onSwiperprogress={onProgress}
// onSwiperslidechange={onSlideChange}
- 此时再打开预览地址,可以看到, Swiper Element 并没有正常加载。
- 通过右键点击 Chrome 浏览器的
检查
按钮,查看 Elements 面板关于 Swiper 组件的部分。可以看到,此时的 DOM Tree ,并没有正常加载到 Shadow Dom。
对比 CSR 版本的 Dom Tree 再看一下, CSR 场景下有 shadow-root 节点的存在,也能正常使用 Shadow Dom。因此,我们可得出结论,Swiper Element 在 CSR 下才能正常运行。
- 至此,全流程完结,也验证了 Swiper Element 在 SSR 模式下是不能正常运行的。
为什么现有环境下 Web Components 无法支持 SSR
在以上的实践操作后,我们可以得出一个结论:
以 Web Components 为基础搭建的 Swiper Element,在 CSR 下能正常运行 ,但在 SSR 下不能运行。
这是为什么呢?
不急,我们现在就来逐步分析一下:
Web Components 由三部分组成,我们可以分析是哪一部分在 SSR 下无法运行,导致整个 Web Components 的 SSR 渲染出现了问题。
Web Components 示例
首先,我们用一个简单的代码片段,来演示 Web Components 是怎么初始化与注册的:
html
<body>
<my-component></my-component>
<template id="simple-template">
<div class="my-component">
<h1>Hello, Web Components!</h1>
<p>This is a simple example of a web component.</p>
</div>
</template>
<script>
// 创建一个自定义的Web组件
class MyComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
// 在组件被添加到文档中时执行
this.render();
}
render() {
// Get the content of the template
const templateContent =
document.getElementById("simple-template").content;
// Append the template content to the shadow DOM
this.shadowRoot.appendChild(templateContent.cloneNode(true));
}
}
// 在自定义元素注册表中注册组件
customElements.define("my-component", MyComponent);
</script>
</body>
拆解一下,注册一个 Web Component 有以下几个步骤:
Web Components 注册步骤
-
定义一个自定义类
MyComponent
,并从标准类 HTMLElement 扩展。 -
在构造函数里通过调用 attachShadow 函数,给指定的元素挂载一个 Shadow DOM,并且返回对
ShadowRoot
的引用。 -
在自定义类中的 connectedCallback 钩子(由 Custom Elements 提供),进行渲染操作。
-
在渲染函数里,通过克隆
template
标签的内容,然后对 ShadowRoot 实现appendChild
操作,来完成页面布局与元素的绘制。 -
通过 customElements 提供的 define 方法,将自定义元素注册到自定义元素注册表(custom element registry),并且定义自定义标签名,如本示例的
my-component
。 -
此时,即可在 HTML 上使用自定义组件的标签了,比如本示例的
<my-component>
分析
我们再来分析一下,有哪些步骤是仅能在 Client 端执行的?
不卖关子了,是这两个:
- customElements.define
- attachShadow
其中,customElements.define
可以通过判断 window 是否存在,使其仅在 client 端执行,且不影响 SSR,如下所示:
注:Swiper Element 也用到了类似的注册方法。
javascript
if (typeof window === "undefined") return;
if (!window.customElements.get("my-contianer")) {
window.customElements.define("my-component", MyComponent);
}
但是,attachShadow
可以用同样的方法,来解决 SSR 的渲染问题吗?要不我们一起来试一试?
尝试在 Next.js SSR 环境下兼容 Shadow Dom
首先,我们通过判断 window 变量是否已定义,来尝试实现 SSR 环境对 Shadow Dom 的兼容。
为了加快速度,我已经完成了一个 CSR + Next.js + Web Components 的示例项目了,点此查看。
现在开始我们的修改吧:
- 将
page.tsx
首行的"use client";
注释掉,并且去掉useEffect
的使用,修改后代码点此查看
typescript
import { registerComponent } from './component';
registerComponent();
export default function Home() {
return <my-component />;
}
- 此时,Next.js 提示错误:Error: HTMLElement is not defined 。可定位到原因出现在
MyComponent
的实现类,在此扩展了 HTML 基础类 。
这里顺便补充一下知识点, Custom Elements 有 2 种大类,均需要扩展不同的基础类:
- 自定义内置元素(Customized built-in element):继承自标准的 HTML 元素,例如
HTMLImageElement
或HTMLParagraphElement
。它们的实现定义了标准元素的行为。 - 独立自定义元素(Autonomous custom element):继承自 HTML 元素基类
HTMLElement
。你必须从零开始实现它们的行为。
- 这个问题在别的github issue也有遇到过,可以看到 Next.js 的 Server Component 是暂时不支持 HTMLElement 的(即使它与 SSR 无关)
- 由于上述原因,接下来,我们就不能用 Next.js 来实现 Web Components 的 SSR 了,让我们再尝试下别的框架吧~
尝试在 Express.js SSR 环境下兼容 Shadow Dom
我们在这个章节,将会使用 Express.js 来实现 Web Components 的 SSR。
为了加快速度,这里同样也提供了一个 Express.js + Custom Elements + SSR 的示例,点此查看。
- 打开上述的 StackBlitz 地址,在右边预览区能看到 Custom Element能正常显示
- 查看项目根目录下的
component.js
,可以看到此时的 Custom Element 实现,是没有用到 Shadow Dom 的。
javascript
class MyCustomElement extends HTMLElement {
connectedCallback() {
console.log('window', window);
this.innerHTML = `
<style>
h2 {
color: blue;
}
p {
color: red;
}
</style>
<h2>Custom Element</h2>
<p>This is a custom element added via SSR.</p>
`;
}
}
customElements.define('my-custom-element', MyCustomElement);
- 让我们来添加上 Shadow Dom 吧,修改 Component.js 的
MyCustomElement
如下:
javascript
class MyCustomElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const style = document.createElement('style');
style.textContent = `
h2 {
color: blue;
}
p {
color: red;
}
`;
const heading = document.createElement('h2');
heading.textContent = 'Custom Element';
const paragraph = document.createElement('p');
paragraph.textContent = 'This is a custom element added via SSR.';
shadow.appendChild(style);
shadow.appendChild(heading);
shadow.appendChild(paragraph);
}
}
- 刷新查看预览页面,可以看到 Custom Elements 没有正常显示。StackBlitz 示例点此查看。
- 通过 Chrome 右键的"检查"功能,查看当前元素,可以看到
Shadow-dom
并没有挂到页面上。
- 应该如何修复呢? 为什么我们
不问问神奇的海螺呢不试试Declarative Shadow Dom
呢?
尝试通过 Declarative Shadow Dom 实现 SSR
在这个章节,让我们开始为页面添加Declarative Shadow Dom
吧,看看能不能最终实现 SSR 呢?好期待呀(笑)!
完整修改代码点此查看。
-
修改
index.js
,为其添加 Declarative Shadow Dom,实现方法为:a. 在 Custom Element 标签内添加一个
shadowrootmode
为open
的 templateb. 添加 slot 标签。
注:在 Chrome 111 版本以后 template 添加的是 shadowrootmode
属性,在 Chrome 90~110 添加的则是shadowroot
属性。
xml
<my-custom-element>
<template shadowrootmode="open">
<slot></slot>
</template>
</my-custom-element>
- 修改 component.js,添加以下几点修改,完整代码点此查看。
- 删除
constructor
的attachShadow
函数调用。 - 在
connectedCallback
中加入对this.shadowRoot
的处理。
如果浏览器支持
Declarative Shadow Dom
特性,则该 Custom Element 的 Shadow Root 会在其初始化后即挂载上 。所以,可以通过判断其是否有值,来反推浏览器是否支持Declarative Shadow Dom
,进而实现后面的 Shadow Dom 操作。
javascript
connectedCallback() {
if (this.shadowRoot) {
const shadow = this.shadowRoot;
// 省略对shadow dom的操作
} else {
// Declarative Shadow Root 不存在
console.error('No Declarative Shadow DOM');
// 这个时候需要手动 attachShadow 来获取 shadow root
// const shadow = this.attachShadow({mode: 'open'});
}
}
- 修改后,再次查看结果页面。看!Custom ELements 的内容又完整显示出来了呢~
- 我们再来手动确认一下。打开预览页面,再打开 Chrome 的元素面板,可以看到,shadow-root 被正确加载了,所以步骤 3 才能正常看到 Custom Element 的显示内容。
- 至此,全流程结束。 我们也成功在 SSR 模式下,实现了 Web Components 的渲染~
总结
我们在这篇文章中,探讨了以下几点:
- Swiper Element 使用 Web Components 实现,不支持 SSR 模式,根本原因是因为 Shadow Dom 不兼容 SSR。
- Shadow Dom 在之前的浏览器(Chrome 版本小于 90)仅支持 JS 方式去挂载,所以,仅在 CSR 场景下 , 使用了 Shadow Dom 的 Web Components 可正常渲染。
javascript
const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';
- 通过新版浏览器支持的(支持情况)的
Declarative Shadow Dom
,我们可以在服务端渲染吐出 HTML 结构时,将 Shadow Dom 以 template 和 slot 的形式声明在 Custom Element 标签下,避免 JS 方式挂载在 SSR 下的不兼容的问题。
javascript
<my-custom-element>
<template shadowrootmode="open">
<slot></slot>
</template>
</my-custom-element>
- 在使用
Declarative Shadow Dom
后,前端使用时无需手动调用attachShadow
去挂载以及获取 shadowRoot,可直接使用 Custom Element 类的 this.shadowRoot 获取 shadowRoot 节点的引用,然后实现对 Declarative Shadow Dom 的操作,与之前对传统 Shadow Dom 的操作是一致的。
javascript
const shadow = this.shadowRoot;
const style = document.createElement('style');
style.textContent = `
p {
color: red;
}
`;
shadow.appendChild(style);
- Next.js 截止 14 版本,由于
Server component
对 HTMLElement 支持的不完善,即使使用了 Declarative Shadow Dom,也暂时无法支持 Web Components 的 SSR 渲染。使用时建议转为Client Component
使用。 - Swiper Element 截止 11 版本,尚未更新对 Declarative Shadow Dom 的支持,所以也是不支持 SSR 渲染的,在使用时需要注意。