Appearance
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>| 两端差异项 | 服务端 | 客户端 |
|---|---|---|
| 全局对象 | global | window |
| 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 的三个结构性痛点:
- 服务端取数与组件分离,且只有页面级粒度 ——
getServerSideProps只能写在页面文件里,深层子组件想参与首屏取数,只能从页面一路 props 往下传(子组件自己在useEffect里请求虽然可行,但那已经是 CSR,不在首屏 HTML 里)。- 没有流式渲染 —— 必须等
getServerSideProps里所有请求都回来,才能吐出第一个字节,TTFB 被最慢的那个接口拖死。- 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 Router | App 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 SSR | App 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,「点击触发的请求」用 $fetchts
// 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.js | Nuxt 3 |
|---|---|---|
| 框架 | React | Vue 3 |
| 数据获取 | getServerSideProps / RSC await | useFetch / useAsyncData |
| 服务端运行时 | Node / Edge | Nitro(可部署到 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 | 每次路由切走时 | 销毁实例、清定时器、解绑事件、移除全局副作用 |
update | loadMicroApp 手动更新 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 里格式化 |
| 随机数 / uuid | Math.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>| 能力 | renderToString | renderToPipeableStream |
|---|---|---|
| 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 数据库| 维度 | SSR | RSC |
|---|---|---|
| 产物 | 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/Set | JSON 化后类型丢失 | 用 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 设计难,易串数据 |
| 流式 SSR | TTFB 降到几十毫秒 | 状态码不可改、需关代理缓冲 |
| 预取 + 并行请求 | 消灭串行瀑布 | 需要手动编排 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.headers | token 串用、越权 | 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 成文本内联 | 卸载时能精确移除;也避免子应用切换时的样式闪烁 |
| 必须 UMD | eval 后要从返回值里拿 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 / EventSource | unmount 后仍在收消息 | 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 |
| 刷新子应用页面 404 | Nginx 没配 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 | 最强 | 最强 | ✅ | 最低 | 外部系统、强隔离后台 |
| qiankun | Proxy 沙箱 | 需配置 | ✅ | 中 | 中后台多团队聚合(最主流) |
| micro-app | Proxy + 自定义元素 | ShadowDOM | ✅ | 低 | 希望像标签一样嵌入 |
| wujie | iframe | ShadowDOM | ✅ | 中 | 要求强隔离 + 保活 |
| 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 Action | Route 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、ORM | Node | DB driver 走 TCP,Edge 跑不了(除非用 HTTP 版驱动) |
| 读本地模板、生成图片、用 sharp / canvas | Node | 依赖原生模块与文件系统 |
| 鉴权重定向、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 加路由懒加载就够了。
连环追问链:
追问:SSR 一定比 CSR 快吗?
- 答:不一定。SSR 快的是 FCP/LCP,但 TTFB 一定比返回静态壳子慢,因为服务端要等数据和渲染。如果服务端数据接口很慢,或者渲染服务被打满排队,首屏可能比 CSR 更慢。另外 TTI 不一定更好 —— HTML 出来了但 JS 还没水合完,页面「看着能用其实点不动」,这个时间差反而可能比 CSR 更长。
追问:那 TTI 可交互时间,SSR 怎么优化?
- 答:三条路。① 减小水合范围:静态内容用 Server Component 或
ssr: false的客户端组件拆出去,只水合真正需要交互的部分;② 流式 SSR + 选择性水合,让先到的 HTML 先水合,用户点哪里优先水合哪里;③ 降低首屏 JS 体积,水合速度基本正比于 bundle 大小。
- 答:三条路。① 减小水合范围:静态内容用 Server Component 或
追问:SSR 和 SSG 怎么选?
- 答:看数据的变化频率和个性化程度。完全不变或变得很慢(文档、博客、营销页)→ SSG,构建时生成,CDN 直出,成本最低;变化频繁但所有人看到的一样(商详、榜单)→ ISR 或 SSR + CDN 缓存;每个人看到的不一样(带用户信息)→ 只能 SSR,而且不能做整页 CDN 缓存。实际项目里常常是混着用的,Next 的 App Router 和 Nuxt 的
routeRules都支持按路由配置。
- 答:看数据的变化频率和个性化程度。完全不变或变得很慢(文档、博客、营销页)→ SSG,构建时生成,CDN 直出,成本最低;变化频繁但所有人看到的一样(商详、榜单)→ ISR 或 SSR + CDN 缓存;每个人看到的不一样(带用户信息)→ 只能 SSR,而且不能做整页 CDN 缓存。实际项目里常常是混着用的,Next 的 App Router 和 Nuxt 的
追问:SSR 场景下,SEO 还需要注意什么?
- 答:HTML 有内容只是第一步。还要:服务端正确注入
title/description/og:*,返回正确的 HTTP 状态码(404 页面必须真的返回 404,不能返回 200 的「页面不存在」),配置canonical避免重复内容,输出结构化数据 JSON-LD,以及保证爬虫拿到的 HTML 和用户一致(不要对 UA 做不同内容的返回,那是作弊)。
- 答:HTML 有内容只是第一步。还要:服务端正确注入
追问:如果 SSR 服务挂了,页面是不是就白屏了?
- 答:不该白屏,这必须有兜底。我们的做法是渲染层加超时和 try/catch,一旦渲染失败或超时,就返回只有
<div id="root">的壳子 HTML + 客户端 bundle,降级为 CSR。再配一个配置中心的降级开关,大促或者下游抖动时可以一键全站切 CSR。SSR 是优化手段,不能成为可用性的单点。
- 答:不该白屏,这必须有兜底。我们的做法是渲染层加超时和 try/catch,一旦渲染失败或超时,就返回只有
✅ 项目关联话术(按真实经历替换):
我们商品详情页原来是 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。
连环追问链:
追问:为什么 React 18 要把不匹配的整棵子树重渲染,而不是像 React 17 那样就地打补丁?
- 答:因为并发渲染下「局部修补」会让 DOM 处于一个 React 自己都不认识的中间状态,后续更新可能基于错误的假设,出现更难查的 bug。React 18 选择了「一致性优于性能」:发现不匹配就放弃这块的服务端产物,退回客户端渲染,保证 DOM 和虚拟树严格对应。代价就是这块的首屏优化失效,所以它才要把问题报得这么响。
追问:
suppressHydrationWarning能解决问题吗?- 答:不能,它只是闭嘴,不是修复。它只对该元素的直接文本内容生效,不会递归到子树,而且 React 仍然会以客户端的渲染结果为准。它的正当用途只有一个:确实无法避免、且差异无害的场景,比如显示一个时间戳。把它当成万能消音器会掩盖真正的数据不一致。
追问:首屏点击没反应,怎么判断是不是水合的问题?
- 答:看三个信号。① HTML 已经渲染出内容但交互无效,说明处于「HTML 已到、JS 未水合完」的窗口期;② Performance 面板里能看到一段长任务,正是水合;③ 如果控制台有 hydration error,那块会被重渲染,表现为短暂闪烁后才可交互。优化方向是减小首屏水合范围和 JS 体积,必要时上流式 SSR 的选择性水合。
追问:怎么在 CI 里防住水合不匹配?
- 答:两层。① 单测/集成测试里把
console.error打到 hydration 相关关键字时直接 fail,React 的 hydration error 走的就是console.error;② E2E 里用 Playwright 加载关键页面,监听 pageerror 和 console error,发现 hydration 关键字就失败。再配一条 ESLint 规则禁止在渲染期使用Date.now/Math.random。
- 答:两层。① 单测/集成测试里把
追问:Vue 的水合和 React 有什么不一样?
- 答:Vue 3 的水合更「宽容」——不匹配时会打一条
Hydration node mismatch警告,然后就地用客户端的结果修正那个节点,不会整棵子树重来。它的代价是问题更容易被忽略,可能长期带着不一致上线。另外 Vue 要求挂载的根节点必须是服务端渲染出来的那个元素,createSSRApp和createApp的区别就在这里:后者会直接清空容器重新渲染。
- 答:Vue 3 的水合更「宽容」——不匹配时会打一条
✅ 项目关联话术(按真实经历替换):
我们活动页上线后监控里出现大量 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、移除子应用样式、沙箱失活回滚全局变量。
连环追问链:
追问:为什么子应用必须开 CORS,而且必须打成 UMD?
- 答:这两件事都源于「JS 必须用 fetch 成文本再 eval」。如果直接往页面里插
<script src>,脚本会在真实window上执行,沙箱就完全失效了;只有拿到源码文本,才能包一层with(window){}把window替换成代理对象。而 fetch 跨域就需要服务端允许 CORS。UMD 的原因是 eval 之后要从返回值里取bootstrap/mount/unmount三个函数,只有 UMD 会把导出挂到全局/返回值上,ESM 和默认的 var 格式都取不到 —— 所以library名还必须和注册的name一致。
- 答:这两件事都源于「JS 必须用 fetch 成文本再 eval」。如果直接往页面里插
追问: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等打了补丁,卸载时统一回收副作用。
- 答:有两种。快照沙箱在激活时遍历
追问:子应用切走再切回来,为什么
bootstrap不会重新执行?- 答:因为子应用的 JS 只被 fetch 和 eval 一次,三个生命周期函数之后是缓存复用的。所以写在模块顶层的代码只跑一次,写在
mount里的每次都跑。这条规则直接决定了一个常见 bug:如果把需要重置的状态(单例、定时器、事件监听)放在模块顶层或者mount里却不在unmount清掉,切换几次就会叠加,表现为内存持续上涨和回调被重复触发。
- 答:因为子应用的 JS 只被 fetch 和 eval 一次,三个生命周期函数之后是缓存复用的。所以写在模块顶层的代码只跑一次,写在
追问:样式隔离你们是怎么做的?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直接找不到节点。改造成本远大于收益,我们只在接入完全不可控的外部应用时才会考虑。
- 答:我们用的是组合方案:子应用一律 CSS Modules 或
追问:微前端的调试和线上问题定位怎么做?
- 答:几个关键手段。① 子应用必须能独立启动运行(用
__POWERED_BY_QIANKUN__判断,独立时走自己的入口),日常开发 90% 时间在独立模式下调,效率最高;② sourcemap 要能映射到子应用源码,否则 eval 出来的代码栈不可读;③ 全局onerror和unhandledrejection按当前激活的子应用名打标,上报到各自的监控项目,避免所有报错都归到主应用头上;④ 在beforeLoad/afterMount打点统计各子应用的加载和挂载耗时;⑤ 子应用注册表由接口下发,支持按用户灰度和故障时一键摘除。
- 答:几个关键手段。① 子应用必须能独立启动运行(用
追问: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的已知短板。
- 答:拿到的是一段 HTML 文本,不是 DOM。它会正则扫一遍,把
✅ 项目关联话术(按真实经历替换):
我们的运营后台原来是一个单体 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 风险也会泄漏敏感数据。
连环追问链:
追问:注水的数据直接
JSON.stringify塞进<script>有什么问题?- 答:两个问题。① 数据里只要出现
</script>这个字符串,HTML 解析器就会提前闭合脚本标签,后面的内容被当成 HTML 解析,攻击者可以注入任意脚本,这是经典的 XSS;② JSON 允许 U+2028/U+2029 这两个字符,但 JS 字面量里它们是换行符,会直接导致语法错误、整页白屏。所以要么把<、>、U+2028、U+2029 全部转义成\u00xx,要么改用<script type="application/json">承载、运行时JSON.parse,后者更稳。
- 答:两个问题。① 数据里只要出现
追问:Next.js 里
getServerSideProps返回的 props 有什么要注意的?- 答:它返回的东西会原封不动地出现在 HTML 的
__NEXT_DATA__里,任何人右键查看源码都能看到。所以绝不能把整个数据库查询结果或者整个用户对象直接返回,必须挑字段。另外 props 必须是可 JSON 序列化的,Date、undefined、Map都会报错或丢类型 —— 需要富类型就上superjson这类库。
- 答:它返回的东西会原封不动地出现在 HTML 的
追问:服务端已经请求过的数据,客户端为什么还会再请求一次?
- 答:说明注水没接上。常见原因是:客户端的请求 key 和服务端 prefetch 时用的 key 不一致(多了个参数、顺序不同);或者 store 初始化时没读
window.__DATA__就直接用了默认值;或者 React Query 的HydrationBoundary没包到对应的组件树上。表现是首屏数据闪一下又重新 loading,网络面板里能看到重复请求。
- 答:说明注水没接上。常见原因是:客户端的请求 key 和服务端 prefetch 时用的 key 不一致(多了个参数、顺序不同);或者 store 初始化时没读
追问:
AsyncLocalStorage为什么能解决这个问题,和挂global有什么区别?- 答:
global.ctx = req在并发下必然被覆盖 —— 请求 A 还在 await 的时候,请求 B 已经把global.ctx改了,A 恢复执行时读到的就是 B 的。AsyncLocalStorage基于async_hooks,它把存储绑定在异步执行上下文上,每条异步调用链各看到自己那份,跨 await 也不会串。代价是有一定性能开销,而且在一些第三方库里丢失上下文时很难查。
- 答:
追问:你们怎么验证没有跨请求污染?
- 答:压测时混打。用
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兜底。
连环追问链:
追问:
onShellReady和onAllReady该用哪个?- 答:面向真实用户用
onShellReady,这才是流式的意义 —— 外壳好了就发。onAllReady是等所有 Suspense 都 resolve 再一次性发完整 HTML,用在爬虫和静态生成场景:爬虫不一定执行后续 chunk 的脚本,SSG 也需要完整产物。实际项目里常常根据 UA 判断是爬虫就走onAllReady。
- 答:面向真实用户用
追问:流式 SSR 下 404 和重定向怎么处理?
- 答:必须在渲染之前决定。路由匹配、鉴权、资源存在性检查都放在渲染前的中间件里,该 302 就 302、该 404 就 404。如果一个资源不存在只能在渲染中途才发现(比如深层组件取数据失败),那时候 header 已经发出去了,只能两个办法:要么提前到渲染前预检,要么接受「返回 200 + 页面内渲染一个 not found」,但这对 SEO 不友好。所以我们的约定是所有影响状态码的判断一律前置。
追问:怎么知道中间层把流给缓冲了?
- 答:最直接的办法是
curl -N请求,观察输出是不是分批到达、还是最后一次性吐出来;也可以看响应头有没有Transfer-Encoding: chunked、有没有被加上Content-Length(有Content-Length说明已经被完整缓冲了)。Nginx 要关proxy_buffering off,并注意 gzip 模块也可能攒包,CDN 侧要确认支持流式回源。
- 答:最直接的办法是
追问:Suspense 边界应该怎么划?
- 答:原则是「按数据依赖和重要性划,不按组件结构划」。首屏必须立刻看到的(导航、标题、主图、价格)放在外壳里,用服务端最快的数据源;依赖慢接口或低优先级的(推荐列表、评论、广告)各包一个 Suspense。边界不能太细,每个边界都有运行时开销和一段注入脚本;也不能太粗,否则一个慢接口又把一大片拖住了。
追问:流式 SSR 和 RSC 是一回事吗?
- 答:不是,经常被混。流式 SSR 解决的是「HTML 怎么分批发出去」;RSC 解决的是「哪些组件的代码根本不下发到客户端」。RSC 产出的是序列化的 UI 描述流而不是 HTML,组件代码永不进客户端 bundle,所以可以直接
await数据库。两者是互补的:Next App Router 里 RSC 的输出就是通过流式传输的,但它们是两个独立的概念。
- 答:不是,经常被混。流式 SSR 解决的是「HTML 怎么分批发出去」;RSC 解决的是「哪些组件的代码根本不下发到客户端」。RSC 产出的是序列化的 UI 描述流而不是 HTML,组件代码永不进客户端 bundle,所以可以直接
✅ 项目关联话术(按真实经历替换):
我们的商详页首屏要聚合五个接口,其中推荐和评论两个平均 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 里加了依赖版本一致性校验,不一致直接拦合并。
连环追问链:
追问:
experimentalStyleIsolation的原理和局限是什么?- 答:原理是在插入子应用样式时,用一个 CSS 解析器遍历所有规则,给每条选择器前面加上
div[data-qiankun="appName"]这样的属性选择器,把作用域限制在子应用容器内。局限有几个:对@media、@keyframes、@supports这类 at-rule 的处理不完整;body/html这种选择器会被改写成容器选择器,语义变了;运行时动态插入的 style 可能漏掉;以及选择器层级变深带来的匹配开销。它是兜底手段,不能替代规范。
- 答:原理是在插入子应用样式时,用一个 CSS 解析器遍历所有规则,给每条选择器前面加上
追问:怎么定位到底是哪个子应用在泄漏?
- 答:几步。① 在
afterMount/afterUnmount打点记录performance.memory.usedJSHeapSize,看切哪个应用后不回落;② 只反复切换两个应用做二分;③ Memory 面板打两个堆快照做 Comparison,看新增的 detached DOM 和闭包指向哪个文件;④ 重点查setInterval、addEventListener('resize'|'scroll')、WebSocket、第三方 SDK 的 init,这几类占了泄漏的绝大多数。
- 答:几步。① 在
追问:公共依赖不统一会出什么具体问题?
- 答:最典型的是 React 两份实例导致 hooks 报错 ——
Invalid hook call,因为 hooks 依赖模块内的 dispatcher 单例。其次是组件库样式互相覆盖、instanceof判断失效、Context 跨实例拿不到值、以及体积翻倍。反过来强制统一的代价是「一个子应用想升 React 18,所有人都得跟着升」,所以要在「隔离」和「共享」之间取舍:我们的策略是框架运行时强制统一、业务库和 UI 库允许各自独立并靠前缀隔离。
- 答:最典型的是 React 两份实例导致 hooks 报错 ——
追问:子应用加载失败怎么办?
- 答:要做熔断降级。在
registerMicroApps的错误回调里统计失败次数,连续失败 N 次就把该应用从注册表摘掉并在容器里渲染一个降级页,保证其他应用还能用。子应用注册表我们是接口下发的,所以也可以服务端直接下掉某个应用或回滚到上一个版本的 entry。同时全局onerror按当前激活应用打标上报,能第一时间知道是谁挂了。
- 答:要做熔断降级。在
追问:微前端的子应用能独立开发调试吗?怎么做?
- 答:必须能,否则开发效率会崩。做法是用
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精准失效。
连环追问链:
追问:Data Cache 和 Full Route Cache 到底什么区别?
- 答:粒度和内容不同。Data Cache 缓存的是一次
fetch的结果,是数据层,多个路由可以共用同一份;Full Route Cache 缓存的是整条路由渲染完的产物(HTML + RSC Payload),是渲染层。所以 Data Cache 失效了,路由还会重新渲染一次;而 Full Route Cache 命中时连渲染都不会发生。revalidateTag打的是前者(会连带使相关路由失效),revalidatePath打的是后者。
- 答:粒度和内容不同。Data Cache 缓存的是一次
追问:为什么说「重新部署一下就好了」是错的?
- 答:因为 Data Cache 是跨部署存活的,它存在持久化存储里(自托管时是
.next/cache,Vercel 上是独立的缓存服务),发版不会清。能被发版清掉的是 Full Route Cache。这个差异导致很多人「本地改好、线上发版、数据还是旧的」,然后去怀疑数据库主从延迟,方向全错。
- 答:因为 Data Cache 是跨部署存活的,它存在持久化存储里(自托管时是
追问:哪些写法会让一条路由从静态变成动态?
- 答:用了
cookies()、headers()、searchParams、draftMode()这些依赖本次请求的 API,或者fetch带了cache: "no-store",或者显式export const dynamic = "force-dynamic"。危险在于它是隐式传染的:有人为了加个埋点在深层组件里读了一下headers(),整条路由就从 CDN 直出退化成每次回源。所以我会在 CI 里对比next build输出的路由表,○ Static变ƒ Dynamic就告警。
- 答:用了
追问:不是
fetch的数据源怎么缓存?比如 Prisma 查库。- 答:Data Cache 是挂在 Next 改写过的
fetch上的,直接查库不走它。要用unstable_cache把查询函数包一层,显式给 key 前缀、revalidate和tags,这样才能享受同样的缓存和按 tag 失效的能力。另外同一次渲染内的去重也没了,需要用 React 的cache()包一下做 memoization。
- 答:Data Cache 是挂在 Next 改写过的
追问:Server Action 里改完数据,客户端的 Router Cache 会自动更新吗?
- 答:会。Server Action 执行完返回时会携带新的 RSC Payload,Next 会用它刷新当前路由的 Router Cache,所以在 Action 里改数据是最省心的路径。但如果是用普通的
fetch调 Route Handler 改数据,客户端完全不知道服务端变了,必须自己调router.refresh(),这是两种写法体验差异的根源。
- 答:会。Server Action 执行完返回时会携带新的 RSC Payload,Next 会用它刷新当前路由的 Router Cache,所以在 Action 里改数据是最省心的路径。但如果是用普通的
✅ 项目关联话术(按真实经历替换):
我们内容站迁到 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上,那就是跨请求污染,会直接导致用户看到别人的数据。另外服务端通常走内网域名直连,省掉一次公网往返和网关开销,但代价是这一跳在网关日志里不可见,必须自己补接口耗时和错误率的埋点。
连环追问链:
追问:为什么服务端不能用相对路径?
- 答:相对路径是相对于「当前文档的 origin」解析的,这是浏览器的概念。Node 里没有「当前页面」,
fetch("/api/user")拿不到 origin,所以直接报Only absolute URLs are supported(或等价错误)。很多人本地没发现,是因为开发时那段代码只在客户端的useEffect里跑过,一旦挪到服务端取数就炸。
- 答:相对路径是相对于「当前文档的 origin」解析的,这是浏览器的概念。Node 里没有「当前页面」,
追问:为什么服务端超时必须比客户端短?
- 答:影响面不同。浏览器里一个请求慢,只有那一个用户等;服务端一个请求慢,会占住一个渲染流程、拖长所有命中该路由的用户的 TTFB,并且连接和内存都被挂住,极端情况下把 Node 的事件循环和 socket 池打满,变成服务级故障。所以我一般给服务端 1~2 秒,超时就降级 —— 要么这块渲染成骨架交给客户端补,要么整页退回 CSR。
追问:透传 cookie 有什么安全风险?
- 答:两个。一是过度透传:把用户所有 cookie 原样转给所有下游,包括第三方埋点和一些敏感凭证,应该按白名单只转需要的;二是污染:如果把 cookie 设到全局实例上,并发时请求 A 的身份会被用于请求 B 的查询,这是会出安全事故的越权。正确做法是把身份作为参数一路传下去,或者用
AsyncLocalStorage绑在异步上下文上。
- 答:两个。一是过度透传:把用户所有 cookie 原样转给所有下游,包括第三方埋点和一些敏感凭证,应该按白名单只转需要的;二是污染:如果把 cookie 设到全局实例上,并发时请求 A 的身份会被用于请求 B 的查询,这是会出安全事故的越权。正确做法是把身份作为参数一路传下去,或者用
追问:服务端取数走内网还是走公网域名?
- 答:优先内网。走公网要多一次 DNS、TLS 和网关转发,而且 SSR 服务和后端通常同机房,内网延迟能低一个量级。代价是三点:环境变量要按环境区分、本地开发可能访问不到内网(需要代理或 mock)、以及这一跳的流量不再经过网关,监控和限流能力要在 SSR 侧自己补齐。
追问:服务端已经取过的数据,客户端怎么不重复请求?
- 答:靠注水。服务端把取数结果序列化进 HTML,客户端初始化状态时读它,而不是再发一次请求。用数据请求库的话就是 React Query 的
dehydrate/HydrationBoundary或 Nuxt 的payload机制 —— 注意 Nuxt 里useFetch/useAsyncData会参与 payload 传递,而裸$fetch不会,服务端用了$fetch客户端还会再请求一遍。踩过这个坑之后我的规则是:组件初始化数据用useFetch,用户交互触发的请求用$fetch。
- 答:靠注水。服务端把取数结果序列化进 HTML,客户端初始化状态时读它,而不是再发一次请求。用数据请求库的话就是 React Query 的
✅ 项目关联话术(按真实经历替换):
我们第一版 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 应用的后台轮询踢回登录页这种无法复现的问题。
连环追问链:
追问:报
Cannot use 'import.meta' outside a module时你会先查什么?- 答:不查业务代码。这个报错几乎可以直接定性为「ESM 产物被 eval 执行了」,排查方向是产物形态:看主应用加载的那个 entry HTML 里的 script 标签有没有
type="module",看插件是否生效、useDevMode配了没、生产构建是否按预期注入了胶水代码。顺着报错去改业务代码会完全跑偏。
- 答:不查业务代码。这个报错几乎可以直接定性为「ESM 产物被 eval 执行了」,排查方向是产物形态:看主应用加载的那个 entry HTML 里的 script 标签有没有
追问:Vite 子应用在开发态还有哪些额外的坑?
- 答:三个。①
server.cors必须开,否则主应用fetchentry 直接跨域失败;② 要配server.origin,不然 dev 下静态资源的 URL 会按主应用域名拼出来变成 404;③ HMR 的 WebSocket 要显式指定 host 和 port,多个子应用同时起容易连错端口,而且子应用unmount后 HMR 连接不会自动断,切换几次后会残留一堆 WebSocket。
- 答:三个。①
追问:既然 Vite 子应用沙箱失效,那隔离怎么保证?
- 答:技术上只剩样式侧还能兜(
experimentalStyleIsolation或 Shadow DOM),JS 侧只能靠规范:禁止往window挂业务变量、全局事件和定时器必须在unmount清理、localStorage加应用前缀。如果业务对隔离有强要求,这个方案就不该选 —— 我会在技术评审时把这条作为明确的风险项提出来,而不是等上线后出问题。
- 答:技术上只剩样式侧还能兜(
追问:微前端下 token 过期刷新怎么做才不会出问题?
- 答:必须单点刷新 + 并发挂起队列。微前端下常有多个子应用在同时轮询,token 一过期会瞬间打出十几个 401。如果每个请求各自去刷新,部分后端实现里后发的刷新会让先发的 token 失效,形成「刷新风暴 → 全部失效 → 用户被踢」。正确做法是主应用维护一个
refreshingPromise,刷新期间所有请求挂进等待队列,刷完统一重放。
- 答:必须单点刷新 + 并发挂起队列。微前端下常有多个子应用在同时轮询,token 一过期会瞬间打出十几个 401。如果每个请求各自去刷新,部分后端实现里后发的刷新会让先发的 token 失效,形成「刷新风暴 → 全部失效 → 用户被踢」。正确做法是主应用维护一个
追问:子应用怎么既能被主应用集成、又能独立调试?
- 答:所有依赖主应用的能力都走「注入 + 兜底」两条路。用
__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 里直接 greplocation.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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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用stringkey,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 lifecycle | webpack 配 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;注册表改成接口下发,支持灰度与一键摘除 |
| 刷新子应用深链接 404 | Nginx 配 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 和微前端各自的项目关联话术替换成自己的真实经历并背熟
- [ ] 复盘:今天的内容里哪三个点自己讲得最不顺,明天开始前先补这三个