Web组件碰上服务端渲染,页面内容闪一下才出来,有没有办法提前弄好?

4,200字
18–27 分钟
in

搞过Next.js这类SSR框架的老铁们,肯定遇到过这糟心事:明明服务端吐出了HTML,浏览器里也看到了页面架子,但那些花里胡哨的选项卡、对话框组件,刚开始要么是一片空白,要么是丑到爆的原始标签,过个零点几秒才“啪”一下变正常。这玩意儿江湖人称FOUC(内容样式闪烁),罪魁祸首就是Web组件跟服务端渲染(SSR)天生八字不合。今儿咱就扒一扒这问题咋来的,再整一套野路子实操流程,让Web组件在SSR页面里第一秒就乖乖现身。

目录

问题源头

服务端渲染说白了就是把组件代码变成纯HTML字符串,一股脑扔给浏览器。像Next.js干完这活后,还得在浏览器里执行一套叫“水合”的流程:重新加载React代码、挂载事件、初始化状态。Web组件的注册代码正好躺在那堆需要水合才能跑的脚本里。所以在水合完成前,浏览器压根不认识<sl-tab-group>这种自定义标签,自然就摆烂不干活。等水合时脚本跑起来,组件才突然觉醒,把正确内容怼进去,于是页面就抽搐了一下。这就像外卖点了预制菜,包装盒上印着红烧肉,打开一看是白米饭配调料包,得自己加热才能吃上正菜。

提前注入脚本

解决思路贼简单:不让Web组件的注册代码等水合,而是直接塞进页面<head>里,并且是阻塞式加载——脚本没跑完,页面别想继续解析。正常没人这么干,因为阻塞脚本会拖慢首屏显示,等于放弃SSR的部分优势。但Web组件注册代码通常不大,头一哆嗦之后还能靠缓存救回来。下面拿Next.js配合Shoelace组件库实操一把。

打包组件定义

先建一个独立的JS文件,专门负责导入所有Web组件的定义和配置。比如新建src/shoelace-bundle.js,里头写上:

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)" },
});

这个文件千万不能直接在项目代码里import,否则会被webpack抓到正常的bundle里,跟着水合流程走,又回到老路上。得用另一个打包工具单独处理它,生成一个独立的脚本文件。这里选Vite,因为它配置简单,输出格式可控。

配置独立打包

安装Vite:npm i vite -D,然后在项目根目录创建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`,
      },
    },
  },
});

执行打包命令:npx vite build,会在shoelace-dir文件夹下生成一个带哈希值的文件,比如shoelace-bundle-a6f19317.js。哈希值确保每次内容变化时文件名不同,方便做缓存失效。

搬运文件到公共目录

Next.js的public文件夹里的内容会作为静态资源直接服务。写一段Node脚本把打包好的文件挪过去,同时记录下文件名,方便后续引用。新建util/process-shoelace-bundle.js

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}";`
);

package.json里加个快捷命令:

"scripts": {
  "bundle-shoelace": "vite build && node util/process-shoelace-bundle"
}

每次Web组件依赖有变动,就跑一遍npm run bundle-shoelace,会更新util/shoelace-bundle-info.js文件,里面导出一个常量shoelacePath,指向最新的资源路径。

注入阻塞脚本

Next.js的pages/_document.js可以自定义<head>内容。打开或创建这个文件,引入刚才生成的路径常量,然后渲染一个普通的<script>标签:

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>
  );
}

这个<script>没有asyncdefer属性,默认就是阻塞式加载和执行。浏览器解析到这儿会停下手里的活儿,先下载并执行完这个脚本,让<sl-tab-group>等自定义元素注册到全局,然后才继续渲染后面的HTML。这样一来,页面首次输出那些自定义标签时,浏览器已经认识它们了,能直接渲染出正确样式,彻底杜绝FOUC。

缓存加个速

阻塞脚本再怎么小,头一回加载也得走网络请求。要是用户每次访问都重新下载,那性能损失就有点肉疼了。好在可以给这个脚本配上强缓存。在Next.js的next.config.js里添加自定义响应头:

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

max-age=31536000表示一年内缓存都有效,immutable告诉浏览器文件绝对不变。因为文件名里已经带了内容哈希,内容一改哈希就变,旧的缓存不会跟新的资源打架。第一次访问时,浏览器下载脚本并缓存起来;之后再次访问,直接从本地缓存读取,连网络请求都没有,阻塞时间几乎为零。

备胎方案:内联脚本

如果连第一次加载的那次网络请求都想省掉,还可以把Web组件注册代码直接内联到HTML里。在_document.js中,用<script dangerouslySetInnerHTML>把整个shoelace-bundle.js的内容塞进去。但这么干会让HTML体积膨胀,而且破坏了脚本的独立缓存能力。通常不推荐,除非组件代码极小(几KB以内)。比较一下两种方案的差异:

方案首次加载二次访问维护成本
外链+强缓存多一次请求0请求
内联脚本0请求0请求

外链方案更符合常规工程实践,内联适合极端追求首屏速度且代码量极小的场景。

跑完这套流程,再打开页面,Web组件就像原生HTML标签一样丝滑出现,没有半点闪烁。虽然手动搞了打包、搬文件、写脚本一堆活儿,有点“造轮子”的嫌疑,但比起等官方支持Declarative Shadow DOM普及,这法子现在就能用上,而且不挑框架——换个Vue或Solid的SSR方案,思路完全一样。