探讨 Web Components 的 SSR 实现

1,269 阅读9分钟

前言

大家好,我是馋嘴的猫,今天来跟大家一起探讨下 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 的示例,点此查看

现在请跟着我,一起来查看该示例的实现~

  1. 查看 app/swiper.tsx, 在这个页面里,我们引入了 Swiper 组件并使用。

Swiper 在 V9 版本开始提供 Web Components 组件,并命名为 Swiper Element,满足我们这次的需求。

注意:我们不能直接如同 Swiper React 般引入 Swiper 组件即可使用。

在使用其提供的swiper-containerswiper-slide 这两个 Custom Elements 前,还需要调用其提供的register方法,来注册对应的 Custom Elements,方可使用。

// swiper.tsx
import {register} from 'swiper/element/bundle';
register();

register的函数实现可以查看 Swiper 的源码。它通过window.customElements.define方法,实现了将swiper-containerswiper-slide注册为 Custom Elements。

// 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);
};
  1. 从 Next.js 13 起,如果需要配置组件为CSR渲染模式,需要手动指定其为 Client Component。做法很简单,只需要在页面开头加上"use client";即可,如下所示
// swiper.tsx
"use client";
// ... 省略其它代码
  1. 此时,我们可以再查看stackblitz 右边的预览区域,正常体验 Web Components 版本的 Swiper 组件了。
image.png
  1. 至此,全流程完结,也验证了 Swiper Element 在 CSR 模式下是能正常运行的。

Web Components 在 SSR 下的表现

我们在刚刚的步骤里,通过指定 swiper.tsxClient Component,成功将 Swiper 组件成功引入并运行起来。接下来,我们将尝试,在 SSR 场景下,Swiper Element 是否也能正常运行?

  1. 定位到app/swiper.tsx,注释代码一开头的"use client";
// swiper.tsx
// "use client";
// ... 省略其它代码
  1. 此时访问预览网址,提示以下错误:
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 使用。

因此,我们也把回调函数给注释掉。

// swiper.tsx
// 注释掉事件处理回调函数
// onSwiperprogress={onProgress}
// onSwiperslidechange={onSlideChange} 
  1. 此时再打开预览地址,可以看到, Swiper Element 并没有正常加载。
image.png
  1. 通过右键点击 Chrome 浏览器的检查按钮,查看 Elements 面板关于 Swiper 组件的部分。可以看到,此时的 DOM Tree ,并没有正常加载到 Shadow Dom。

对比 CSR 版本的 Dom Tree 再看一下, CSR 场景下有 shadow-root 节点的存在,也能正常使用 Shadow Dom。因此,我们可得出结论,Swiper Element 在 CSR 下才能正常运行。

  1. 至此,全流程完结,也验证了 Swiper Element 在 SSR 模式下是不能正常运行的。

为什么现有环境下 Web Components 无法支持 SSR

在以上的实践操作后,我们可以得出一个结论:

以 Web Components 为基础搭建的 Swiper Element,在 CSR 下能正常运行,但在 SSR 下不能运行

这是为什么呢?

不急,我们现在就来逐步分析一下:

Web Components 由三部分组成,我们可以分析是哪一部分在 SSR 下无法运行,导致整个 Web Components 的 SSR 渲染出现了问题。

  1. Custom Elements
  2. Shadow Dom
  3. HTML Templates

Web Components 示例

首先,我们用一个简单的代码片段,来演示 Web Components 是怎么初始化与注册的:

<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 注册步骤

  1. 定义一个自定义类 MyComponent,并从标准类 HTMLElement 扩展。

  2. 在构造函数里通过调用 attachShadow 函数,给指定的元素挂载一个 Shadow DOM,并且返回对 ShadowRoot 的引用。

  3. 在自定义类中的 connectedCallback 钩子(由 Custom Elements 提供),进行渲染操作。

  4. 在渲染函数里,通过克隆 template 标签的内容,然后对 ShadowRoot 实现 appendChild 操作,来完成页面布局与元素的绘制。

  5. 通过 customElements 提供的 define 方法,将自定义元素注册到自定义元素注册表(custom element registry),并且定义自定义标签名,如本示例的my-component

  6. 此时,即可在 HTML 上使用自定义组件的标签了,比如本示例的<my-component>

分析

我们再来分析一下,有哪些步骤是仅能在 Client 端执行的?

不卖关子了,是这两个:

  • customElements.define
  • attachShadow

其中,customElements.define 可以通过判断 window 是否存在,使其仅在 client 端执行,且不影响 SSR,如下所示:

注:Swiper Element 也用到了类似的注册方法。

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 的示例项目了,点此查看

现在开始我们的修改吧:

  1. page.tsx首行的 "use client";注释掉,并且去掉useEffect的使用,修改后代码点此查看
import { registerComponent } from './component';
registerComponent();

export default function Home() {
  return <my-component />;
}
  1. 此时,Next.js 提示错误:Error: HTMLElement is not defined。可定位到原因出现在 MyComponent的实现类,在此扩展了 HTML 基础类 。

这里顺便补充一下知识点, Custom Elements 有 2 种大类,均需要扩展不同的基础类:

  • 自定义内置元素(Customized built-in element):继承自标准的 HTML 元素,例如 HTMLImageElementHTMLParagraphElement。它们的实现定义了标准元素的行为。
  • 独立自定义元素(Autonomous custom element):继承自 HTML 元素基类 HTMLElement。你必须从零开始实现它们的行为。
  1. 这个问题在别的github issue也有遇到过,可以看到 Next.js 的 Server Component 是暂时不支持 HTMLElement 的(即使它与 SSR 无关)

  1. 由于上述原因,接下来,我们就不能用 Next.js 来实现 Web Components 的 SSR 了,让我们再尝试下别的框架吧~

尝试在 Express.js SSR 环境下兼容 Shadow Dom

我们在这个章节,将会使用 Express.js 来实现 Web Components 的 SSR。

为了加快速度,这里同样也提供了一个 Express.js + Custom Elements + SSR 的示例,点此查看

  1. 打开上述的 StackBlitz 地址,在右边预览区能看到 Custom Element能正常显示
  1. 查看项目根目录下的component.js,可以看到此时的 Custom Element 实现,是没有用到 Shadow Dom 的。
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);

  1. 让我们来添加上 Shadow Dom 吧,修改 Component.js 的 MyCustomElement 如下:
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);
  }
}
  1. 刷新查看预览页面,可以看到 Custom Elements 没有正常显示。StackBlitz 示例点此查看
  1. 通过 Chrome 右键的“检查”功能,查看当前元素,可以看到 Shadow-dom 并没有挂到页面上。
  1. 应该如何修复呢? 为什么我们不问问神奇的海螺呢不试试Declarative Shadow Dom呢?

尝试通过 Declarative Shadow Dom 实现 SSR

在这个章节,让我们开始为页面添加Declarative Shadow Dom吧,看看能不能最终实现 SSR 呢?好期待呀(笑)!

完整修改代码点此查看

  1. 修改 index.js,为其添加 Declarative Shadow Dom,实现方法为:

    a. 在 Custom Element 标签内添加一个shadowrootmodeopen的 template

    b. 添加 slot 标签。

注:在 Chrome 111 版本以后 template 添加的是 shadowrootmode属性,在 Chrome 90~110 添加的则是shadowroot属性。

 <my-custom-element>
  <template shadowrootmode="open">
    <slot></slot>
  </template>
</my-custom-element>
  1. 修改 component.js,添加以下几点修改,完整代码点此查看
  • 删除 constructorattachShadow 函数调用。
  • connectedCallback 中加入对this.shadowRoot的处理。

如果浏览器支持 Declarative Shadow Dom 特性,则该 Custom Element 的 Shadow Root 会在其初始化后即挂载上。所以,可以通过判断其是否有值,来反推浏览器是否支持Declarative Shadow Dom,进而实现后面的 Shadow Dom 操作。

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'});
    }
}
  1. 修改后,再次查看结果页面。看!Custom ELements 的内容又完整显示出来了呢~
  1. 我们再来手动确认一下。打开预览页面,再打开 Chrome 的元素面板,可以看到,shadow-root 被正确加载了,所以步骤 3 才能正常看到 Custom Element 的显示内容。
  1. 至此,全流程结束。 我们也成功在 SSR 模式下,实现了 Web Components 的渲染~

总结

我们在这篇文章中,探讨了以下几点:

  1. Swiper Element 使用 Web Components 实现,不支持 SSR 模式,根本原因是因为 Shadow Dom 不兼容 SSR。
  2. Shadow Dom 在之前的浏览器(Chrome 版本小于 90)仅支持 JS 方式去挂载,所以,仅在 CSR 场景下 , 使用了 Shadow Dom 的 Web Components 可正常渲染。
const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';
  1. 通过新版浏览器支持的(支持情况)的 Declarative Shadow Dom,我们可以在服务端渲染吐出 HTML 结构时,将 Shadow Dom 以 template 和 slot 的形式声明在 Custom Element 标签下,避免 JS 方式挂载在 SSR 下的不兼容的问题。
 <my-custom-element>
  <template shadowrootmode="open">
    <slot></slot>
  </template>
</my-custom-element>
  1. 在使用 Declarative Shadow Dom 后,前端使用时无需手动调用 attachShadow 去挂载以及获取 shadowRoot,可直接使用 Custom Element 类的 this.shadowRoot 获取 shadowRoot 节点的引用,然后实现对 Declarative Shadow Dom 的操作,与之前对传统 Shadow Dom 的操作是一致的。
const shadow = this.shadowRoot;
const style = document.createElement('style');
style.textContent = `
    p {
      color: red;
    }
`;
shadow.appendChild(style);
  1. Next.js 截止 14 版本,由于 Server component 对 HTMLElement 支持的不完善,即使使用了 Declarative Shadow Dom,也暂时无法支持 Web Components 的 SSR 渲染。使用时建议转为 Client Component 使用。
  2. Swiper Element 截止 11 版本,尚未更新对 Declarative Shadow Dom 的支持,所以也是不支持 SSR 渲染的,在使用时需要注意。

参考

  1. Declarative Shadow DOM  |  CSS and UI  |  Chrome for Developers
  2. Web Component - Web API 接口参考 | MDN
  3. Swiper Element
  4. Rendering: Client Components | Next.js