在 Next(或任何 SSR 框架)中使用 Web Components

Avatar of Adam Rackis
Adam Rackis

DigitalOcean 提供适用于旅程各个阶段的云产品。立即开始使用 价值 200 美元的免费信用额度!

在我的 上一篇文章 中,我们介绍了 Shoelace,它是一个组件库,拥有完整的 UX 组件套件,这些组件美观、可访问,并且 - 可能出乎意料 - 使用 Web Components 构建。这意味着它们可以与任何 JavaScript 框架一起使用。虽然 React 的 Web Component 互操作性目前并不理想,但 有一些解决方法

但是,Web Components 的一个严重缺陷是它们目前缺乏对服务器端渲染 (SSR) 的支持。正在开发一项名为声明式 Shadow DOM (DSD) 的技术,但目前对其的支持非常有限,实际上它需要你的 Web 服务器的认可才能为 DSD 发出特殊标记。目前正在为 Next.js 进行一些开发工作,我期待着看到结果。但对于这篇文章,我们将探讨如何从任何 SSR 框架(如 Next.js)管理 Web Components,即刻

最终我们将进行大量的手动工作,并在过程中略微损害页面的启动性能。然后,我们将探讨如何最大限度地减少这些性能成本。但请不要误解:这种解决方案并非没有权衡,因此不要抱有其他期望。始终测量和分析。

问题

在我们深入研究之前,让我们花点时间真正解释一下问题。为什么 Web Components 不适合服务器端渲染?

像 Next.js 这样的应用程序框架会获取 React 代码并通过 API 运行它,本质上是“字符串化”它,这意味着它将你的组件转换为纯 HTML。因此,React 组件树将在托管 Web 应用程序的服务器上渲染,该 HTML 将与 Web 应用程序的其余 HTML 文档一起发送到用户的浏览器。除了 HTML 之外,还有一些<script>标签加载 React,以及所有 React 组件的代码。当浏览器处理这些<script>标签时,React 将重新渲染组件树,并将它们与发送下来的 SSR'd HTML 匹配。此时,所有效果将开始运行,事件处理程序将连接起来,状态实际上会…包含状态。在这个时候,Web 应用程序变得交互式。在客户端重新处理你的组件树并连接所有内容的过程称为水合

那么,这与 Web Components 有什么关系呢?好吧,当你渲染某些东西时,比如我们上次访问过的同一个 Shoelace <sl-tab-group> 组件上次

<sl-tab-group ref="{tabsRef}">
  <sl-tab slot="nav" panel="general"> General </sl-tab>
  <sl-tab slot="nav" panel="custom"> Custom </sl-tab>
  <sl-tab slot="nav" panel="advanced"> Advanced </sl-tab>
  <sl-tab slot="nav" panel="disabled" disabled> Disabled </sl-tab>

  <sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
  <sl-tab-panel name="custom">This is the custom tab panel.</sl-tab-panel>
  <sl-tab-panel name="advanced">This is the advanced tab panel.</sl-tab-panel>
  <sl-tab-panel name="disabled">This is a disabled tab panel.</sl-tab-panel>
</sl-tab-group>

…React(或者说实话,任何 JavaScript 框架)都会看到这些标签并简单地将它们传递下去。React(或 Svelte,或 Solid)不负责将这些标签转换为格式良好的标签。该代码隐藏在定义这些 Web Components 的任何代码中。在我们的案例中,该代码位于 Shoelace 库中,但代码可以位于任何地方。重要的是代码何时运行

通常,注册这些 Web Components 的代码将通过 JavaScript import 引入你的应用程序的正常代码中。这意味着此代码将最终进入你的 JavaScript 包并在水合过程中执行,这意味着在用户第一次看到 SSR'd HTML 和水合发生之间,这些标签(或任何 Web Component)都不会渲染正确的内容。然后,当水合发生时,正确的内容将显示,这可能会导致这些 Web Components 周围的内容移动并适应格式良好的内容。这被称为未格式化内容闪烁或 FOUC。理论上,你可以在所有这些 <sl-tab-xyz> 标签之间插入标记以匹配最终输出,但这在实践中几乎是不可能的,尤其是对于像 Shoelace 这样的第三方组件库而言。

移动 Web Component 注册代码

因此,问题在于,使 Web Components 做好它们应该做的事情的代码实际上不会在水合发生之前运行。在这篇文章中,我们将探讨如何更快地运行该代码;实际上是立即运行。我们将看看如何自定义打包 Web Component 代码,并在文档的 <head> 中手动添加一个脚本,以便它立即运行并阻止文档的其余部分运行,直到它运行。这通常是一件可怕的事情。服务器端渲染的重点是不要阻止页面处理,直到 JavaScript 处理完毕。但一旦完成,就意味着在文档最初渲染来自服务器的 HTML 时,Web Components 将被注册,并将立即且同步地发出正确的内容。

在我们的案例中,我们只是想在阻止脚本中运行 Web Component 注册代码。此代码并不庞大,我们将通过在某些缓存头中添加一些内容以帮助后续访问来显着减少性能损失。这不是一个完美的解决方案。用户第一次浏览你的页面时,始终会在加载该脚本文件时被阻止。后续访问将很好地缓存,但这种权衡可能不适合你 - 电子商务,有人吗?无论如何,分析、测量并为你的应用程序做出正确的决定。此外,在未来,Next.js 很可能完全支持 DSD 和 Web Components。

入门

我们将介绍的所有代码都位于 这个 GitHub 仓库 中,并 在此处使用 Vercel 部署。Web 应用程序渲染了一些 Shoelace 组件以及在水合后会更改颜色和内容的文本。你应该能够看到文本更改为“Hydrated”,Shoelace 组件已正确渲染。

自定义打包 Web Component 代码

我们的第一步是创建一个单个 JavaScript 模块,该模块导入所有 Web Component 定义。对于我使用的 Shoelace 组件,我的代码如下

import { setDefaultAnimation } from "@shoelace-style/shoelace/dist/utilities/animation-registry";

import "@shoelace-style/shoelace/dist/components/tab/tab.js";
import "@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js";
import "@shoelace-style/shoelace/dist/components/tab-group/tab-group.js";

import "@shoelace-style/shoelace/dist/components/dialog/dialog.js";

setDefaultAnimation("dialog.show", {
  keyframes: [
    { opacity: 0, transform: "translate3d(0px, -20px, 0px)" },
    { opacity: 1, transform: "translate3d(0px, 0px, 0px)" },
  ],
  options: { duration: 250, easing: "cubic-bezier(0.785, 0.135, 0.150, 0.860)" },
});
setDefaultAnimation("dialog.hide", {
  keyframes: [
    { opacity: 1, transform: "translate3d(0px, 0px, 0px)" },
    { opacity: 0, transform: "translate3d(0px, 20px, 0px)" },
  ],
  options: { duration: 250, easing: "cubic-bezier(0.785, 0.135, 0.150, 0.860)" },
});

它加载了 <sl-tab-group><sl-dialog> 组件的定义,并覆盖了对话框的一些默认动画。很简单。但这里有趣的部分是将此代码放入我们的应用程序中。我们不能简单地import此模块。如果我们这样做,它将被捆绑到我们的正常 JavaScript 包中并在水合过程中运行。这会导致我们试图避免的 FOUC。

虽然 Next.js 确实有一些 webpack 钩子来自定义打包东西,但我将使用 Vite。首先,使用 npm i vite 安装它,然后创建一个 vite.config.js 文件。我的看起来像这样

import { defineConfig } from "vite";
import path from "path";

export default defineConfig({
  build: {
    outDir: path.join(__dirname, "./shoelace-dir"),
    lib: {
      name: "shoelace",
      entry: "./src/shoelace-bundle.js",
      formats: ["umd"],
      fileName: () => "shoelace-bundle.js",
    },
    rollupOptions: {
      output: {
        entryFileNames: `[name]-[hash].js`,
      },
    },
  },
});

这将在 shoelace-dir 文件夹中构建一个包含我们的 Web Component 定义的包文件。让我们将它移动到 public 文件夹中,以便 Next.js 可以提供服务。我们还应该跟踪文件的准确名称,包括末尾的哈希值。以下是一个将文件移动并写入一个 JavaScript 模块的 Node 脚本,该模块导出一个包含包文件名(这将在稍后派上用场)的简单常量

const fs = require("fs");
const path = require("path");

const shoelaceOutputPath = path.join(process.cwd(), "shoelace-dir");
const publicShoelacePath = path.join(process.cwd(), "public", "shoelace");

const files = fs.readdirSync(shoelaceOutputPath);

const shoelaceBundleFile = files.find(name => /^shoelace-bundle/.test(name));

fs.rmSync(publicShoelacePath, { force: true, recursive: true });

fs.mkdirSync(publicShoelacePath, { recursive: true });
fs.renameSync(path.join(shoelaceOutputPath, shoelaceBundleFile), path.join(publicShoelacePath, shoelaceBundleFile));
fs.rmSync(shoelaceOutputPath, { force: true, recursive: true });

fs.writeFileSync(path.join(process.cwd(), "util", "shoelace-bundle-info.js"), `export const shoelacePath = "/shoelace/${shoelaceBundleFile}";`);

以下是一个配套的 npm 脚本

"bundle-shoelace": "vite build && node util/process-shoelace-bundle",

应该可以了。对我来说,util/shoelace-bundle-info.js 现在已经存在,看起来像这样

export const shoelacePath = "/shoelace/shoelace-bundle-a6f19317.js";

加载脚本

让我们进入 Next.js \_document.js 文件,并引入 Web Component 包文件的名称

import { shoelacePath } from "../util/shoelace-bundle-info";

然后,我们在 <head> 中手动渲染一个 <script> 标签。以下是我的整个 _document.js 文件的样子

import { Html, Head, Main, NextScript } from "next/document";
import { shoelacePath } from "../util/shoelace-bundle-info";

export default function Document() {
  return (
    <Html>
      <Head>
        <script src={shoelacePath}></script>
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

应该可以了!我们的 Shoelace 注册将在阻止脚本中加载,并在页面处理初始 HTML 时立即可用。

提高性能

我们可以保持现状,但让我们为 Shoelace 包添加缓存。我们将告诉 Next.js 使这些 Shoelace 包可缓存,方法是在我们的 Next.js 配置文件中添加以下条目

async headers() {
  return [
    {
      source: "/shoelace/shoelace-bundle-:hash.js",
      headers: [
        {
          key: "Cache-Control",
          value: "public,max-age=31536000,immutable",
        },
      ],
    },
  ];
}

现在,在后续浏览我们的网站时,我们会看到 Shoelace 包很好地缓存了!

DevTools Sources panel open and showing the loaded Shoelace bundle.

如果我们的 Shoelace 包发生更改,文件名将发生更改(通过上面的 source 属性中的 :hash 部分),浏览器会发现它没有缓存该文件,并且会简单地从网络上重新请求它。

总结

这可能看起来像是很多手工工作;而且确实如此。不幸的是,Web 组件没有为服务器端渲染提供更好的开箱即用支持。

但我们不应该忘记它们带来的好处:能够使用不受特定框架约束的优质 UX 组件是一件好事。能够使用全新的框架进行实验,比如 Solid,而无需寻找(或拼凑)某种标签、模态框、自动完成或任何组件,这也是一件好事。