Skip to content

服务端渲染 ​

Next.js 与 Nuxt 都开箱支持:服务端直出的 HTML 里已经有组件标签与文本, 不是等 JS 到了才出现的空壳(少数光 DOM 元素除外,见下面的例外)。两条链路目前不完全等价,如实记录:

React(Next.js)Vue(Nuxt)
服务端直出组件标签与文案是是
Declarative Shadow DOM(影子树进 HTML)是否,刻意关闭
hydration 无报错是是

Next.js ​

组件在服务端渲染成 Declarative Shadow DOM, 样式和结构在 JS 到达之前就已经在页面上 —— 这是「不闪烁」的关键。

tsx
// app/page.tsx —— Server Component
import { PtBadge } from '@ptengine/ui-react/ssr';

export default function Page() {
  return <PtBadge variant="ai">服务端渲染</PtBadge>;
}

需要交互的部分从 @ptengine/ui-react 导入,放在 'use client' 组件里。

next.config.mjs 里要声明 transpile —— 这几个包发的是未编译的 ESM, Next 默认不会把 node_modules 里的 ESM 过一遍编译,不声明就会在 server bundle 里报语法错:

js
export default {
  transpilePackages: ['@ptengine/ui', '@ptengine/ui-react']
};

Nuxt ​

ts
// nuxt.config.ts
export default defineNuxtConfig({
  css: ['@ptengine/ui/dist/ptengine-ui/ptengine-ui.css'],
  build: {
    transpile: ['@ptengine/ui-vue', '@ptengine/ui']
  }
});

同样的原因:这几个包是未编译的 ESM,不声明的话 Nitro 打服务端包时会原样搬进去,运行时报语法错。

Vue 侧为什么没有 Declarative Shadow DOM ​

Vue 侧当前刻意不启用 DSD(stencil.config.ts 里的 vueOutputTarget 没有配 hydrateModule)。 试过,不能用:@stencil/vue-output-target 0.14 的 SSR 实现会把插槽内容交给一个嵌套的 createSSRApp 渲成字符串,产出里带着 Vue 的 fragment 锚点注释,而客户端 vdom 里是纯文本节点 —— 每个带插槽的组件都报 hydration mismatch;同一个实现还会把嵌套的包装组件(如 <PtOption>)整个丢掉。

不配这一项时包装层退回普通的 vnode 渲染:服务端照常直出 <pt-button …>文案</pt-button>, 首屏 HTML 里仍有标签与文本(SEO 与首屏文本都在),影子树改为在浏览器 upgrade 时建立, hydrated 标记的 visibility 兜底避免闪烁。上游修好后会打开,e2e 里那条 DSD 断言就是验收标准。

例外 ​

几个元素不参与服务端渲染,或在 React 侧由手写的包装替换了生成的导出。都是为了水合不出错,使用方写法不变:

  • 不参与服务端渲染:pt-kbd、pt-kbd-group、pt-breadcrumb-item、pt-pagination、pt-pagination-item。 它们是光 DOM 元素,Stencil 服务端渲染会把直接文本换成注释占位,与框架的水合冲突,所以排除在外 (@ptengine/ui-react/ssr 导出的是它们的客户端版本)。前三个的文字由使用方写在标签里,首屏照常可见; 分页的链接由组件按属性渲染,首屏只有空标签,升级后才出现。
  • 推迟写 ARIA:PtCollapsible、PtMenu、PtPopover、PtTooltip 会往使用方的触发元素上补 aria-expanded 等属性。 React 包装给它们带上 data-pt-hydrating 标记,等 React 水合完这棵树再写,否则 Next dev 会报属性不一致。
  • 不比对子节点:PtPaginationItem 的子节点全由组件自己渲染,React 包装让 React 不比对它的子节点,避免 #418。

只在客户端渲染的降级写法 ​

某个组件在 SSR 下出了问题时,可以先把它降级成仅客户端渲染,不必阻塞整页:

tsx
const PtSelect = dynamic(() => import('@ptengine/ui-react').then(m => m.PtSelect), { ssr: false });
vue
<ClientOnly>
  <PtSelect />
</ClientOnly>

代价是那一块会有一次内容跳入。遇到需要这么做的组件请提 issue —— 这属于我们的缺陷。

我们怎么保证它一直能用 ​

每次发版前跑 SSR 冒烟测试:起真实的 Next.js 与 Nuxt 应用,断言

  • 服务端返回的 HTML 里确实有组件标签,不是空壳
  • 首屏 HTML 里包含 shadowrootmode="open"(目前只对 Next.js 断言,原因见上)
  • hydration 期间控制台没有任何报错
  • 接管后交互立刻可用

另外还在 Next dev / Nuxt dev 下各起一次(pnpm test:e2e:dev,test:e2e 的最后一段):dev 模式会报生产构建不报的 属性级水合告警,整页水合完控制台不能有任何 hydration 告警。

Apache-2.0 协议开源