Skip to content

Day10 SSR 服务端渲染 + 微前端(Next/Nuxt、Qiankun 沙箱、实战落地) ​

📖 基础知识讲解【基础原理 + 高级深挖知识点】 ​

基础原理 ​

1. 五种渲染模式全景 ​

同一个页面有五种产出 HTML 的方式,面试被问「你们为什么选 SSR」时,真正要答的是这五种模式的取舍,而不是「SSR 首屏快」。

text
CSR  客户端渲染
浏览器 ──请求──> CDN ──> 返回几乎空的 <div id="app"></div> + bundle.js
                             │
                             └─> 下载 JS → 执行 → 请求接口 → 渲染 DOM
   白屏时间 = 下载 JS + 执行 JS + 接口耗时     SEO:爬虫拿到空壳

SSR  服务端渲染(每次请求实时生成)
浏览器 ──请求──> Node 服务 ──> 查接口/DB → 渲染组件 → 完整 HTML 字符串
                             │
                             └─> 浏览器直接绘制(有内容了)→ 下载 JS → hydrate
   白屏时间 = 服务端 RT + HTML 下载            SEO:爬虫拿到完整内容

SSG  静态站点生成(构建期生成)
build 时 ──> 跑一遍所有路由 → 生成 N 个 .html → 丢 CDN
   访问 = CDN 直出,最快                       缺点:数据更新要重新构建

ISR  增量静态再生(SSG + 过期重建)
首次请求返回缓存的静态页 → 后台按 revalidate 周期重新生成 → 下次请求拿新的

Streaming SSR  流式渲染
服务端边渲染边 flush:先吐出骨架 + 已就绪的部分,慢组件后到
   指标优势:TTFB 极低,FCP 早,长接口不再拖垮整页
模式HTML 生成时机TTFB首屏SEO服务器压力典型场景
CSR不生成极低慢差无(纯静态)后台管理、工具型应用
SSR每次请求取决接口快好高商品详情、个性化首页
SSG构建期极低最快好无文档、博客、营销落地页
ISR构建期 + 过期重建极低最快好低列表页、内容站
Streaming SSR每次请求(分块)极低快好高有慢接口的 SSR 页面

选型口诀: 内容不随用户变、更新不频繁 → SSG/ISR;需要 SEO 且内容因人/因时而变 → SSR;纯内部系统、不要 SEO → CSR 足够,强上 SSR 只是给自己加一台需要运维的 Node 服务。

2. SSR 完整请求链路 ​

text
① 浏览器 GET /product/123
②            ↓
   Node 服务(Next/Nuxt/自建 Koa)
   ├─ 路由匹配 → 确定要渲染的页面组件
   ├─ 执行数据预取(getServerSideProps / asyncData / useFetch)
   ├─ 用拿到的数据渲染组件树
   │     React: renderToString / renderToPipeableStream
   │     Vue:   renderToString(@vue/server-renderer)
   ├─ 把数据序列化进 HTML:window.__INITIAL_STATE__ = {...}
   └─ 拼模板:<html><head>注入 title/meta/css</head><body>HTML + script</body>
③            ↓
   浏览器收到完整 HTML → 立刻绘制(此时页面可见但点不动)
④            ↓
   下载并执行客户端 bundle
   ├─ 读取 window.__INITIAL_STATE__ 还原 store(避免客户端二次请求)
   ├─ hydrate:复用已有 DOM,只绑定事件、建立响应式关系
   └─ 页面可交互(TTI)

三个必须分清的时间点:

指标含义SSR 的影响
TTFB收到第一个字节变慢(服务端要查数据、渲染)
FCP首次内容绘制变快(HTML自带内容)
TTI可交互不一定变快(仍要等 JS 下载 + 水合)

SSR 最容易被忽略的真相: 它优化的是「看到」,不是「能点」。如果 JS 体积不收敛,用户会遇到更难受的体验 —— 页面看着好了却点不动(俗称 Uncanny Valley)。所以 SSR 必须配合代码分割、关键 JS 瘦身一起做。

3. 同构(Isomorphic)与水合(Hydration) ​

同构:一套组件代码跑两端。服务端负责产出 HTML 字符串,客户端负责接管这堆 DOM。

text
同构应用的双入口
src/
├── App.vue / App.tsx        ← 两端共用的组件代码
├── entry-server.ts          ← 服务端入口:导出 render(url) → html 字符串
└── entry-client.ts         ← 客户端入口:hydrate / createSSRApp + mount

水合做的事(以 React / Vue 为例):

text
普通渲染 render()                 水合 hydrate()
┌──────────────────┐             ┌──────────────────┐
│ 创建全新 DOM      │             │ 复用服务端的 DOM  │
│ 插入容器          │             │ 按虚拟树逐个比对   │
│ 绑定事件          │             │ 只绑定事件/建响应式 │
└──────────────────┘             └──────────────────┘
   容器内容被覆盖                    DOM 不重建(理想情况)
js
// React 18:客户端入口必须用 hydrateRoot,不能用 createRoot
import { hydrateRoot } from "react-dom/client";
hydrateRoot(document.getElementById("root"), <App />);
// 用 createRoot 会把服务端 HTML 全部丢弃重新渲染 → SSR 白做了

// Vue 3:createSSRApp 而不是 createApp
import { createSSRApp } from "vue";
createSSRApp(App).mount("#app"); // createSSRApp 的 mount 自动走水合
常见水合问题现象
水合未执行页面能看不能点,控制台无报错(JS 没加载/报错中断)
水合不匹配控制台 Hydration failed / text content did not match,React 18 会整棵子树回退到客户端渲染,页面闪一下
水合过慢FCP 很快但 TTI 很久,用户点击无响应

一句话说清水合: 服务端给的是「照片」,水合是「把照片换成能操作的实物」。两边必须对着同一份数据渲染出完全相同的结构,否则框架无法安全复用 DOM。

4. SSR 下不存在的浏览器 API ​

服务端只有 Node 环境,没有 window、document、localStorage、navigator。组件的渲染阶段代码会在服务端执行,所以这类访问必须隔离。

js
// ❌ 模块顶层/渲染阶段直接访问 → 服务端直接 ReferenceError 崩溃
const width = window.innerWidth;

// ✅ 方案一:环境判断
const isClient = typeof window !== "undefined";
const width = isClient ? window.innerWidth : 0;

// ✅ 方案二:放进只在客户端执行的生命周期
// Vue: onMounted / React: useEffect —— 服务端渲染不会执行它们
onMounted(() => {
  console.log(window.innerWidth);
});

// ✅ 方案三:组件级禁用 SSR
// Next.js
const Chart = dynamic(() => import("./Chart"), { ssr: false });
// Nuxt
// <ClientOnly><Chart /></ClientOnly>
两端差异项服务端客户端
全局对象globalwindow
DOM / BOM❌ 不存在✅
存储无(只有 cookie 字符串)localStorage / sessionStorage
定时器有,但不会被清理会泄漏随页面卸载销毁
请求的「当前用户」来自 req 的 cookie/header来自 document.cookie
相对路径请求❌ 必须写完整域名✅ 可用 /api/x

最隐蔽的一条: 服务端拿用户身份只能从 req 上取,不能用全局单例存「当前用户」—— 那会让 A 用户看到 B 用户的数据,是 SSR 最严重的事故类型,后面「跨请求状态污染」一节会专门展开。

5. Next.js:React 的 SSR 框架 ​

Next 13 之后有两套路由体系,可以在同一个项目里共存(同路径时 App Router 优先级更高),面试必须能说清差异。

一句话核心差异:

  • Pages Router:文件驱动路由,组件默认是客户端组件,SSR / SSG 是页面级预渲染 + 全量注水。
  • App Router:文件夹驱动路由,底层基于 RSC(React Server Components),组件默认跑在服务端,支持嵌套布局、流式渲染、组件级取数。

这句话里有 6 个术语,先用大白话翻译一遍,后面的内容才看得懂:

术语大白话解释
文件驱动路由你新建一个文件,就等于多了一个页面地址。pages/about.tsx 存盘的瞬间,/about 就能访问了,不用写任何路由配置。
文件夹驱动路由你新建一个文件夹才算多了一个地址层级,而且文件夹里必须放一个叫 page.tsx 的文件,这个地址才真的能打开。
客户端组件组件代码最终会被打包、下载到浏览器里执行。所以它能用 useState、能绑 onClick,但也意味着这份代码用户能看到、体积要算进首屏。
服务端组件(RSC)组件代码只在服务器上跑一次,跑完把结果(HTML / UI 描述)发给浏览器,代码本身不下载给浏览器。所以它能直连数据库、读密钥,但不能有 onClick 和 useState。
页面级 vs 组件级取数页面级:只有「页面文件」能导出 getServerSideProps / getStaticProps 这类取数函数,子组件导出了也不会被调用,所以数据只能在页面里捞齐再一层层 props 传下去。组件级:App Router 里不带 "use client" 的服务端组件可以自己写 async + await fetch,谁要数据谁取,不用往下传。(容易混淆,下面专门拆一节)
全量注水(hydrate)服务端先给一张「不会动的 HTML 图片」,浏览器拿到后再下载 JS,把事件、状态重新装回去,页面才能点。Pages Router 是整页一次性装,App Router 只装标了 "use client" 的那几块。

📌 单独澄清:「页面级取数」不等于「子组件不能请求数据」

这是最容易混淆的一点。两者说的根本不是同一件事:

维度页面级取数(Pages Router)组件级取数(App Router 的服务端组件)
限制的是什么限制的是「谁能导出 getServerSideProps」没有这个限制,任何服务端组件都能自己 await
代码跑在哪这些函数只在 Node 服务端执行,不会打包进浏览器组件函数体只在服务端执行一次,代码不下发给浏览器
子组件能请求吗能,但只能在 useEffect / SWR / React Query 里发,那是浏览器发起的 CSR 请求,不参与首屏 HTML能,而且是服务端发起,结果直接进首屏 HTML
瀑布流风险子组件自己在 useEffect 里请求 = 首屏 HTML 里没有这块数据,要等 JS 加载完再请求再渲染多个服务端组件的 await 可以并行,都在首屏 HTML 里
tsx
// ===== Pages Router:取数函数只有「页面文件」能导出 =====

// pages/product/[id].tsx  ✅ 页面文件,导出才有效
export async function getServerSideProps({ params }) {
  const data = await db.product.findById(params.id); // 只在服务端跑
  return { props: { data } };
}
export default function ProductPage({ data }) {
  // 数据只能这样一层层往下传
  return <ProductCard data={data} />;
}

// components/ProductCard.tsx  ❌ 子组件里导出同名函数,Next 根本不会调用
export async function getServerSideProps() {
  /* 永远不执行 */
}
export default function ProductCard({ data }) {
  // ✅ 但子组件想自己要数据不是不行,只是只能走浏览器端
  const { data: extra } = useSWR("/api/extra", fetcher); // CSR 请求,不在首屏 HTML 里
  return <div>{data.name}</div>;
}
tsx
// ===== App Router:服务端组件可以自己取数 =====

// app/components/Card.tsx
// 文件顶部没有 "use client" → 这就是服务端组件 ✅
export default async function Card({ id }: { id: string }) {
  // fetch 在服务端执行,结果直接进首屏 HTML
  const res = await fetch(`https://xxx/api/detail/${id}`, {
    next: { revalidate: 60 }, // 还能顺带声明缓存策略
  });
  const data = await res.json();
  return <div>{data.name}</div>;
}

// app/components/ClientCard.tsx
"use client";
export function ClientCard() {
  // ❌ 客户端组件不能写成 async 组件
  // 要数据只能放 useEffect / useSWR / React Query,由浏览器发起请求
  const { data } = useSWR("/api/detail", fetcher);
  return <div onClick={() => alert(1)}>{data?.name}</div>;
}

一句话记牢:

  • Pages Router 的「页面级」限制的是服务端取数函数的书写位置,不是限制子组件联网。
  • App Router 的「组件级」能力来自 RSC:组件本身就跑在服务端,所以它可以直接 await;一旦加了 "use client",这个能力立刻失效,退回到 useEffect 的老模式。
  • 判断依据永远是同一个问题:这段代码最终跑在 Node 还是浏览器?

一个生活类比:

  • Pages Router 像点外卖:厨房(服务端)必须等所有菜都做完,才一起装袋送出;你收到的是一整份套餐,还得自己把所有餐具(JS)全拆开摆好才能吃。
  • App Router 像上菜制的餐厅:凉菜(骨架)先端上来,热菜(慢接口)做好了再陆续端;而且大部分菜是厨房直接做好的(Server Component),只有需要你自己蘸料的那几道("use client")才给你一副餐具。

① 路由规则与目录约定

Pages Router 是「一个文件 = 一个路由」,App Router 是「一个文件夹 = 一个路由段,page.tsx 才是可访问入口」。

通俗说就是:Pages Router 看文件名,App Router 看文件夹名。

text
想做出 /blog/hello 这个地址:

Pages Router → 建一个文件就行
  pages/blog/[slug].tsx        ← 文件名决定地址

App Router → 必须建文件夹,里面再放约定名的文件
  app/blog/[slug]/page.tsx     ← 文件夹名决定地址,page.tsx 只是"入口标记"
  app/blog/[slug]/layout.tsx   ← 顺手还能在同一层放布局、loading、error
  app/blog/[slug]/loading.tsx

为什么 App Router 要绕这一圈? 因为「一个地址」需要的东西不止页面本身,还有这一层专属的布局、加载态、错误兜底。给每个路由段开一个文件夹,就能把这些文件就近放在一起;而 Pages Router 一个路由只有一个文件,这些能力就没地方挂,只能全塞到全局的 _app.tsx 里。

text
Pages Router(pages/,文件驱动)        App Router(app/,文件夹驱动)
├─ index.tsx            → /            ├─ page.tsx                  → /
├─ about.tsx            → /about       ├─ about/page.tsx            → /about
├─ blog/[slug].tsx      → /blog/:slug  ├─ blog/[slug]/page.tsx      → /blog/:slug
├─ _app.tsx   全局根组件(唯一布局层) ├─ layout.tsx   嵌套布局,层层包裹且切路由不重渲染
├─ _document.tsx  定制 html/head/body  ├─ loading.tsx  该路由段的 Suspense fallback
├─ _error.tsx     错误页               ├─ error.tsx    该路由段的错误边界
├─ api/*.ts       API 路由             ├─ not-found.tsx  404
└─ next/head      写页面元信息         ├─ route.ts     Route Handler(替代 pages/api)
                                       ├─ metadata / generateMetadata  元信息
                                       └─ @slot / (.)intercept  并行路由、拦截路由(独有)

「Pages Router 没有原生嵌套布局」到底是什么意思?

先说什么叫嵌套布局。假设有个后台系统,页面长这样:

text
页面结构(从外到内)
  顶部导航      → 所有页面都有
  左侧菜单      → 只有 /admin 下面的页面才有
  右侧内容区    → 切换 /admin/user、/admin/order 时只有这块变

你的期望是:点「用户列表 → 订单列表」时,顶部导航和左侧菜单原地不动,只有右边内容换掉。左侧菜单如果里面有「展开的子菜单」「搜索框里刚输入的关键字」「滚动到一半的位置」,这些状态都应该保留。

用目录结构直接对比,最能看清差别在哪:

text
App Router —— 每一层文件夹都能放自己的 layout.js ✅

app/
├── layout.js              # 全局根布局:所有路由都会包一层
├── admin/
│   ├── layout.js          # admin 专属布局:/admin、/admin/dashboard 都套
│   ├── page.js            # → /admin
│   └── dashboard/
│       └── page.js        # → /admin/dashboard
└── marketing/
    ├── layout.js          # 营销页专属布局:/marketing、/marketing/activity 都套
    ├── page.js            # → /marketing
    └── activity/
        └── page.js        # → /marketing/activity

嵌套关系完全由文件夹层级自动推导,不用写一行配置:

text
访问 /admin/dashboard  渲染成:
  app/layout.js
    └─ app/admin/layout.js
         └─ app/admin/dashboard/page.js

访问 /marketing/activity  渲染成:
  app/layout.js
    └─ app/marketing/layout.js
         └─ app/marketing/activity/page.js

从 /admin 跳到 /admin/dashboard 时:
  app/layout.js         实例复用,不卸载
  app/admin/layout.js   实例复用,不卸载   ← 左侧菜单状态因此得以保留
  只有最内层的 page.js 被替换

从 /admin 跳到 /marketing 时:
  app/layout.js         实例复用,不卸载
  app/admin/layout.js   卸载
  app/marketing/layout.js  挂载           ← 只有"分叉点以下"的部分才重建

同样的需求放到 Pages Router,会发现根本没有地方放那个中间层布局:

text
Pages Router —— 整个 pages/ 只有一个全局入口,中间无处可挂 ❌

pages/
├── _app.tsx               # 唯一的布局层,全站共享,无法按目录分层
├── admin/
│   ├── index.tsx          # → /admin          ← 这里放 layout.tsx 不会被识别
│   └── dashboard.tsx      # → /admin/dashboard
└── marketing/
    ├── index.tsx          # → /marketing
    └── activity.tsx       # → /marketing/activity

想给 /admin/* 单独加左侧菜单,但 pages/admin/ 下
没有任何「约定文件名」能承载这一层布局
→ 要么每个页面自己 return 里包一层(状态丢失)
→ 要么在 _app.tsx 里写 router.pathname.startsWith('/admin') 做分支判断
  • App Router:天生支持。把 layout.tsx 放进 app/admin/ 就行,Next 会自动把它套在所有 /admin/* 页面外面,并且切换子路由时这个 layout 组件实例不会被销毁重建,所以菜单状态天然保住。
jsx
// app/layout.tsx —— 最外层,所有页面共享(顶部导航)
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <TopNav />
        {children}
      </body>
    </html>
  );
}

// app/admin/layout.tsx —— 只有 /admin/* 共享(左侧菜单)
export default function AdminLayout({ children }) {
  return (
    <div className="flex">
      <SideMenu /> {/* 切换 /admin/user ↔ /admin/order 时,它不会重新挂载 */}
      <main>{children}</main>
    </div>
  );
}

// 最终自动变成:RootLayout > AdminLayout > page,层层嵌套,不用手写一个字
  • Pages Router:只有一个全局入口 _app.tsx。所有页面,不管在哪个目录,都只被它包一层,中间没有任何「分层」的地方。想给 /admin/* 单独加个左侧菜单,只有两种笨办法,都有坑:
jsx
// ❌ 办法一:每个页面自己在 return 里包一层 AdminLayout
export default function UserPage() {
  return (
    <AdminLayout>
      <UserTable />
    </AdminLayout>
  );
}
// 坑:路由一变,整棵页面树(含 AdminLayout)被卸载重建
//     → 左侧菜单的展开状态、搜索词、滚动位置全部丢失,还会重新发一次菜单接口
jsx
// ✅ 办法二(社区通行的"造轮子"方案):getLayout 静态属性
// pages/admin/user.tsx
function UserPage() {
  return <UserTable />;
}
// 给页面组件挂一个静态方法,声明"我要用哪个壳"
UserPage.getLayout = (page) => (
  <RootLayout>
    <AdminLayout>{page}</AdminLayout>
  </RootLayout>
);
export default UserPage;

// pages/_app.tsx:在全局入口里把这个约定读出来
export default function App({ Component, pageProps }) {
  // 页面没声明就用默认的直出
  const getLayout = Component.getLayout ?? ((page) => page);
  return getLayout(<Component {...pageProps} />);
}

办法二为什么能保住状态? 因为 _app 组件本身不会随路由卸载,它每次只是把新的 <Component /> 当作 page 参数塞进同一棵 layout 树里;React 做 diff 时发现 AdminLayout 的位置和类型都没变,就复用了原来的实例,只替换了里面的 children。

但它依然是「手工活」: 布局嵌套关系要在每个页面文件里手写一遍(漏写就没布局)、Component.getLayout 在 TS 里还得自己扩类型、而且布局没法单独取数(数据还是只能在页面的 getServerSideProps 里捞,再想办法透传给布局)。

一句话总结: App Router 把「嵌套布局」从开发者的约定变成了框架的能力 —— 你只要把 layout.tsx 放进对应文件夹,嵌套关系、实例复用、独立取数框架全给你办了。这正是 App Router 诞生的核心动因之一。

② Pages Router 的渲染模型:页面级取数 + 全量注水

所有组件默认都是客户端组件(SSR 阶段也会在服务端跑一遍,所以仍要躲开 window)。数据只能在页面文件里通过三个专属导出函数获取:

text
请求进来
  → 服务端执行 getServerSideProps / 读构建期 getStaticProps 结果
  → 拿到 props,把页面组件渲染成完整 HTML
  → props 原样序列化进 __NEXT_DATA__ 脚本标签
  → 浏览器下载该页面的全部 JS chunk
  → React 用同一份 props 全量 hydrate,页面可交互
jsx
// Pages Router:三种数据获取
// ① SSR —— 每次请求都在服务端执行
export async function getServerSideProps(ctx) {
  const { id } = ctx.params;
  const data = await fetch(`https://api.x.com/p/${id}`).then((r) => r.json());
  if (!data) return { notFound: true }; // 返回 404
  return { props: { data } }; // props 会被序列化进 HTML,必须是 JSON 安全的
}

// ② SSG —— 构建期执行一次
export async function getStaticProps() {
  return { props: { list: await getList() }, revalidate: 60 }; // 加 revalidate = ISR
}

// ③ 动态路由的 SSG 需要声明要预渲染哪些路径
export async function getStaticPaths() {
  return {
    paths: [{ params: { id: "1" } }, { params: { id: "2" } }],
    fallback: "blocking", // false=未列出的走404;true=先给骨架;blocking=现场 SSR
  };
}

Pages Router 的三个结构性痛点:

  1. 服务端取数与组件分离,且只有页面级粒度 —— getServerSideProps 只能写在页面文件里,深层子组件想参与首屏取数,只能从页面一路 props 往下传(子组件自己在 useEffect 里请求虽然可行,但那已经是 CSR,不在首屏 HTML 里)。
  2. 没有流式渲染 —— 必须等 getServerSideProps 里所有请求都回来,才能吐出第一个字节,TTFB 被最慢的那个接口拖死。
  3. JS 体积大 —— 页面上所有组件(包括纯展示的静态部分)的代码都要打进 bundle 下发并注水。

③ App Router 的渲染模型:RSC + 流式分片

组件默认是 Server Component,需要交互(useState、onClick、浏览器 API)才手动加 "use client" 变成客户端组件。

Server Component 的两个关键性质: 代码只在服务端执行,不会被打进前端 bundle(所以可以直连数据库、读私钥而不泄露);同时不能访问浏览器 API、不能有事件处理器、不能用 hooks。

text
请求进来
  → 服务端自上而下渲染 Server Component 树,组件内直接 await 取数
  → 遇到 <Suspense>/loading.tsx:先把 fallback 作为第一个分片发出(TTFB 极快)
  → 慢组件算完后,以独立分片追加写入同一个响应流,由内联脚本替换占位
  → 遇到 "use client" 边界:只把这部分组件的 JS 打包下发
  → 浏览器收到 RSC Payload(序列化的 UI 指令树)+ HTML
  → React 只对 "use client" 边界做局部注水,Server Component 永不注水
jsx
// App Router 默认 Server Component:组件内直接 async / await,不需要数据函数
export default async function Blog({ params }) {
  const post = await db.post.findUnique({ where: { slug: params.slug } }); // 可直连 DB
  return (
    <article>
      <h1>{post.title}</h1>
      {/* 慢模块单独包 Suspense,不阻塞主内容先出 */}
      <Suspense fallback={<SkeletonComments />}>
        <Comments postId={post.id} />
      </Suspense>
      <LikeBtn initial={post.likes} />
    </article>
  );
}
jsx
"use client"; // 必须在文件第一行;它标记的是「客户端边界」,其所有子组件都变成客户端组件
import { useState } from "react";

export function LikeBtn({ initial }) {
  const [count, setCount] = useState(initial);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
jsx
// App Router:fetch 的缓存语义决定渲染模式(Next 自己重写了 fetch)
await fetch(url); // Next 14 默认缓存 → 等价 SSG
await fetch(url, { cache: "no-store" }); // 不缓存 → 等价 SSR
await fetch(url, { next: { revalidate: 60 } }); // 60s 过期重建 → 等价 ISR
await fetch(url, { next: { tags: ["post"] } }); // 配合 revalidateTag 按需失效

④ 对比总表(背这张就够)

维度Pages RouterApp Router
目录pages/app/
路由驱动文件文件夹 + 约定文件(page / layout)
组件默认环境客户端(SSR 时也在服务端跑一遍)服务端(RSC),要交互须加 "use client"
数据获取页面级导出函数 getServerSideProps 等组件级 await,可嵌套、可并行
布局仅全局 _app,无原生嵌套原生嵌套 layout,每层路由段都能有,切路由不重渲染
流式渲染❌ 不支持,等全部数据才出 HTML✅ 内置 Suspense 流式分片
缓存粒度ISR revalidate,页面粒度fetch 自带 cache / revalidate / tags,请求粒度
元信息next/head,组件内写导出 metadata / generateMetadata
接口pages/api/*route.ts(Route Handler)+ Server Actions
打到客户端的 JS组件代码全部打包Server Component 代码不进 bundle,体积更小
loading / error手动实现loading.tsx / error.tsx 约定式
学习成本低,传统 SSR 思路高,必须想清楚 Server / Client 边界
迁移成本—心智模型变化大,第三方 UI 库常需 "use client" 包一层

面试高频点: getServerSideProps 只在服务端执行,所以里面可以直连数据库、读私钥,这些代码不会进客户端 bundle;但它返回的 props 会原样写进 HTML,绝对不能把 token、内部字段塞进去 —— 这是真实发生过的信息泄漏事故。

⑤ 面试必问:Pages 的 SSR 和 App Router 的「服务端渲染」是一回事吗?

不是。两者都在服务端出 HTML,但产物形态和注水方式完全不同:

对比项Pages Router SSRApp Router(RSC 流式)
响应形态一次性返回完整 HTML分片流式返回,先骨架后补内容
数据时机全部数据就位才能发第一个字节快的先发,慢的后追,TTFB 不被最慢接口绑定
客户端产物HTML + __NEXT_DATA__HTML + RSC Payload(序列化 UI 指令树)
注水范围全量注水整个页面只注水 "use client" 边界,Server Component 不注水

常见误区:App Router ≠ 全都是 SSR。 它用一套模型同时覆盖 SSG / ISR / SSR / CSR,具体走哪条取决于:fetch 的 cache / revalidate 配置、是否用了 cookies() / headers() / searchParams 等动态 API(用了就强制转为动态渲染)、以及是否显式声明 export const dynamic = "force-dynamic"。

⑥ 选型建议

  • 新项目 → App Router(官方主推,流式 + RSC 的收益在内容型页面上最明显)。
  • 老项目 → Pages Router 官方承诺不会下线,可以两个目录共存、按路由渐进迁移,不必一次重写。
  • 交互极重的后台 / SPA → Pages Router 上手更快,Server Component 的边界约束反而是负担。
  • SEO 优先的内容站、营销页 → App Router 收益最大(首屏快、JS 小、metadata API 友好)。

背诵版(一口气说完): Pages Router 是文件式路由,组件默认在客户端,靠 getServerSideProps / getStaticProps 在页面级预取数据,服务端出完整 HTML 后全量注水,没有嵌套布局也不支持流式;App Router 基于 React Server Components,文件夹式路由,组件默认在服务端、内部直接 await 取数,原生支持嵌套 layout 和 Suspense 流式分片,Server Component 不进客户端 bundle 所以 JS 更小,缓存粒度细到单次 fetch。

6. Nuxt:Vue 的 SSR 框架 ​

text
Nuxt 3 目录约定
├── pages/          文件即路由,自动生成 vue-router 配置
├── layouts/        布局,default.vue 自动套用
├── components/     自动导入,无需 import
├── composables/    自动导入的组合式函数
├── server/api/     Nitro 服务端接口(server/api/user.ts→ /api/user)
├── middleware/     路由中间件(可声明 server/client 侧)
├── plugins/        插件,文件名 .client.ts / .server.ts 控制执行端
└── nuxt.config.ts  ssr: true | false,routeRules 配置混合渲染
ts
// 数据获取:useFetch / useAsyncData —— 关键在「服务端取完把数据带给客户端」
const { data, pending, error, refresh } = await useFetch("/api/product/1", {
  key: "product-1", // 用于两端数据复用的唯一 key,缺省按 url+options 生成
  server: true, // 是否在服务端执行(默认 true)
  lazy: false, // true 则不阻塞导航
  transform: (res) => res.list, // 裁剪数据,减少注入 HTML 的体积
  getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key], // 复用已有 payload
});

// $fetch 是裸请求,不参与 payload 传递:服务端用了客户端还会再请求一次
// 所以「组件初始化数据」用 useFetch,「点击触发的请求」用 $fetch
ts
// nuxt.config.ts:按路由混合渲染,这是 Nuxt 3 最实用的能力
export default defineNuxtConfig({
  routeRules: {
    "/": { prerender: true }, // 构建期静态化
    "/blog/**": { isr: 3600 }, // ISR,1 小时重建
    "/product/**": { ssr: true }, // 实时 SSR
    "/admin/**": { ssr: false }, // 纯客户端渲染(后台不需要 SEO)
    "/api/**": { cors: true, headers: { "cache-control": "s-maxage=60" } },
  },
});
对照项Next.jsNuxt 3
框架ReactVue 3
数据获取getServerSideProps / RSC awaituseFetch / useAsyncData
服务端运行时Node / EdgeNitro(可部署到 Node/Edge/Serverless/静态)
状态注入__NEXT_DATA__window.__NUXT__(payload)
混合渲染按页面 API 决定routeRules 集中配置
SEO 头部next/head / metadata 导出useHead / useSeoMeta

两个框架的共性结论: 它们本质都在解决同一组问题 —— ① 两端共用组件;② 服务端取到的数据怎么安全传给客户端;③ 怎么按路由选择渲染策略;④ 怎么把 head/meta 和 CSS 在服务端正确注入。自己写 SSR 时这四件事全都得手动实现,这就是框架的价值。

7. 微前端:它到底解决什么问题 ​

微前端不是「技术很酷」,而是组织架构问题的技术解法。只有出现下面这些症状时才值得上:

症状微前端如何缓解
单仓库几十万行,装依赖 10 分钟、启动 3 分钟子应用独立仓库,只启自己那块
十几个小组共用一个发布流水线,改一行要全量回归子应用独立构建、独立发布,互不阻塞
老系统是 Vue2 / jQuery,新需求想用 React技术栈无关,新旧共存,可渐进替换
一个模块的报错把整站白屏子应用错误边界隔离,坏一个不死全站
第三方/外部团队提供的页面要嵌进来比 iframe 更好的集成(共享路由与登录态)

反过来说,什么时候不要用: 一个 5 人团队、一个仓库、一条流水线就能搞定的项目上微前端,只会换来「调试困难 + 样式冲突 + 公共依赖版本地狱」。面试里能说出「我们评估过不上微前端的场景」反而是加分项。

text
微前端的三种集成方式
┌────────────────────────────────────────────────────────────────┐
│ iframe                                                          │
│   天然隔离(JS/CSS/路由全隔离),零改造                          │
│   ❌ 弹窗无法覆盖全屏、路由无法同步、刷新丢状态、通信麻烦、白屏慢 │
├────────────────────────────────────────────────────────────────┤
│ 运行时集成(single-spa / qiankun / micro-app / wujie)           │
│   主应用运行时拉取子应用资源并挂载,共享一个 document            │
│   ✅ 体验接近单应用  ❌ 需要自己做 JS/CSS 隔离                   │
├────────────────────────────────────────────────────────────────┤
│ 构建时集成(Module Federation)                                  │
│   远程模块像 npm 包一样 import,共享依赖只加载一份               │
│   ✅ 依赖共享最优、类型友好  ❌ 版本耦合强,隔离能力弱            │
└────────────────────────────────────────────────────────────────┘

8. qiankun 的完整工作流程 ​

qiankun 基于 single-spa 做了「开箱即用」的封装:single-spa 负责路由劫持 + 生命周期调度,qiankun 补上了HTML Entry + JS 沙箱 + 样式隔离 + 资源预加载。

text
主应用注册
registerMicroApps([{ name, entry, container, activeRule }])
start()
   │
   ▼
① 劫持路由(history.pushState / replaceState 被重写 + 监听 popstate/hashchange)
   │
   ▼
② URL 变化 → 按 activeRule 匹配出「该激活哪个子应用」
   │
   ▼
③ 加载子应用:import-html-entry 请求 entry 的 HTML
   ├─ 用正则抽出 <script> 的 src 和内联代码
   ├─ 抽出 <link rel=stylesheet> 与 <style>
   ├─ 把 HTML 的其余部分当作模板插入 container
   └─ fetch 所有 JS 文本(所以 **子应用必须开 CORS**)
   │
   ▼
④ 创建沙箱(Proxy / 快照),把 JS 包进 (window) => eval(code) 执行
   ├─ 执行时 window 被换成代理对象
   └─ 从代理对象上取出子应用 UMD 导出的三个生命周期
   │
   ▼
⑤ 调度生命周期:bootstrap → mount(props) → (切走时)unmount
   │
   ▼
⑥ 卸载:清空 container DOM、移除子应用 style、沙箱失活并回滚全局变量
js
// 主应用:注册 + 启动
import { registerMicroApps, start, setDefaultMountApp } from "qiankun";

registerMicroApps(
  [
    {
      name: "sub-vue", // 唯一,必须与子应用 webpack library 名一致
      entry: "//localhost:7101", // 子应用地址(HTML entry)
      container: "#subapp-container", // 挂载点,mount 时必须已存在于 DOM
      activeRule: "/sub-vue", // 路由前缀匹配即激活
      props: { token: getToken(), eventBus }, // 下发给子应用的 props
    },
    { name: "sub-react", entry: "//localhost:7102", container: "#subapp-container", activeRule: "/sub-react" },
  ],
  {
    beforeLoad: (app) => console.log("before load", app.name),
    beforeMount: (app) => console.log("before mount", app.name),
    afterUnmount: (app) => console.log("after unmount", app.name),
  }
);

start({
  prefetch: "all", // 空闲时预加载所有子应用资源
  sandbox: { strictStyleIsolation: false, experimentalStyleIsolation: true },
  singular: true, // 同一时刻只渲染一个子应用(默认 true)
});
js
// 子应用:必须导出三个生命周期(UMD 格式)
let instance = null;

export async function bootstrap() {
  // 只在第一次加载时执行一次:做全局的一次性初始化
}

export async function mount(props) {
  // props 里有主应用下发的数据 + container(挂载容器 DOM)
  const { container, token, onGlobalStateChange, setGlobalState } = props;
  instance = createApp(App);
  instance.mount(container ? container.querySelector("#app") : "#app");
  // 注意:必须从 container 里找挂载点,不能直接 '#app'(会命中主应用的节点)
}

export async function unmount() {
  instance?.unmount(); // Vue3;Vue2 是 $destroy()
  instance = null; // 置空,等待下次 mount 重建
}

export async function update(props) {
  // 只有 loadMicroApp 手动加载的应用会用到
}
js
// 子应用 webpack 必须的三项配置
module.exports = {
  output: {
    library: `sub-vue-[name]`,
    libraryTarget: "umd", // 必须 UMD,qiankun 才能取到生命周期
    chunkLoadingGlobal: `webpackJsonp_sub-vue`, // 避免多应用的 jsonp 全局变量冲突
  },
  devServer: {
    headers: { "Access-Control-Allow-Origin": "*" }, // 必须开 CORS,否则 fetch 不到
  },
};
// 子应用还要设置 publicPath,否则异步 chunk / 图片会相对主应用域名去取
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
生命周期执行时机该做什么
bootstrap子应用首次加载,只执行一次一次性初始化(全局配置、国际化、埋点 SDK)
mount每次路由匹配激活时创建应用实例、挂载到 container、接收 props
unmount每次路由切走时销毁实例、清定时器、解绑事件、移除全局副作用
updateloadMicroApp 手动更新 props 时响应 props 变化

注意:子应用的 JS 资源默认会被缓存,第二次激活不会重新下载和执行脚本,只会重新调用 mount。这意味着模块级变量是跨 mount 保留的 —— 上一轮 mount 里挂到模块作用域的定时器、单例、事件监听,如果 unmount 没清,就会越切越多,这是微前端最高频的内存泄漏来源。

9. JS 沙箱:两种实现 ​

text
快照沙箱 SnapshotSandbox(兼容不支持 Proxy 的浏览器)
active()   : 遍历 window 存一份快照 → O(n) 开销
            子应用运行期间直接改真实 window(会污染)
inactive() : 对比当前 window 与快照,把差异记录到 modifyPropsMap
            并把 window 恢复成快照状态
❌ 只能支持「单实例」:两个子应用同时跑会互相覆盖
text
Proxy 沙箱 ProxySandbox(默认,现代浏览器)
new Proxy(fakeWindow, {
  set(target, key, value) {       // 写:只落到自己的 fakeWindow
    target[key] = value;
    return true;
  },
  get(target, key) {              // 读:先找自己的,没有再回退到真实 window
    return key in target ? target[key] : window[key];
  },
})
✅ 写操作天然隔离,支持多实例同时运行;卸载只需丢弃 fakeWindow
对比项快照沙箱Proxy 沙箱
依赖无(ES5 即可)需要 ES6 Proxy(不可 polyfill)
是否污染 window会,运行期直接写真实 window不会,写入落在 fakeWindow
多实例❌ 不支持✅ 支持
性能每次激活/失活都要遍历 window只在读取时做一次属性查找
卸载还原靠 diff 回滚,可能漏丢弃代理对象即可

沙箱不是绝对隔离。 它只拦得住 window.x = 1 这类全局变量写入,拦不住对共享对象的改动:改 Array.prototype、给 document.body 加类名、document.title、往 localStorage 写同名 key、CSS 全局变量、以及直接操作主应用 DOM。这些都要靠团队约定。

10. 样式隔离:三种方案 ​

text
① 动态样式表(qiankun 默认行为)
   mount   时把子应用的 <style>/<link> 插入 head
   unmount 时把它们全部移除
   ✅ 解决「切走后样式残留」    ❌ 不解决「同时存在时的类名冲突」

② experimentalStyleIsolation: true  —— 运行时给选择器加前缀
   .btn { color: red }
   ↓ 改写为
   div[data-qiankun="sub-vue"] .btn { color: red }
   ✅ 改造成本 0,不破坏弹窗  ❌ 对 @media/@keyframes/body 选择器支持有限,有性能开销

③ strictStyleIsolation: true  —— Shadow DOM 真隔离
   子应用整体塞进 shadowRoot,样式物理隔绝
   ✅ 最彻底  ❌ 坑最多:UI 库挂到 document.body 的弹窗样式全丢、
                        事件冒泡被 retarget、第三方库 querySelector 找不到节点
方案隔离强度改造成本主要风险
命名约定 + BEM 前缀弱中靠人守,新人容易破坏
CSS Modules / scoped中低第三方 UI 库的全局样式仍会冲突
experimentalStyleIsolation中强零动态插入的样式、@keyframes 可能漏
Shadow DOM最强高弹窗/Portal/第三方库大面积不兼容
不同 UI 库前缀隔离中强中需要定制主题变量(如 Ant Design prefixCls)

工程上的最优组合: 子应用一律用 CSS Modules 或 scoped + UI 库设置独立 prefixCls/命名前缀 + 开 experimentalStyleIsolation 兜底 + 约定「禁止写裸的 body/*/标签选择器」。Shadow DOM 只在接入完全不可控的外部应用时才考虑。

✨ 高级深挖知识点 ​

1. 水合不匹配(Hydration Mismatch)的全部成因与治法 ​

React 18 之后水合不匹配会直接报 Hydration failed because the initial UI does not match what was rendered on the server,并丢弃整棵子树重新客户端渲染(降级为 CSR),这是线上性能劣化最隐蔽的原因之一。

text
不匹配的根因只有一句话:
   服务端那次渲染的输入 ≠ 客户端第一次渲染的输入

输入包括:数据、时间、随机数、环境(UA/宽度/时区/语言)、localStorage、第三方脚本改过的 DOM
成因典型代码治法
时间/时区new Date().toLocaleString()服务端输出 ISO 字符串,客户端 useEffect 里格式化
随机数 / uuidMath.random()、uuid() 当 key用 useId(),或由服务端生成后注水下发
typeof window !== 'undefined'两端走了不同分支统一渲染占位,差异放进 useEffect
读 localStorage / cookie 决定 UI主题色、登录态主题存 cookie 由服务端读;或首屏渲染兜底态
按 UA / 窗口宽度分端isMobile ? <M/> : <PC/>服务端按 UA 判断并把结果注水;或 CSS 媒体查询代替 JS
非法 HTML 嵌套<p><div/></p>、<table> 少 <tbody>浏览器会自动纠正 DOM 导致结构不一致,改合法结构
浏览器插件 / 第三方脚本注入节点翻译插件、客服 SDK把容器标 suppressHydrationWarning 或延后挂载
两端数据源不一致服务端查 DB,客户端又请求了一次接口必须复用注水数据,不要二次请求
jsx
// ❌ 典型错误:两端输出不同
function Clock() {
  return <span>{new Date().toLocaleTimeString()}</span>;
}

// ✅ 方案 1:首帧用服务端值,挂载后再更新
function Clock({ serverISO }) {
  const [text, setText] = useState(serverISO); // 两端首帧一致
  useEffect(() => {
    const t = setInterval(() => setText(new Date().toISOString()), 1000);
    return () => clearInterval(t);
  }, []);
  return <span suppressHydrationWarning>{formatTime(text)}</span>;
}

// ✅ 方案 2:明确「只在客户端渲染」的部分
function ClientOnly({ children, fallback = null }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted ? children : fallback; // 服务端与首帧都渲染 fallback
}
// Next.js 里等价于 dynamic(() => import('./C'), { ssr: false })

三个必须记住的点: ① suppressHydrationWarning 只压警告、不会修复内容,且只作用于该节点的直接文本;② 水合阶段 React 不会修补属性差异(只比对文本和结构),class/style 不一致可能静默错乱;③ 不匹配在开发环境可能只是黄色警告,生产环境却会整块降级 CSR,一定要在 CI 里把 hydration error 当失败处理。

2. 流式 SSR 与 Suspense:从「全有或全无」到「边算边发」 ​

传统 renderToString 是同步阻塞:任何一个慢接口都会拖住整个 HTML,TTFB 等于最慢的那次查询。流式 SSR 把 HTML 拆成多个 chunk 用 Transfer-Encoding: chunked 分批推送。

text
renderToString(阻塞)
请求 ──────[等所有数据 800ms]────── 发送完整 HTML ── 浏览器开始解析
TTFB = 800ms

renderToPipeableStream(流式)
请求 ─[外壳 50ms]→ 发 shell(含 loading 占位)── 浏览器已能绘制
        └─[慢数据 800ms]→ 发 <template> + 一小段 script 替换占位
TTFB = 50ms,FCP 大幅提前,慢模块不阻塞首屏
jsx
// 服务端:Node 用 renderToPipeableStream,Edge/Worker 用 renderToReadableStream
import { renderToPipeableStream } from "react-dom/server";

app.get("/*", (req, res) => {
  let didError = false;
  const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
    bootstrapScripts: ["/client.js"],
    onShellReady() {
      // 外壳(Suspense 边界之外的部分)渲染完成 → 可以开始发了
      res.statusCode = didError ? 500 : 200;
      res.setHeader("Content-Type", "text/html");
      pipe(res);
    },
    onShellError() {
      // 外壳就挂了 → 没法流式降级,直接返回兜底页让客户端 CSR
      res.statusCode = 500;
      res.send('<!doctype html><div id="root"></div><script src="/client.js"></script>');
    },
    onAllReady() {
      // 全部 Suspense 都 resolve(爬虫/SSG 场景可以等这里再一次性发)
    },
    onError(err) {
      didError = true;
      logger.error(err);
    },
  });
  // 兜底:超时则放弃服务端渲染剩余部分,交给客户端
  setTimeout(abort, 5000);
});
jsx
// 组件侧:用 Suspense 划分「可以晚到」的区块
<Layout>
  <Header /> {/* 外壳,必须快 */}
  <Suspense fallback={<SkeletonList />}>
    <SlowRecommendList /> {/* 慢接口,先出骨架屏 */}
  </Suspense>
  <Suspense fallback={<SkeletonComments />}>
    <Comments />
  </Suspense>
</Layout>
能力renderToStringrenderToPipeableStream
TTFB等最慢数据只等外壳
Suspense不支持(直接报错)原生支持,fallback 先占位
选择性水合否是:谁的 HTML 先到谁先水合
交互优先否用户点哪里,React 优先水合那块
错误隔离整页 500单个 Suspense 边界降级为客户端渲染
HTTP 状态码可随意设置发出第一个 chunk 后就改不了了

流式的两个硬约束: ① 一旦开始 pipe,status code 与 header 都无法再修改,所以鉴权/重定向必须在渲染前做完;② 中间层(Nginx、网关、CDN)若开了响应缓冲,流式会被重新攒成一整包发出,白做——必须关掉 proxy_buffering 并确认不要 gzip 缓冲整体。

3. RSC(React Server Components)与 SSR 的区别 ​

面试高频混淆点:RSC 不是 SSR。

text
SSR:把「组件」在服务端跑成 HTML 字符串,组件代码仍会打进客户端 bundle 用于水合
RSC:组件只在服务端存在,产出的是「序列化的 UI 描述流(RSC Payload)」
     组件代码**永不下发**,所以体积为 0,可以直接 await 数据库
维度SSRRSC
产物HTML 字符串/流RSC Payload(类 JSON 的 UI 描述流)
组件代码是否下发下发(水合需要)不下发
能否有状态/事件能(水合后)不能,须拆出 'use client' 组件
能否直接访问 DB可以但要小心泄漏可以,这是设计目的
导航时的表现整页 HTML 或客户端路由只拉变化部分的 Payload,保留客户端状态
jsx
// Server Component(默认):可以 async,可以直连 DB,但不能有 useState/onClick
async function ProductPage({ params }) {
  const product = await db.product.findUnique({ where: { id: params.id } });
  return (
    <section>
      <h1>{product.name}</h1>
      <AddToCart id={product.id} /> {/* 需要交互 → 必须是客户端组件 */}
    </section>
  );
}

// Client Component:文件顶部声明
("use client");
export function AddToCart({ id }) {
  const [n, setN] = useState(1);
  return <button onClick={() => addCart(id, n)}>加入购物车</button>;
}

边界规则: 服务端组件可以 import 客户端组件;客户端组件不能 import 服务端组件(只能通过 children 插槽传入)。跨边界传的 props 必须可序列化 —— 函数、Date 之外的类实例、Symbol 都会报错。

4. 数据注水(Dehydrate)与脱水(Hydrate) ​

服务端取到的数据必须「原样」交给客户端,否则客户端会二次请求,首屏白等一轮。

text
服务端                                      客户端
fetch 数据 ──→ 渲染 HTML
          └──→ 序列化塞进 <script id="__DATA__">
                         │
                HTML 到达浏览器
                         │
              读取 window.__DATA__ ──→ 填进 store/queryClient ──→ hydrate
                         └──→ 组件首帧就有数据,不再请求
js
// ❌ 直接 JSON.stringify 塞进 <script> 会被 XSS 和解析打穿
`<script>window.__DATA__ = ${JSON.stringify(data)}</script>`;
// data 里有 "</script>" → 提前闭合标签,任意脚本执行
// data 里有 U+2028/U+2029 → JS 里是换行符,直接语法错误

// ✅ 做法 1:转义危险字符
const safe = JSON.stringify(data)
  .replace(/</g, "\\u003c")
  .replace(/>/g, "\\u003e")
  .replace(/\u2028/g, "\\u2028")
  .replace(/\u2029/g, "\\u2029");

// ✅ 做法 2(更稳):用 type="application/json" 承载,运行时再 parse
`<script id="__DATA__" type="application/json">${JSON.stringify(data).replace(/</g, "\\u003c")}</script>`;
// 客户端:JSON.parse(document.getElementById('__DATA__').textContent)
js
// React Query 的标准注水流程
// --- server ---
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ["user", id], queryFn: () => getUser(id) });
const state = dehydrate(queryClient); // 只导出已成功的查询
const html = renderToString(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);
queryClient.clear(); // ⚠️ 每个请求一个 client,渲染完立刻清,否则跨请求串数据

// --- client ---
<QueryClientProvider client={new QueryClient()}>
  <HydrationBoundary state={window.__RQ_STATE__}>
    <App />
  </HydrationBoundary>
</QueryClientProvider>;
注水风险后果处理
整个数据源直接下发泄漏 token / 手机号 / 内网字段白名单挑字段,或在序列化前 pick()
</script> 注入XSS转义 < > 或用 type="application/json"
U+2028/U+2029客户端 JS 语法报错,整页白屏一并转义
数据体积过大HTML 几百 KB,TTFB 和解析都变慢只注水首屏必需;其余走客户端懒加载
Date/Map/SetJSON 化后类型丢失用 superjson/devalue 等支持富类型的序列化

5. SSR 的性能与缓存策略 ​

SSR 把渲染成本从「用户的手机」搬到了「你的服务器」,所以 SSR 的核心优化就是让 render 少跑几次。

text
缓存层级(从外到内,命中越外越便宜)
CDN 整页缓存  →  ①  s-maxage + stale-while-revalidate,命中率最高
网关/Redis 页面片段 →  ②  按 URL + 关键维度(地区/语言/登录态)做 key
组件级缓存    →  ③  renderToString 结果按 props 哈希缓存
数据层缓存    →  ④  接口结果缓存,减少下游压力
什么都没命中  →  ⑤  完整 SSR(最贵)
js
// ① CDN 层:ISR 式缓存头 —— 过期也先返回旧的,后台静默更新
res.setHeader("Cache-Control", "public, s-maxage=60, stale-while-revalidate=600");
// 注意:带个性化内容(用户名/购物车数)的页面绝不能这样缓存,会串号
// 做法:页面主体 CDN 缓存 + 个性化部分客户端请求补齐(CSR 补洞)

// ② 关键维度要进 cache key,否则串缓存
const cacheKey = `page:${pathname}:${lang}:${isMobile ? "m" : "d"}:${abBucket}`;

// ③ 并发合并:同一 key 同时来 100 个请求,只渲染一次
const inflight = new Map();
function renderOnce(key, fn) {
  if (inflight.has(key)) return inflight.get(key); // 防缓存击穿
  const p = fn().finally(() => inflight.delete(key));
  inflight.set(key, p);
  return p;
}
优化手段作用代价/风险
CDN 整页缓存QPS 承载提升几十倍个性化内容会串号
stale-while-revalidate永不让用户等重算可能看到最多 N 秒的旧数据
组件级缓存复用页头页脚、商品卡等缓存 key 设计难,易串数据
流式 SSRTTFB 降到几十毫秒状态码不可改、需关代理缓冲
预取 + 并行请求消灭串行瀑布需要手动编排 Promise.all
降级开关服务端压力过大时整站切 CSR 保可用首屏体验与 SEO 短期受损
超时 abort单个慢依赖不拖垮整个服务该区块降级为客户端渲染

必须有的两个兜底: ① 渲染超时(如 1~3s)后放弃 SSR,直接吐出骨架 HTML 让客户端接管,绝不能让 Node 线程被拖死;② 熔断降级开关,大促或下游抖动时一键切 CSR。SSR 服务挂掉导致整站不可用,比首屏慢一点严重得多。

6. 跨请求状态污染:SSR 最危险的 Bug ​

浏览器里一个用户一个 JS 运行时;服务端是所有用户共用一个 Node 进程。任何模块级的单例,都会被所有请求共享。

text
浏览器                        服务端
┌─────────────┐              ┌──────────────────────────┐
│ 1 用户      │              │ 1 个进程                  │
│ 1 份模块实例│              │ 模块只被 require 一次     │
│ 刷新即重置  │              │ 进程不重启,状态一直累积  │
└─────────────┘              │ 请求 A 写的,请求 B 读到  │
                             └──────────────────────────┘
js
// ❌ 灾难级写法:所有用户共用一个 store / axios 实例 / 单例
import { createStore } from "vuex";
export const store = createStore({ state: { user: null } }); // 模块级单例!
// 请求 A 登录后 state.user = A,请求 B 的 HTML 里就渲染出了 A 的手机号

// ❌ 同样危险:全局 axios 上挂 token
axios.defaults.headers.common.Authorization = req.headers.token; // 会被下一个请求读到

// ✅ 正确:工厂函数,每个请求创建全新实例
export function createStoreForRequest() {
  return createStore({ state: () => ({ user: null }) });
}
export function createApp() {
  const app = createSSRApp(App);
  app.use(createStoreForRequest());
  app.use(createRouterForRequest()); // router 同理,必须每请求一份
  return app;
}
// axios 也要 per-request:
const api = axios.create({ headers: { Authorization: req.headers.token } });
污染源表现正确做法
模块级 store / router用户看到别人的数据(最严重)工厂函数,每请求新建
全局 axios defaults.headerstoken 串用、越权axios.create() per-request
模块级缓存 const cache = {}把 A 的个性化结果给了 B缓存 key 必须包含用户维度,或改请求级
全局事件总线累积监听内存泄漏 + 回调被反复触发请求级 bus,或渲染后销毁
global.__xxx 存上下文并发时被覆盖用 AsyncLocalStorage 传上下文
单例日期/语言设置时区、i18n 错乱作为参数传递,不要设全局
js
// 需要「隐式传递请求上下文」时的正解:AsyncLocalStorage
import { AsyncLocalStorage } from "node:async_hooks";
export const ctx = new AsyncLocalStorage();

app.use((req, res, next) => ctx.run({ traceId: req.id, user: req.user }, next));
// 任意深层代码里取当前请求上下文,天然并发安全
function getUser() {
  return ctx.getStore()?.user;
}

自测方式: 本地用 autocannon -c 50 之类的工具并发打同一个 SSR 页面,用不同 token 混打,看返回的 HTML 里有没有串号。单请求手测永远测不出跨请求污染。

7. HTML Entry 的实现原理(import-html-entry 做了什么) ​

single-spa 原生要求子应用提供 JS Entry(手写一个入口 JS 的地址);qiankun 的体验提升主要来自 HTML Entry —— 直接给一个 URL 就行。

text
import-html-entry(entry) 的步骤
① fetch(entry) 拿到 HTML 文本
② 正则扫描 HTML:
   - <script src>      → 收集外链脚本地址(相对路径按 entry 域名补全)
   - <script>inline    → 收集内联代码
   - <link rel=stylesheet> / <style> → 收集样式
   - entry 脚本(带 entry 属性或最后一个 script)→ 标记为「取导出值的那个」
③ 把所有 <script> 从模板里注释掉,得到纯 DOM 模板 template
④ 把外链 CSS fetch 成文本,内联进 template(避免闪烁 + 便于卸载)
⑤ 返回 { template, assetPublicPath, getExternalScripts, execScripts }
⑥ execScripts(sandboxProxy) 时:
   把每段 JS 包成
     (function(window, self, globalThis){ with(window){ 代码 } }).bind(proxy)(proxy, proxy, proxy)
   最后 eval 执行,并取回 entry 脚本的 UMD 导出
js
// 极简版:理解「为什么必须开 CORS」「为什么脚本要包一层」
async function importHTML(entry) {
  const html = await fetch(entry).then((r) => r.text());

  const scripts = [];
  // 把 script 标签抽出来并从模板中剔除
  const template = html.replace(/<script[^>]*>([\s\S]*?)<\/script>/gi, (tag, inline) => {
    const src = /\ssrc=["']?([^"'\s>]+)/i.exec(tag)?.[1];
    scripts.push(src ? { src: new URL(src, entry).href } : { code: inline });
    return `<!-- script replaced -->`;
  });

  return {
    template,
    async execScripts(proxy) {
      let exports = {};
      for (const s of scripts) {
        // 外链也必须 fetch 成文本(而不是插 <script src>),才能换掉它的 window
        const code = s.code ?? (await fetch(s.src).then((r) => r.text()));
        const wrapped = `(function(window, self, globalThis){with(window){${code}
        }}).call(proxy, proxy, proxy, proxy)`;
        // eslint-disable-next-line no-eval
        exports = eval(wrapped) ?? exports;
      }
      return exports;
    },
  };
}
为什么这么做原因
用 fetch 而不是插 <script src>插标签无法替换其 window,沙箱就失效了 → 所以必须 CORS
包一层 with(window){} + 绑定 this让代码里裸写的 window/self/globalThis/this 全指向代理
外链 CSS 也 fetch 成文本内联卸载时能精确移除;也避免子应用切换时的样式闪烁
必须 UMDeval 后要从返回值里拿 bootstrap/mount/unmount
需要 publicPath 注入否则子应用的异步 chunk 会按主应用域名去取,404

模板侧:为什么非得把 <script> 摘出来

text
container.innerHTML = template
  container = document.querySelector('#subapp-container')
┌────────────────────────────────────────────────────────────────────┐
│ ✅ div / img / svg 等节点正常生成,子应用根节点 #app 就位            │
│ ❌ 模板里残留的 <script> 不会执行 —— 浏览器原生规则:              │
│    innerHTML 插入的脚本标签一律不跑代码                             │
│ ❌ 样式此刻还没生效(style / link 已被摘走,由 qiankun 单独插入)    │
│ ⚠️ 模板里的 <img src> 会立刻发起请求,时机不受控                    │
└────────────────────────────────────────────────────────────────────┘
→ 结论:脚本必须摘出来由自己决定「何时执行、在谁的 window 上执行」;
  插进去的骨架只是 DOM 占位,此时页面还是空的,
  真正的渲染发生在 execScripts 之后,由子应用自己 mount 到 #app
❌ 常见误解✅ 事实
骨架 innerHTML 进去子应用就渲染好了只是 DOM 占位;挂载逻辑写在 JS 里,JS 没跑页面就是空白
把摘出来的脚本 createElement('script') 再 append 回去append 的脚本在真实 window 上执行,沙箱当场失效
new Function / eval 本身就能隔离它们只是换了执行入口;隔离完全靠传进去的 fakeWindow 代理
用 DOMParser 解析更规范解析过程会提前触发部分资源加载,加载时机不可控;且仍要自己 append
正则解析 HTML 很可靠注释里、JS 字符串里出现 </script> 就会截断错位 —— 这是该库的历史短板

脚本侧:必须按 HTML 里的出现顺序串行执行

text
scripts = [ {code: '...'}, {src: '/js/chunk-vendors.js'}, {src: '/js/app.js'} ]
                  ↑ 严格保持 HTML 中 <script> 的先后顺序

for (const s of scripts) {          // 串行 await,不能 Promise.all
  const code = s.code ?? await fetch(s.src).then(r => r.text());
  执行(code, proxy);                 // 上一个执行完才轮到下一个
}
→ 模拟浏览器对「非 async/defer 脚本」的顺序执行语义:
  vendors(Vue/React 本体)必须先执行完,app.js 才能拿到它
  一旦并发执行,就会出现「Vue is not defined」这类随机失败

container 到底是谁:主应用容器 vs 子应用根节点

text
registerMicroApps([{ container: '#subapp-container' }])   ← 传的是「选择器字符串」
        │ qiankun 内部 document.querySelector(...) → 真实 DOM 元素
        ▼
<div id="subapp-container">     ← 主应用自己写在页面上的容器(qiankun 不会帮你创建)
  <div id="app"></div>          ← 骨架带进来的子应用根节点,由子应用 JS 挂载
</div>
        │ 这个 DOM 元素会通过 props.container 下发给子应用
        ▼
unmount 时 qiankun 执行 container.innerHTML = '',再装下一个子应用的骨架

这就是前面「子应用必须 props.container.querySelector('#app')」的根因:主应用页面上可能也有 #app,直接写 mount('#app') 会命中主应用节点。另外因为卸载时整个 container 会被清空,所有挂在子应用 DOM 之外的副作用(body 上的弹窗、全局事件、定时器)都不会被一起清掉,必须在 unmount 里自己处理。

两个常见报错的根因: application died in status LOADING_SOURCE_CODE: You need to export lifecycle 几乎都是「没配 libraryTarget: 'umd'」或「library 名与注册的 name 不一致」;Failed to fetch 则是子应用没开 CORS 或 entry 地址写错(末尾少了 /、HTTPS 混用 HTTP)。

8. 沙箱拦不住的东西,以及 qiankun 的补丁 ​

除了 window 属性,qiankun 还对一批「会产生跨应用副作用」的 API 打了补丁(patchers),卸载时统一回收。

text
qiankun 的 patch 分两类
单实例补丁(singular 模式)
  - window.setInterval / setTimeout      → 记录 id,unmount 时全清
  - window.addEventListener/removeEventListener → 记录监听,unmount 时全解
  - history.pushState / replaceState     → 路由劫持
  - window.onerror / onunhandledrejection → 归属到对应子应用
多实例补丁
  - document.head.appendChild / insertBefore / removeChild
      拦截 <style>/<link>/<script> 的插入:
      · 动态插入的 style → 记到当前子应用名下,unmount 时移除
      · 动态插入的 script → 走沙箱 eval,而不是真的插进 head
  - document.createElement / querySelector → 把挂载范围限制在子应用容器内
沙箱拦不住的操作后果约定/解法
修改 Array.prototype 等原型全局污染,另一个应用行为异常代码规范 + ESLint 禁止扩展原生原型
操作 document.body / document.title互相覆盖标题统一由主应用管(通过通信上报)
localStorage / sessionStorage同名 key 互相覆盖统一加子应用名前缀,或主应用封装存储层
cookie同域共享,鉴权相互影响登录态统一由主应用管理
直接 document.querySelector 改主应用 DOM越界破坏规定只能在 props.container 内操作
CSS 自定义属性 / :root 变量主题互相串变量加前缀命名空间
已经 new 出来的 WebSocket / EventSourceunmount 后仍在收消息unmount 里显式 close()
js
// 子应用 unmount 必须做的「副作用清理清单」
let timer, ws, ro, off;
export async function mount(props) {
  timer = setInterval(poll, 1000);
  ws = new WebSocket(WS_URL);
  ro = new ResizeObserver(onResize);
  ro.observe(props.container);
  off = props.onGlobalStateChange(handler, true);
}
export async function unmount() {
  clearInterval(timer); // 定时器
  ws?.close(); // 长连接
  ro?.disconnect(); // 观察者
  off?.(); // 事件订阅
  instance?.unmount(); // 框架实例
  timer = ws = ro = off = instance = null;
}

一个必考细节: 为什么 unmount 后再 mount,bootstrap 不会再跑?因为子应用的 JS 只会被 fetch + eval 一次,之后生命周期函数被缓存复用。所以「写在模块顶层的代码」只执行一次,而「写在 mount 里的代码」每次都执行 —— 不要把需要重置的状态放在模块顶层。

9. 应用间通信:四种方案与选型 ​

text
通信方式按「耦合度」从低到高
① props 下发(主 → 子,单向)           最简单,首选
② GlobalState / 发布订阅(双向)        适合登录态、主题、语言
③ 自定义事件 CustomEvent(跨技术栈)    零依赖,天然解耦
④ 共享运行时(同一个 store 实例)       最快但强耦合,谨慎
js
// ① + ② qiankun 官方 GlobalState
// --- 主应用 ---
import { initGlobalState } from "qiankun";
const actions = initGlobalState({ user: null, theme: "light" });
actions.onGlobalStateChange((state, prev) =>console.log("主应用收到", state, prev));
actions.setGlobalState({ user: { id: 1, name: "Tom" } });

// --- 子应用(从 mount 的 props 里拿) ---
export async function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    if (state.theme !== prev.theme) applyTheme(state.theme);
  }, true); // 第二个参数 true = 立即用当前值触发一次
  props.setGlobalState({ fromSub: Date.now() });
}
// ⚠️ GlobalState 只支持**一级属性**的变更检测,嵌套对象整体替换才会触发
js
// ③ CustomEvent:技术栈完全无关,连 jQuery 老应用也能接
// 发送方
window.dispatchEvent(new CustomEvent("mf:cart-updated", { detail: { count: 3 } }));
// 接收方(注意 unmount 时一定要解绑)
const onCart = (e) => setCount(e.detail.count);
window.addEventListener("mf:cart-updated", onCart);
// unmount: window.removeEventListener('mf:cart-updated', onCart)

// 更规范一点:自己封一个带命名空间的 EventBus,强制前缀 + 自动收集解绑函数
function createBus(ns) {
  const subs = [];
  return {
    emit: (t, d) => window.dispatchEvent(new CustomEvent(`${ns}:${t}`, { detail: d })),
    on(t, fn) {
      const h = (e) => fn(e.detail);
      window.addEventListener(`${ns}:${t}`, h);
      subs.push(() => window.removeEventListener(`${ns}:${t}`, h));
      return () => window.removeEventListener(`${ns}:${t}`, h);
    },
    destroy: () => subs.splice(0).forEach((f) => f()),
  };
}
方案技术栈无关调试难度耦合度适用场景
props✅低低一次性下发 token、环境配置
GlobalState✅中中登录态、主题、语言等全局态
CustomEvent✅中高(难追溯来源)最低零散的跨应用通知
共享 store 实例❌低高同框架同版本、强关联业务
URL / 路由参数✅最低最低可被收藏/刷新保留的状态

原则:通信越少越好。 微前端的价值是解耦,如果两个子应用之间需要频繁双向通信,说明边界划错了,它们本该是一个应用。能走 URL 的就别走事件,能主应用下发的就别子应用互通。

10. 路由设计:两层路由如何不打架 ​

text
URL: https://host/sub-vue/order/123?tab=detail
            └─ 主应用 activeRule: '/sub-vue'
                       └─ 子应用自己的路由: '/order/:id'

关键三件事
① 子应用必须设置 base:Vue  createWebHistory('/sub-vue')
                      React <BrowserRouter basename="/sub-vue">
   且 base 要在 qiankun 环境下才加(独立运行时为 '/')
② 主应用路由与 activeRule 不能重叠,否则同一 URL 两边都想渲染
③ 子应用内部跳转用自己的 router;跨应用跳转必须用 history.pushState
   (直接 location.href 会整页刷新,丢掉微前端的意义)
js
// 子应用:base 要动态判断
const router = createRouter({
  history: createWebHistory(window.__POWERED_BY_QIANKUN__ ? "/sub-vue" : "/"),
  routes,
});

// 跨应用跳转:主应用暴露一个统一方法下发给子应用
props.navigate = (path) => history.pushState(null, "", path);
// 子应用调用 props.navigate('/sub-react/report')
// qiankun 劫持了 pushState,会触发重新匹配 → 卸载当前应用、挂载目标应用
坑原因解决
子应用页面空白,但资源都加载了没配 base,子应用路由匹配不到动态设置 history base
刷新子应用页面 404Nginx 没配 try_files 回落到主应用 HTML配置 history 模式回退
一个 URL 两个应用同时挂载activeRule 与主应用路由重叠给子应用统一前缀,主应用不占用该前缀
跨应用跳转整页刷新用了 location.href改 history.pushState
子应用 hash 路由 + 主应用 history两者混用导致匹配混乱全站统一路由模式

11. 性能:预加载与公共依赖 ​

js
// ① 预加载:start({ prefetch }) 的四种取值
start({ prefetch: true }); // 默认:首个应用 mount 后,空闲时预加载其余应用
start({ prefetch: "all" }); // start 后立刻预加载全部(首屏网络会被抢占,慎用)
start({ prefetch: ["sub-vue"] }); // 只预加载指定的
start({ prefetch: (apps) => ({ criticalAppNames: ["sub-vue"], minorAppsName: ["sub-react"] }) });
// 预加载走的是 requestIdleCallback + fetch,资源进 HTTP 缓存,不会执行
js
// ② 公共依赖:externals + CDN,避免 React/Vue 被打进每个子应用
// 子应用 webpack
module.exports = {
  externals: { react: "React", "react-dom": "ReactDOM", vue: "Vue" },
};
// 主应用 index.html 统一引入一份 CDN
// ⚠️ 代价:所有子应用被锁死在同一个大版本,升级必须整体联动
//    React 还会因为「两份 React 实例」导致 hooks 报错,externals 恰好能避免这点
优化点做法风险
子应用资源预加载prefetch + requestIdleCallback'all' 会抢首屏带宽
公共依赖只下一份externals + CDN 或 Module Federation shared版本耦合,升级需全局协同
子应用按需加载而非全量注册loadMicroApp 手动加载局部区块需自己管理生命周期
切换时保留实例keep-alive 式缓存容器,不真正 unmount内存占用上升,需控制上限
子应用首屏骨架主应用在 beforeMount 显示 loading需统一 loading 规范
资源体积治理每个子应用独立 bundle-analyzer + 体积卡口需要 CI 支持

12. 其他方案对比:Module Federation / micro-app / wujie ​

text
Module Federation(webpack5 / rspack / vite 插件)
  host 运行时 import('remote/Button'),remote 暴露模块
  共享依赖通过 shared 协商版本,只加载一份
  ✅ 依赖共享最优、可共享任意模块(不止页面)、类型可打通
  ❌ 构建工具强绑定、版本协商复杂、没有 JS/CSS 隔离

micro-app(京东)
  用 CustomElement + ShadowDOM 把子应用包成一个 HTML 标签
  <micro-app name="sub" url="http://..."></micro-app>
  ✅ 接入成本极低、子应用几乎零改造
  ❌ ShadowDOM 带来的弹窗/样式问题;依赖浏览器新特性

wujie(无界,腾讯)
  iframe 跑 JS(天然 JS 沙箱) + ShadowDOM 渲染 DOM(样式隔离)
  ✅ 隔离性强且性能好、支持应用保活与预加载
  ❌ iframe 与主文档的 DOM/事件桥接复杂,调试链路长
方案JS 隔离CSS 隔离技术栈无关接入成本适合
iframe最强最强✅最低外部系统、强隔离后台
qiankunProxy 沙箱需配置✅中中后台多团队聚合(最主流)
micro-appProxy + 自定义元素ShadowDOM✅低希望像标签一样嵌入
wujieiframeShadowDOM✅中要求强隔离 + 保活
Module Federation❌ 无❌ 无基本需同栈中高同技术栈、重依赖共享
npm 包 / 组件库❌ 无取决于实现❌低耦合紧、无需独立发布

选型一句话: 多团队、多技术栈、要独立发布 → qiankun / wujie / micro-app;同一技术栈、核心诉求是共享依赖与模块复用 → Module Federation;只是想嵌一个完全不信任的外部系统 → 老老实实 iframe。

13. 部署与运维 ​

text
典型部署拓扑
                    ┌──────────── CDN ────────────┐
用户 ── Nginx ──→ 主应用 index.html(不缓存/短缓存)
          │       主应用静态资源(长缓存 + hash)
          ├──→ /sub-vue/*   → 子应用 A 的静态资源(独立目录/独立域名)
          ├──→ /sub-react/* → 子应用 B
          └──→ /api/*       → 后端网关

必须注意
① 主应用 index.html 绝不能长缓存,否则发版后仍加载旧的子应用注册表
② 子应用 entry 若用独立域名,必须配 CORS(Access-Control-Allow-Origin)
③ history 模式下所有未命中静态文件的路径都要回落到主应用 index.html
④ 子应用版本信息最好由接口下发,而不是硬编码在主应用里 → 子应用可独立发版
text
# Nginx 关键片段(示意)
location = /index.html {
  add_header Cache-Control "no-cache, must-revalidate";
}
location /sub-vue/ {
  alias /data/sub-vue/;
  add_header Access-Control-Allow-Origin *;
  try_files $uri $uri/ /index.html;   # 回落到主应用
}
location / {
  try_files $uri $uri/ /index.html;
}
运维要点做法
子应用注册表动态化主应用启动时拉 /api/micro-apps 获取 entry 列表与开关
灰度发布注册表按用户分桶返回不同 entry 版本
子应用熔断加载失败 N 次自动摘除并展示降级页,不阻塞其他应用
错误归因全局 onerror 按当前激活应用打标,上报到各自的监控项目
性能归因在 beforeLoad/afterMount 打点,统计各子应用挂载耗时
版本一致性公共依赖版本在 CI 里统一校验,不一致直接拦截合并

14. App Router 的四层缓存:为什么「数据改了页面还是旧的」 ​

这是 App Router 上线后最高频的线上疑问。Next.js 默认激进缓存,一次请求会穿过四层,任何一层命中都拿不到新数据。

text
浏览器端                              服务端
┌──────────────────┐   ┌──────────────────────────────────────────┐
│ ④ Router Cache   │   │ ③ Full Route Cache                       │
│  客户端路由缓存  │   │  整条路由的 RSC Payload + HTML            │
│  内存、会话级    │   │  构建期/首次请求后产出,部署或 revalidate 失效 │
│  默认 30s(动态)  │   │                                          │
│      /5min(静态) │   │ ② Data Cache                             │
└──────────────────┘   │  fetch 结果持久缓存,跨请求、跨部署存活    │
                       │                                          │
                       │ ① Request Memoization                    │
                       │  同一次渲染内,相同 URL+options 的 fetch   │
                       │  只真正发一次(React 层能力,渲染结束即丢) │
                       └──────────────────────────────────────────┘
层作用域存活时间怎么关 / 怎么失效
① Request Memoization单次渲染内渲染结束即丢关不掉(可用 AbortSignal 区分);它是去重不是缓存
② Data Cache全服务、跨部署永久,直到被失效cache: "no-store" / next.revalidate / revalidateTag
③ Full Route Cache全服务到下次部署或路由失效路由内出现动态 API、dynamic = "force-dynamic"、revalidatePath
④ Router Cache单个用户的浏览器30s / 5min,刷新即清router.refresh();Server Action 返回后自动清
js
// —— 读取侧:三种语义要分清 ——
await fetch(url);                                  // 默认行为随版本变化,务必显式声明
await fetch(url, { cache: "no-store" });           // 每次都回源(动态渲染)
await fetch(url, { next: { revalidate: 60 } });    // 60s 过期重建(ISR 语义)
await fetch(url, { next: { tags: ["post:1"] } });  // 打标签,等人来按需失效

// 非 fetch 的数据源(Prisma / Redis / fs)不走 Data Cache,要自己包
import { unstable_cache } from "next/cache";
const getPost = unstable_cache(
  async (id) => db.post.findUnique({ where: { id } }),
  ["post"],                                  // key 前缀
  { revalidate: 60, tags: ["post"] }
);

// —— 写入侧:改完数据必须主动失效,否则读侧永远是旧的 ——
"use server";
import { revalidateTag, revalidatePath } from "next/cache";
export async function updatePost(id, data) {
  await db.post.update({ where: { id }, data });
  revalidateTag(`post:${id}`);   // 精准失效:只打掉带该 tag 的 Data Cache
  revalidatePath("/posts");      // 路径失效:打掉该路由的 Full Route Cache
}

排查顺序(从外往里剥): ① 先 硬刷新——如果刷新后是新数据,问题在 ④ Router Cache,缺 router.refresh() 或 Server Action 没返回;② 刷新仍是旧的,看响应头有没有 x-nextjs-cache: HIT,命中说明是 ③ Full Route Cache,检查路由是否被静态化了;③ 换个无痕窗口 / 重启服务还旧,就是 ② Data Cache,检查 fetch 的 cache / revalidate 配置和写侧有没有调 revalidateTag。

两个最容易踩的认知错误: ① Data Cache 跨部署存活,以为「重新发版就好了」是错的,必须显式失效;② 用了 cookies() / headers() / searchParams 会让整条路由转为动态渲染,于是「某天加了个读 cookie 的埋点,首屏突然从 CDN 直出变成每次回源」,QPS 和 TTFB 一起恶化——这类改动必须在 CI 里看 next build 输出的路由表(○ Static / ƒ Dynamic)是否发生变化。

15. Server Actions 的本质与安全边界 ​

很多人把 Server Actions 当成「可以直接在组件里调的服务端函数」,这个心智模型会直接写出漏洞。

text
它的真实样子
  "use server" 的函数    →  构建时被编译成一个 HTTP POST 端点
  <form action={fn}>     →  浏览器提交表单到该端点(JS 没加载也能提交 = 渐进增强)
  onClick 里 await fn()  →  一次带序列化参数的 POST 请求

关键结论
  ① 它是一个「公网可直接 curl 的接口」,不是内部函数调用
  ② 函数 ID 会出现在客户端 bundle 里,任何人都能构造请求打它
  ③ 所以:鉴权、入参校验、越权判断,一条都不能省
js
// ❌ 危险写法:把它当内部函数,完全信任调用方
"use server";
export async function deletePost(id) {
  await db.post.delete({ where: { id } }); // 任何人 curl 这个端点就能删任意文章
}

// ✅ 正确写法:当成一个公开 API 来写
"use server";
import { z } from "zod";
const Schema = z.object({ id: z.string().uuid() });

export async function deletePost(raw) {
  const session = await auth();                       // ① 鉴权:必须自己查
  if (!session) throw new Error("UNAUTHORIZED");

  const { id } = Schema.parse(raw);                   // ② 校验:入参一律不可信

  const post = await db.post.findUnique({ where: { id } });
  if (post.authorId !== session.user.id) throw new Error("FORBIDDEN"); // ③ 越权

  await db.post.delete({ where: { id } });
  revalidateTag(`post:${id}`);                        // ④ 改完就失效缓存
  return { ok: true };
}
对比项Server ActionRoute Handler(route.ts)
调用方式直接 await fn(args),参数自动序列化自己写 fetch,自己定协议
HTTP 方法只有 POST任意
适合表单提交、页面内的写操作(变更 + 失效缓存)对外开放的 API、Webhook、第三方回调
渐进增强<form action> 下 JS 未加载也能用无
类型安全端到端共享 TS 类型需手写或用 codegen

三个必须记住的点: ① Server Action 只能是 async 函数且参数必须可序列化(不能传类实例、函数、Date 以外的复杂对象要小心);② 它不能用来读数据——读数据就用服务端组件直接 await,用 Action 读等于白白多一次 POST 且绕过了 Data Cache;③ 抛出的错误信息会被生产环境脱敏成通用提示,所以需要给用户看的校验失败要用返回值({ ok: false, message })而不是 throw,配合 useActionState 渲染。

16. Node Runtime vs Edge Runtime:中间件为什么连 fs 都不能用 ​

SSR 框架里「运行时」是个独立于「渲染模式」的维度,选错了会在部署后才爆。

text
Node Runtime(默认)            Edge Runtime(V8 Isolate)
├─ 完整 Node API:fs/net/crypto ├─ 只有 Web 标准 API:fetch/Request/Response/
├─ 可跑任意 npm 包              │   URL/TextEncoder/crypto.subtle/streams
├─ 冷启动慢(百 ms ~ s 级)     ├─ 冷启动近乎为 0,全球边缘节点就近执行
├─ 包体积无硬限制               ├─ 包体积有硬上限(常见 1~4MB)
└─ 长连接、重计算、文件读写 OK  └─ ❌ 无 fs、❌ 无原生模块、❌ 无 TCP、
                                   ❌ 多数 ORM / DB driver 不可用
场景该选原因
页面渲染要查数据库 / 用 Prisma、ORMNodeDB driver 走 TCP,Edge 跑不了(除非用 HTTP 版驱动)
读本地模板、生成图片、用 sharp / canvasNode依赖原生模块与文件系统
鉴权重定向、A/B 分流、地域语言判断Edge要求极低延迟,且只需读 cookie/header
简单的 KV / HTTP API 聚合Edge只用到 fetch,能吃到就近执行的红利
js
//路由级声明运行时(Next.js)
export const runtime = "nodejs"; // 或 "edge"

// middleware.ts —— 它固定跑在 Edge Runtime 上,这是最常见的翻车点
import { NextResponse } from "next/server";

export function middleware(req) {
  // ✅ 可以:读 cookie / header、改写 URL、重定向、加响应头
  const token = req.cookies.get("token")?.value;
  if (!token && req.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", req.url));
  }
  // ❌ 不可以:require("fs")、连数据库、用 jsonwebtoken(依赖 Node crypto)
  // → 校验 JWT 要换成 jose 这类基于 WebCrypto 的库
  return NextResponse.next();
}

export const config = {
  // 必须收窄 matcher,否则静态资源、_next/*、图片请求全走一遍中间件
  matcher: ["/dashboard/:path*", "/api/:path*"],
};

三个坑的真实表现: ① 本地 next dev 用的是 Node 侧的模拟实现,Edge 的限制常常只在部署后才暴露,必须在预发环境验证;② 中间件 matcher 写成 /:path* 全量匹配,会给每个静态资源都加一次边缘计算,QPS 和账单一起涨;③ Edge 上不存在跨请求的进程内存,所有「模块级 Map 当缓存」的写法在这里不仅污染风险高,而且根本不生效(Isolate 随时被回收)——缓存必须外置到 KV / Redis。

17. 同构请求层:SSR 项目里 90% 的「本地好的、上线不行」都在这 ​

同一份取数代码要在浏览器和 Node 里都能跑,差异远比想象的多。这是 SSR 工程化里最容易被低估的一块。

text
同一行 fetch("/api/user"),两端行为完全不同
浏览器                                Node 服务端
├─ 相对路径自动补当前 origin         ├─ ❌ 报 "Only absolute URLs are supported"
├─ cookie 浏览器自动带上             ├─ ❌ 不存在 document.cookie,默认不带任何身份
├─ 走公网域名 → 经过网关/CDN         ├─ 可以走内网域名/Service 名,更快更省流量
├─ 后端看到的是用户真实 IP           ├─ 后端看到的是 SSR 服务器 IP(风控/限流会误判)
└─ 跨域受 CORS 约束                  └─ 无 CORS 概念,但有服务间鉴权
差异点必须做的处理
URL服务端补 baseURL;浏览器用相对路径走同源,避免跨域与预检
身份服务端透传本次请求的 cookie / Authorization,而且只透传必要的那几个
真实 IP透传 x-forwarded-for / x-real-ip,否则后端风控把所有用户当成同一个 IP
链路追踪透传 x-trace-id,否则 SSR 这一跳在全链路里断掉,线上问题无法关联
超时服务端必须比浏览器更短(如 1~2s),它拖的是所有用户,不是一个用户
401 处理浏览器可以跳登录页;服务端只能返回重定向响应或降级渲染游客态,绝不能跳
并发上限服务端要有连接池与并发限制,否则一个慢下游就打满 Node 的 socket
js
// request.js —— 同构请求层的关键分叉(配合 AsyncLocalStorage 拿请求上下文)
const isServer = typeof window === "undefined";

export async function request(path, options = {}) {
  let url = path;
  const headers = { ...options.headers };

  if (isServer) {
    const ctx = store.getStore(); // { cookie, ip, traceId } 由中间件注入
    url = (process.env.INTERNAL_API_BASE || "http://api.internal") + path; // ① 内网直连
    if (ctx?.cookie) headers.cookie = ctx.cookie;                          // ② 透传身份
    if (ctx?.ip) headers["x-forwarded-for"] = ctx.ip;                      // ③ 透传真实 IP
    if (ctx?.traceId) headers["x-trace-id"] = ctx.traceId;                 // ④ 透传链路
  }

  // ⑤ 两端都要超时,服务端更短
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), isServer ? 1500 : 10000);
  try {
    const res = await fetch(url, { ...options, headers, signal: ac.signal });
    if (res.status === 401) {
      if (isServer) throw new UnauthorizedError(); // 交给上层决定重定向还是渲染游客态
      location.href = "/login";                    // 浏览器才能跳
    }
    return await res.json();
  } finally {
    clearTimeout(timer);
  }
}

三个必须记住的点: ① 绝不要把服务端的 cookie 透传成「全量 + 长期持有」——不要把 headers.cookie 存到模块级变量或 axios 实例的 defaults 上,那就是跨请求污染的经典成因,必须 per-request 传参;② 服务端超时一定要短于浏览器,SSR 的慢是放大的,一个 5s 的下游接口会让整台 Node 的事件循环排队;③ 内网 baseURL 要做好可观测性 —— 走内网后 CDN/网关的日志里看不到这一跳,SSR 侧必须自己打接口耗时与错误率,否则下游抖动时你只会看到「首屏变慢」却查不出原因。

18. 首屏样式与资源:CSS-in-JS 的 SSR、字体闪烁与图片抖动 ​

SSR 把 HTML 提前了,但如果样式和资源没跟上,用户看到的是「先闪一版丑的,再跳成正常的」——体验比 CSR 还差,而且直接打 CLS 指标。

text
FOUC(无样式内容闪烁)的成因链
服务端只吐了 HTML,没吐这个页面用到的 CSS
  → 浏览器先用默认样式绘制(字体巨大、布局错乱)
  → 外链 CSS / JS 里的样式稍后才到
  → 页面「跳」一下才变正常

所以 SSR 必须解决:渲染时把「这次用到的样式」一起收集出来内联进 HTML
样式方案SSR 需要做什么代价 / 坑
原子化(Tailwind / UnoCSS)几乎不用管,构建期产出静态 CSS 文件,配好 <link> 即可首个 CSS 文件可能略大,可配 critical 提取
CSS Modules / Sass构建期产出静态 CSS,按路由 chunk 拆分路由切换时异步 CSS 到达前可能闪一下
CSS-in-JS 运行时(styled-components / emotion)必须在渲染时收集样式并内联,否则必 FOUC服务端有额外 CPU 开销;RSC 下运行时方案基本不可用
Zero-runtime CSS-in-JS(linaria / vanilla-extract)同静态 CSS,构建期抽离写法受限,不能用运行时变量拼样式
js
// styled-components 的 SSR 样式收集(不做这一步 100% FOUC)
import { ServerStyleSheet, StyleSheetManager } from "styled-components";

const sheet = new ServerStyleSheet();
try {
  const body = renderToString(
    <StyleSheetManager sheet={sheet.instance}>
      <App />
    </StyleSheetManager>
  );
  const styleTags = sheet.getStyleTags(); // 本次渲染真正用到的 CSS
  res.end(`<!DOCTYPE html><html><head>${styleTags}</head><body>${body}</body></html>`);
} finally {
  sheet.seal(); // ⚠️ 必须释放,否则并发下样式表互相串、内存泄漏
}
text
字体与图片的两个独立问题
字体  ①  FOIT:字体没到前文字不可见(空白一片)
      ②  FOUT:先用系统字体显示,字体到了再换(文字跳动)
      → 解法:font-display: swap(选 FOUT)+ preload 关键字体
        + size-adjust / fallback 字体度量对齐,把换字时的位移降到 0

图片  ③  不写宽高 → 图片加载前高度为 0,加载后把下方内容顶下去(CLS)
      → 解法:一律给显式 width/height 或 aspect-ratio 占位
        next/image、nuxt-img 的核心价值就是强制你给尺寸 + 自动生成
          响应式 srcset + 懒加载 + 占位图

三个必须记住的点: ① CSS-in-JS 运行时方案在 SSR 下的样式收集容器必须 per-request 新建(上面的 new ServerStyleSheet()),复用同一个实例是跨请求污染的另一种形态,表现为「A 用户的页面带上了 B 页面的样式」;② 首屏 CSS 要内联、非首屏要异步,全量内联会让 HTML 膨胀到几百 KB,反而拖慢 TTFB,取舍点是「首屏视口内用到的规则」;③ CLS 是 SSR 项目特有的高发指标——CSR 本来就是白屏到渲染完,而 SSR 让用户提前看到了内容,于是任何后到的样式、字体、图片尺寸变化都会被用户看见并计入 CLS。

19. SEO 工程化落地:从「HTML 有内容」到「真的能被收录」 ​

前面说过 SEO 不止是输出 HTML。这里把前端真正需要落地的部分列成可执行清单,重点是约定式文件和怎么验证这两块。

text
收录链路(任何一环断了,前面都白做)
爬虫来访 → robots.txt 允许? → sitemap.xml 发现了这个 URL?
        → 返回 200 且 HTML 里有内容? → canonical 指向自己?
        → 没有 noindex? → 内容与用户看到的一致? → 进索引
要素前端要做的事做错的典型后果
robots.txt放行要收录的路径,屏蔽 /api、搜索结果页、无限筛选参数页爬虫预算被垃圾 URL 吃光
sitemap.xml动态生成,带 lastmod;量大时拆分 + sitemap-index新页面迟迟不被发现
canonical每页指向唯一规范 URL,过滤掉排序/追踪参数同一内容多 URL,权重被稀释
HTTP 状态码不存在→真 404;已迁移→301;临时→302返回 200 的「页面不存在」会被收录
hreflang多语言站点互相声明,并包含自引用把英文版推给了中文用户
结构化数据(JSON-LD)商品/文章/面包屑按 schema.org 输出拿不到富摘要展示
og:* / twitter:*分享卡片标题、描述、图片(图片必须绝对 URL)分享出去是一片空白
分页列表页每页都可独立访问且有自己的 canonical只有第一页被收录
js
// Next.js App Router:约定式文件,天然同构、天然服务端生成
// app/sitemap.ts
export default async function sitemap() {
  const posts = await getAllPosts();
  return [
    { url: "https://x.com", lastModified: new Date(), priority: 1 },
    ...posts.map((p) => ({ url: `https://x.com/p/${p.slug}`, lastModified: p.updatedAt })),
  ];
}

// app/robots.ts
export default function robots() {
  return {
    rules: [{ userAgent: "*", allow: "/", disallow: ["/api/", "/search"] }],
    sitemap: "https://x.com/sitemap.xml",
  };
}

// app/p/[slug]/page.tsx —— 动态元信息 + 真 404
import { notFound } from "next/navigation";
export async function generateMetadata({ params }) {
  const post = await getPost(params.slug);
  if (!post) return {};
  return {
    title: post.title,
    description: post.summary,
    alternates: { canonical: `https://x.com/p/${post.slug}` },
    openGraph: { title: post.title, images: ["https://x.com/og.png"] }, // 必须绝对路径
  };
}
export default async function Page({ params }) {
  const post = await getPost(params.slug);
  if (!post) notFound(); // 真正返回 404 状态码,而不是渲染一个「不存在」的 200 页面
  return <Article data={post} />;
}
text
验证手段(必须亲手跑过,不能靠猜)
① curl -s URL | grep -o '<title>.*</title>'     看爬虫拿到的原始 HTML 有没有标题
② curl -I URL                                   确认状态码、canonical 相关头
③ 浏览器「禁用 JavaScript」后刷新               还能看到内容才是真 SSR
④ DevTools → Network → Doc → Response           看的是原始响应,不是渲染后的 DOM
⑤ Search Console / 站长平台「网址检查」          看搜索引擎实际抓到了什么

三个必须记住的点: ① 判断「是不是真 SSR」只能看原始响应(curl 或 Network 里的 Response),Elements 面板看到的是 JS 执行后的 DOM,永远是有内容的,骗了无数人;② 状态码比页面内容更重要——软 404(内容说不存在但返回 200)会让大量垃圾页进索引,SPA 改 SSR 时这是最常见的遗漏;③ og:image 之类必须是绝对 URL,SSR 下写相对路径在页面里看起来正常,但抓取方拿不到。

20. Vite 子应用接入 qiankun:为什么「什么都没错但就是加载不了」 ​

qiankun 的设计前提是 UMD + eval 执行脚本,而 Vite 的设计前提是 原生 ESM,两者在根上冲突。这是现在接入微前端最高频的一类问题。

text
冲突点逐条拆
① 脚本类型冲突
   Vite dev/build 产物是 <script type="module">
   qiankun 把脚本 fetch 成字符串后用 eval 执行
   → eval 里不是模块作用域:import / export 语法直接 SyntaxError
   → import.meta 也不可用:"Cannot use 'import.meta' outside a module"

② 导不出生命周期
   qiankun 要从 window[appName] 上拿 bootstrap/mount/unmount(UMD 挂全局)
   ESM 的 export 不会挂到 window
   → 报 "You need to export lifecycle functions in xxx entry"

③ 沙箱拦不住 ESM
   原生 <script type="module"> 由浏览器直接加载执行,不经过 qiankun 的沙箱
   → 即使能跑起来,JS 隔离也是失效的

④ 开发态的额外问题
   Vite dev 用 esbuild 按需编译 + HMR 走 WebSocket
   → 子应用被 unmount 后 HMR 连接未清理;多子应用 HMR 端口/路径冲突
方案做法取舍
vite-plugin-qiankun构建时注入「把生命周期挂到 window[name]」的胶水代码;dev 下把入口改写成非 module 脚本最省事,但对 Vite 大版本升级比较敏感
生产用 Vite、构建成 UMD子应用 build.lib 输出 UMD,rollupOptions 配 name构建配置改动大,代码分割能力受限
改用 wujie / micro-app它们基于 iframe / WebComponent,原生支持 ESM,不靠 eval换框架;但这是技术栈含 Vite 时最顺的路
Module Federation(Vite 版)走 @module-federation/vite,模块级共享不是「应用级」微前端,路由与隔离要自己处理
js
// vite.config.ts —— vite-plugin-qiankun 的最小接入
import qiankun from "vite-plugin-qiankun";
export default defineConfig(({ command }) => ({
  // ⚠️ 必须:子应用资源要能被主应用域名正确引用
  base: command === "serve" ? "/" : "/sub-vite/",
  plugins: [vue(), qiankun("sub-vite", { useDevMode: true })],
  server: {
    port: 7101,
    cors: true,                     // 主应用跨域 fetch entry 必需
    origin: "http://localhost:7101", // 否则 dev 下静态资源路径会指向主应用
    hmr: { protocol: "ws", host: "localhost", port: 7101 }, // 避免 HMR 连到主应用端口
  },
}));

// main.ts —— 生命周期导出 + 独立运行分叉
import { renderWithQiankun, qiankunWindow } from "vite-plugin-qiankun/dist/helper";

let app;
function render(container) {
  app = createApp(App);
  app.use(createRouter({ history: createWebHistory(qiankunWindow.__POWERED_BY_QIANKUN__ ? "/sub-vite" : "/") }));
  app.mount(container ? container.querySelector("#app") : "#app");
}

renderWithQiankun({
  bootstrap() {},
  mount(props) { render(props.container); },
  unmount() { app?.unmount(); app = null; },
});

// 独立运行时走这里
if (!qiankunWindow.__POWERED_BY_QIANKUN__) render();

三个必须记住的点: ① 报 Cannot use 'import.meta' outside a module 或 Unexpected token 'export' 时,不要去怀疑业务代码,这是 eval 执行 ESM 的必然结果,方向是改打包产物形态或换方案;② Vite 子应用在 qiankun 里JS 沙箱实质是失效的(module 脚本绕过了代理 window),所以全局变量污染只能靠规范而非技术兜底,这点要在技术评审时讲清楚;③ 如果新项目技术栈已经全面 Vite,优先评估 wujie / micro-app,用 vite-plugin-qiankun 是在给自己留一个长期的升级包袱。

21. 微前端下的登录态与权限:谁管鉴权、401 怎么收口 ​

多个子应用各自发请求、各自跳登录页,是微前端上线后用户体感最差的一类问题(连点几下就被踢回登录页、或者同时弹三个「登录已过期」)。

text
职责划分原则:鉴权只能有一个归属,就是主应用
┌─────────────────── 主应用(唯一责任人)────────────────────┐
│ ① 登录 / 登出 / 刷新 token,只有它能跳登录页               │
│ ② 启动时拉用户信息 + 权限码,通过 GlobalState 下发         │
│ ③ 提供统一的 request 实例(或拦截器)给子应用注入          │
│ ④ 收口 401:任何子应用的 401 都冒泡到主应用统一处理         │
└────────────────────────────────────────────────────────────┘
   ↓ props / GlobalState 注入
子应用:只消费,不自己决定「要不要跳登录」
问题❌ 常见错法✅ 正确做法
token 存哪每个子应用往 localStorage 写自己的 key统一一份;最好 httpOnly cookie,前端不碰
token 过期刷新各子应用各自拦截 401 去刷新主应用单点刷新 + 并发请求挂起队列,刷新完统一重放
401 处理每个子应用都 location.href = '/login'冒泡给主应用,由它判断(可能只是某个接口无权,不该整站踢出)
权限码每个子应用自己调一次权限接口主应用拉一次,GlobalState 下发,子应用订阅
子应用独立运行时的鉴权代码里写死依赖主应用__POWERED_BY_QIANKUN__ 分叉:独立时走自己的登录流程
js
// 主应用:单点刷新 + 并发挂起(微前端下尤其重要,多个子应用会同时打出 401)
let refreshing = null;
const waiters = [];

async function onUnauthorized(retry) {
  if (!refreshing) {
    refreshing = refreshToken()
      .then((t) => { setToken(t); waiters.splice(0).forEach((fn) => fn()); })
      .catch(() => { waiters.length = 0; location.href = "/login"; })
      .finally(() => { refreshing = null; });
  }
  // 刷新期间的请求全部挂起,刷完统一重放,避免 N 个子应用触发 N 次刷新
  return new Promise((resolve) => waiters.push(() => resolve(retry())));
}

// 主应用注册子应用时把能力注入下去
registerMicroApps([
  {
    name: "sub-order",
    entry: "//localhost:7101",
    container: "#subapp",
    activeRule: "/order",
    props: {
      request,               // 统一请求实例(已带鉴权与 401 收口)
      getUser: () => store.user,
      permissions: () => store.permissions,
      onUnauthorized,        // 子应用兜不住时上抛
    },
  },
]);

// 子应用:只消费注入的能力,自己不实现登录逻辑
export async function mount(props) {
  const request = props.request ?? createStandaloneRequest(); // 独立运行时兜底
  app = createApp(App).provide("request", request).provide("perms", props.permissions?.() ?? []);
  app.mount(props.container.querySelector("#app"));
}

三个必须记住的点: ① 登录态必须单点管理,一旦允许子应用自己跳登录页,就会出现「用户在 A 应用操作时被 B 应用的后台轮询 401 踢出去」这种无法复现的诡异 bug;② 刷新 token 必须有并发挂起队列,微前端场景下同时有多个应用在轮询,不做挂起会瞬间打出十几次刷新请求,部分实现里后发的刷新会让先发的 token 失效,形成「刷新风暴 → 全部失效 → 踢出」;③ 子应用必须能在没有主应用注入时降级自跑,否则独立开发调试直接不可用。

❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】 ​

题 1:SSR 和 CSR 的区别是什么?什么项目该上 SSR? ​

精简口述答案:

区别在于首屏 HTML 是谁生成的。CSR 返回一个空壳 HTML,靠下载执行 JS 再渲染;SSR 在服务端把组件跑成带内容的 HTML 直接返回,浏览器拿到就能绘制,之后再水合接上事件。

带来的差异是:SSR 的 FCP 更快、SEO 友好、弱网低端机表现更好;代价是服务器要承担渲染成本、需要 Node 运行时、代码必须同构、TTFB 变长,而且引入了水合不匹配和跨请求状态污染这类 CSR 没有的问题。

选型上我只在两种场景推 SSR:需要被搜索引擎收录的(官网、商详、内容站)和首屏体验直接影响转化的(落地页、活动页)。中后台系统用户都是登录态、不需要 SEO、页面复杂度高,上 SSR 纯粹是给自己找运维成本,CSR 加路由懒加载就够了。

连环追问链:

  1. 追问:SSR 一定比 CSR 快吗?

    • 答:不一定。SSR 快的是 FCP/LCP,但 TTFB 一定比返回静态壳子慢,因为服务端要等数据和渲染。如果服务端数据接口很慢,或者渲染服务被打满排队,首屏可能比 CSR 更慢。另外 TTI 不一定更好 —— HTML 出来了但 JS 还没水合完,页面「看着能用其实点不动」,这个时间差反而可能比 CSR 更长。
  2. 追问:那 TTI 可交互时间,SSR 怎么优化?

    • 答:三条路。① 减小水合范围:静态内容用 Server Component 或 ssr: false 的客户端组件拆出去,只水合真正需要交互的部分;② 流式 SSR + 选择性水合,让先到的 HTML 先水合,用户点哪里优先水合哪里;③ 降低首屏 JS 体积,水合速度基本正比于 bundle 大小。
  3. 追问:SSR 和 SSG 怎么选?

    • 答:看数据的变化频率和个性化程度。完全不变或变得很慢(文档、博客、营销页)→ SSG,构建时生成,CDN 直出,成本最低;变化频繁但所有人看到的一样(商详、榜单)→ ISR 或 SSR + CDN 缓存;每个人看到的不一样(带用户信息)→ 只能 SSR,而且不能做整页 CDN 缓存。实际项目里常常是混着用的,Next 的 App Router 和 Nuxt 的 routeRules 都支持按路由配置。
  4. 追问:SSR 场景下,SEO 还需要注意什么?

    • 答:HTML 有内容只是第一步。还要:服务端正确注入 title/description/og:*,返回正确的 HTTP 状态码(404 页面必须真的返回 404,不能返回 200 的「页面不存在」),配置 canonical 避免重复内容,输出结构化数据 JSON-LD,以及保证爬虫拿到的 HTML 和用户一致(不要对 UA 做不同内容的返回,那是作弊)。
  5. 追问:如果 SSR 服务挂了,页面是不是就白屏了?

    • 答:不该白屏,这必须有兜底。我们的做法是渲染层加超时和 try/catch,一旦渲染失败或超时,就返回只有 <div id="root"> 的壳子 HTML + 客户端 bundle,降级为 CSR。再配一个配置中心的降级开关,大促或者下游抖动时可以一键全站切 CSR。SSR 是优化手段,不能成为可用性的单点。

✅ 项目关联话术(按真实经历替换):

我们商品详情页原来是 CSR,首屏白屏 2.4 秒左右,而且 SEO 团队反馈页面收录不到内容。我接手后改成了 Next.js 的 SSR + CDN 缓存:页面主体服务端渲染并设置 s-maxage=60, stale-while-revalidate=600,个性化的部分(购物车数量、我的优惠券)拆成客户端组件挂载后补齐,避免个性化内容污染 CDN 缓存。改完 LCP 从 2.4s 降到 1.1s,收录量两周内涨了一倍多。同时加了渲染超时 1.5 秒自动降级 CSR 的兜底,上线半年里触发过两次下游抖动,都没造成页面不可用。

题 2:什么是水合(Hydration)?水合不匹配会发生什么、怎么排查? ​

精简口述答案:

水合是「给已有的 HTML 装上 JS 的灵魂」。服务端返回的 HTML 只是静态结构,没有事件也没有状态。客户端 JS 加载后会再跑一遍相同的组件树,但不重新创建 DOM,而是复用服务端已有的节点,只把事件监听和内部状态挂上去,这个过程就是水合。React 用 hydrateRoot,Vue 用 createSSRApp 配合已有 HTML。

水合的前提是两端渲染出的结构必须一致。一旦不一致,React 18 会抛 hydration error,并且丢弃整棵不匹配的子树重新用客户端渲染,等于这块的 SSR 白做了,而且会出现内容闪烁。Vue 则会警告并就地修正 DOM。

排查思路是倒推「两端输入的差异」:时间、随机数、typeof window 分支、localStorage、UA 判断、非法 HTML 嵌套、浏览器插件注入。定位方法是看报错指向的组件、把可疑组件换成 ClientOnly 二分、或者直接对比 view-source 的 HTML 和 DevTools 里的 DOM。

连环追问链:

  1. 追问:为什么 React 18 要把不匹配的整棵子树重渲染,而不是像 React 17 那样就地打补丁?

    • 答:因为并发渲染下「局部修补」会让 DOM 处于一个 React 自己都不认识的中间状态,后续更新可能基于错误的假设,出现更难查的 bug。React 18 选择了「一致性优于性能」:发现不匹配就放弃这块的服务端产物,退回客户端渲染,保证 DOM 和虚拟树严格对应。代价就是这块的首屏优化失效,所以它才要把问题报得这么响。
  2. 追问:suppressHydrationWarning 能解决问题吗?

    • 答:不能,它只是闭嘴,不是修复。它只对该元素的直接文本内容生效,不会递归到子树,而且 React 仍然会以客户端的渲染结果为准。它的正当用途只有一个:确实无法避免、且差异无害的场景,比如显示一个时间戳。把它当成万能消音器会掩盖真正的数据不一致。
  3. 追问:首屏点击没反应,怎么判断是不是水合的问题?

    • 答:看三个信号。① HTML 已经渲染出内容但交互无效,说明处于「HTML 已到、JS 未水合完」的窗口期;② Performance 面板里能看到一段长任务,正是水合;③ 如果控制台有 hydration error,那块会被重渲染,表现为短暂闪烁后才可交互。优化方向是减小首屏水合范围和 JS 体积,必要时上流式 SSR 的选择性水合。
  4. 追问:怎么在 CI 里防住水合不匹配?

    • 答:两层。① 单测/集成测试里把 console.error 打到 hydration 相关关键字时直接 fail,React 的 hydration error 走的就是 console.error;② E2E 里用 Playwright 加载关键页面,监听 pageerror 和 console error,发现 hydration 关键字就失败。再配一条 ESLint 规则禁止在渲染期使用 Date.now/Math.random。
  5. 追问:Vue 的水合和 React 有什么不一样?

    • 答:Vue 3 的水合更「宽容」——不匹配时会打一条 Hydration node mismatch 警告,然后就地用客户端的结果修正那个节点,不会整棵子树重来。它的代价是问题更容易被忽略,可能长期带着不一致上线。另外 Vue 要求挂载的根节点必须是服务端渲染出来的那个元素,createSSRApp 和 createApp 的区别就在这里:后者会直接清空容器重新渲染。

✅ 项目关联话术(按真实经历替换):

我们活动页上线后监控里出现大量 hydration error,LCP 反而比改 SSR 之前还慢。排查发现两个原因:一是倒计时组件直接在渲染里用了 new Date(),服务端和客户端的秒数不同;二是按 UA 判断移动端渲染了不同的组件树,而我们前面有 CDN 缓存,缓存的 HTML 和实际设备不匹配。前者改成服务端下发 ISO 时间戳、客户端 useEffect 里再启动倒计时;后者改成用 CSS 媒体查询做响应式,彻底去掉 JS 分端。另外我在 CI 的 E2E 里加了「控制台出现 hydration 关键字就失败」的卡口,之后没再漏到线上。

题 3:微前端解决什么问题?qiankun 的加载流程讲一下 ​

精简口述答案:

微前端解决的是组织和工程层面的问题,不是技术炫技。症状是:单仓库太大导致启动和构建极慢、多个团队共用一条发布流水线相互阻塞、老系统技术栈无法升级但新需求要用新框架、一个模块崩溃导致整站白屏。微前端把一个大应用拆成多个可独立开发、独立构建、独立发布的子应用,由主应用在运行时按路由组合起来。

qiankun 基于 single-spa:single-spa 负责路由劫持和生命周期调度,qiankun 补上了 HTML Entry、JS 沙箱、样式隔离和资源预加载。

流程是:主应用 registerMicroApps 注册 + start() 启动→ qiankun 重写 pushState/replaceState 并监听 popstate 来劫持路由 → URL 变化时按 activeRule 匹配出该激活的子应用 → 用 import-html-entry fetch 子应用的 HTML,正则抽出 script 和 style,HTML 剩余部分作为模板插入容器 → 创建沙箱,把每段 JS 包一层 with(window) 并把 window 换成代理对象后 eval 执行,从 UMD 导出里取出三个生命周期 → 依次调 bootstrap、mount(props) → 路由切走时调 unmount,清容器 DOM、移除子应用样式、沙箱失活回滚全局变量。

连环追问链:

  1. 追问:为什么子应用必须开 CORS,而且必须打成 UMD?

    • 答:这两件事都源于「JS 必须用 fetch 成文本再 eval」。如果直接往页面里插 <script src>,脚本会在真实 window 上执行,沙箱就完全失效了;只有拿到源码文本,才能包一层 with(window){} 把 window 替换成代理对象。而 fetch 跨域就需要服务端允许 CORS。UMD 的原因是 eval 之后要从返回值里取 bootstrap/mount/unmount 三个函数,只有 UMD 会把导出挂到全局/返回值上,ESM 和默认的 var 格式都取不到 —— 所以 library 名还必须和注册的 name 一致。
  2. 追问:qiankun 的 JS 沙箱是怎么实现的?能完全隔离吗?

    • 答:有两种。快照沙箱在激活时遍历 window 存一份快照,子应用运行期间直接改真实 window,失活时 diff 出改动并回滚 —— 它会污染全局,而且只支持单实例。Proxy 沙箱是默认实现:创建一个 fakeWindow,写操作只落到 fakeWindow,读操作先查自己再回退真实 window,所以天然支持多实例,卸载只要丢掉这个对象。
    • 但它不能完全隔离。沙箱只拦得住全局变量读写,拦不住对共享对象的修改:改 Array.prototype、动 document.body 和 document.title、往 localStorage/cookie 写同名 key、CSS 全局变量、直接 querySelector 操作主应用 DOM。这些只能靠规范约束。另外 qiankun 还对 setTimeout、addEventListener、document.head.appendChild 等打了补丁,卸载时统一回收副作用。
  3. 追问:子应用切走再切回来,为什么 bootstrap 不会重新执行?

    • 答:因为子应用的 JS 只被 fetch 和 eval 一次,三个生命周期函数之后是缓存复用的。所以写在模块顶层的代码只跑一次,写在 mount 里的每次都跑。这条规则直接决定了一个常见 bug:如果把需要重置的状态(单例、定时器、事件监听)放在模块顶层或者 mount 里却不在 unmount 清掉,切换几次就会叠加,表现为内存持续上涨和回调被重复触发。
  4. 追问:样式隔离你们是怎么做的?Shadow DOM 为什么不推荐?

    • 答:我们用的是组合方案:子应用一律 CSS Modules 或 scoped,UI 库设置独立的 prefixCls,再开 experimentalStyleIsolation 兜底 —— 它会在运行时给选择器加 div[data-qiankun="app"] 前缀,零改造成本。再加一条规范:禁止写裸的 body、*、标签选择器。
    • strictStyleIsolation 也就是 Shadow DOM 虽然隔离最彻底,但坑太多:UI 库的弹窗、下拉、message 默认挂到 document.body,跑到 shadowRoot 外面就完全没样式;事件会被 retarget 导致第三方库拿不到正确的 target;很多库内部 document.querySelector 直接找不到节点。改造成本远大于收益,我们只在接入完全不可控的外部应用时才会考虑。
  5. 追问:微前端的调试和线上问题定位怎么做?

    • 答:几个关键手段。① 子应用必须能独立启动运行(用 __POWERED_BY_QIANKUN__ 判断,独立时走自己的入口),日常开发 90% 时间在独立模式下调,效率最高;② sourcemap 要能映射到子应用源码,否则 eval 出来的代码栈不可读;③ 全局 onerror 和 unhandledrejection 按当前激活的子应用名打标,上报到各自的监控项目,避免所有报错都归到主应用头上;④ 在 beforeLoad/afterMount 打点统计各子应用的加载和挂载耗时;⑤ 子应用注册表由接口下发,支持按用户灰度和故障时一键摘除。
  6. 追问:HTML Entry 拿到 HTML 之后到底怎么处理的?为什么不直接整段 innerHTML 塞进容器?

    • 答:拿到的是一段 HTML 文本,不是 DOM。它会正则扫一遍,把 <script>(内联的取代码、外链的记地址)和 <style>/<link> 全部从字符串里剔除、单独收集起来,剩下的 HTML 骨架才 innerHTML 进容器。
    • 不能整段塞的原因很直接:innerHTML 插入的 <script> 浏览器根本不会执行,这是原生规则;即便会执行,它也是在真实 window 上跑,沙箱就没意义了。所以脚本必须摘出来,由我们决定「什么时候跑、在谁的 window 上跑」。
    • 骨架插进去之后是个中间态:DOM 节点在了(#app 就位),但 JS 没跑、样式也还没插,页面还是空白的 —— 真正的渲染是 execScripts 执行完、子应用自己 mount 到 #app 才发生的。脚本执行还必须按 HTML 里的出现顺序串行,不能并发,否则 app.js 可能先于 vendors 执行,报 Vue is not defined。
    • 顺带说下为什么是正则而不是 DOMParser:DOMParser 解析时会提前触发部分资源加载,加载时机不可控,而且解析完还得自己 append。代价是正则解析 HTML 本身不严谨 —— 注释里或 JS 字符串里出现 </script> 就会截断错位,这是 import-html-entry 的已知短板。

✅ 项目关联话术(按真实经历替换):

我们的运营后台原来是一个单体 Vue2 项目,四个小组在同一个仓库里开发,装依赖加启动将近四分钟,每次发版都要等所有人的功能都测完,平均两周才能发一次。我主导拆成了 qiankun 微前端:主应用只保留登录、菜单、权限和公共布局,四个业务域拆成独立子应用,其中新建的两个用了 Vue3。

落地过程中踩的坑主要有三个:一是子应用没配 publicPath 注入,异步 chunk 全部按主应用域名去取导致 404;二是切换子应用后定时器和 WebSocket 没在 unmount 里清,切十几次浏览器内存涨到两百多兆,后来统一约定了一份「unmount 清理清单」并在 code review 里卡;三是两个子应用的 Ant Design 版本不同导致样式互相覆盖,最后给每个子应用设了独立 prefixCls 并开了 experimentalStyleIsolation。

拆完之后单个子应用启动降到 40 秒以内,各组可以独立发版,发布频率从两周一次变成按需随时发。

题 4:SSR 项目里为什么不能用模块级单例?数据怎么从服务端传到客户端? ​

精简口述答案:

因为浏览器是一个用户一个运行时,服务端是所有用户共用一个 Node 进程。模块在进程里只会被 require 一次,任何写在模块顶层的 store、router、axios 实例、缓存对象,都会被所有并发请求共享。结果就是请求 A 写进去的用户信息,被请求 B 渲染到了 HTML 里 —— 这是会出安全事故的越权泄漏,而且单人手测永远测不出来。

正确做法是所有带状态的东西都用工厂函数,每个请求创建一份:createApp()、createStore()、createRouter()、axios.create()。需要在深层代码里隐式拿到请求上下文时,用 Node 的 AsyncLocalStorage,而不是挂 global。

数据传递靠注水/脱水:服务端请求完数据,一边渲染 HTML,一边把数据序列化塞进页面的一个 <script> 里;客户端启动时先把它读出来填进 store 或 queryClient,再执行水合,这样组件首帧就有数据,不会二次请求。序列化这一步必须转义 <、> 和 U+2028/U+2029,并且只注水首屏需要的字段,否则既有 XSS 风险也会泄漏敏感数据。

连环追问链:

  1. 追问:注水的数据直接 JSON.stringify 塞进 <script> 有什么问题?

    • 答:两个问题。① 数据里只要出现 </script> 这个字符串,HTML 解析器就会提前闭合脚本标签,后面的内容被当成 HTML 解析,攻击者可以注入任意脚本,这是经典的 XSS;② JSON 允许 U+2028/U+2029 这两个字符,但 JS 字面量里它们是换行符,会直接导致语法错误、整页白屏。所以要么把 <、>、U+2028、U+2029 全部转义成 \u00xx,要么改用 <script type="application/json"> 承载、运行时 JSON.parse,后者更稳。
  2. 追问:Next.js 里 getServerSideProps 返回的 props 有什么要注意的?

    • 答:它返回的东西会原封不动地出现在 HTML 的 __NEXT_DATA__ 里,任何人右键查看源码都能看到。所以绝不能把整个数据库查询结果或者整个用户对象直接返回,必须挑字段。另外 props 必须是可 JSON 序列化的,Date、undefined、Map 都会报错或丢类型 —— 需要富类型就上 superjson 这类库。
  3. 追问:服务端已经请求过的数据,客户端为什么还会再请求一次?

    • 答:说明注水没接上。常见原因是:客户端的请求 key 和服务端 prefetch 时用的 key 不一致(多了个参数、顺序不同);或者 store 初始化时没读 window.__DATA__ 就直接用了默认值;或者 React Query 的 HydrationBoundary 没包到对应的组件树上。表现是首屏数据闪一下又重新 loading,网络面板里能看到重复请求。
  4. 追问:AsyncLocalStorage 为什么能解决这个问题,和挂 global 有什么区别?

    • 答:global.ctx = req 在并发下必然被覆盖 —— 请求 A 还在 await 的时候,请求 B 已经把 global.ctx 改了,A 恢复执行时读到的就是 B 的。AsyncLocalStorage 基于 async_hooks,它把存储绑定在异步执行上下文上,每条异步调用链各看到自己那份,跨 await 也不会串。代价是有一定性能开销,而且在一些第三方库里丢失上下文时很难查。
  5. 追问:你们怎么验证没有跨请求污染?

    • 答:压测时混打。用 autocannon 或自己写脚本,拿多个不同用户的 token 并发打同一个 SSR 路由,把返回的 HTML 里的用户标识抽出来,和请求用的 token 对一遍,出现任何不一致就是污染。另外在 code review 里卡一条规则:模块顶层不允许出现 new、createStore、createRouter、axios.create 这类产生实例的语句,必须包在工厂函数里。

✅ 项目关联话术(按真实经历替换):

我们接手一个老 Vue SSR 项目时,线上偶发用户看到别人昵称的投诉,一直查不出来。我 review 代码发现 store/index.js 直接 export default new Vuex.Store({...}),是模块级单例 —— 本地单人访问永远正常,只有并发时才串。改成 createStore() 工厂、在每个请求的 createApp 里实例化之后问题消失。

之后我做了两件事防复发:一是写了个压测脚本,用 20 个不同 token 并发打首页,断言返回 HTML 里的用户 ID 必须和请求 token 一致,接进了上线前的流水线;二是加了 ESLint 规则禁止在模块顶层创建 store/router/axios 实例。同时顺手修了注水的 XSS 隐患 —— 原来是裸 JSON.stringify 拼进 script 的,改成了转义 + type="application/json"。

题 5:流式 SSR 是什么?和普通 SSR 比优势在哪,有什么代价? ​

精简口述答案:

普通 renderToString 是同步阻塞的:必须等所有数据都拿到、整棵树都渲染完,才能发出完整 HTML,所以 TTFB 等于最慢那个接口的耗时。流式 SSR 用 renderToPipeableStream,配合 Suspense 把页面切成多个块:外壳渲染完就立刻开始推送,浏览器马上能绘制;慢的区块先输出 fallback 骨架,数据到了再追加一小段 HTML 和脚本把占位替换掉。

优势是 TTFB 和 FCP 大幅提前,慢模块不再阻塞首屏,而且配合选择性水合 —— 谁的 HTML 先到谁先水合,用户点哪里 React 优先水合哪块,TTI 体验也更好。单个 Suspense 边界出错只降级那一块,不会整页 500。

代价有三个:① 发出第一个 chunk 后 HTTP 状态码和响应头就改不了了,所以鉴权、重定向、404 判断必须在渲染前做完;② 中间链路(Nginx、网关、CDN)如果开了响应缓冲,流式会被重新攒成一整包发出,优化完全白做;③ 心智成本更高,要自己规划 Suspense 边界,还要加超时 abort 兜底。

连环追问链:

  1. 追问:onShellReady 和 onAllReady 该用哪个?

    • 答:面向真实用户用 onShellReady,这才是流式的意义 —— 外壳好了就发。onAllReady 是等所有 Suspense 都 resolve 再一次性发完整 HTML,用在爬虫和静态生成场景:爬虫不一定执行后续 chunk 的脚本,SSG 也需要完整产物。实际项目里常常根据 UA 判断是爬虫就走 onAllReady。
  2. 追问:流式 SSR 下 404 和重定向怎么处理?

    • 答:必须在渲染之前决定。路由匹配、鉴权、资源存在性检查都放在渲染前的中间件里,该 302 就 302、该 404 就 404。如果一个资源不存在只能在渲染中途才发现(比如深层组件取数据失败),那时候 header 已经发出去了,只能两个办法:要么提前到渲染前预检,要么接受「返回 200 + 页面内渲染一个 not found」,但这对 SEO 不友好。所以我们的约定是所有影响状态码的判断一律前置。
  3. 追问:怎么知道中间层把流给缓冲了?

    • 答:最直接的办法是 curl -N 请求,观察输出是不是分批到达、还是最后一次性吐出来;也可以看响应头有没有 Transfer-Encoding: chunked、有没有被加上 Content-Length(有 Content-Length 说明已经被完整缓冲了)。Nginx 要关 proxy_buffering off,并注意 gzip 模块也可能攒包,CDN 侧要确认支持流式回源。
  4. 追问:Suspense 边界应该怎么划?

    • 答:原则是「按数据依赖和重要性划,不按组件结构划」。首屏必须立刻看到的(导航、标题、主图、价格)放在外壳里,用服务端最快的数据源;依赖慢接口或低优先级的(推荐列表、评论、广告)各包一个 Suspense。边界不能太细,每个边界都有运行时开销和一段注入脚本;也不能太粗,否则一个慢接口又把一大片拖住了。
  5. 追问:流式 SSR 和 RSC 是一回事吗?

    • 答:不是,经常被混。流式 SSR 解决的是「HTML 怎么分批发出去」;RSC 解决的是「哪些组件的代码根本不下发到客户端」。RSC 产出的是序列化的 UI 描述流而不是 HTML,组件代码永不进客户端 bundle,所以可以直接 await 数据库。两者是互补的:Next App Router 里 RSC 的输出就是通过流式传输的,但它们是两个独立的概念。

✅ 项目关联话术(按真实经历替换):

我们的商详页首屏要聚合五个接口,其中推荐和评论两个平均 600ms 以上,导致 TTFB 被拖到 900ms。我把页面改成了 renderToPipeableStream:商品主信息留在外壳,推荐位和评论各包一个 Suspense 出骨架屏,并加了 3 秒 abort 兜底。

上线时踩了两个坑:一是 Nginx 默认开着 proxy_buffering,本地流式正常、上预发完全没效果,curl -N 一看是整包返回的,关掉缓冲才生效;二是原来有个「商品下架返回 404」的逻辑写在组件里,流式之后状态码已经发出去改不了了,我把它前置到了渲染前的中间件。

最终 TTFB 从 900ms 降到 120ms 左右,LCP 提升约 35%,推荐接口偶发超时也不再影响首屏可用。

题 6:微前端的样式冲突、内存泄漏、公共依赖这几个问题你们怎么解决的? ​

精简口述答案:

这三个是微前端落地后最真实的三座大山。

样式冲突:组合方案。子应用一律 CSS Modules 或 scoped;UI 库设置独立 prefixCls/命名前缀避免组件库间互相覆盖;开 experimentalStyleIsolation 做运行时选择器加前缀兜底;再加一条硬规范 —— 禁止写裸的 body、*、标签选择器和全局 CSS 变量。Shadow DOM 只在接入不可控外部应用时才用,因为弹窗和第三方库大面积不兼容。

内存泄漏:根因是子应用 JS 只执行一次、生命周期被缓存复用,所以 mount 里产生的副作用如果 unmount 没清就会逐次累积。我们统一约定了一份清理清单:定时器、长连接、ResizeObserver/IntersectionObserver、全局事件监听、全局状态订阅、框架实例,全部在 unmount 里显式释放并置空。验证方式是反复切换 20 次子应用,在 DevTools Memory 面板里强制 GC 后看 detached node 和堆大小是否回落。

公共依赖:用 externals + CDN 把 React/Vue 这类运行时抽成一份,或者上 Module Federation 的 shared。代价是所有子应用被锁在同一大版本上,升级必须全局协同,所以我们在 CI 里加了依赖版本一致性校验,不一致直接拦合并。

连环追问链:

  1. 追问:experimentalStyleIsolation 的原理和局限是什么?

    • 答:原理是在插入子应用样式时,用一个 CSS 解析器遍历所有规则,给每条选择器前面加上 div[data-qiankun="appName"] 这样的属性选择器,把作用域限制在子应用容器内。局限有几个:对 @media、@keyframes、@supports 这类 at-rule 的处理不完整;body/html 这种选择器会被改写成容器选择器,语义变了;运行时动态插入的 style 可能漏掉;以及选择器层级变深带来的匹配开销。它是兜底手段,不能替代规范。
  2. 追问:怎么定位到底是哪个子应用在泄漏?

    • 答:几步。① 在 afterMount/afterUnmount 打点记录 performance.memory.usedJSHeapSize,看切哪个应用后不回落;② 只反复切换两个应用做二分;③ Memory 面板打两个堆快照做 Comparison,看新增的 detached DOM 和闭包指向哪个文件;④ 重点查 setInterval、addEventListener('resize'|'scroll')、WebSocket、第三方 SDK 的 init,这几类占了泄漏的绝大多数。
  3. 追问:公共依赖不统一会出什么具体问题?

    • 答:最典型的是 React 两份实例导致 hooks 报错 —— Invalid hook call,因为 hooks 依赖模块内的 dispatcher 单例。其次是组件库样式互相覆盖、instanceof 判断失效、Context 跨实例拿不到值、以及体积翻倍。反过来强制统一的代价是「一个子应用想升 React 18,所有人都得跟着升」,所以要在「隔离」和「共享」之间取舍:我们的策略是框架运行时强制统一、业务库和 UI 库允许各自独立并靠前缀隔离。
  4. 追问:子应用加载失败怎么办?

    • 答:要做熔断降级。在 registerMicroApps 的错误回调里统计失败次数,连续失败 N 次就把该应用从注册表摘掉并在容器里渲染一个降级页,保证其他应用还能用。子应用注册表我们是接口下发的,所以也可以服务端直接下掉某个应用或回滚到上一个版本的 entry。同时全局 onerror 按当前激活应用打标上报,能第一时间知道是谁挂了。
  5. 追问:微前端的子应用能独立开发调试吗?怎么做?

    • 答:必须能,否则开发效率会崩。做法是用 window.__POWERED_BY_QIANKUN__ 判断运行环境:为 true 时导出三个生命周期、读注入的 publicPath、路由 base 加前缀;为 false 时走自己的 main.js 正常挂载到 #app、base 为 /。日常开发基本都在独立模式下进行,只在联调和回归时起主应用。另外 sourcemap 一定要配好,否则 eval 出来的代码栈完全不可读。

✅ 项目关联话术(按真实经历替换):

我们微前端上线后一个月内处理过三类线上问题,正好对应这三座山。

样式上,两个子应用的 Ant Design 版本不同,.ant-btn 的样式互相覆盖,表现为按钮在某些路径下变形。最终给每个子应用设了独立 prefixCls 并通过 less 变量定制,再开 experimentalStyleIsolation 兜底。

内存上,有个带实时大盘的子应用,切换十几次后浏览器内存涨到两百多兆、页面明显卡顿。查出来是 mount 里起的 setInterval 轮询和 WebSocket 都没在 unmount 关。我们沉淀了一份「unmount 清理清单」写进团队规范,并在 code review 里作为必查项,同时加了「反复切换 20 次后堆大小必须回落」的手工验收步骤。

依赖上,一个子应用偷偷升了 React 小版本并且没配 externals,导致页面里出现两份 React,直接 Invalid hook call 白屏。之后我在 CI 里加了依赖版本一致性校验脚本,核心框架版本不一致就直接拦住合并。

题 7:Next.js App Router 里,我明明改了数据库,页面还是旧的,怎么排查? ​

精简口述答案:

因为 App Router 默认是激进缓存的,一次请求会穿过四层缓存,任何一层命中都拿不到新数据。从外往里是:浏览器的 Router Cache(客户端路由缓存,30 秒到 5 分钟)、服务端的 Full Route Cache(整条路由的 HTML 和 RSC Payload)、Data Cache(fetch 结果,跨请求甚至跨部署存活),以及单次渲染内的 Request Memoization(同一个 URL 的重复 fetch 只真正发一次,是去重不是缓存)。

排查我按从外往里剥:先硬刷新,如果好了就是 Router Cache,说明写操作后没调 router.refresh() 或没走 Server Action;刷新还旧就看响应头 x-nextjs-cache 是不是 HIT,是的话问题在 Full Route Cache,这条路由被静态化了;再不行就是 Data Cache,检查 fetch 的 cache / revalidate 配置,以及写侧有没有调 revalidateTag / revalidatePath。

根因通常只有一个:只写了读的那一半,没写失效的那一半。 正确的做法是读侧给 fetch 打 tags,写侧在变更成功后 revalidateTag 精准失效。

连环追问链:

  1. 追问:Data Cache 和 Full Route Cache 到底什么区别?

    • 答:粒度和内容不同。Data Cache 缓存的是一次 fetch 的结果,是数据层,多个路由可以共用同一份;Full Route Cache 缓存的是整条路由渲染完的产物(HTML + RSC Payload),是渲染层。所以 Data Cache 失效了,路由还会重新渲染一次;而 Full Route Cache 命中时连渲染都不会发生。revalidateTag 打的是前者(会连带使相关路由失效),revalidatePath 打的是后者。
  2. 追问:为什么说「重新部署一下就好了」是错的?

    • 答:因为 Data Cache 是跨部署存活的,它存在持久化存储里(自托管时是 .next/cache,Vercel 上是独立的缓存服务),发版不会清。能被发版清掉的是 Full Route Cache。这个差异导致很多人「本地改好、线上发版、数据还是旧的」,然后去怀疑数据库主从延迟,方向全错。
  3. 追问:哪些写法会让一条路由从静态变成动态?

    • 答:用了 cookies()、headers()、searchParams、draftMode() 这些依赖本次请求的 API,或者 fetch 带了 cache: "no-store",或者显式 export const dynamic = "force-dynamic"。危险在于它是隐式传染的:有人为了加个埋点在深层组件里读了一下 headers(),整条路由就从 CDN 直出退化成每次回源。所以我会在 CI 里对比 next build 输出的路由表,○ Static 变 ƒ Dynamic 就告警。
  4. 追问:不是 fetch 的数据源怎么缓存?比如 Prisma 查库。

    • 答:Data Cache 是挂在 Next 改写过的 fetch 上的,直接查库不走它。要用 unstable_cache 把查询函数包一层,显式给 key 前缀、revalidate 和 tags,这样才能享受同样的缓存和按 tag 失效的能力。另外同一次渲染内的去重也没了,需要用 React 的 cache() 包一下做 memoization。
  5. 追问:Server Action 里改完数据,客户端的 Router Cache 会自动更新吗?

    • 答:会。Server Action 执行完返回时会携带新的 RSC Payload,Next 会用它刷新当前路由的 Router Cache,所以在 Action 里改数据是最省心的路径。但如果是用普通的 fetch 调 Route Handler 改数据,客户端完全不知道服务端变了,必须自己调 router.refresh(),这是两种写法体验差异的根源。

✅ 项目关联话术(按真实经历替换):

我们内容站迁到 App Router 后,运营反馈「后台改了文章,前台要等很久才变」。当时第一反应是去查 CDN,结果绕了一圈发现是 Data Cache —— 读侧写的是默认 fetch,写侧在另一个管理后台项目里,根本没调过 revalidateTag。

后来我们定了两条规范:一是所有 fetch 必须显式声明缓存语义,不允许用默认值,CI 里加 lint 规则检查;二是读写配对,读侧打 post:{id} 这样的 tag,写侧在事务提交后调同名 revalidateTag,管理后台走一个内部的 revalidate 接口通知前台。

另外顺手修了个性能问题:有人在页脚组件里读了 headers() 拿 UA 做埋点,导致全站路由都变成动态渲染,首屏 TTFB 从几十毫秒涨到两百多毫秒。改成客户端组件里上报之后恢复了,也因此在 CI 里加上了静态/动态路由数量的对比告警。

题 8:SSR 项目里,同一份取数代码要在浏览器和 Node 里都跑,请求层怎么封装? ​

精简口述答案:

关键是认清两端的差异,然后在请求层里把差异收口,业务代码只管调。差异主要五条:URL(服务端不支持相对路径,必须补 baseURL)、身份(浏览器自动带 cookie,服务端必须手动透传本次请求的 cookie)、链路信息(真实 IP 和 traceId 要透传,否则后端风控把所有用户当成 SSR 服务器那一个 IP,全链路追踪也在这一跳断掉)、超时(服务端必须比浏览器更短,它拖的是所有用户)、错误处理(401 在浏览器可以直接跳登录,在服务端只能抛给上层去决定是重定向还是渲染游客态)。

实现上用 typeof window === "undefined" 分叉,服务端那一支从 AsyncLocalStorage 里取当前请求的上下文来拼 header。这里最大的红线是:透传的 cookie 绝对不能写到模块级变量或 axios 实例的 defaults 上,那就是跨请求污染,会直接导致用户看到别人的数据。

另外服务端通常走内网域名直连,省掉一次公网往返和网关开销,但代价是这一跳在网关日志里不可见,必须自己补接口耗时和错误率的埋点。

连环追问链:

  1. 追问:为什么服务端不能用相对路径?

    • 答:相对路径是相对于「当前文档的 origin」解析的,这是浏览器的概念。Node 里没有「当前页面」,fetch("/api/user") 拿不到 origin,所以直接报 Only absolute URLs are supported(或等价错误)。很多人本地没发现,是因为开发时那段代码只在客户端的 useEffect 里跑过,一旦挪到服务端取数就炸。
  2. 追问:为什么服务端超时必须比客户端短?

    • 答:影响面不同。浏览器里一个请求慢,只有那一个用户等;服务端一个请求慢,会占住一个渲染流程、拖长所有命中该路由的用户的 TTFB,并且连接和内存都被挂住,极端情况下把 Node 的事件循环和 socket 池打满,变成服务级故障。所以我一般给服务端 1~2 秒,超时就降级 —— 要么这块渲染成骨架交给客户端补,要么整页退回 CSR。
  3. 追问:透传 cookie 有什么安全风险?

    • 答:两个。一是过度透传:把用户所有 cookie 原样转给所有下游,包括第三方埋点和一些敏感凭证,应该按白名单只转需要的;二是污染:如果把 cookie 设到全局实例上,并发时请求 A 的身份会被用于请求 B 的查询,这是会出安全事故的越权。正确做法是把身份作为参数一路传下去,或者用 AsyncLocalStorage 绑在异步上下文上。
  4. 追问:服务端取数走内网还是走公网域名?

    • 答:优先内网。走公网要多一次 DNS、TLS 和网关转发,而且 SSR 服务和后端通常同机房,内网延迟能低一个量级。代价是三点:环境变量要按环境区分、本地开发可能访问不到内网(需要代理或 mock)、以及这一跳的流量不再经过网关,监控和限流能力要在 SSR 侧自己补齐。
  5. 追问:服务端已经取过的数据,客户端怎么不重复请求?

    • 答:靠注水。服务端把取数结果序列化进 HTML,客户端初始化状态时读它,而不是再发一次请求。用数据请求库的话就是 React Query 的 dehydrate/HydrationBoundary 或 Nuxt 的 payload 机制 —— 注意 Nuxt 里 useFetch/useAsyncData 会参与 payload 传递,而裸 $fetch 不会,服务端用了 $fetch 客户端还会再请求一遍。踩过这个坑之后我的规则是:组件初始化数据用 useFetch,用户交互触发的请求用 $fetch。

✅ 项目关联话术(按真实经历替换):

我们第一版 SSR 请求层就是把客户端的 axios 实例直接搬到了服务端,上线后出了两个问题。

第一个是风控误杀:后端看到的请求 IP 全是 SSR 容器的几个 IP,频控规则直接把正常用户当成刷接口拦了。排查到后在服务端透传了 x-forwarded-for,并和后端约定好信任链。

第二个更严重:有同学为了方便,在中间件里写了 axios.defaults.headers.common.Authorization = token,压测时就出现了用户信息串号。改成每个请求 axios.create 新实例、上下文走 AsyncLocalStorage 之后解决,同时加了 ESLint 规则禁止修改全局 defaults。

现在这份请求层统一做了四件事:服务端补内网 baseURL、按白名单透传 cookie 和链路头、两端不同的超时阈值、以及 401 在服务端抛专用错误交给页面层决定降级策略。

题 9:Vite 技术栈的子应用怎么接进 qiankun?登录态又该谁管? ​

精简口述答案:

先说为什么难接:qiankun 的实现前提是 UMD + eval——它把子应用的脚本 fetch 成字符串,在沙箱里 eval 执行,再从 window[appName] 上取三个生命周期。而 Vite 的产物是 原生 ESM(<script type="module">)。eval 里不是模块作用域,所以 import / export / import.meta 全都是语法错误,生命周期也挂不到 window 上,直接报「need to export lifecycle」。

解法有三条:最省事的是用 vite-plugin-qiankun,它在构建时注入胶水代码把生命周期挂到全局、开发态把入口改写成非 module 脚本;或者把子应用按 build.lib 打成 UMD;如果技术栈已经全面 Vite,更推荐直接换 wujie / micro-app,它们基于 iframe + WebComponent,原生支持 ESM,不靠 eval。

还有一个必须讲清楚的事实:Vite 子应用在 qiankun 里 JS 沙箱实质是失效的,module 脚本由浏览器直接执行,绕过了代理 window,全局污染只能靠规范约束。

登录态则必须单点归主应用:主应用负责登录、刷新 token、下发用户信息和权限码,并且是唯一能跳登录页的人;子应用只消费注入的 request 和权限,401 一律上抛。否则就会出现用户在 A 应用操作时、被 B 应用的后台轮询踢回登录页这种无法复现的问题。

连环追问链:

  1. 追问:报 Cannot use 'import.meta' outside a module 时你会先查什么?

    • 答:不查业务代码。这个报错几乎可以直接定性为「ESM 产物被 eval 执行了」,排查方向是产物形态:看主应用加载的那个 entry HTML 里的 script 标签有没有 type="module",看插件是否生效、useDevMode 配了没、生产构建是否按预期注入了胶水代码。顺着报错去改业务代码会完全跑偏。
  2. 追问:Vite 子应用在开发态还有哪些额外的坑?

    • 答:三个。① server.cors 必须开,否则主应用 fetch entry 直接跨域失败;② 要配 server.origin,不然 dev 下静态资源的 URL 会按主应用域名拼出来变成 404;③ HMR 的 WebSocket 要显式指定 host 和 port,多个子应用同时起容易连错端口,而且子应用 unmount 后 HMR 连接不会自动断,切换几次后会残留一堆 WebSocket。
  3. 追问:既然 Vite 子应用沙箱失效,那隔离怎么保证?

    • 答:技术上只剩样式侧还能兜(experimentalStyleIsolation 或 Shadow DOM),JS 侧只能靠规范:禁止往 window 挂业务变量、全局事件和定时器必须在 unmount 清理、localStorage 加应用前缀。如果业务对隔离有强要求,这个方案就不该选 —— 我会在技术评审时把这条作为明确的风险项提出来,而不是等上线后出问题。
  4. 追问:微前端下 token 过期刷新怎么做才不会出问题?

    • 答:必须单点刷新 + 并发挂起队列。微前端下常有多个子应用在同时轮询,token 一过期会瞬间打出十几个 401。如果每个请求各自去刷新,部分后端实现里后发的刷新会让先发的 token 失效,形成「刷新风暴 → 全部失效 → 用户被踢」。正确做法是主应用维护一个 refreshing Promise,刷新期间所有请求挂进等待队列,刷完统一重放。
  5. 追问:子应用怎么既能被主应用集成、又能独立调试?

    • 答:所有依赖主应用的能力都走「注入 + 兜底」两条路。用 __POWERED_BY_QIANKUN__ 判断环境:被集成时用 props 注入的 request、用户信息、权限码、容器节点和路由 base;独立运行时走自己的登录流程和默认 request,挂载到 #app、base 为 /。关键是不要让业务组件直接读全局,一律通过 provide/inject 或 Context 拿,这样两种模式才能共存。

✅ 项目关联话术(按真实经历替换):

我们有个新子应用是 Vite + Vue3,接 qiankun 时卡了半天,报的是 Unexpected token 'export'。当时我们顺着报错去看业务代码完全没方向,后来意识到是 eval 执行 ESM 的必然结果,才转向产物形态。最后用 vite-plugin-qiankun 接通了,但我在技术文档里明确写了两条风险:这个插件对 Vite 大版本升级敏感,以及该子应用的 JS 沙箱是失效的。

登录态上我们之前踩过一个很难复现的问题:用户在订单子应用填表单时突然被踢回登录页。查了很久才发现是另一个带实时大盘的子应用在后台轮询,token 过期后它自己拦截 401 执行了 location.href = '/login',把正在填表的用户也带走了。

之后我们做了收口:主应用提供统一 request 通过 props 注入,所有 401 冒泡给主应用处理,刷新走单点 Promise + 挂起队列;子应用禁止出现任何跳登录页的代码,code review 里直接 grep location.href 卡这一条。

✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】 ​

题 1:手写一个极简 SSR 同构服务(含数据注水与安全转义) ​

核心实现:

js
// server.js —— 不依赖任何框架,把 SSR 的四件事都做全
const express = require("express");
const React = require("react");
const { renderToString } = require("react-dom/server");
const App = require("./src/App").default;

const app = express();
app.use("/static", express.static("dist"));

// ① 安全序列化:防 </script> 闭合 + 防 U+2028/U+2029 语法错误
function safeJSON(data) {
  return JSON.stringify(data)
    .replace(/</g, "\\u003c")
    .replace(/>/g, "\\u003e")
    .replace(/\u2028/g, "\\u2028")
    .replace(/\u2029/g, "\\u2029");
}

// ② 每个请求一套独立上下文,杜绝跨请求污染
function createRequestContext(req) {
  return {
    traceId: req.headers["x-trace-id"] || Math.random().toString(36).slice(2),
    api: createApi(req.headers.cookie), // axios.create(),不要用全局实例
    store: createStore(), // 工厂函数,不要模块级单例
  };
}

app.get("*", async (req, res) => {
  const ctx = createRequestContext(req);

  try {
    // ③ 渲染前取数(带超时,单个慢依赖不能拖垮进程)
    const data = await withTimeout(fetchPageData(ctx, req.path), 1500);

    const html = renderToString(React.createElement(App, { url: req.path, data }));

    // ④ 只注水首屏必需字段,绝不把整个数据源下发
    const initial = safeJSON(pick(data, ["list", "total", "pageTitle"]));

    res.status(200).send(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>${escapeHtml(data.pageTitle)}</title>
</head>
<body>
  <div id="root">${html}</div>
  <script id="__DATA__" type="application/json">${initial}</script>
  <script src="/static/client.js" defer></script>
</body>
</html>`);
  } catch (err) {
    // ⑤ 兜底:渲染失败/超时就降级 CSR,绝不白屏、绝不 500
    console.error("[ssr-fallback]", ctx.traceId, err);
    res.status(200).send(`<!DOCTYPE html><html><body>
  <div id="root"></div>
  <script src="/static/client.js" defer></script>
</body></html>`);
  }
});

function withTimeout(p, ms) {
  return Promise.race([p, new Promise((_, rj) => setTimeout(() => rj(new Error("SSR_TIMEOUT")), ms))]);
}
function escapeHtml(s = "") {
  return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function pick(obj, keys) {
  return keys.reduce((o, k) => (k in obj ? ((o[k] = obj[k]), o) : o), {});
}

app.listen(3000);
jsx
// client.js —— 客户端入口:先脱水,再水合(而不是 render)
import { hydrateRoot } from "react-dom/client";
import App from "./src/App";

const raw = document.getElementById("__DATA__")?.textContent || "{}";
const data = JSON.parse(raw);

hydrateRoot(document.getElementById("root"), <App url={location.pathname} data={data} />);
// ⚠️ 必须是 hydrateRoot;用 createRoot().render() 会清空容器重渲染,SSR 白做

三个必须记住的点: ① 注水必须转义,裸 JSON.stringify 拼进 <script> 等于开了个 XSS 后门;② 所有带状态的实例必须 per-request,模块级单例会跨请求串数据;③ 必须有超时和降级,SSR 是优化手段不能变成可用性单点,渲染失败要退回 CSR 而不是返回 500。

✅ 边界 Case 自测:

  • 数据里包含 </script>、<img onerror=...>、U+2028 时,页面是否仍正常且不执行注入脚本
  • 并发 50 个不同 token 的请求,返回 HTML 里的用户标识是否与请求一一对应(跨请求污染)
  • 把数据接口人为 delay 到 5 秒,是否在 1.5 秒时降级 CSR 而不是挂起
  • 数据接口抛错时,是否返回 200 的 CSR 壳子,而不是 500 白屏
  • 把 hydrateRoot 换成 createRoot().render(),观察容器是否被清空重渲染(验证水合的意义)
  • 组件内用 new Date(),刷新页面看控制台是否出现 hydration 警告
  • 路由不存在时是否真的返回 404 状态码,而不是 200 的「页面不存在」
  • 关掉 JS 后页面是否仍能看到首屏内容(验证 SSR 真的生效)

🔁 变种拓展题:

js
// 变种 1:改成流式 SSR,TTFB 只等外壳
const { renderToPipeableStream } = require("react-dom/server");

app.get("*", (req, res) => {
  let didError = false;
  const { pipe, abort } = renderToPipeableStream(<App url={req.path} />, {
    bootstrapScripts: ["/static/client.js"],
    onShellReady() {
      res.statusCode = didError ? 500 : 200; // 最后一次能改状态码的机会
      res.setHeader("Content-Type", "text/html; charset=utf-8");
      pipe(res);
    },
    onShellError() {
      res.status(500).send('<div id="root"></div><script src="/static/client.js"></script>');
    },
   onError(e) {
      didError = true;
      console.error(e);
    },
  });
  setTimeout(abort, 3000); // 超时放弃剩余 SSR,交给客户端
});
js
// 变种 2:加一层 LRU 页面缓存 + 并发合并(防缓存击穿)
const cache = new Map(); // 生产请换成 LRU / Redis
const inflight = new Map();

async function renderWithCache(key, render) {
  const hit = cache.get(key);
  if (hit && hit.expire > Date.now()) return hit.html; // 命中
  if (inflight.has(key)) return inflight.get(key); // 同 key 并发只渲染一次

  const task = render()
    .then((html) => {
      cache.set(key, { html, expire: Date.now() + 60_000 });
      return html;
    })
    .finally(() => inflight.delete(key));

  inflight.set(key, task);
  return task;
}
// cache key 必须包含所有影响渲染的维度,否则串缓存:
// `${path}:${lang}:${isMobile}:${abBucket}` —— 带个人信息的页面不要进这层缓存

题 2:手写 JS 沙箱(快照沙箱 + Proxy 沙箱) ​

核心实现:

js
// 方案一:快照沙箱(ES5 可用,单实例)
class SnapshotSandbox {
  constructor(name) {
    this.name = name;
    this.active = false;
    this.snapshot = {}; // 激活时 window 的快照
    this.modifyMap = {}; // 失活时记录的「子应用改过的属性」
  }

  activate() {
    this.snapshot = {};
    // 1. 存一份当前 window 的浅快照
    for (const key in window) {
      if (Object.prototype.hasOwnProperty.call(window, key)) {
        this.snapshot[key] = window[key];
      }
    }
    // 2. 把上次记录的改动还原回去(二次激活时恢复现场)
    Object.keys(this.modifyMap).forEach((k) => (window[k] = this.modifyMap[k]));
    this.active = true;
  }

  deactivate() {
    this.modifyMap = {};
    for (const key in window) {
      if (Object.prototype.hasOwnProperty.call(window, key)) {
        if (window[key] !== this.snapshot[key]) {
          this.modifyMap[key] = window[key]; // 记录改动
          window[key] = this.snapshot[key]; // 还原 window
        }
      }
    }
    this.active = false;
  }
}
js
// 方案二:Proxy 沙箱(默认实现,支持多实例)
class ProxySandbox {
  constructor(name) {
    this.name = name;
    this.active = false;
    const fakeWindow = Object.create(null); // 子应用的「私有 window」
    const rawWindow = window;

    this.proxy = new Proxy(fakeWindow, {
      // 写:只落到 fakeWindow,真实 window 零污染
      set: (target, key, value) => {
        if (!this.active) return true; // 失活后忽略写入,防止卸载后的异步回调污染
        target[key] = value;
        return true;
      },
      // 读:先查自己的,没有再回退真实 window;函数需绑定原 this
      get: (target, key) => {
        if (key === "window" || key === "self" || key === "globalThis") return this.proxy;
        if (key === "top" || key === "parent") return rawWindow; // 避免子应用误判被 iframe 嵌套
        if (key === "hasOwnProperty") return (k) => k in target || k in rawWindow;

        const value = key in target ? target[key] : rawWindow[key];
        // alert / setTimeout 等原生方法必须绑回 window,否则 Illegal invocation
        if (typeof value === "function" && /^[a-z]/.test(String(key))) {
          return value.bind(rawWindow);
        }
        return value;
      },
      // with(window) 依赖 has 来判断「变量是否存在」,必须返回 true 才能拦住向上查找
      has: () => true,
      deleteProperty: (target, key) => {
        if (key in target) delete target[key];
        return true;
      },
      getOwnPropertyDescriptor: (target, key) => {
        const desc = key in target
          ? Object.getOwnPropertyDescriptor(target, key)
          : Object.getOwnPropertyDescriptor(rawWindow, key);
        // 真实 window 上有不可配置的属性,直接返回会让 Proxy 抛不变量错误
        if (desc && !desc.configurable) desc.configurable = true;
        return desc;
      },
    });
  }

  activate() {
    this.active = true;
  }
  deactivate() {
    this.active = false; // 丢弃即可,不需要 diff 还原
  }
}
js
// 配套:在沙箱里执行子应用代码
function execScriptInSandbox(code, sandbox) {
  // with 把自由变量的查找重定向到 proxy;call(proxy) 让顶层 this 也指向 proxy
  const wrapped = `
    (function(window, self, globalThis){
      with(window){
        ${code}
      }
      return typeof module !== 'undefined' ? module.exports : undefined;
    })`;
  // eslint-disable-next-line no-new-func
  const fn = new Function(`return ${wrapped}`)();
  return fn.call(sandbox.proxy, sandbox.proxy, sandbox.proxy, sandbox.proxy);
}

// 使用
const box = new ProxySandbox("sub-vue");
box.activate();
execScriptInSandbox(`window.__APP__ = 'sub'; var a = 1;`, box);
console.log(window.__APP__); // undefined —— 真实 window 没被污染 ✅

三个必须记住的点: ① has 陷阱必须返回 true,否则 with 判断变量不存在会继续向上找到真实 window,沙箱形同虚设;② 从代理上取出的原生函数必须 bind(rawWindow),否则 setTimeout、alert 这类方法 this 不对会抛 Illegal invocation;③ deactivate 后要拦住 set,子应用卸载后残留的异步回调还可能写全局,这是最隐蔽的污染来源。

✅ 边界 Case 自测:

  • 子应用里 window.a = 1 后,真实 window.a 是否为 undefined
  • 子应用里裸写 a = 1(无 var 隐式全局),是否也被拦到 fakeWindow
  • 子应用里调用 setTimeout、alert、fetch,是否不报 Illegal invocation
  • 子应用读 window.document、window.location 是否能正确回退到真实对象
  • 两个 ProxySandbox 同时激活,各自写同名变量是否互不影响(多实例验证)
  • deactivate() 后残留的 setTimeout 回调里写 window.x,是否被忽略
  • 子应用里 delete window.b、'b' in window、Object.keys(window) 行为是否合理
  • 子应用修改 Array.prototype.foo,观察沙箱确实拦不住(理解边界)
  • 快照沙箱下两个应用同时激活,验证确实会互相覆盖(理解为什么要 Proxy)

🔁 变种拓展题:

js
// 变种 1:给沙箱加「副作用自动回收」—— 定时器与事件监听
function patchSideEffects(proxy, rawWindow) {
  const timers = new Set();
  const listeners = [];

  proxy.setInterval = (...args) => {
    const id = rawWindow.setInterval(...args);
    timers.add(id);
    return id;
  };
  proxy.clearInterval = (id) => {
    timers.delete(id);
    return rawWindow.clearInterval(id);
  };
  proxy.addEventListener = (type, fn, opts) => {
    listeners.push([type, fn, opts]);
    return rawWindow.addEventListener(type, fn, opts);
  };

  // 卸载时一键回收,杜绝「切十次卡成狗」
  return function free() {
    timers.forEach((id) => rawWindow.clearInterval(id));
    timers.clear();
    listeners.splice(0).forEach(([t, f, o]) => rawWindow.removeEventListener(t, f, o));
  };
}
js
// 变种 2:拦截动态插入的 style/script,实现「卸载时自动移除样式」
function patchDocumentHead(appName) {
  const rawAppend = document.head.appendChild.bind(document.head);
  const styles = [];

  document.head.appendChild = (node) => {
    if (node.tagName === "STYLE" || (node.tagName === "LINK" && node.rel === "stylesheet")) {
      node.setAttribute("data-qiankun", appName); // 打上归属标记
      styles.push(node);
    }
    return rawAppend(node);
  };

  return function free() {
    document.head.appendChild = rawAppend;
    styles.splice(0).forEach((n) => n.parentNode?.removeChild(n));
  };
}
js
// 变种 3:给 localStorage 加命名空间,解决同名 key 互相覆盖
function createScopedStorage(appName, raw = localStorage) {
  const k = (key) => `${appName}::${key}`;
  return {
    getItem: (key) => raw.getItem(k(key)),
    setItem: (key, v) => raw.setItem(k(key), v),
    removeItem: (key) => raw.removeItem(k(key)),
    clear() {
      Object.keys(raw)
        .filter((key) => key.startsWith(`${appName}::`))
        .forEach((key) => raw.removeItem(key)); // 只清自己的,不动别人的
    },
  };
}

题 3:手写一个微前端加载器(路由劫持 + HTML Entry + 生命周期调度) ​

核心实现:

js
// micro.js —— 一个能跑的 mini-qiankun,覆盖核心流程
const apps = []; // 注册表
let current = null; // 当前激活的应用
const cache = new Map(); // 生命周期缓存:JS 只 fetch + eval 一次

export function registerMicroApps(list) {
  apps.push(...list.map((a) => ({ ...a, status: "NOT_LOADED" })));
}

export function start() {
  hijackRoute();
  reroute(); // 首次进入也要匹配一次
}

// ───── ① 路由劫持:重写 pushState/replaceState + 监听 popstate/hashchange ─────
function hijackRoute() {
  ["pushState", "replaceState"].forEach((type) => {
    const raw = history[type];
    history[type] = function (...args) {
      const before = location.href;
      const res = raw.apply(this, args);
      if (before !== location.href) reroute(); // URL 真变了才重新匹配
      return res;
    };
  });
  window.addEventListener("popstate", reroute);
  window.addEventListener("hashchange", reroute);
}

// ───── ② 匹配 → 卸载旧的 → 挂载新的 ─────
async function reroute() {
  const next = apps.find((a) => matchRule(a.activeRule, location.pathname));
  if (next === current?.meta) return; // 同一应用内部跳转,不重新挂载

  if (current) {
    const el = document.querySelector(current.meta.container);
    await current.unmount({ container: el });
    current.free?.(); // 回收副作用(定时器/事件/样式)
    el.innerHTML = "";
    current.meta.status = "NOT_MOUNTED";
    current = null;
  }

  if (!next) return;
  const lifecycles = await loadApp(next);
  current = { ...lifecycles, meta: next };

  if (next.status === "NOT_LOADED") {
    await current.bootstrap?.(); // 只执行一次
    next.status = "BOOTSTRAPPED";
  }
  await current.mount({ container: document.querySelector(next.container), ...next.props });
  next.status = "MOUNTED";
}

function matchRule(rule, path) {
  if (typeof rule === "function") return rule(location);
  return path.startsWith(rule);
}
js
// ───── ③ HTML Entry:fetch HTML → 抽 script/style → 插模板 → 沙箱里 eval ─────
async function loadApp(app) {
  if (cache.has(app.name)) return cache.get(app.name); // 生命周期复用

  const html = await fetch(app.entry).then((r) => r.text());
  const { template, scripts, styles } = parseHTML(html, app.entry);

  const container = document.querySelector(app.container);
  container.innerHTML = template;
  container.setAttribute("data-micro", app.name); // 供样式加前缀使用
  styles.forEach((css) => injectStyle(css, app.name));

  const sandbox = new ProxySandbox(app.name);
  sandbox.activate();
  sandbox.proxy.__POWERED_BY_QIANKUN__ = true;
  // 注入 publicPath,否则子应用异步 chunk 会按主应用域名去取 → 404
  sandbox.proxy.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = new URL(".", app.entry).href;

  let exports = {};
  for (const s of scripts) {
    const code = s.code ?? (await fetch(s.src).then((r) => r.text())); // 必须 CORS
    exports = execScriptInSandbox(code, sandbox) || exports;
  }
  const lifecycles = exports.default || exports; // UMD 导出
  if (!lifecycles?.mount) {
    throw new Error(`[${app.name}] 未导出 mount,检查 libraryTarget: 'umd' 与 library 名`);
  }

  const result = { ...lifecycles, sandbox };
  cache.set(app.name, result);
  return result;
}

// ───── ④ 极简 HTML 解析 ─────
function parseHTML(html, entry) {
  const scripts = [];
  const styles = [];

  const template = html
    .replace(/<script[^>]*>([\s\S]*?)<\/script>/gi, (tag, inline) => {
      const src = /\ssrc=["']?([^"'\s>]+)/i.exec(tag)?.[1];
      scripts.push(src ? { src: new URL(src, entry).href } : { code: inline });
      return "<!-- script -->";
    })
    .replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, css) => {
      styles.push(css);
      return "<!-- style -->";
    });

  return { template, scripts, styles };
}

function injectStyle(css, appName) {
  const el = document.createElement("style");
  el.textContent = scopeCSS(css, appName); // 见题 4
  el.setAttribute("data-micro", appName); // 打标,卸载时按 appName 精确移除
  document.head.appendChild(el);
}

三个必须记住的点: ① 路由劫持必须同时覆盖 pushState/replaceState/popstate/hashchange 四个入口,少一个就会出现「URL 变了但应用没切」;② 生命周期要缓存、bootstrap 只跑一次,这是性能关键,也是「模块顶层状态不会重置」的根源;③ 外链 JS 必须 fetch 成文本再 eval,直接插 <script src> 会在真实 window 上执行、沙箱直接失效,这就是子应用必须开 CORS 的原因。

✅ 边界 Case 自测:

  • 首次直接访问 /sub-vue/order 深链接,是否能正确匹配并挂载(不只是点击跳转)
  • 在同一子应用内部跳转(/sub-vue/a → /sub-vue/b),是否没有重新 mount
  • 来回切换两个子应用 10 次,bootstrap 是否只各执行一次、mount/unmount 各 10 次
  • 子应用 entry 404 或未开 CORS 时,是否有明确报错且不影响其他应用
  • 子应用没配 libraryTarget: 'umd' 时,报错信息能否直接定位问题
  • 卸载后容器 DOM 是否清空、该子应用的 <style data-micro> 是否全部移除
  • 子应用里的相对路径图片、异步 chunk 是否按子应用域名加载(验证 publicPath 注入)
  • 快速连续切换路由(抖动点击),是否出现「旧应用 unmount 未完成就 mount 新应用」的 DOM 残留
  • 不匹配任何 activeRule 的路径下,是否所有子应用都被卸载

🔁 变种拓展题:

js
// 变种 1:防止快速切换导致的竞态 —— 用「版本号」作废过期的挂载流程
let rerouteToken = 0;
async function safeReroute() {
  const token = ++rerouteToken; // 每次重路由递增
  const next = apps.find((a) => matchRule(a.activeRule, location.pathname));
  if (next === current?.meta) return;

  await unmountCurrent();
  if (token !== rerouteToken) return; // 期间又切了路由 → 放弃本次挂载

  const lifecycles = await loadApp(next);
  if (token !== rerouteToken) return; // 加载是异步的,加载完也要再校验一次
  await lifecycles.mount({ container: document.querySelector(next.container) });
}
js
// 变种 2:空闲预加载(prefetch),资源进 HTTP 缓存但不执行
function prefetchApps(list) {
  const run = () =>
    list.forEach(async (app) => {
      const html = await fetch(app.entry).then((r) => r.text());
      const { scripts } = parseHTML(html, app.entry);
      scripts.forEach((s) => s.src && fetch(s.src)); // 只 fetch,不 eval、不插入
    });

  if (window.requestIdleCallback) requestIdleCallback(run, { timeout: 3000 });
  else setTimeout(run, 2000); // 兜底,别和首屏抢带宽
}
js
// 变种 3:手动加载模式 loadMicroApp —— 一个页面同时挂多个子应用(多实例)
export async function loadMicroApp(app) {
  const lifecycles = await loadApp(app);
  const container = document.querySelector(app.container);
  await lifecycles.bootstrap?.();
  await lifecycles.mount({ container, ...app.props });

  return {
    update: (props) => lifecycles.update?.({ container, ...props }),
    unmount: async () => {
      await lifecycles.unmount({ container });
      container.innerHTML = "";
    },
  };
}
// 注意:多实例必须用 ProxySandbox(快照沙箱会互相覆盖),
// 且不能依赖 singular 模式下的全局补丁(setTimeout/addEventListener 补丁只适用单实例)

题 4:手写样式隔离(选择器加前缀)与跨应用通信(GlobalState) ​

核心实现:

js
// ① 样式隔离:运行时给选择器加作用域前缀(experimentalStyleIsolation 的简化原理)
function scopeCSS(css, appName) {
  const prefix = `div[data-micro="${appName}"]`;

  return css
    // 先去注释,避免注释里的花括号干扰解析
    .replace(/\/\*[\s\S]*?\*\//g, "")
    // 处理普通规则:selector { ... }
    .replace(/(^|\})\s*([^{}@]+)\{/g, (_m, brace, selectors) => {
      const scoped = selectors
        .split(",")
        .map((s) => s.trim())
        .filter(Boolean)
        .map((sel) => {
          // html/body/:root 必须「替换」成容器本身,拼接会永远匹配不到
          if (/^(html|body|:root)\b/.test(sel)) return sel.replace(/^(html|body|:root)/, prefix);
          return `${prefix} ${sel}`;
        })
        .join(",");
      return `${brace} ${scoped}{`;
    });
}
// .btn{color:red}  →  div[data-micro="sub-vue"] .btn{color:red}
// body{margin:0}   →  div[data-micro="sub-vue"]{margin:0}

// ② @keyframes 的动画名也要加前缀,否则两个应用同名动画互相覆盖(最难查的样式 bug)
function scopeKeyframes(css, appName) {
  const names = [];
  let out = css.replace(/@keyframes\s+([\w-]+)/g, (_, name) => {
    names.push(name);
    return `@keyframes ${appName}-${name}`;
  });
  names.forEach((n) => {
    out = out.replace(new RegExp(`(animation(?:-name)?\\s*:[^;]*?)\\b${n}\\b`, "g"), `$1${appName}-${n}`);
  });
  return out;
}
js
// ③ 跨应用通信:带命名空间 + 一级属性 diff 的 GlobalState
export function initGlobalState(initial = {}) {
  let state = { ...initial };
  const listeners = new Map(); // appName -> callback

  function emit(prev) {
    listeners.forEach((fn) => {
      try {
        fn({ ...state }, { ...prev }); // 传副本,防止订阅方直接改源 state
      } catch (e) {
        console.error("[globalState] listener error", e); // 一个订阅者报错不能影响其他人
      }
    });
  }

  function setGlobalState(patch = {}, appName = "unknown") {
    const prev = { ...state };
    let changed = false;
    Object.keys(patch).forEach((k) => {
      // 只允许修改已声明的一级 key,杜绝子应用随意塞字段
      if (!(k in state)) {
        console.warn(`[globalState] "${k}" 未声明,忽略(来自 ${appName})`);
        return;
      }
      if (state[k] !== patch[k]) {
        state[k] = patch[k];
        changed = true;
      }
    });
    if (changed) emit(prev); // 一级引用没变就不触发 → 嵌套对象必须整体替换
    return changed;
  }

  function registerApp(appName) {
    return {
      setGlobalState: (patch) => setGlobalState(patch, appName),
      getGlobalState: (key) => (key ? state[key] : { ...state }),
      onGlobalStateChange(cb, fireImmediately) {
        if (listeners.has(appName)) console.warn(`[globalState] ${appName} 重复订阅,已覆盖`);
        listeners.set(appName, cb);
        if (fireImmediately) cb({ ...state }, { ...state });
        return () => listeners.delete(appName); // 返回解绑函数,unmount 必须调用
      },
      offGlobalStateChange: () => listeners.delete(appName),
    };
  }

  return { setGlobalState, registerApp, getState: () => ({ ...state }) };
}
js
// ④ 子应用侧的标准用法:订阅 + 必须解绑
let offState = null;
export async function mount(props) {
  offState = props.onGlobalStateChange((state, prev) => {
    if (state.theme !== prev.theme) applyTheme(state.theme);
    if (state.user !== prev.user) store.setUser(state.user);
  }, true);
}
export async function unmount() {
  offState?.(); // ⚠️ 不解绑 → 切换 N 次就有 N 个监听,回调重复触发 + 内存泄漏
  offState = null;
}

三个必须记住的点: ① 加前缀时 body/html/:root 必须替换成容器选择器而不是拼接,否则 div[x] body 永远匹配不到;② @keyframes 的动画名也要加前缀,否则两个子应用同名动画互相覆盖;③ GlobalState 只做一级属性的引用对比,改嵌套字段必须整体替换新对象,state.user.name = 'x' 不会触发更新。

✅ 边界 Case 自测:

  • .a, .b { } 多选择器是否每个都被加上前缀
  • body { margin: 0 } 是否变成容器选择器而非 prefix body
  • @media (max-width: 768px) { .a {} } 内部规则是否被正确处理、at-rule 是否保留
  • 两个子应用定义同名 @keyframes fade,动画是否互不干扰
  • CSS 里含注释、属性值里含花括号(如 content: "{")时是否被误处理
  • 卸载子应用后,document.head 里带该 data-micro 的 style 是否全部移除
  • 同一子应用重复 onGlobalStateChange 是否有告警且不会叠加监听
  • 子应用 setGlobalState 一个未声明的 key,是否被拦住并告警
  • 修改嵌套字段 state.user.name 后,观察订阅确实不触发(理解 diff 粒度)
  • 某个订阅回调内部抛错,其他子应用的订阅是否仍正常收到通知
  • 切换子应用 20 次后,监听器数量是否稳定为 1(验证 unmount 解绑生效)

🔁 变种拓展题:

js
// 变种 1:用 Shadow DOM 做真隔离,并处理「弹窗挂到 body 丢样式」的问题
function mountInShadow(container, template, styles) {
  const shadow = container.attachShadow({ mode: "open" });
  styles.forEach((css) => {
    const s = document.createElement("style");
    s.textContent = css; // 样式物理隔绝在 shadowRoot 内,无需加前缀
    shadow.appendChild(s);
  });
  const wrapper = document.createElement("div");
  wrapper.innerHTML = template;
  shadow.appendChild(wrapper);

  // UI 库弹窗默认 appendChild 到 document.body,会跑到 shadow 外面丢样式
  // 解法:给组件库指定挂载容器
  // Ant Design:   <ConfigProvider getPopupContainer={() => wrapper}>
  // Element Plus: popper-append-to-body="false" / :teleported="false"
  return { shadow, wrapper };
}
js
// 变种 2:基于 CustomEvent 的跨技术栈事件总线(连 jQuery 老应用都能接)
export function createBus(namespace) {
  const offs = [];
  return {
    emit(type,detail) {
      window.dispatchEvent(new CustomEvent(`${namespace}:${type}`, { detail }));
    },
    on(type, fn) {
      const name = `${namespace}:${type}`;
      const handler = (e) => fn(e.detail);
      window.addEventListener(name, handler);
      const off = () => window.removeEventListener(name, handler);
      offs.push(off);
      return off;
    },
    destroy() {
      offs.splice(0).forEach((f) => f()); // unmount 时一键清空所有订阅
    },
  };
}
js
// 变种 3:给通信加「操作日志」,解决事件来源难追溯的问题
function withTrace(bus, appName) {
  return {
    ...bus,
    emit(type, detail) {
      window.__BUS_LOG__ = (window.__BUS_LOG__ || []).slice(-199).concat({
        from: appName,
        type,
        detail,
        at: Date.now(),
        stack: new Error().stack?.split("\n")[2]?.trim(),
      });
      bus.emit(type, detail);
    },
  };
}
// 线上排查时直接看 window.__BUS_LOG__ 的最近 200 条跨应用通信,比纯事件好查太多

题 5:手写一个同构请求层(上下文透传 + 两端分叉 + 超时 + 请求去重) ​

核心实现:

js
// ---------- ctx.js:请求上下文(服务端专用,天然并发安全)----------
const { AsyncLocalStorage } = require("node:async_hooks");
const als = new AsyncLocalStorage();

// Express 中间件:每个请求建一份独立上下文
function ctxMiddleware(req, res, next) {
  als.run(
    {
      cookie: pickCookies(req.headers.cookie, ["token", "uid", "lang"]), // ① 白名单,不全量透传
      ip: (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim(),
      traceId: req.headers["x-trace-id"] || Math.random().toString(36).slice(2),
      memo: new Map(), // ② 单次请求内的 fetch 去重表,请求结束随上下文一起被回收
    },
    next
  );
}

function pickCookies(raw = "", names) {
  return raw
    .split(";")
    .map((s) => s.trim())
    .filter((s) => names.includes(s.split("=")[0]))
    .join("; ");
}

// ---------- request.js:同构请求层 ----------
const isServer = typeof window === "undefined";

class HttpError extends Error {
  constructor(status, body) {
    super(`HTTP ${status}`);
    this.status = status;
    this.body = body;
  }
}
class UnauthorizedError extends HttpError {}

const TIMEOUT = isServer ? 1500 : 10000; // ③ 服务端必须更短:它拖的是所有用户

async function request(path, options = {}) {
  const { method = "GET", body, headers: extra, dedupe = true, retry = 0 } = options;
  const ctx = isServer ? als.getStore() : null;

  // ④ URL 分叉:服务端走内网绝对地址,浏览器走相对路径(同源、无预检)
  const url = isServer ? (process.env.INTERNAL_API_BASE || "http://api.internal") + path : path;

  // ⑤ 单次渲染内去重:同一个 GET 被多个组件调用只真正发一次
  const key = `${method} ${url} ${body ? JSON.stringify(body) : ""}`;
  if (isServer && dedupe && method === "GET" && ctx?.memo.has(key)) {
    return ctx.memo.get(key);
  }

  const headers = { "content-type": "application/json", ...extra };
  if (isServer && ctx) {
    // ⑥ 身份与链路一律按参数传递,绝不写进任何全局/模块级实例
    if (ctx.cookie) headers.cookie = ctx.cookie;
    if (ctx.ip) headers["x-forwarded-for"] = ctx.ip;
    headers["x-trace-id"] = ctx.traceId;
  }

  const task = (async () => {
    for (let attempt = 0; ; attempt++) {
      const ac = new AbortController();
      const timer = setTimeout(() => ac.abort(), TIMEOUT);
      const started = Date.now();
      try {
        const res = await fetch(url, {
          method,
          headers,
          body: body ? JSON.stringify(body) : undefined,
          signal: ac.signal,
        });

        if (res.status === 401) {
          // ⑦ 401 两端语义不同:服务端只能上抛,让页面层决定重定向还是渲染游客态
          if (isServer) throw new UnauthorizedError(401);
          location.href = `/login?from=${encodeURIComponent(location.pathname)}`;
          return new Promise(() => {}); // 挂住,避免后续逻辑在跳转途中继续跑
        }
        if (!res.ok) throw new HttpError(res.status, await res.text().catch(() => ""));
        return await res.json();
      } catch (err) {
        // ⑧ 只重试「幂等 + 可恢复」的情况:GET 且是超时/网络错误
        const retryable = method === "GET" && (err.name === "AbortError" || err instanceof TypeError);
        if (attempt < retry && retryable) continue;
        if (isServer && ctx) console.error("[api]", ctx.traceId, method, path, Date.now() - started,err.message);
        throw err;
      } finally {
        clearTimeout(timer);
      }
    }
  })();

  if (isServer && dedupe && method === "GET") ctx?.memo.set(key, task);
  return task;
}

module.exports = { request, ctxMiddleware, UnauthorizedError, HttpError };

✅ 边界 Case 自测:

  • 服务端调 request("/api/user") 是否被正确补成内网绝对地址,浏览器端是否仍走相对路径
  • 用两个不同 token 并发打同一个 SSR 路由,返回 HTML 里的用户标识是否严格一一对应(验证没有任何全局态)
  • 同一次渲染里三个组件都调 request("/api/config"),后端日志里是否只收到一次请求
  • 把下游接口故意 sleep 5s,服务端是否在 1.5s 超时并走降级路径,而不是挂住整个渲染
  • 下游返回 401 时,服务端是否抛出 UnauthorizedError 而没有尝试跳转,浏览器端是否正确跳登录
  • retry 设为 2 时,POST 请求是否不会被重试(非幂等请求重试会造成重复下单)
  • 请求结束后 memo 是否随上下文一起被回收,长时间压测内存是否平稳(验证去重表没有变成全局缓存)
  • 在 setTimeout 或第三方库回调里调用 request,als.getStore() 是否还能拿到上下文(验证异步上下文是否丢失)

🔁 变种拓展题:

js
// 变种 1:加跨请求的数据缓存(注意:必须和「单次请求去重」严格区分开)
// memo 是 per-request 的去重,下面这层才是跨请求共享的缓存,两者职责不同
const dataCache = new Map(); // key -> { value, expireAt }

async function cachedGet(path, { ttl = 60_000 } = {}) {
  const hit = dataCache.get(path);
  if (hit && hit.expireAt > Date.now()) return hit.value;
  const value = await request(path);
  dataCache.set(path, { value, expireAt: Date.now() + ttl });
  return value;
}
// ⚠️ 红线:只能缓存「与用户无关」的数据(配置、字典、榜单)
// 一旦缓存了带身份的响应,就是跨用户数据泄漏 —— 这类 key 必须带上用户维度或干脆不缓存

// 变种 2:慢依赖降级为「返回兜底值」而不是抛错,让首屏不被单点拖垮
async function softGet(path, fallback) {
  try {
    return await request(path, { dedupe: true });
  } catch (err) {
    console.warn("[api-degrade]", path, err.message);
    return fallback; // 非关键模块(推荐位、广告位)超时就渲染空态,不影响主干内容
  }
}

// 变种 3:服务端并发上限,防止一个慢下游打满 socket
function createLimiter(max) {
  let running = 0;
  const queue = [];
  const next = () => {
    if (running >= max || !queue.length) return;
    running++;
    const { fn, resolve, reject } = queue.shift();
    fn().then(resolve, reject).finally(() => { running--; next(); });
  };
  return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); next(); });
}
const limit = createLimiter(32);
const limitedRequest = (path, opts) => limit(() => request(path, opts));

题 6:手写标签化数据缓存(tag 反向索引 + 按需失效 + SWR) ​

这题是深挖 #14 的动手版。Next.js 的 Data Cache 之所以能用 revalidateTag("post") 一把失效掉一堆页面,核心就是维护了一份 tag → key 的反向索引。面试时能把这个结构写出来,比背 API 名字有说服力。

核心实现:

js
// tagged-cache.js
function createTaggedCache({ defaultTtl = 60_000, swr = true } = {}) {
  const store = new Map();     // key -> { value, expireAt, tags, revalidating }
  const tagIndex = new Map();  // tag -> Set<key>   反向索引,失效的关键
  const inflight = new Map();  // key -> Promise    并发回源合并(防缓存击穿)

  function bindTags(key, tags) {
    for (const tag of tags) {
      if (!tagIndex.has(tag)) tagIndex.set(tag, new Set());
      tagIndex.get(tag).add(key);
    }
  }

  function unbindTags(key, tags) {
    for (const tag of tags) {
      const set = tagIndex.get(tag);
      if (!set) continue;
      set.delete(key);
      if (!set.size) tagIndex.delete(tag); // 及时清空,否则 tagIndex 只增不减就是内存泄漏
    }
  }

  function dropKey(key) {
    const entry = store.get(key);
    if (!entry) return;
    unbindTags(key, entry.tags);
    store.delete(key);
  }

  // 后台回源:先把旧值还给调用方,再悄悄更新(stale-while-revalidate)
  function revalidate(key, loader, tags, ttl) {
    if (inflight.has(key)) return inflight.get(key);
    const p = Promise.resolve()
      .then(loader)
      .then((value) => {
        const old = store.get(key);
        if (old) unbindTags(key, old.tags); // tags 可能变了,先解绑再重绑
        store.set(key, { value, expireAt: Date.now() + ttl, tags, revalidating: false });
        bindTags(key, tags);
        return value;
      })
      .catch((err) => {
        // 回源失败不要把旧值删掉:有旧数据总比白屏好
        const old = store.get(key);
        if (old) { old.revalidating = false; return old.value; }
        throw err;
      })
      .finally(() => inflight.delete(key));
    inflight.set(key, p);
    return p;
  }

  return {
    async get(key, loader, { tags = [], ttl = defaultTtl } = {}) {
      const entry = store.get(key);
      const fresh = entry && entry.expireAt > Date.now();
      if (fresh) return entry.value;

      // 过期但有旧值 + 开了 SWR:立刻返回旧值,后台刷新
      if (entry && swr) {
        if (!entry.revalidating) {
          entry.revalidating = true;
          revalidate(key, loader, tags, ttl); // 故意不 await
        }
        return entry.value;
      }
      // 完全没有旧值:只能同步等(多个并发共用同一个 inflight,避免打穿下游)
      return revalidate(key, loader, tags, ttl);
    },

    // 按 tag 批量失效:一次写操作失效掉所有相关页面
    revalidateTag(tag) {
      const keys = tagIndex.get(tag);
      if (!keys) return 0;
      const list = [...keys];
      list.forEach(dropKey);
      return list.length;
    },

    revalidateKey(key) { dropKey(key); },

    clear() { store.clear(); tagIndex.clear(); inflight.clear(); },

    stats() { return { keys: store.size, tags: tagIndex.size, inflight: inflight.size }; },
  };
}

module.exports = { createTaggedCache };

✅ 边界 Case 自测:

  • 同一个 key 并发 100 次首次读取,loader 是否只执行 1 次(验证 inflight 合并生效)
  • 缓存过期后第一次读取,是否立刻拿到旧值且后台发起了刷新(SWR 语义,不是同步等待)
  • 后台刷新失败时,旧值是否仍然可读、revalidating 是否被复位(否则这个 key 以后再也不刷新了)
  • 调 revalidateTag("post") 后,挂了这个 tag 的所有 key 是否全部清掉,无关 key 是否不受影响
  • 同一个 key 两次回源传入不同 tags,tagIndex 里是否没有残留旧 tag(验证先解绑再重绑)
  • 反复 set/失效 1 万次后 stats() 里 tags 是否回落到 0(验证空 Set 被删除、没有内存泄漏)
  • 关掉 swr 后,过期读取是否变成同步等待新值

🔁 变种拓展题:

  • 加 LRU 容量上限:store 用「访问即移到队尾」的 Map,超过 maxSize 时淘汰队首,淘汰时记得调 unbindTags
  • 区分「用户态」与「公共态」:key 生成函数强制要求传 scope,带身份的数据必须拼上 userId,否则直接抛错 —— 把跨用户泄漏拦在编码期
  • 换成 Redis 实现:store 用 string key,tagIndex 用 Redis 的 Set,revalidateTag 变成 SMEMBERS + DEL + SREM,这样多实例部署才能共享失效(进程内 Map 在多 Pod 下各失效各自的,是线上最常见的「刷了没生效」)
  • 加失效事件总线:失效时广播 tag 到 MQ,各节点订阅后清本地缓存,解决多级缓存不一致

⚠️ 当日踩坑实录 ​

❌ 坑✅ 解决方案
SSR 里直接用 window/document/localStorage,服务端报 window is not defined放进 useEffect/onMounted;或 typeof window !== 'undefined' 守卫;或组件改 ssr: false 动态导入
用 createRoot().render() 接 SSR 的 HTML,容器被清空重渲染必须用 hydrateRoot;Vue 必须用 createSSRApp 而不是createApp
组件里 new Date()/Math.random() 导致 hydration error,整块降级 CSR服务端下发固定值,客户端 useEffect 里再更新;key 用 useId()
suppressHydrationWarning 一加了之后就不管了它只压警告不修内容、且不递归子树;真正的数据不一致必须修源头
按 UA 判断移动端渲染不同组件树 + 前面有 CDN 缓存 → 大面积不匹配改用 CSS 媒体查询做响应式;必须分端就把 UA 结果注水下发,并把设备维度加进 cache key
export default new Vuex.Store() 模块级单例,用户看到别人的数据一律改工厂函数 createStore(),每个请求新建 store/router/axios 实例
axios.defaults.headers.common.Authorization = token 全局设置改 axios.create({ headers }) per-request;上下文透传用 AsyncLocalStorage
注水时裸 JSON.stringify 拼进 <script>,数据含 </script> 被 XSS转义 < > U+2028 U+2029,或用 <script type="application/json"> + 运行时 parse
getServerSideProps 直接返回整个 DB 查询结果,源码里能看到手机号/token序列化前 pick() 白名单字段;敏感字段永不进 props
服务端已取数,客户端又请求一次,首屏数据闪一下重新 loading检查 queryKey 是否完全一致、HydrationBoundary 是否包对;store 初始化必须读注水数据
流式 SSR 上线后没效果,curl -N 发现是整包返回Nginx 关 proxy_buffering,确认 gzip 不攒包、CDN 支持流式回源
流式 SSR 下想在组件里返回 404,发现状态码改不了所有影响状态码的判断(鉴权、重定向、资源存在性)一律前置到渲染前中间件
单个慢接口把 Node 进程拖死,SSR 服务雪崩Promise.race 加渲染超时 + abort();超时降级返回 CSR 壳子;配熔断开关可一键全站切 CSR
SSR 渲染失败直接返回 500,整站不可用catch 住返回 200 的 CSR 壳子,SSR 是优化不能成为可用性单点
带用户昵称的页面做了整页 CDN 缓存 → 串号个性化部分拆成客户端组件挂载后补齐;或把用户维度加进 cache key(命中率会暴跌)
同一 key 并发 100 个请求全部触发渲染,缓存击穿inflight Map 做并发合并,同 key 只渲染一次
子应用报 You need to export lifecyclewebpack 配 libraryTarget: 'umd',且 library 名与注册的 name 严格一致
子应用 Failed to fetch子应用 devServer/Nginx 加 Access-Control-Allow-Origin;检查 entry 末尾斜杠与 HTTP/HTTPS 混用
子应用异步 chunk 和图片 404,路径指向主应用域名入口判断 __POWERED_BY_QIANKUN__ 后设置 __webpack_public_path__
子应用页面空白但资源都加载成功子应用路由没设 base,createWebHistory('/sub-vue');React 用 basename
子应用 mount 里 instance.mount('#app') 命中了主应用的节点必须从 props.container.querySelector('#app') 找挂载点
想省事把子应用 HTML 整段 innerHTML 进容器,脚本一行都没跑innerHTML 插入的 <script> 浏览器原生不执行;必须摘出脚本手动 fetch + 包沙箱执行
把摘出的脚本 createElement('script') append 回页面,window 被污染append 的脚本在真实 window 上执行,沙箱直接失效;只能 fetch 成文本后在代理上执行
子应用 HTML 的内联脚本里出现 </script> 字符串,骨架和脚本都错位正则解析 HTML 的固有缺陷;别在入口 HTML 写复杂内联脚本,挪到独立 JS 文件
脚本并发加载执行,随机报 Vue is not defined必须按 HTML 中出现顺序串行 await 执行,模拟浏览器对非 async/defer 脚本的语义
多个 webpack 子应用的 jsonp 全局变量冲突,chunk 加载错乱各自设置唯一的 chunkLoadingGlobal / jsonpFunction
切换子应用十几次后内存涨到几百兆、页面卡顿unmount 里统一清理:定时器、WebSocket、Observer、全局事件、状态订阅、框架实例并置空
unmount 后 bootstrap 不再执行,模块顶层状态没重置JS 只 eval 一次、生命周期被缓存;需要重置的状态一律放 mount 里初始化
两个子应用 Ant Design 版本不同,.ant-btn 样式互相覆盖各设独立 prefixCls + less 变量定制;再开 experimentalStyleIsolation 兜底
开了 strictStyleIsolation(Shadow DOM)后弹窗/下拉全丢样式给组件库指定 getPopupContainer 指向 shadowRoot 内节点;或放弃 Shadow DOM
两个子应用同名 @keyframes 互相覆盖,动画诡异动画名加应用前缀,并同步替换 animation/animation-name 里的引用
子应用写裸的 body/*/标签选择器,污染主应用规范硬性禁止;experimentalStyleIsolation 会把 body 改写成容器选择器
子应用往 localStorage 写同名 key,互相覆盖登录态存储层统一加 appName:: 前缀;登录态由主应用集中管理
快速连点菜单,出现两个子应用 DOM 同时残留重路由加 token 版本号,异步步骤前后都校验,过期流程直接放弃
子应用之间直接 import 对方的 store 实例,耦合成一团通信只走 props / GlobalState / CustomEvent;频繁双向通信说明边界划错了
onGlobalStateChange 没解绑,切换几次回调被重复触发保存返回的解绑函数并在 unmount 调用;同名重复订阅要告警
改了 state.user.name 但订阅没触发GlobalState 只做一级属性引用对比,嵌套对象必须整体替换新对象
子应用偷升 React 版本又没配 externals,页面里两份 React 白屏Invalid hook call 的典型成因;CI 加核心依赖版本一致性校验,不一致拦合并
prefetch: 'all' 抢了首屏带宽,首屏反而变慢改默认 true(首个应用挂载后空闲预加载)或只指定关键应用
发版后用户仍加载旧的子应用列表主应用 index.html 必须 no-cache;注册表改成接口下发,支持灰度与一键摘除
刷新子应用深链接 404Nginx 配 history 回落 try_files $uri $uri/ /index.html
所有子应用的报错都归到主应用监控里,无法定位全局 onerror/unhandledrejection 按当前激活应用打标,分项目上报
子应用无法独立启动,开发全靠起主应用,效率极低用 __POWERED_BY_QIANKUN__ 分叉入口:独立时正常 mount('#app'),base 为 /
eval 出来的代码栈不可读,线上报错定位不到源码配好子应用 sourcemap 并保证能被加载到
5 人小团队、单仓库项目强上微前端,调试与样式问题吃掉所有收益先评估是否真有「多团队并行发布 / 技术栈共存 / 构建过慢」的痛点,没有就别上

| 改完数据库刷新页面还是旧值,重启服务就好了 | 命中了 Full Route Cache;写操作后调 revalidateTag/revalidatePath,别靠重启 | | revalidateTag 调了但多 Pod 环境只有一个实例生效 | 进程内缓存各失效各自的;Data Cache 要外置(Redis/自定义 handler)或走 CDN 统一失效 | | 本地 npm run dev 一切正常,next build 后页面变成静态不再更新 | dev 不走 Full Route Cache;验证缓存行为必须用 next build && next start | | 深层组件里读了 cookies()/headers(),整条路由悄悄变成动态渲染 | 用 export const dynamic 显式声明意图;需要静态就把身份相关逻辑上移到 middleware/layout | | Server Action 被当普通函数到处 import,漏了鉴权成了未授权接口 | 它编译后就是公开 POST 端点;每个 action 内部必做「鉴权 + 入参校验 + 资源归属校验」三件套 | | Server Action 里只校验了参数格式,没校验这条数据是不是当前用户的 | 典型越权(IDOR);where: { id, userId: session.userId } 把归属写进查询条件 | | middleware 里 import jwt from 'jsonwebtoken' 直接构建报错 | middleware 固定跑 Edge Runtime,没有 Node API;换 jose 这类 Web Crypto 实现 | | middleware 没配 matcher,静态资源和图片请求全走一遍鉴权,TTFB 变差 | matcher 收窄到业务路由,排除 _next/static、favicon.ico、图片等 | | 服务端 fetch('/api/user') 报 Failed to parse URL | Node 没有相对路径基准;请求层按环境分叉,服务端补成内网绝对地址 | | 服务端请求超时设得比客户端还长,慢下游直接拖垮渲染进程 | 服务端超时必须更短(1~2s)并配 AbortController,超时走降级而不是等 | | 服务端下游返回 401 时代码里写了 location.href='/login' | 服务端没有 location;401 在服务端抛错交给框架重定向,在浏览器端才跳登录 | | styled-components SSR 复用了同一个 ServerStyleSheet,样式串页 | 必须每个请求 new 一个;collect 完即丢,不能提到模块作用域 | | SSR 首屏闪一下无样式(FOUC) | 关键 CSS 必须随 HTML 一起下发;检查 CSS-in-JS 收集是否漏了、样式是否被异步 chunk 带走 | | 自定义字体加载期间文字消失或换字时整页跳动 | font-display: swap + 预加载字体 + size-adjust 对齐字形度量,减少 CLS | | 图片不写宽高,首屏图片加载完把下面内容顶下去 | 一律给固定宽高或 aspect-ratio;用框架 Image 组件自动占位 | | og:image 写了相对路径,分享出去没有缩略图 | OG 标签里的 URL 必须是带协议的绝对地址 | | 不存在的详情页渲染了「暂无数据」且返回 200,被判定为软 404 | 调 notFound() 真实返回 404 状态码,别用 200 页面假装 | | 在 Elements 面板看到完整 DOM 就以为 SSR 生效了 | Elements 是水合后的结果;看「查看网页源代码」或 curl 才是真实 SSR 产物 | | Vite 子应用接 qiankun 报 Unexpected token 'export' | 沙箱用 eval 执行,不认 ESM;用 vite-plugin-qiankun 或打成 UMD/IIFE | | Vite 子应用能加载但沙箱不生效,window 被污染 | ESM 下沙箱降级;接受「子应用自治 + 规范约束」,或 build 阶段切 UMD 产物 | | Vite 子应用在主应用里 HMR 连不上、静态资源 404 | 配 server.origin、server.cors、固定 hmr.port,让资源和 ws 指回子应用自己的地址 | | 子应用自己检测到 401 就 location.href='/login',用户填的表单全没了 | 登录态收口到主应用:子应用只上报 401,由主应用决定刷新 token 还是跳登录 | | 多个子应用同时收到 401,各自刷一遍 token,造成刷新风暴与互相顶号 | 主应用单点刷新 + 并发挂起队列,刷新成功后统一放行重试 | | 主应用鉴权没就绪就挂载了子应用,子应用拿不到用户信息报错 | 鉴权结果通过 props/GlobalState 下发,子应用挂载前先 await 主应用的 ready 态 |

📌 当日复盘任务 ​

  • [ ] 能脱稿说清 CSR / SSR / SSG / ISR / Streaming SSR 五种模式的差异与选型依据
  • [ ] 能画出 SSR 的完整请求链路,并指出 TTFB / FCP / TTI 分别在哪个阶段确定
  • [ ] 能解释「水合」到底做了什么,以及为什么必须用 hydrateRoot 而不是 createRoot
  • [ ] 能列出至少 6 种水合不匹配的成因,并给出对应的修法
  • [ ] 动手复现一次 hydration error,观察 React 18 是否真的把子树降级为 CSR
  • [ ] 手写一个不依赖框架的极简 SSR 服务,跑通「取数 → 渲染 → 注水 → 水合」全链路
  • [ ] 在上面的服务里注入含 </script> 的数据,验证转义前后的差异
  • [ ] 写一个模块级单例的 store,用多 token 并发压测复现跨请求污染,再改成工厂函数修掉
  • [ ] 用 AsyncLocalStorage 实现一次请求上下文透传,理解它和挂 global 的区别
  • [ ] 把极简 SSR 改成 renderToPipeableStream,用 curl -N 验证 chunk 是否分批到达
  • [ ] 给流式 SSR 加 abort 超时,验证慢区块会降级为客户端渲染而不是挂起
  • [ ] 能说清 RSC 和 SSR 的本质区别,以及服务端/客户端组件的 import 边界规则
  • [ ] 给 SSR 页面加一层 LRU 缓存 + 并发合并,压测对比 QPS 变化
  • [ ] 能说出「什么项目不该上 SSR」和「什么项目不该上微前端」各三条理由
  • [ ] 能完整复述 qiankun 的加载流程:路由劫持 → 匹配 → HTML Entry → 沙箱 eval → 生命周期
  • [ ] 能解释为什么子应用必须开 CORS、必须打 UMD、必须注入 publicPath
  • [ ] 手写快照沙箱和 Proxy 沙箱,验证前者会污染 window 而后者不会
  • [ ] 在 Proxy 沙箱里去掉 has 陷阱,观察沙箱如何失效(理解 with 的作用)
  • [ ] 在 Proxy 沙箱里去掉 bind(rawWindow),复现 Illegal invocation
  • [ ] 手写 mini 微前端加载器,跑通两个不同技术栈子应用的注册、挂载与切换
  • [ ] 给加载器加 token 版本号,验证快速切换路由不会出现 DOM 残留
  • [ ] 手写 scopeCSS 给选择器加前缀,并处理好 body / @media / @keyframes 三种情况
  • [ ] 实现 GlobalState,验证一级属性 diff 的粒度和「嵌套字段改不触发」的现象
  • [ ] 反复切换子应用 20 次,用 Memory 面板确认堆大小能回落、监听器数量稳定
  • [ ] 整理一份团队可用的「子应用 unmount 清理清单」
  • [ ] 对比 iframe / qiankun / micro-app / wujie / Module Federation 五种方案,能说出各自取舍
  • [ ] 为微前端设计一套可落地的部署方案:缓存头、CORS、history 回落、注册表下发
  • [ ] 设计一套微前端的监控归因方案:错误打标、挂载耗时打点、加载失败熔断
  • [ ] 能画出 App Router 四层缓存(Request Memoization / Data Cache / Full Route Cache / Router Cache)的位置与生效顺序
  • [ ] 对每一层缓存都写出至少一种失效手段,并在本地 next build && next start 下验证一遍
  • [ ] 能说清「为什么 dev 下看不出缓存问题」,以及缓存类 Bug 的排查顺序(从外往里)
  • [ ] 写一个 Server Action,并亲手补上「鉴权 + 入参校验 + 资源归属校验」三件套
  • [ ] 能解释 Server Action 和 Route Handler 各自的适用场景与安全差异
  • [ ] 能说出 Node Runtime 与 Edge Runtime 的三条关键差异,以及 middleware 为什么不能用 Node API
  • [ ] 给 middleware 配一次 matcher,对比配置前后静态资源请求是否还走鉴权
  • [ ] 手写同构请求层,跑通 URL 分叉、cookie 透传、请求去重、超时中断、401 两端分叉五件事
  • [ ] 用两个不同 token 并发压测 SSR 路由,确认返回内容严格一一对应(无跨请求污染)
  • [ ] 手写标签化数据缓存,验证 tag 反向索引、按 tag 批量失效与 SWR 三种行为
  • [ ] 在上面的缓存里反复失效一万次,确认 tagIndex 不残留、内存能回落
  • [ ] 能说清 per-request 去重和跨请求缓存的区别,以及哪些数据绝对不能跨请求缓存
  • [ ] 给 CSS-in-JS 项目加一次 SSR 样式收集,复现复用同一个 sheet 导致的样式串页
  • [ ] 做一遍 SEO 自查:robots、sitemap、canonical、JSON-LD、真 404,并用 curl 验证源码里有内容
  • [ ] 用 vite-plugin-qiankun 接一个 Vite 子应用,记录 ESM / 沙箱 / HMR 三处踩坑的解法
  • [ ] 设计一套微前端登录态方案:主应用单点鉴权、401 冒泡、单点刷新挂起队列、子应用降级自跑
  • [ ] 把 Day10 的 9 道面试题按「精简口述答案」计时练一遍,每题控制在 90 秒内
  • [ ] 把 SSR 和微前端各自的项目关联话术替换成自己的真实经历并背熟
  • [ ] 复盘:今天的内容里哪三个点自己讲得最不顺,明天开始前先补这三个