知识广场
按学科筛选:计算机科学 / 前端开发
«计算机科学 / 前端开发» 分类下共 52 篇帖子
CRA 已经废弃,Next.js 太重——纯 SPA 现在的标准答案是 Vite。 下面建立一个开箱即用的最小项目,包含路由、CSS 模块化、ESLint、 开发 + 生产构建。 ## 1. 脚手架 ```bash npm create vite@latest my-app -- --template react-ts cd my-app npm install ``` `--template react-ts` 直接给 TypeScript。 ## 2. 装常用依赖 ```bash npm i react-router-dom npm i -D @types/node prettier eslint-config-prettier ``` ## 3. 给 `tsconfig.json` 加 path alias ```jsonc { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } } ``` 让 Vite 也认 alias: ```ts // vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'node:path' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, server: { port: 5173, host: true }, build: { sourcemap: true, rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom', 'react-router-dom'], }, }, }, }, }) ``` `manualChunks` 把 React 切成独立 chunk,业务代码变化不会重新拉这部分。 ## 4. 主入口 ```tsx // src/main.tsx import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from '@/App' import '@/index.css' createRoot(document.getElementById('root')!).render( <StrictMode> <BrowserRouter> <App /> </BrowserRouter> </StrictMode>, ) ``` ## 5. App + 路由 ```tsx // src/App.tsx import { Routes, Route, Link } from 'react-router-dom' import Home from '@/pages/Home' import About from '@/pages/About' export default function App() { return ( <> <nav> <Link to="/">Home</Link> | <Link to="/about">About</Link> </nav> <Routes> <Route path="/" element={<Home />} /> <Route path="/about" element={<About />} /> </Routes> </> ) } ``` ```tsx // src/pages/Home.tsx export default function Home() { return <h1>Hello</h1> } ``` ## 6. CSS Modules ```css /* src/pages/Home.module.css */ .title { font-size: 2rem; color: #2563eb; } ``` ```tsx import s from './Home.module.css' export default function Home() { return <h1 className={s.title}>Hello</h1> } ``` TypeScript 默认认得 `.module.css` —— vite/client 类型定义里包含了。 ## 7. ESLint + Prettier ```bash npm i -D eslint @eslint/js typescript-eslint eslint-plugin-react-hooks \ eslint-plugin-react-refresh prettier ``` `eslint.config.js`: ```js import js from '@eslint/js' import ts from 'typescript-eslint' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' export default ts.config( { ignores: ['dist'] }, js.configs.recommended, ...ts.configs.recommended, { plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': 'warn', }, }, ) ``` `package.json` 加 script: ```json { "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", "lint": "eslint .", "format": "prettier -w ." } } ``` ## 8. 跑 ```bash npm run dev # 开发,HMR # http://localhost:5173 npm run build # tsc 类型检查 + Vite 打包,输出到 dist/ npm run preview # 本地预览生产构建 ``` ## 9. 部署(静态托管) ```bash npm run build # dist/ 直接 rsync 到任何静态服务器: rsync -avz --delete ./dist/ user@server:/var/www/myapp/ ``` 服务端 nginx: ```nginx server { listen 80; server_name myapp.example.com; root /var/www/myapp; index index.html; # SPA 路由:所有未命中文件的请求都 fallback 到 index.html location / { try_files $uri $uri/ /index.html; } # 资源缓存 1 年(文件名带 hash 所以安全) location ~* \.(css|js|png|jpg|svg|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; } } ``` ## 踩过的坑 - 用 alias `@/` 后 IDE 不识别 → 重启 TypeScript Server(VSCode:Cmd+Shift+P → "TypeScript: Restart TS Server")。 - HMR 突然不工作:检查文件名首字母是否大写。`Home.tsx` 默认 export 必须是 `Home`(首字母大写),否则 react-refresh 不接管。 - 生产构建 hash 文件名后部署时新文件先到,旧 index.html 还在引用, 瞬间 404。解决:先部署 `assets/` 再部署 `index.html`,并保留旧 hash 文件 2-3 个版本。 - `vite preview` 不能完全替代真实生产 nginx,特别是 try_files fallback —— preview 自带 SPA fallback,nginx 没配就 404。
## 起因 一个 React app build 完 `main.js` 1.2 MB(gzipped 320 KB),首屏卡顿。 "哪些包是大头?删得掉吗?"——光看 `dist/` 看不出。 `rollup-plugin-visualizer` 给 vite build 加可视化报告: treemap 看每个 dependency 的大小占比,10 分钟能砍 30-60%。 ## 解决方案 ### 装 ```bash npm i -D rollup-plugin-visualizer ``` `vite.config.ts`: ```ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { visualizer } from 'rollup-plugin-visualizer' export default defineConfig({ plugins: [ react(), visualizer({ open: true, // build 后自动开浏览器 gzipSize: true, brotliSize: true, filename: 'dist/stats.html', }), ], }) ``` ```bash npm run build # 浏览器自动开 dist/stats.html ``` 显示一张 treemap:每个矩形大小 = 该 module 占 bundle 的比例。 hover 看具体 KB(raw / gzip / brotli)。 ## 常见"巨包" + 处理 ### A. moment.js 占 200 KB+ ``` node_modules/moment/ → 200 KB(含全部 locale) ``` 修复:换成 day.js(2 KB,API 兼容)或 date-fns(tree-shakeable)。 ```diff - import moment from 'moment' + import dayjs from 'dayjs' - moment().format('YYYY-MM-DD') + dayjs().format('YYYY-MM-DD') ``` 如果坚持用 moment,至少剔除 locale: ```js // vite.config.ts build: { rollupOptions: { plugins: [ // 忽略 moment locale { name: 'remove-moment-locale', resolveId(id) { if (id.includes('moment/locale')) return false }, }, ], }, } ``` 省 150 KB+。 ### B. lodash 全 import 占 80 KB ```js import _ from 'lodash' // ❌ 整个 lodash _.debounce(...) ``` → ```js import debounce from 'lodash/debounce' // ✅ 只 import 用到的 debounce(...) ``` 或者用 `lodash-es` + ESM tree-shaking: ```diff - "lodash": "^4.17.21" + "lodash-es": "^4.17.21" ``` ```js import { debounce, throttle } from 'lodash-es' // Vite tree-shake 后只 bundle debounce + throttle ``` ### C. icon 库一次进 1000+ icon ```js import * as Icons from 'react-icons/fa' // ❌ 全部 ``` → ```js import { FaUser, FaBell } from 'react-icons/fa' // ✅ 只用到的 ``` 或换成 lucide-react(更轻 + tree-shakeable): ```js import { User, Bell } from 'lucide-react' ``` ### D. chart 库(chart.js / echarts)整套 import ```js import * as echarts from 'echarts' // ❌ 1MB+ ``` → 按需 import: ```js import * as echarts from 'echarts/core' import { BarChart } from 'echarts/charts' import { GridComponent, TooltipComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers' echarts.use([BarChart, GridComponent, TooltipComponent, CanvasRenderer]) ``` 只 bundle 用到的 chart 类型 + components。1MB → 200 KB。 ### E. 重复包 stats.html 里看到 `react` 出现两次(不同版本)→ 包冲突。 ```bash npm dedup # 或: npm ls react # 看依赖树哪几条用了不同 react 版本 ``` resolutions / overrides 强制单版本: ```json { "overrides": { "react": "18.3.1", "react-dom": "18.3.1" } } ``` ### F. 大 polyfill ``` core-js → 150 KB ``` target 现代浏览器后大多不需要: ```js // vite.config.ts build: { target: 'es2020', // 现代浏览器 } ``` 砍掉一堆 polyfill。 ## 代码分割:route-level lazy ```tsx import { lazy, Suspense } from 'react' const Dashboard = lazy(() => import('./pages/Dashboard')) const Settings = lazy(() => import('./pages/Settings')) <Routes> <Route path="/" element={<Home />} /> <Route path="/dashboard" element={ <Suspense fallback={<Spinner />}> <Dashboard /> </Suspense> } /> </Routes> ``` 每个路由独立 chunk,访问到才下载。 首屏 bundle 只含 Home + 公共代码。 ```bash npm run build # 看到 dist/assets/Dashboard-abc123.js + dist/assets/Settings-def456.js ``` ## 第三方分割:manualChunks 把 React 等"很少变" 的 dep 抽独立 chunk,业务代码改不影响 cache: ```ts build: { rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom', 'react-router-dom'], ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'], charts: ['echarts'], }, }, }, } ``` 业务代码更新 → 用户重下 main.js(小)+ 命中 react.js / ui.js cache。 ## 动态 import 大 dep ```tsx async function exportPDF() { const { jsPDF } = await import('jspdf') // 用到才下载 const doc = new jsPDF() // ... } ``` PDF 导出按钮 click 才 fetch jspdf chunk。 ## 监控 bundle size on PR ```yaml # .github/workflows/size.yml - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci - run: npm run build - uses: andresz1/size-limit-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} ``` PR 显示 "bundle 增加 +12 KB",超 budget 就 fail。 防止某天某人引一个巨包没注意。 `.size-limit.json`: ```json [ { "name": "main bundle", "path": "dist/assets/index-*.js", "limit": "200 KB" } ] ``` ## 实测:从 1.2 MB → 380 KB 我们一个真实 React app: | 阶段 | bundle (raw) | gzip | |---|---|---| | 初始 | 1.2 MB | 320 KB | | 删 moment → dayjs | 950 KB | 260 KB | | lodash 按需 import | 850 KB | 235 KB | | icon 改 lucide | 730 KB | 200 KB | | echarts 按需 | 480 KB | 145 KB | | route lazy split (main) | 380 KB | 115 KB | | 总改动 | -68% | -64% | LCP 从 4.5s → 1.8s。改动总共半天。 ## 其它分析工具 ### webpack-bundle-analyzer 风格 `source-map-explorer` 也支持 Vite: ```bash npm i -D source-map-explorer npm run build npx source-map-explorer dist/assets/index-*.js ``` 需要 source map 开(vite 默认开发开 / production 关; build sourcemap: true)。 ### bundlephobia 写代码前查:"如果我加这个包,bundle 变多大": ``` https://bundlephobia.com/package/moment # Bundle size: 290.7 KB # Minified + Gzipped: 71.2 KB ``` 知道代价再决定要不要装。 ### Vite Bundle Visualizer 升级 Vite 5 + `--report` 选项: ```bash vite build --report ``` 内置 visualizer。无需额外 plugin。 ## CDN 拆解 如果 React 等大库走 CDN: ```html <script src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script> ``` ```ts build: { rollupOptions: { external: ['react', 'react-dom'], output: { globals: { react: 'React', 'react-dom': 'ReactDOM' }, }, }, } ``` bundle 不含 React → 共享 CDN cache 跨站。但 CDN 多次 DNS / 信任问题, 现代项目少用。 ## 踩过的坑 1. **chunk 太碎**:每页一个 chunk + 每包一个 chunk → 总文件数几百。 HTTP/1.1 client 多次连接慢。HTTP/2 没事;HTTP/1 视乎服务端。 2. **manual chunks 改后 hash 变** → cache 失效。`manualChunks` 改一次 全 dependency cache 一次性失效,所有用户重下。慎重。 3. **dynamic import 路径动态化** → vite 静态分析不到 → 不切 chunk: ```ts const m = await import(`./pages/${name}`) // ❌ vite 不知道哪些 page ``` 要给 vite 提示: ```ts const m = await import(`./pages/${name}.tsx`) // 静态可分析 // 或:vite-ignore 注释 + 自己管 ``` 4. **bundle 在 dev 看不出问题**:dev 模式 vite 不打包,每个 module 独立 HTTP。production 才看真实大小。永远以 production build 衡量。 5. **react 包很小但 hydrate 慢**:bundle size 不是 LCP 唯一决定因素。 React 启动 / hydrate 也耗时。RSC + 减少 client component 是补充 优化方向。
flexbox 适合一维,CSS Grid 适合二维。"侧栏 + 主内容 + 副栏"经典三栏 布局用 Grid 写比 flex 简洁 5 倍。 ## 1. 最简版 ```html <div class="layout"> <aside>侧栏</aside> <main>主内容</main> <section>副栏</section> </div> ``` ```css .layout { display: grid; grid-template-columns: 200px 1fr 280px; gap: 24px; } ``` `fr` 是"剩余空间分数"。`1fr` 中间吃满,左右固定。 ## 2. 窄屏自动折叠 ```css .layout { display: grid; grid-template-columns: 200px 1fr 280px; gap: 24px; } @media (max-width: 1000px) { .layout { grid-template-columns: 200px 1fr; } .layout > section { grid-column: 1 / -1; /* 副栏跨整行,垂直堆叠 */ } } @media (max-width: 700px) { .layout { grid-template-columns: 1fr; } } ``` `grid-column: 1 / -1` 是 Grid 的常用语法:从第 1 条线到最后一条线, 即占满全宽。 ## 3. 命名区域(更清晰的多栏 / 多行) ```css .layout { display: grid; grid-template-columns: 200px 1fr 280px; grid-template-rows: auto 1fr auto; grid-template-areas: "header header header" "sidebar main rail" "footer footer footer"; gap: 16px; min-height: 100vh; } .layout > header { grid-area: header; } .layout > aside { grid-area: sidebar; } .layout > main { grid-area: main; } .layout > section { grid-area: rail; } .layout > footer { grid-area: footer; } ``` 窄屏重排,只改 `grid-template-areas`: ```css @media (max-width: 800px) { .layout { grid-template-columns: 1fr; grid-template-areas: "header" "main" "rail" "sidebar" "footer"; } } ``` ## 4. 12 列网格系统(不需要 Bootstrap) ```css .grid12 { display: grid; grid-template-columns: repeat(12, 1fr); gap: 20px; } .col-3 { grid-column: span 3; } .col-4 { grid-column: span 4; } .col-6 { grid-column: span 6; } .col-12 { grid-column: span 12; } @media (max-width: 800px) { .col-3, .col-4, .col-6 { grid-column: span 12; } } ``` ```html <div class="grid12"> <div class="col-4">A</div> <div class="col-4">B</div> <div class="col-4">C</div> <div class="col-6">D</div> <div class="col-6">E</div> </div> ``` 整套不到 30 行 CSS,干掉 Bootstrap grid 一整个模块。 ## 5. 让卡片网格自动决定列数(最常用!) ```css .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } ``` `auto-fill` + `minmax`:每列最小 260px,最大平分;窗口宽就放多列、 窗口窄就放少列,不需要任何 media query。视口超过 4×260 时一行 4 个, 缩到 2×260 时一行 2 个。 ## 6. 让子元素拉满高度 ```css .cards { display: grid; grid-auto-rows: 1fr; ... } ``` `grid-auto-rows: 1fr` 让每行所有 cell 等高,再加 `align-self: stretch` 内部内容拉满。 ## 7. subgrid(2024 + 浏览器全支持) 子元素的 grid track 对齐父元素: ```css .card { display: grid; grid-template-rows: subgrid; grid-row: span 3; /* 跨父网格 3 行 */ } ``` 适合"卡片列表里每张卡片内部各自有 header / body / footer,但希望同一行 卡片的 header / body / footer 严格对齐"。 ## 8. DevTools Chrome / Firefox 的 Inspector 里点 `grid` 标记会显示 grid 线条和 area 名字, 是调试 grid 唯一有效的方法。别盲调。 ## 踩过的坑 - `minmax(260px, 1fr)` 在窄屏(< 260px 视口)会撑破父容器;想严格防溢出 用 `minmax(min(260px, 100%), 1fr)`。 - `gap` 在 Safari 14 之前的 flexbox 不支持,Grid 支持。如果用 flex 老 Safari 兼容性差。 - 用 `grid-template-areas` 时 area 名要 **每个 cell 都有** 字符串。 `"header . header"` 用 `.` 表示空。 - 大量 grid 嵌套对老设备性能不友好。深嵌套 grid 时考虑用 `contain: layout` 限制重排范围。
## 起因 第一次看到 React Server Components(RSC)的介绍很懵: "在服务端渲染的组件"——这不就是 SSR 吗?为什么搞个新东西? 直到我真的写了一个 RSC 项目(Next.js 14 app router)才明白它解的 是另一个问题:**让组件能直接访问后端资源(DB / 文件 / API), 零 JS bundle 开销**。 ## 传统 SSR 的局限 传统 SSR(getServerSideProps / loader): ```jsx // pages/posts/[id].js (传统 Next.js pages router) export async function getServerSideProps({ params }) { const post = await db.posts.findById(params.id) const author = await db.users.findById(post.author_id) const comments = await db.comments.findByPost(params.id) return { props: { post, author, comments } } } export default function Page({ post, author, comments }) { return ( <article> <h1>{post.title}</h1> <AuthorBlock user={author} /> <CommentList items={comments} /> </article> ) } ``` 问题: - 数据获取集中在一个函数里,组件需要数据时要 prop drill 传 - 整个组件树都 hydrate(即使大部分不需要交互) - 所有数据 JSON serialize 进 HTML,包大 - `<AuthorBlock>` 和 `<CommentList>` 各自需要的数据要 page 这层先取 好再传 ## RSC 的做法 ```tsx // app/posts/[id]/page.tsx (Next.js 14 app router) // 默认就是 Server Component! import { db } from '@/db' import AuthorBlock from './AuthorBlock' import CommentList from './CommentList' export default async function Page({ params }: { params: { id: string }}) { const post = await db.posts.findById(params.id) return ( <article> <h1>{post.title}</h1> <AuthorBlock userId={post.author_id} /> <CommentList postId={params.id} /> </article> ) } ``` ```tsx // app/posts/[id]/AuthorBlock.tsx // 也是 Server Component,自己取数据 import { db } from '@/db' export default async function AuthorBlock({ userId }: { userId: string }) { const user = await db.users.findById(userId) return ( <div> <img src={user.avatar} alt="" /> <strong>{user.name}</strong> </div> ) } ``` ```tsx // app/posts/[id]/CommentList.tsx import { db } from '@/db' export default async function CommentList({ postId }: { postId: string }) { const comments = await db.comments.findByPost(postId) return ( <ul> {comments.map(c => <li key={c.id}>{c.body}</li>)} </ul> ) } ``` 变化: - 组件**就是 async 函数**,直接 await DB / API - 每个组件管自己的数据获取 - 没有 prop drilling - 这些组件的 JS 完全不进客户端 bundle(因为它们只在服务端跑) ## 客户端组件交互("use client") 需要 useState / 事件 / 浏览器 API 的组件加 `"use client"`: ```tsx // app/posts/[id]/LikeButton.tsx "use client" import { useState } from 'react' export default function LikeButton({ postId, initialCount }: ...) { const [count, setCount] = useState(initialCount) return ( <button onClick={() => { setCount(c => c + 1) fetch(`/api/posts/${postId}/like`, { method: 'POST' }) }}> ❤️ {count} </button> ) } ``` ```tsx // app/posts/[id]/page.tsx (server component) import LikeButton from './LikeButton' export default async function Page({ params }: ...) { const post = await db.posts.findById(params.id) return ( <> <h1>{post.title}</h1> <LikeButton postId={post.id} initialCount={post.likes} /> </> ) } ``` `LikeButton` 是 client component,JS 进 bundle、可以 useState。 其它 server component 完全不进 bundle。 ## 实际收益 我们的项目(中等规模博客): | | 传统 SSR | RSC | |---|---|---| | 首页 JS bundle | 280 KB | 60 KB | | First Contentful Paint | 1.8s | 0.9s | | LCP | 2.4s | 1.4s | | 代码可读性 | 数据 / UI 分离明显 | 数据在用它的组件里 | bundle 显著小(因为大部分组件不进客户端),首屏快得多。 ## 什么时候用 client component `"use client"` 的场景: - `useState` / `useEffect` 等 React hooks - onClick / onChange 等事件 - 浏览器 API(localStorage / window / etc) - 用了 client-only 库(如 framer-motion / chart.js) - Context provider(虽然 server component 也能 consume) 其它一律 server component。原则:"静态展示 → server;交互 → client"。 ## 数据获取最佳实践 ### 1. 直接 DB / ORM ```tsx import { prisma } from '@/lib/db' export default async function Page() { const posts = await prisma.post.findMany({ take: 20 }) return <PostList posts={posts} /> } ``` 不再需要 REST / GraphQL 层。component → ORM → DB。 ### 2. 并行获取 ```tsx export default async function Page({ params }: ...) { const [post, author] = await Promise.all([ db.posts.findById(params.id), db.users.findById(params.userId), ]) return ... } ``` 避免 await 串行让请求慢。 ### 3. Streaming + Suspense ```tsx import { Suspense } from 'react' export default function Page({ params }: ...) { return ( <> <h1>Article</h1> <Suspense fallback={<div>loading post...</div>}> <PostBody id={params.id} /> </Suspense> <Suspense fallback={<div>loading comments...</div>}> <CommentList postId={params.id} /> </Suspense> </> ) } ``` server 先 stream 出 `<h1>` + fallback,post / comments 各自异步加载完 就 stream 出真实 DOM 替换 fallback。用户看到 "渐进显示" 而不是 "全部等好再显示"。 ## Server Actions:从 client 调 server 函数 ```tsx // app/posts/[id]/page.tsx async function deletePost(postId: string) { 'use server' await db.posts.delete(postId) revalidatePath('/posts') } export default function Page({ params }: ...) { return ( <form action={async () => { 'use server'; deletePost(params.id) }}> <button>删除</button> </form> ) } ``` 不需要写 `/api/posts/:id/delete` endpoint。直接调函数,Next.js 自动 hook 成 RPC。 ## 与传统 SSR / SPA 共存 不是非黑即白。常见混合: - 营销页 / 文章 / 列表 → server component - 仪表盘 / 复杂表单 / 实时聊天 → client component - Next.js app router 默认 server,按需 `"use client"` ## 效果 - 首屏体验大幅改善(bundle 减少) - 数据 / UI 不再分离,组件代码更聚合 - 不需要专门维护 REST API 给前端用(直接调 ORM) - 但要熟悉"哪些代码跑服务端 / 哪些跑客户端" 心智模型 ## 踩过的坑 1. **在 server component 里用 useState** → 编译报错。新人最常见的 错。要么加 "use client",要么把状态下移到 client 子组件。 2. **import 服务端库进 client component**:bundle 暴涨 + 可能泄漏 secret。`import { db } from '@/db'` 在 client component 里就是 重大失误。lint 规则强制检查。 3. **server / client 边界传 props 必须 serializable**:函数 / Map / class 实例传不过去。只能传 plain object / 数组 / 基础类型。 4. **revalidate / cache 复杂**:Next.js 默认激进 cache,dev / prod 行为差异大。明确用 `cache: 'no-store'` / `revalidate: 60` / `cookies()`。 5. **Vercel / 自托管差异**:Server Actions 等功能在自托管 Next.js 还要配 standalone build。Vercel 上很丝滑,自部署要折腾。
Redux Toolkit 适合大型项目,对小项目却有大量样板。Zustand 是 Pmndrs(与 Three.js / Jotai 同作者)的极简方案,2024 后越来越流行。 ## 安装 ```bash npm i zustand ``` ## 最小 store ```ts // stores/counter.ts import { create } from 'zustand' interface State { count: number inc: () => void reset: () => void } export const useCounter = create<State>((set) => ({ count: 0, inc: () => set(s => ({ count: s.count + 1 })), reset: () => set({ count: 0 }), })) ``` ```tsx function Counter() { const count = useCounter(s => s.count) const inc = useCounter(s => s.inc) return <button onClick={inc}>{count}</button> } ``` 无 Provider、无 reducer、无 action type。selector 写法天然做了 memoization (只有 selector 返回值变了才重渲染)。 ## persist middleware ```ts import { persist, createJSONStorage } from 'zustand/middleware' export const useSettings = create( persist<Settings>( (set) => ({ theme: 'light', setTheme: (t) => set({ theme: t }), }), { name: 'settings', storage: createJSONStorage(() => localStorage), version: 1, } ) ) ``` 刷新页面状态自动还原。 ## immer middleware(写嵌套结构方便) ```ts import { immer } from 'zustand/middleware/immer' const useTodos = create<TodoState>()( immer((set) => ({ todos: [], addTodo: (text) => set(s => { s.todos.push({ id: Date.now(), text }) }), toggle: (id) => set(s => { const t = s.todos.find(t => t.id === id) if (t) t.done = !t.done }), })) ) ``` 直接 mutate(看起来),immer 内部生成 immutable 更新。 ## 多 store vs 单 store Zustand 鼓励"多个小 store",每个独立: ```ts useAuth() // 登录状态 useTheme() // 主题 useTodos() // 待办列表 ``` 而不是 Redux 那种"一个 root reducer"。 小 store 更容易代码分割 / 测试 / 删除。 ## 异步 action ```ts const useUser = create<UserState>((set) => ({ user: null, loading: false, async fetchUser(id: string) { set({ loading: true }) try { const r = await fetch(`/api/users/${id}`) set({ user: await r.json(), loading: false }) } catch (e) { set({ loading: false }) } }, })) ``` 异步逻辑就是普通 async 函数,无需 thunk / saga。 ## 选择多个字段时避免 re-render ```tsx // ❌ 每次新对象 → 总是重渲 const { name, email } = useUser(s => ({ name: s.name, email: s.email })) // ✅ 用 shallow 比较 import { shallow } from 'zustand/shallow' const { name, email } = useUser(s => ({ name: s.name, email: s.email }), shallow) // 或者分别 select const name = useUser(s => s.name) const email = useUser(s => s.email) ``` ## getState / setState(store 之外用) ```ts useAuth.getState().logout() useAuth.setState({ user: null }) // 订阅外部 const unsub = useAuth.subscribe( (state) => state.user, (user) => console.log('user changed', user) ) ``` 适合在路由 hook、worker 等非 React 上下文里用。 ## Devtools ```ts import { devtools } from 'zustand/middleware' const useStore = create(devtools<State>( (set) => ({ ... }), { name: 'MyStore' } )) ``` Redux DevTools 扩展能看到时间线 + state diff。 ## 与 React Server Components Zustand 是 client-side 状态。RSC 里直接用 fetch;只在交互组件("use client") 里用 store。 ## 测试 ```ts import { act } from 'react' it('inc works', () => { const { inc, count } = useCounter.getState() expect(count).toBe(0) act(() => inc()) expect(useCounter.getState().count).toBe(1) }) // 测后重置: beforeEach(() => useCounter.setState({ count: 0 })) ``` ## vs Jotai / Recoil - **Zustand**:单值存储 + selector,类似 mini Redux - **Jotai**:atomic state,类似 SolidJS signal - **Recoil**:Facebook 出的 atom 风格,已停止维护 中型项目用 Zustand 最实用;大量原子化派生状态用 Jotai。 ## 踩过的坑 - selector 返回新对象 → 总是重渲。要么 shallow 要么拆字段 select。 - store 闭包了旧值:set 用函数形式 `set(s => ...)` 而不是 `set({})`。 - store 不要循环依赖(A store 里 select B store)。把跨 store 派生 搬到组件层。 - persist 跨大版本:手动写 migrate 函数;不写则旧 state 直接覆盖 默认值有可能丢字段。
## 起因 React UI 库选择困难: - **Material UI**:很全 + 重 + 偏 google look - **Chakra UI**:现代 + 灵活但 bundle 大 - **Ant Design**:业务向,复杂表单强 - **Radix UI**:unstyled headless - **Tailwind UI**:付费 component 每个都是"npm install 进项目,按 props 用"。 痛点: - 改 design 难(要覆盖默认 style) - 升级 lib 版本可能 break style - bundle 总是吃满(你只用 button 但 import 全 lib) **shadcn/ui** 提出不同模式:**copy component code 进项目**。 不是 library,是 component template + Radix 底层。 ## 加 component ```bash npx shadcn@latest init # 配置 tailwind / 颜色主题 npx shadcn@latest add button # 把 Button 源码 copy 到 components/ui/button.tsx ``` ```tsx import { Button } from '@/components/ui/button'; <Button variant="default">Click</Button> <Button variant="destructive">Delete</Button> <Button variant="outline" size="sm">Cancel</Button> ``` UI 跟 Tailwind UI 类似(基于 Tailwind class)。 ## 改 component 是 yours ```bash npx shadcn@latest add card ``` 文件 `components/ui/card.tsx` 现在是你的。 要改 padding / 圆角 / 加 prop → 直接改源码。 没 lib 升级问题(没 lib)。 ## 模式核心 ``` Radix UI (headless, accessible) + Tailwind CSS (styling) + 你 own 的 code = shadcn/ui ``` - Radix 提供 a11y / behavior(focus trap / aria 等) - Tailwind 提供 styling - 你拥有源码 + 修改自由 ## 优势 - **零 lock-in**:lib 没了你 code 不挂 - 改 design 直接改 file - bundle 只含你用的 component(tree-shake 友好) - TS first-class - 跟现代 stack(Next.js / Vite / Astro)天然 fit ## 劣势 - 不是 install 即用(每 component 要 add + 看 code) - 升级要手动(lib 出新版要 copy 新 source) - 设计语言比 Material 单调(neutral 风格,要自己丰富) ## 完整 setup ```bash # Next.js + Tailwind 项目 npx shadcn@latest init # 装常用 npx shadcn@latest add button card input label dialog dropdown-menu sheet ``` 生成: ``` components/ui/ button.tsx card.tsx dialog.tsx ... lib/utils.ts # cn() helper ``` `cn()` 是 clsx + tailwind-merge 包装,让 className 智能合并: ```tsx <Button className={cn('w-full', isLoading && 'opacity-50', className)}> ``` ## form 模式 shadcn + react-hook-form + zod: ```tsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; const schema = z.object({ email: z.string().email(), }); function MyForm() { const form = useForm({ resolver: zodResolver(schema) }); return ( <Form {...form}> <form onSubmit={form.handleSubmit(console.log)}> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <Button type="submit">Submit</Button> </form> </Form> ); } ``` template 嗦但极灵活 + a11y 完整 + validation 自动。 ## theme `globals.css`: ```css @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; /* ... */ } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; /* ... */ } } ``` CSS variable 驱动主题。dark mode 加 `class="dark"` on root。 改 brand color:改 `--primary`。 ## sonner / vaul 等 shadcn 推荐组合的几个: - **sonner**:toast notification - **vaul**:bottom sheet (mobile-style drawer) - **cmdk**:command palette - **react-day-picker**:date picker 都是高质量 headless lib,shadcn add 帮你 scaffold 集成。 ## v0 / 自动生成 UI `v0.dev`(Vercel)用 LLM 生成 shadcn/ui-based React component: ``` prompt: "user profile card with avatar, name, bio, and follow button" → 生成 component code 用 shadcn pieces ``` 新 component prototype 1 分钟出。 之后人 review + 调整。 ## 与 Material / Chakra 对比 | | shadcn | Material UI | Chakra | |---|---|---|---| | 装 | copy code | npm install | npm install | | 改 design | 改 code | override theme / sx | override theme | | 学习曲线 | 中(要懂 Tailwind) | 高(API 大) | 中 | | bundle | tree-shake 极好 | 大 | 中 | | 设计语言 | minimal neutral | google material | 现代 | | 适合 | custom design / 中型项目 | 企业产品 | 通用 | 我 2024+ 项目 100% shadcn。 老 Material 项目维持。 ## 跟 Tailwind UI 对比 Tailwind UI(付费): - 设计精良,付费许可 - 复制 HTML 进项目(不分 component) shadcn: - 免费 - React component(不只是 HTML) - 跟 Radix 集成(a11y) 如果钱不是问题且要顶级设计 → Tailwind UI 块 + shadcn 组件 混用。 ## 何时不用 - 不用 React → shadcn 是 React-only(但 Svelte/Vue 有 community port) - 不用 Tailwind → 不适合(核心是 Tailwind class) - 极简项目(landing page)→ Tailwind 直接写 HTML 够 ## 真实 case 新项目 admin dashboard: - 1 周 setup shadcn + 10+ component - 0 设计稿,直接 v0 生成 + 微调 - bundle 200 KB(vs Material 类似项目 600 KB+) - 想改 button radius 全局 → 改 `--radius` CSS var 迭代速度 + 灵活性大幅提升。 ## 踩过的坑 1. **不识别相对路径**:shadcn 默认用 `@/components/ui/...` alias。 要 tsconfig + vite config 配 alias。 2. **冲突 className**:`<Button className="w-full bg-red-500">` 跟内 建 variant 冲突。`cn()` + tailwind-merge 解决。 3. **暗色模式切换闪烁**:SSR 时 server 不知道用户 prefer。Next.js 用 next-themes 处理 hydration。 4. **lib 更新没拉新**:shadcn 出新 version Button → 你的没自动升。 手动 `npx shadcn add button --overwrite` 或者 diff merge。 5. **设计简陋**:default 风格中性 → 看起来朴素。要加品牌色 / 图标 / 插图才"鲜活"。
## 起因 JS 项目测试框架历史: - mocha + chai + sinon(老) - jest(Facebook,2014+,事实标准) - vitest(Vite 团队,2021+,Vite-native) 新项目选谁?老 jest 项目要不要迁?下面经验。 ## jest ```js // my.test.js const { add } = require('./math'); describe('math', () => { test('add', () => { expect(add(1, 2)).toBe(3); }); }); ``` ```bash npx jest ``` 10 年生态,全 JS 圈子默认。 ### 优势 - 教程 / 答案最多 - 巨大插件生态(jest-dom / jest-axe / ...) - snapshot testing 成熟 - mock 系统强(auto-mock / manual mock) ### 劣势 - 启动慢(2-5 秒只为跑 1 个测试) - ESM 支持差(CJS 优先,ESM 配置复杂) - TS 要 ts-jest / @swc/jest 等 - 慢(百 test 几十秒) ## vitest ```js // my.test.js import { test, expect, describe } from 'vitest'; import { add } from './math'; describe('math', () => { test('add', () => { expect(add(1, 2)).toBe(3); }); }); ``` ```bash npx vitest ``` API jest-compat(`expect` / `describe` 大部分一致)。 基于 Vite,原生 ESM + esbuild + TS 直接。 ### 优势 - **极快**:watch mode HMR-like,改文件即重跑相关 test - ESM 原生 + TS 原生 - Vite 项目同 config 共享 - jest API 兼容(容易迁移) - 内置 coverage / UI / browser mode ### 劣势 - 生态比 jest 小(但每年增长快) - 某些 jest 插件没等价 - snapshot 跟 jest 略不同(导致迁移微调) ## 性能对比 中型项目 500 test: | | jest | vitest | |---|---|---| | cold start | 8s | 1.5s | | 全跑 | 25s | 6s | | watch(改 1 file) | 5s | 0.3s | | coverage | 35s | 10s | vitest 普遍 3-5x 快。开发循环 watch mode 差距更大。 ## 写法对比 写法 99% 一致: ```js // 通用 describe('x', () => { beforeEach(() => { ... }); test('does y', async () => { expect(...).toBe(...); }); }); ``` vitest 加: ```js import { vi } from 'vitest'; // jest 是全局 vi → jest.fn / mock ``` `jest.fn()` → `vi.fn()`,`jest.spyOn` → `vi.spyOn`,`jest.mock` → `vi.mock`。 config 注入全局可让 jest 写法直接跑: ```ts // vitest.config.ts export default defineConfig({ test: { globals: true }, // 启用 describe / test / expect 全局 }); ``` ## mock ```js // vitest import { vi } from 'vitest'; vi.mock('./api', () => ({ fetchUser: vi.fn(() => Promise.resolve({ name: 'mock' })), })); const spy = vi.spyOn(console, 'log'); ``` 跟 jest 几乎一样。 ## snapshot ```js expect(rendered).toMatchSnapshot(); ``` 生成 `__snapshots__/my.test.js.snap`。 vitest 用 jest 相同格式。 inline snapshot: ```js expect(rendered).toMatchInlineSnapshot(`"<div>hello</div>"`); ``` review 时 inline 直观。 ## React Testing Library 集成 ```ts // vitest.config.ts import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', setupFiles: './test/setup.ts', }, }); ``` ```ts // test/setup.ts import '@testing-library/jest-dom/vitest'; // matchers ``` ```tsx import { render, screen } from '@testing-library/react'; import { test, expect } from 'vitest'; import App from './App'; test('renders title', () => { render(<App />); expect(screen.getByText('Hello')).toBeInTheDocument(); }); ``` 跟 jest 配 RTL 几乎一样。 ## browser mode(vitest 1.0+) ```ts test: { browser: { enabled: true, name: 'chromium' }, } ``` 测试在真实浏览器跑(替代 jsdom)。 适合:测 component 在真实环境(layout / CSS / fetch)。 jest 不能直接跑浏览器(要 jest-playwright 等组合)。 ## 迁移 jest → vitest ```bash npm install -D vitest @vitest/ui ``` `package.json`: ```json "scripts": { "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage" } ``` 代码层 99% 测试直接跑: ```ts // 全局 import 替代 - import { describe, test, expect } from '@jest/globals'; + // (with globals: true 不需要) + import { describe, test, expect } from 'vitest'; - jest.fn() + vi.fn() - jest.mock('./api') + vi.mock('./api') ``` 一般 sed 批量替换 + 个别手动调。 中型项目几小时迁移完。 ## CI 集成 ```yaml - run: pnpm test --coverage - uses: codecov/codecov-action@v4 ``` 不变。GitHub Actions / GitLab / CircleCI 都 vitest 友好。 ## 真实迁移 case 我们一个 React 项目,jest + RTL + 200 test: - ci 时间从 90s → 25s - watch mode 改文件秒返馈 - ts-jest 配置删了(vite 自带 TS) - 配置文件简化 但迁完一周才完全稳: - 几个 mock 行为微差 - snapshot whitespace 略不同需 regenerate - 某些 jest plugin(如 jest-axe)没 vitest 版本(找替代) ## 何时不必迁 - 老项目大 jest,团队稳定 → 不动 - 用极偏 jest 插件 → 维持 ## 决策 - **新项目** → vitest(无脑选) - **大老项目 + 团队稳** → jest,可不迁 - **小老项目** → 半天迁 ## 与 node:test 对比 Node 20+ 内置 `node --test`: ```js import { test } from 'node:test'; import assert from 'node:assert'; test('add', () => { assert.strictEqual(add(1, 2), 3); }); ``` 无依赖。简单 unit test 够。 但 mock / snapshot / coverage 弱于 jest / vitest,复杂项目仍 vitest。 ## 踩过的坑 1. **vi.mock 提升**:vite 把 mock 提升到 file 顶部 → 跟 import 顺序 交互奇怪。简单场景 OK,复杂用 mock factory + lazy。 2. **`globals: true` 没设**:jest 写法报 `describe is not defined`。 3. **CSS import 报错**:vitest 不像 jest 自动 mock CSS。配 `vitest.config.ts` 加 `css: true` 或 `css.modules.classNameStrategy`。 4. **timeout 默认 5s**:复杂 e2e 测试超时。`testTimeout: 30000`。 5. **watch 没 trigger**:file change 但 test 没重跑 → vitest cache bug。`vitest --no-cache` 或 restart。
## 起因 复杂表单 / 多级嵌套组件共享 state 时,Zustand 的"一个 store 多字段" 还是会让"用 fieldA 的组件" 因为 fieldB 改而 re-render(除非每字段 单独 selector)。 Jotai 思路完全不同:**每个状态是一个独立的 atom**。组件只订阅它真用到 的 atom,atom 变才重渲。天然细粒度。 ## 解决方案 ### 装 ```bash npm i jotai ``` ### 第一个 atom ```tsx import { atom, useAtom } from 'jotai' const countAtom = atom(0) function Counter() { const [count, setCount] = useAtom(countAtom) return <button onClick={() => setCount(c => c + 1)}>{count}</button> } function Display() { const [count] = useAtom(countAtom) return <span>{count}</span> } ``` `useAtom` 跟 `useState` 几乎一样的 API。 **多个组件共享同一 atom** 时自动同步。 不需要 Provider(默认全局): ```tsx function App() { return ( <> <Counter /> <Display /> </> ) } ``` 两个组件用同一 atom,互相同步。 ## Derived atom(计算属性) ```tsx const countAtom = atom(0) const doubledAtom = atom((get) => get(countAtom) * 2) function Doubled() { const [doubled] = useAtom(doubledAtom) return <span>{doubled}</span> } ``` `doubledAtom` 自动跟 `countAtom` 关联:count 变 → doubled 重算 → 订阅 doubled 的组件重渲。 ## Async atom ```tsx const userIdAtom = atom('alice') const userAtom = atom(async (get) => { const id = get(userIdAtom) const r = await fetch(`/api/users/${id}`) return r.json() }) function UserCard() { const [user] = useAtom(userAtom) return <div>{user.name}</div> } function App() { return ( <Suspense fallback={<Spinner />}> <UserCard /> </Suspense> ) } ``` 异步 atom 自动用 React Suspense。改 userIdAtom 触发 refetch。 ## Atom family(动态创建 atoms) 每个 user id 一个 atom: ```tsx import { atomFamily } from 'jotai/utils' const userAtomFamily = atomFamily((id: string) => atom(async () => fetch(`/api/users/${id}`).then(r => r.json())) ) function UserCard({ id }: { id: string }) { const [user] = useAtom(userAtomFamily(id)) return <div>{user.name}</div> } ``` 每个 `id` 独立 atom;不同 UserCard 不会互相影响。 ## 写组合:写一个 atom 触发多个修改 ```tsx const firstNameAtom = atom('') const lastNameAtom = atom('') const updateNameAtom = atom( null, // read function(这里没读) (get, set, fullName: string) => { const [f, l] = fullName.split(' ') set(firstNameAtom, f) set(lastNameAtom, l) } ) function Form() { const [, setName] = useAtom(updateNameAtom) return <input onChange={e => setName(e.target.value)} /> } ``` action / mutation 风格。 ## 持久化 ```tsx import { atomWithStorage } from 'jotai/utils' const themeAtom = atomWithStorage('theme', 'light') function ThemeToggle() { const [theme, setTheme] = useAtom(themeAtom) return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}> {theme} </button> } ``` 自动 localStorage。 ## 跟 Zustand 比 | | Jotai | Zustand | |---|---|---| | 粒度 | atom(极细) | store(粗,需 selector) | | API 风格 | 函数式 hook | mutate / function | | 学习曲线 | 中(atom 思维转换) | 低 | | 大组件树 | 优秀(自动只更新订阅者) | 需精确 selector | | 异步 | 一等公民 + Suspense | 手写 | | 复杂派生 | 自然 | 需 useMemo 等 | | Devtools | 有 | 有 | 适合 Jotai 的场景: - 复杂表单(每字段独立 atom) - 大量派生状态 - 需要 Suspense 异步 适合 Zustand: - 简单全局 store(用户 auth / theme) - 倾向 imperative API ## 实战:表单 with field-level state ```tsx import { atom, useAtom } from 'jotai' const emailAtom = atom('') const emailErrorAtom = atom((get) => { const e = get(emailAtom) if (!e) return null if (!/@/.test(e)) return '邮箱格式错' return null }) const passwordAtom = atom('') const passwordErrorAtom = atom((get) => { const p = get(passwordAtom) if (p.length < 8) return '密码至少 8 位' return null }) const canSubmitAtom = atom((get) => get(emailErrorAtom) === null && get(passwordErrorAtom) === null && get(emailAtom) && get(passwordAtom) ) function EmailField() { const [email, setEmail] = useAtom(emailAtom) const [error] = useAtom(emailErrorAtom) return ( <div> <input value={email} onChange={e => setEmail(e.target.value)} /> {error && <p>{error}</p>} </div> ) } // PasswordField 类似 function SubmitButton() { const [canSubmit] = useAtom(canSubmitAtom) return <button disabled={!canSubmit}>提交</button> } ``` 每个字段独立 atom + 独立 error atom + 派生 canSubmit。 只有"我用到的" 重渲。 对比 react-hook-form: - RHF 更成熟 + 更多功能(registration / validation chain) - Jotai 概念上更轻 + 不限于表单 复杂表单仍推荐 RHF;中等表单 + 跨组件共享 Jotai 更灵活。 ## 与 React Query 集成 ```tsx import { atomWithQuery } from 'jotai-tanstack-query' const userAtom = atomWithQuery((get) => ({ queryKey: ['user', get(userIdAtom)], queryFn: async ({ queryKey: [, id] }) => fetch(`/api/users/${id}`).then(r => r.json()), })) function UserCard() { const [{ data, isPending }] = useAtom(userAtom) if (isPending) return <Spinner /> return <div>{data.name}</div> } ``` React Query 的 cache + Jotai 的 atom 组合 = 服务端状态 + 客户端 state 一致管理。 ## 调试:Jotai Devtools ```tsx import { useAtomDevtools } from 'jotai-devtools' function DebugAll() { useAtomDevtools(countAtom, { name: 'count' }) useAtomDevtools(userAtom, { name: 'user' }) return null } ``` Redux DevTools 显示所有 atom 变化时间线 + 当前值。 ## 性能注意 Jotai 极细粒度 = 每个 atom 一份 React subscription。 **几千个 atom + 大量更新** 时 React scheduler 压力大。 > 千级 atom 场景考虑: - 用 `atomFamily` 而非手写百份 atom - 把不需要响应的数据放普通对象 / Map - 必要时用 Jotai 内部 store 做 batched update ## 何时不用 Jotai - 全局只有几个简单 state(auth + theme)→ Zustand 足够 - 已经深度用 Redux Toolkit → 切换成本大 - 团队不熟函数式 → 学习曲线一道槛 ## 踩过的坑 1. **atom 在组件外创建**:放函数体内每次 render 新 atom → state 重置。 atom 一定在 module top-level 创建。 2. **派生 atom 依赖循环**:A 依赖 B,B 依赖 A → 无限循环 stack overflow。 设计时检查依赖图无环。 3. **async atom + Suspense**:默认 fetch 失败抛 ErrorBoundary。 不想用 Suspense 写 `loadable(asyncAtom)` 退化到 loading state pattern。 4. **atom 大对象**:整个对象一变所有订阅者重渲。改为多 atom 细分 + selector 派生。 5. **SSR / Next.js**:Jotai SSR 需要 `Provider` 隔离 request-level state,不能全局。注意配 hydration。
## 起因 React 里每次写"组件 mount 时 fetch 数据,loading 状态显示 spinner, error 显示 toast,组件 unmount 时 abort"——这一套 boilerplate 在 useState + useEffect 里写起来 30 行: ```tsx function UserCard({ id }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { let alive = true setLoading(true) fetch(`/api/users/${id}`) .then(r => r.json()) .then(data => { if (alive) { setUser(data); setLoading(false) } }) .catch(e => { if (alive) { setError(e); setLoading(false) } }) return () => { alive = false } }, [id]) if (loading) return <Spinner /> if (error) return <ErrorBlock /> return <div>{user.name}</div> } ``` 而且这里还没处理:去重(同时多个组件查同一 id)、refetch 策略、 后台静默更新、optimistic update、stale-while-revalidate…… `TanStack Query`(前身 React Query)把这套抽象成 hook,代码减少 80%。 ## 解决方案 ### 装 ```bash npm i @tanstack/react-query ``` ### 顶层 Provider ```tsx // main.tsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 分钟内复用 cache,不 refetch gcTime: 5 * 60 * 1000, // 5 分钟没人订阅就 gc retry: 2, refetchOnWindowFocus: true, }, }, }) <QueryClientProvider client={queryClient}> <App /> <ReactQueryDevtools /> {/* dev 用 */} </QueryClientProvider> ``` ### useQuery:上面那 30 行变成 ```tsx import { useQuery } from '@tanstack/react-query' function UserCard({ id }) { const { data: user, isPending, error } = useQuery({ queryKey: ['user', id], queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()), }) if (isPending) return <Spinner /> if (error) return <ErrorBlock message={error.message} /> return <div>{user.name}</div> } ``` 5 行。自动: - mount 时 fetch - unmount abort(如果 queryFn 支持 signal) - 同 queryKey 多组件去重(只一次请求) - staleTime 内复用 cache,不重 fetch - focus tab / 网络恢复时 background refetch - error 时自动重试 ### queryKey 是核心 ```tsx useQuery({ queryKey: ['user', id] }) // GET /api/users/:id useQuery({ queryKey: ['users', { tag: 'admin', page: 2 }] }) useQuery({ queryKey: ['posts', userId, 'comments'] }) ``` queryKey 像 cache 字典的 key。同 key 共享一份 cache。 对象 / 数组按值比较(deep equality)。 ### 失效 cache ```tsx import { useQueryClient } from '@tanstack/react-query' function CreatePostForm() { const qc = useQueryClient() const mutate = async (data) => { await fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) }) // 让所有 posts 相关查询失效,触发自动 refetch qc.invalidateQueries({ queryKey: ['posts'] }) } // ... } ``` `invalidateQueries({ queryKey: ['posts'] })` 让所有以 `['posts', ...]` 开头的 cache 标记 stale,触发 refetch。 ### useMutation:POST / PUT / DELETE ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query' function DeleteButton({ id }) { const qc = useQueryClient() const mutation = useMutation({ mutationFn: (postId) => fetch(`/api/posts/${postId}`, { method: 'DELETE' }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['posts'] }) }, }) return ( <button onClick={() => mutation.mutate(id)} disabled={mutation.isPending} > {mutation.isPending ? '删除中...' : '删除'} </button> ) } ``` ### Optimistic update(界面立刻反映,再发请求) ```tsx const mutation = useMutation({ mutationFn: (newPost) => fetch('/api/posts', ...).then(r => r.json()), onMutate: async (newPost) => { // 取消正在跑的 query await qc.cancelQueries({ queryKey: ['posts'] }) // 保存 snapshot const prev = qc.getQueryData(['posts']) // 乐观更新 cache qc.setQueryData(['posts'], (old) => [...old, newPost]) return { prev } }, onError: (err, newPost, ctx) => { // 回滚 qc.setQueryData(['posts'], ctx.prev) }, onSettled: () => { qc.invalidateQueries({ queryKey: ['posts'] }) }, }) ``` UI 立刻看到新 post,服务端失败自动回滚。用户体验大幅改善。 ### 分页 / 无限滚动 ```tsx import { useInfiniteQuery } from '@tanstack/react-query' const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 0 }) => fetch(`/api/posts?page=${pageParam}`).then(r => r.json()), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, }) return ( <> {data?.pages.map(page => page.items.map(p => <PostCard key={p.id} {...p} />))} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}> 加载更多 </button> )} </> ) ``` ### prefetch 页面将进入前先预加载下个页面: ```tsx function PostLink({ id }) { const qc = useQueryClient() return ( <a href={`/posts/${id}`} onMouseEnter={() => qc.prefetchQuery({ queryKey: ['post', id], queryFn: () => fetch(`/api/posts/${id}`).then(r => r.json()), })} > 详情 </a> ) } ``` 鼠标 hover 时预 fetch,用户点进去秒开。 ### Devtools 极爽 `<ReactQueryDevtools />` 在 dev 模式右下角浮窗: - 所有 query 状态(fresh / fetching / stale / inactive) - cache 内容 - 手动 invalidate / refetch 测试 调试缓存问题神器。 ### SSR / Next.js 集成 ```tsx // app/posts/page.tsx import { HydrationBoundary, dehydrate, QueryClient } from '@tanstack/react-query' export default async function PostsPage() { const qc = new QueryClient() await qc.prefetchQuery({ queryKey: ['posts'], queryFn: () => fetchPosts(), }) return ( <HydrationBoundary state={dehydrate(qc)}> <PostList /> </HydrationBoundary> ) } ``` 服务端预先取数据塞进 client cache,hydrate 后客户端立刻有数据。 ## 效果 我们的 SPA 改造后: - 网络请求量降 40%(多组件共享 cache + staleTime 复用) - 用户感知到的"loading"次数下降 60%(cache 命中秒显示) - bug 数下降明显(边界情况 framework 处理而非自己写) - 代码量净减少 30% ## 替代品 - **SWR**(Vercel 出品):API 更简洁,能力少一些 - **Apollo Client**(GraphQL):GraphQL 项目优秀 - **RTK Query**(Redux Toolkit):已经用 Redux 的话 - **TanStack Query**:通用、最强大、生态最大 新项目无 GraphQL → TanStack Query 默认选。 ## 踩过的坑 1. **queryKey 不稳定**:每次 render 新建对象 / 数组 → query 永远 stale。 `useMemo(() => ['user', id], [id])` 或者扁平 `['user', id]`。 2. **mutation 后忘 invalidate**:UI 不刷新。永远在 `onSuccess` invalidate 相关 query。 3. **staleTime: 0 + refetchOnWindowFocus**:tab 切回来都 refetch, API 调用爆炸。生产 staleTime 至少 30s-1min。 4. **`useQuery` 在条件渲染分支里**:违反 hooks 规则。用 `enabled: !!id`: ```tsx useQuery({ queryKey: ['user', id], queryFn: ..., enabled: !!id }) ``` 5. **`useQuery` 直接 throw error**:导致 React error boundary 接管。 用 `error` 字段做条件渲染,或 `useErrorBoundary: true` 显式启用。
Tailwind 的核心是 utility-first CSS,但生产里要配合品牌设计系统, 直接用默认色板 / 间距是不行的。下面是 5 个把 Tailwind 用到位的点。 ## 1. 设计令牌:用 CSS 变量做主题 ```css /* src/styles/tokens.css */ @layer base { :root { --color-brand: 220 90% 56%; /* HSL,分量分开存便于 alpha */ --color-brand-soft: 220 90% 96%; --color-text: 222 47% 11%; --color-bg: 0 0% 100%; --radius: 0.75rem; } .dark { --color-brand: 220 90% 65%; --color-text: 210 40% 96%; --color-bg: 222 47% 11%; } } ``` `tailwind.config.ts`: ```ts export default { content: ['./src/**/*.{ts,tsx}'], darkMode: 'class', theme: { extend: { colors: { brand: 'hsl(var(--color-brand) / <alpha-value>)', 'brand-soft': 'hsl(var(--color-brand-soft) / <alpha-value>)', text: 'hsl(var(--color-text) / <alpha-value>)', bg: 'hsl(var(--color-bg) / <alpha-value>)', }, borderRadius: { DEFAULT: 'var(--radius)', }, }, }, } ``` 之后: ```html <div class="bg-brand text-white rounded"> <p class="text-brand/80">透明度 80%</p> </div> ``` `/80` 是 Tailwind v3.1+ 的 alpha 语法,配合 `<alpha-value>` 占位让 HSL 变量与 opacity 联动。 切换 dark mode: ```ts document.documentElement.classList.toggle('dark') ``` 所有 token 通过 CSS 变量级联变化。 ## 2. 自定义 plugin ```ts // tailwind.config.ts import plugin from 'tailwindcss/plugin' export default { plugins: [ plugin(({ addUtilities, theme }) => { addUtilities({ '.text-balance': { 'text-wrap': 'balance' }, '.scrollbar-thin': { 'scrollbar-width': 'thin', }, // 用 theme 引用其它 token '.no-x-scroll': { 'overflow-x': 'hidden', 'background': theme('colors.bg / 50%'), }, }) }), ], } ``` `<div class="text-balance">` 应用 `text-wrap: balance`(让标题换行均衡)。 ## 3. 任意值 / arbitrary values ```html <div class="w-[273px] grid-cols-[200px_1fr_auto] before:content-['→']"> ``` 方括号语法让你写一次性的非标准值,避免为了某个奇异需求改 config。 但常用值还是放 config 里。 ## 4. group / peer 改善交互样式 ```html <!-- 父 hover 时子元素响应 --> <div class="group"> <h3 class="group-hover:text-brand">Title</h3> <p class="group-hover:opacity-100 opacity-50">Detail</p> </div> <!-- 兄弟元素影响 --> <input type="checkbox" class="peer"> <label class="peer-checked:text-brand">Toggle me</label> <!-- 命名 peer --> <input type="checkbox" class="peer/agree"> <label class="peer-checked/agree:underline">同意条款</label> ``` `group-*` / `peer-*` 是 Tailwind 的 ":has" 替代品,不需要 JS。 ## 5. responsive + container queries ```html <!-- 视口响应式 --> <div class="text-sm md:text-base lg:text-lg"> <!-- 容器查询(Tailwind v3.4+) --> <div class="@container"> <div class="@md:flex @lg:grid"> ``` 启用 container queries 插件: ```ts import containerQueries from '@tailwindcss/container-queries' export default { plugins: [containerQueries], } ``` ## 6. dark mode 策略 ```ts darkMode: 'class' // 用 .dark 类 // 或: darkMode: 'media' // 用 prefers-color-scheme // 或最新: darkMode: ['selector', '[data-mode="dark"]'] ``` `class` 模式最灵活——可以手动切;`media` 跟随系统。 现代项目通常 class + 手动初始化跟随系统。 ## 7. arbitrary properties ```html <div class="[mask:linear-gradient(black,transparent)]"> <div class="[--my-var:42px] [width:var(--my-var)]"> ``` 让任何 CSS 属性都能写在 class 里——逃生舱。 ## 8. apply(谨慎用) ```css .btn { @apply px-4 py-2 rounded bg-brand text-white; } .btn:hover { @apply bg-brand-dark; } ``` `@apply` 让你把 Tailwind class 抽成自定义类。适合: - 公司风格按钮 / input 等基础组件 - 把超长 class 列表抽出(可读性) 不适合:日常 UI(违反 utility-first 哲学)。 ## 9. just-in-time(JIT)默认开启 Tailwind v3+ 默认 JIT 模式: - 只生成你用到的 class - 任意值(`w-[273px]`)即时编译 - watch 模式毫秒级重新生成 生产 CSS 体积通常 5-20 KB(gzipped),比传统 CSS 框架轻。 ## 10. 与 shadcn/ui `shadcn/ui` 不是组件库,是 "复制粘贴的 Tailwind 组件代码": ```bash npx shadcn-ui@latest init npx shadcn-ui@latest add button card dialog ``` 把组件 .tsx 文件复制到你的项目里,你拥有源码可改。 2024 后 React 项目用 Tailwind + shadcn 几乎是 default。 ## 11. typography 插件 ```bash npm i -D @tailwindcss/typography ``` ```html <article class="prose dark:prose-invert"> <h1>...</h1> <p>...</p> </article> ``` 给纯 markdown 渲染的文章一键加上专业排版(标题大小、行高、代码块、 列表等)。 ## 12. forms 插件 ```bash npm i -D @tailwindcss/forms ``` normalize 浏览器默认 input / select / checkbox 样式差异, 让你 utility class 控制更可预测。 ## 踩过的坑 - `content` 配置漏路径:JIT 扫不到你的源文件,class 没生成 → 看着没样式。 确认 `content: ['./src/**/*.{ts,tsx,html}']` 覆盖。 - 类名拼接 / 动态字符串:`text-{color}` 这种 Tailwind 扫描不到。 改成完整字符串 `text-red-500` 写出来,或在 safelist 里列出。 - `@apply` 滥用:所有按钮都 `.btn` `.btn-lg` `.btn-primary`,最后 CSS 比手写还多。utility-first 才是 Tailwind 的正道。 - dark mode class 加错位置:必须在 `<html>` 而不是 `<body>` 上,否则 某些组件查不到。
## 起因 TypeScript 是编译时类型检查。运行时 API 返回数据 / 用户输入 / localStorage 读出来的东西,TS 不知道: ```ts type User = { id: number; email: string }; const res = await fetch('/api/me'); const user: User = await res.json(); // ⚠️ 类型断言不验运行时 user.email.toLowerCase(); // 万一 API 返回 null 或者 email 是 undefined? ``` 类型 lie → 运行时崩。 `Zod` 是 runtime schema validation + 自动 TS 类型推导。一份 schema 既 是验证 + 又是 type。 ## 基本 ```ts import { z } from 'zod'; const UserSchema = z.object({ id: z.number(), email: z.string().email(), name: z.string().min(1).max(100), age: z.number().int().positive().optional(), }); type User = z.infer<typeof UserSchema>; // { id: number; email: string; name: string; age?: number } const data = await res.json(); const user = UserSchema.parse(data); // throw if invalid // user 类型 = User,且运行时保证符合 ``` 一份 schema 解决: - 运行时验证 - TS 类型(infer) - 错误信息(with 路径) ## 复杂 schema ```ts const PostSchema = z.object({ id: z.string().uuid(), title: z.string(), body: z.string(), status: z.enum(['draft', 'published', 'archived']), tags: z.array(z.string()), author: z.object({ name: z.string(), avatar: z.string().url().optional(), }), createdAt: z.string().datetime(), }); ``` 嵌套 object + array + enum + format check(uuid / url / datetime)。 ## transform ```ts const DateSchema = z.string().datetime().transform((s) => new Date(s)); // input: string // output: Date const result = DateSchema.parse('2025-03-14T10:00:00Z'); // result: Date instance ``` parse 时把 string 自动转 Date。 input / output type 不同(z.infer 拿 output)。 ## refine (自定义校验) ```ts const passwordSchema = z .string() .min(8) .refine((s) => /[A-Z]/.test(s), { message: 'Need uppercase' }) .refine((s) => /\d/.test(s), { message: 'Need digit' }); ``` ## 错误处理 ```ts const result = UserSchema.safeParse(data); if (!result.success) { console.log(result.error.format()); // { // email: { _errors: ['Invalid email'] }, // age: { _errors: ['Expected positive number'] } // } return; } console.log(result.data); // 通过的 typed data ``` `safeParse` 不 throw,返回 result object。 form 验证场景常用。 ## 跟 React Hook Form 集成 ```tsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; const FormSchema = z.object({ email: z.string().email(), password: z.string().min(8), }); function LoginForm() { const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(FormSchema), }); return ( <form onSubmit={handleSubmit((data) => console.log(data))}> <input {...register('email')} /> {errors.email && <p>{errors.email.message}</p>} <input {...register('password')} type="password" /> {errors.password && <p>{errors.password.message}</p>} <button>Login</button> </form> ); } ``` form 校验自动用 Zod schema → message 自动显。 ## API 验证 (tRPC / Hono) ```ts // tRPC procedure import { z } from 'zod'; const userRouter = t.router({ create: t.procedure .input(z.object({ email: z.string().email(), name: z.string().min(1), })) .mutation(({ input }) => { // input 类型 + 运行时都符合 return db.user.create({ data: input }); }), }); ``` ```ts // Hono app.post('/users', zValidator('json', UserSchema), (c) => { const user = c.req.valid('json'); // typed return c.json(user); }); ``` backend 入口验证 input → 后续 code 类型 + runtime 一致。 ## 与 yup / joi 对比 | | Zod | yup | joi | |---|---|---|---| | TS 类型推导 | ✅ first-class | 弱 | 弱 | | bundle | 中(14 KB) | 中 | 大 | | API | builder | builder | builder | | 生态 | 大(现代) | 中 | 后端流行 | | 性能 | 中 | 中 | 慢 | TS 项目 → Zod。 JS 老项目 → yup。 Node API server 不要 TS → joi。 ## 与 valibot / arktype 对比 新一代竞争: - **valibot**:tree-shakable,bundle 极小(500B-2KB) - **arktype**:TS-as-schema(直接写 TS 类型当 schema),运行时极快 ```ts // valibot import { object, string, number } from 'valibot'; const UserSchema = object({ email: string([email()]), age: number([integer()]), }); ``` ```ts // arktype import { type } from 'arktype'; const UserSchema = type({ email: 'email', 'age?': 'number.integer>0', }); ``` valibot 适合极致 bundle size(移动 web)。 arktype 适合纯 TS 语法控。 Zod 仍最普及 + 生态最大。 ## ecosystem zod 生态丰富: - `@hookform/resolvers/zod` - `zod-to-openapi`:自动 OpenAPI 3 spec - `zod-to-ts`:生成 TS file - `prisma-zod-generator`:从 Prisma schema 生成 Zod - `zod-form-data`:multipart form 验证 ## 性能 百万次 parse benchmark: | | ops/sec | |---|---| | Zod | 350k | | valibot | 800k | | arktype | 1500k | | yup | 100k | Zod 不是最快但够用。极致性能场景才换。 ## 真实 case:API 返回防御 API 三方 / 老接口经常返回奇怪: ```ts // API 文档说返回 number,实际偶尔 null const result = await fetch('/old/api').then(r => r.json()); // 防御 const ResultSchema = z.object({ count: z.number().nullable().transform((n) => n ?? 0), items: z.array(ItemSchema), }); const safe = ResultSchema.parse(result); // safe.count 永远 number(null 转 0) ``` 避免下游 `result.count + 5` 报 NaN。 ## 复杂 union ```ts const EventSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('click'), x: z.number(), y: z.number() }), z.object({ type: z.literal('input'), value: z.string() }), z.object({ type: z.literal('submit'), formId: z.string() }), ]); const event = EventSchema.parse(data); if (event.type === 'click') { event.x; // typed } ``` `discriminatedUnion` 比 plain union 快 + type narrowing 更准。 ## env 变量验证 ```ts const envSchema = z.object({ DATABASE_URL: z.string().url(), PORT: z.coerce.number().int().positive(), NODE_ENV: z.enum(['development', 'production', 'test']), }); export const env = envSchema.parse(process.env); ``` 启动时验证 env,缺 / 错立刻报。 之后 code 用 `env.PORT` 类型 number 不是 string。 ## 踩过的坑 1. **z.string().uuid() too strict**:传统 v1 UUID 不过。`.uuid()` 是 严格 v4 格式。 2. **.parse vs .safeParse**:parse throw + safeParse 返 result。 form / API boundary 用 safeParse 控制响应;trust source 用 parse 简洁。 3. **infer 大 schema 慢**:深嵌套 union 等 TS 编译慢。break into smaller schema。 4. **transform 后 input type 隐**:`z.infer<typeof schema>` 拿 output; `z.input<typeof schema>` 拿 input。文档 / API 注意区分。 5. **生产 bundle 大**:Zod 14 KB gzip 在 mobile critical 可能多。 考虑 valibot 替换关键路径。
无限滚动早年是监听 `scroll` 事件 + 算 `scrollTop`,性能差且容易漏触发。 `IntersectionObserver` 是浏览器原生的 "元素进入视口" 通知机制, 节能 + 精准。 ## 1. 最小实现 ```html <ul id="list"></ul> <div id="sentinel" style="height: 1px"></div> ``` ```js const list = document.getElementById('list') const sentinel = document.getElementById('sentinel') let page = 0 let loading = false let done = false async function fetchPage(n) { const res = await fetch(`/api/items?page=${n}`) return res.json() // { items: [...], hasMore: true } } async function loadMore() { if (loading || done) return loading = true const data = await fetchPage(++page) data.items.forEach(item => { const li = document.createElement('li') li.textContent = item.title list.appendChild(li) }) if (!data.hasMore) done = true loading = false } const observer = new IntersectionObserver( entries => { if (entries[0].isIntersecting) loadMore() }, { rootMargin: '300px' } // 距视口 300px 时就触发,提前预加载 ) observer.observe(sentinel) loadMore() // 启动 ``` 关键点: - **sentinel 元素**:放在列表末尾的一个空 div,作为"快到底"的探测点 - **rootMargin**:把视口边界往外扩 300px,提前触发,用户感受不到等待 - **loading guard**:防止滚动太快连续触发同一次加载 ## 2. React 版 ```tsx import { useEffect, useRef, useState, useCallback } from 'react' export function useInfinite<T>(fetchFn: (page: number) => Promise<{items: T[], hasMore: boolean}>) { const [items, setItems] = useState<T[]>([]) const [page, setPage] = useState(0) const [loading, setLoading] = useState(false) const [done, setDone] = useState(false) const sentinelRef = useRef<HTMLDivElement | null>(null) const loadMore = useCallback(async () => { if (loading || done) return setLoading(true) const next = page + 1 const data = await fetchFn(next) setItems(prev => [...prev, ...data.items]) setPage(next) if (!data.hasMore) setDone(true) setLoading(false) }, [loading, done, page, fetchFn]) useEffect(() => { const el = sentinelRef.current if (!el) return const obs = new IntersectionObserver( entries => { if (entries[0].isIntersecting) loadMore() }, { rootMargin: '300px' } ) obs.observe(el) return () => obs.disconnect() }, [loadMore]) return { items, loading, done, sentinelRef } } ``` 使用: ```tsx function FeedPage() { const { items, loading, done, sentinelRef } = useInfinite(p => fetch(`/api/feed?page=${p}`).then(r => r.json()) ) return ( <> {items.map(i => <Item key={i.id} {...i} />)} <div ref={sentinelRef} /> {loading && <Spinner />} {done && <div>到底了</div>} </> ) } ``` ## 3. 处理"立即可见的 sentinel" 如果列表初始就短,sentinel 一开始就在视口里,`IntersectionObserver` 立即触发 → 立即触发下一页 → 加载完 sentinel 仍在视口 → 又触发…… 循环到 `done` 才停。 通常没问题(连续加载到首屏满),但如果数据源慢,会发出一堆并发请求。 解决:保留 loading guard + 让 loadMore 在 fetch 完成前不允许并发, 就够了。 更稳的做法:用 `useCallback` + 检查 `loading` 状态。React 18 严格模式 下会双调用,loading 必须用 ref 而不是 state: ```tsx const loadingRef = useRef(false) const loadMore = useCallback(async () => { if (loadingRef.current || done) return loadingRef.current = true try { /* ... */ } finally { loadingRef.current = false } }, [done]) ``` ## 4. 中断 / 卸载安全 组件卸载时正在 fetch:要么 abort,要么忽略结果。 ```tsx useEffect(() => { const ctrl = new AbortController() fetch('/api/...', { signal: ctrl.signal }) .then(r => r.json()) .then(data => { /* setState */ }) .catch(e => { if (e.name !== 'AbortError') console.error(e) }) return () => ctrl.abort() }, [page]) ``` ## 5. virtualized 列表的配合 万条以上的列表,光无限滚动还不够,DOM 节点太多会卡。配 react-virtuoso 或 react-window 做虚拟滚动,只渲染视口内的几十行。Virtuoso 自带 `endReached` 回调,IntersectionObserver 都省了。 ## 6. 浏览器兼容 `IntersectionObserver` 在 Safari 12.1+ 全支持,2024 年的所有目标浏览器都行。 不再需要 polyfill。 ## 踩过的坑 - 忘了 `observer.disconnect()` → 内存泄漏。React 用 cleanup 函数; vanilla JS 用一个全局 observer 在路由切换时手动 disconnect。 - sentinel 是 `display: none` → 永远不触发。要给它真实尺寸(至少 1px)。 - 滚动容器不是 window 时(例如内嵌可滚动的 div),要给 `IntersectionObserver` 设 `root: scrollContainer`,否则 viewport 默认是 window。 - 移动端慢网下重复触发:用户慢慢滑,每次 sentinel 进入视口都触发; 服务端在还没响应时收到多个相同 page 请求。后端用幂等返回 OK。
## 起因 你的网站有: - Google Analytics - Google Tag Manager - Facebook Pixel - HubSpot / Intercom chat - Segment 第三方 script 通常 200-500 KB 不少,跑在 main thread → 占 CPU + 拖慢 首屏(hydration / interaction)。 Lighthouse 性能跌 20-30 分常因这些。 `Partytown`(Builder.io):把第三方 script 丢到 web worker 跑。 main thread 专心 render,性能不被第三方坑。 ## 原理 ``` Main thread: - 你的 React / Vue - DOM 操作 - 用户交互 Web Worker (Partytown): - GA / GTM / 等 - 通过 proxy 间接访问 DOM (synchronous via SharedArrayBuffer) ``` 第三方 script 调 `document.cookie` / `window.dataLayer` → Partytown 代理 到 main thread → script 以为自己在 main thread 跑。 ## 装 (Next.js) ```bash npm install @builder.io/partytown ``` ```tsx // next.config.js module.exports = { experimental: { nextScriptWorkers: true }, }; // _document.tsx 或 app/layout.tsx import Script from 'next/script'; <Script strategy="worker" // 关键 src="https://www.googletagmanager.com/gtag/js?id=G-XXX" /> <Script id="gtag-init" strategy="worker"> {` window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag('js', new Date()); gtag('config', 'G-XXX'); `} </Script> ``` 跑起来 → GA script 在 worker。 GA 看到的数据跟正常一样。 ## 装 (vanilla / 任意 framework) ```html <head> <script> window.partytown = { forward: ['dataLayer.push'], // 这些 method 转 worker }; </script> <script src="/~partytown/partytown.js"></script> <script type="text/partytown" src="https://www.googletagmanager.com/gtag/js?id=G-XXX"></script> <script type="text/partytown"> window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag('js', new Date()); gtag('config', 'G-XXX'); </script> </head> ``` `type="text/partytown"` 是关键 → Partytown 拦截 + 在 worker 跑。 ## 性能效果 我们一个 marketing 站: | metric | before | after | |---|---|---| | Lighthouse Performance | 62 | 92 | | TBT (Total Blocking Time) | 850ms | 80ms | | TTI (Time to Interactive) | 5.2s | 2.1s | | LCP | 3.8s | 2.5s | 主要 GA + GTM + HubSpot 三个第三方加起来约 600 KB JS。 丢 worker 后 main thread 几乎不卡。 ## 副作用 / 限制 1. **不能用 `document.write`**:worker 没 DOM 写。多数现代 script OK。 2. **synchronous DOM access 延迟**:worker → main 调用有几 ms 开销。 GA 类 batched analytics 无感;动画类 script 不行。 3. **某些 script 不兼容**:检测自己环境会发现"不在 main thread" 报错。少数老 script。 4. **需要 `Cross-Origin-*` header**:用 SharedArrayBuffer 优化要 COOP/COEP。 不配也能跑(fallback 慢 IPC)。 ## 哪些第三方适合 适合: - analytics (GA, Mixpanel, Segment, Amplitude) - tag manager (GTM) - chat widget (Intercom, HubSpot) - ad pixel (Facebook, TikTok) - A/B testing (Optimizely) 不适合: - 必须 sync DOM 操作(如 Stripe Elements 表单内嵌) - 视频 / 动画 SDK(需 60fps) - 关键功能 script(auth / payment 核心) ## debug Chrome devtools 看: - main thread:你的 app - worker thread:partytown + 第三方 network tab 看 partytown 跑的 request(带 `partytown` referer)。 ## 与 GTM server-side 对比 GTM server-side container:把 GTM 处理移到自己 server,client 只发原始 event。 | | partytown | GTM SS | |---|---|---| | 复杂度 | 低 | 高 | | client perf | 好 | 极好 | | 成本 | 0 | server cost | | 控制 | 中 | 强 | 大网站常两者结合:partytown for 简单 script + GTM SS for 核心 tracking。 ## 与 facade pattern YouTube embed / chat widget 等:先显示假封面,用户 hover/click 才加载真 iframe。 ```jsx const [loaded, setLoaded] = useState(false); return loaded ? <YouTubeEmbed /> : <FakePoster onClick={() => setLoaded(true)} />; ``` partytown 移走 main thread 加载;facade 直接延迟加载。 互补。 ## 实际接入步骤 1. Lighthouse 跑当前 → 找 main thread blocking script 2. 列出第三方 script 清单 3. 一个一个 wrap partytown → 测 GA / etc 仍正常报数据 4. 重 Lighthouse → 看分提升 通常 1 天内能上线 + 显著效果。 ## 静态站特别合适 Astro / Gatsby / 11ty 等静态站性能"就差第三方拖" → partytown 神药。 Astro 内置 partytown integration: ```js // astro.config.mjs import partytown from '@astrojs/partytown'; export default { integrations: [partytown()] }; ``` ```html <script type="text/partytown" src="..."></script> ``` ## 跟 Next.js Script 对比 Next.js 自己也有 `Script strategy`: - `beforeInteractive`:阻塞,head 内 - `afterInteractive`:default,body 末 - `lazyOnload`:idle 时 - `worker`:partytown(实验) `worker` 内部就是 partytown 集成。 ## 真实 case 某客户 marketing 站 SEO / 性能死活上不去: - GTM 200 KB - HubSpot 300 KB - GA + 5 个 pixel LCP 5s,Google 算"slow" → SEO 罚。 接 partytown 一周: - LCP 2.3s - Core Web Vitals 全绿 - 自然流量 +15%(SEO 改善) 第三方需求没变 → 性能彻底改善。 ## 不要乱开 不是所有 script 都该丢 worker。 仔细想:这 script 真的不需要 sync main thread? 比如 Stripe element 在 form 里要 sync 渲染 → 不行。 GA fire-and-forget event → 完美。 ## 踩过的坑 1. **第三方更新挂掉**:GA 新 SDK 跟 partytown 不兼容(罕见)。 monitor 数据连续性,发现异常 fallback 普通 script。 2. **COOP/COEP header 跟 OAuth iframe 冲突**:需要 cross-origin iframe 的功能可能挂。试个 staging。 3. **dev mode performance 反慢**:partytown 在 dev 调试模式 IPC 开 销大。生产无问题。 4. **dataLayer race condition**:用 partytown 后 dataLayer 是异步 推。需要 sync read 的 code 会拿空。 5. **CSP 配 worker-src**:CSP 严格时 worker 加载被 block。 `worker-src 'self'` 加。
## 起因 后端 API 还没好,前端要先做 UI;或者测试时不想真打后端(速度 + 隔离)。 传统做法:在 fetch 调用处 `if (mocked) return mockData`,丑陋且要清理。 `MSW` 在 service worker / Node 层拦截 HTTP 请求,让"前端代码无修改 + 真的发请求 + 拦截返 mock 数据"。同一套 mock 给开发 / 测试 / Storybook 共用。 ## 解决方案 ### 装 ```bash npm i -D msw npx msw init public/ --save # 给浏览器生成 service worker 文件 ``` ### 写 handler ```ts // src/mocks/handlers.ts import { http, HttpResponse, delay } from 'msw' export const handlers = [ http.get('/api/users/:id', ({ params }) => { return HttpResponse.json({ id: params.id, name: 'Alice', email: '[email protected]', }) }), http.get('/api/users', async ({ request }) => { const url = new URL(request.url) const page = Number(url.searchParams.get('page') ?? 1) await delay(300) // 模拟网络延迟 return HttpResponse.json({ items: [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ], page, hasMore: page < 5, }) }), http.post('/api/login', async ({ request }) => { const body = await request.json() as { email: string; pw: string } if (body.email === '[email protected]' && body.pw === 'secret') { return HttpResponse.json({ token: 'fake-jwt-token' }) } return new HttpResponse(null, { status: 401 }) }), http.delete('/api/posts/:id', async () => { await delay(200) return new HttpResponse(null, { status: 204 }) }), ] ``` ### 浏览器集成(dev) ```ts // src/mocks/browser.ts import { setupWorker } from 'msw/browser' import { handlers } from './handlers' export const worker = setupWorker(...handlers) ``` ```ts // src/main.ts 入口 async function enableMocking() { if (import.meta.env.MODE !== 'development') return const { worker } = await import('./mocks/browser') return worker.start({ onUnhandledRequest: 'bypass', // 未 mock 的请求放行 }) } enableMocking().then(() => { // 启动你的 React / Vue app ReactDOM.createRoot(...).render(<App />) }) ``` `npm run dev`,所有 fetch / axios 调用被 MSW 拦截返 mock。 Console 显示: ``` [MSW] GET /api/users/1 (200 OK) [MSW] GET /api/users?page=1 (200 OK) ``` DevTools Network 也能看到这些请求(带 [from service worker] 标记)。 ### Node 集成(Jest / Vitest 测试) ```ts // src/mocks/server.ts import { setupServer } from 'msw/node' import { handlers } from './handlers' export const server = setupServer(...handlers) ``` ```ts // vitest.setup.ts (或 jest.setup.ts) import { server } from './src/mocks/server' beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) ``` 测试代码不需要 mock fetch,直接调真函数: ```ts test('login flow', async () => { const { getByLabelText, getByRole } = render(<LoginForm />) await userEvent.type(getByLabelText('Email'), '[email protected]') await userEvent.type(getByLabelText('Password'), 'secret') await userEvent.click(getByRole('button', { name: 'Sign in' })) await waitFor(() => { expect(localStorage.getItem('token')).toBe('fake-jwt-token') }) }) ``` MSW 在 Node 端拦截 fetch(Node 18+ 内置 fetch)/ axios,返 mock 数据。 ### 单测里 override handler ```ts test('shows error on 500', async () => { server.use( http.get('/api/users', () => { return new HttpResponse(null, { status: 500 }) }), ) const { findByText } = render(<UserList />) expect(await findByText(/something went wrong/i)).toBeInTheDocument() }) ``` `server.use(...)` 临时覆盖;每个测试 `resetHandlers()` 清回基准。 ### Storybook 集成 ```bash npm i -D msw-storybook-addon ``` `.storybook/preview.ts`: ```ts import { initialize, mswLoader } from 'msw-storybook-addon' initialize() export default { loaders: [mswLoader], } ``` 每个 story 设特定 handler: ```ts export const ErrorState: Story = { parameters: { msw: { handlers: [ http.get('/api/users', () => new HttpResponse(null, { status: 500 })), ], }, }, } ``` Storybook 里展示"网络错误"状态而不需要真实坏后端。 ### E2E (Playwright) ```ts import { test, expect } from '@playwright/test' test('login', async ({ page }) => { await page.route('/api/login', async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ token: 'fake' }), }) }) await page.goto('/login') await page.fill('[name=email]', '[email protected]') await page.fill('[name=password]', 'secret') await page.click('button[type=submit]') await expect(page).toHaveURL('/dashboard') }) ``` Playwright 自己有 `page.route` 类似机制;用 MSW 时直接复用 handlers 更统一。 ### 动态响应(基于状态) mock 用户数据库 + 增删改: ```ts let users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] export const handlers = [ http.get('/api/users', () => HttpResponse.json(users)), http.post('/api/users', async ({ request }) => { const data = await request.json() as { name: string } const u = { id: Date.now(), name: data.name } users.push(u) return HttpResponse.json(u, { status: 201 }) }), http.delete('/api/users/:id', ({ params }) => { users = users.filter(u => String(u.id) !== params.id) return new HttpResponse(null, { status: 204 }) }), ] ``` 前端做 CRUD 流,mock 像真的在工作。 ### 与 OpenAPI 集成 ```bash npm i -D openapi-msw ``` ```ts import { createOpenApiMock } from 'openapi-msw' import type { paths } from './generated-api-types' const http = createOpenApiMock<paths>() export const handlers = [ http.get('/api/users/{id}', () => HttpResponse.json({ id: 1, name: 'Alice', // 类型完全跟着 OpenAPI schema,缺字段编译报错 })), ] ``` 后端给 OpenAPI 文档 → 前端类型 + mock 一起自动有,零手写。 ## 效果 - 后端 delay 一周时前端可独立开发 - 单测从"mock fetch 函数"升到"在网络层拦截",更接近真实场景 - Storybook 演示"loading / error / empty / 满数据"四种状态都有 - E2E 测试稳定(不依赖真后端 / 不污染数据库) - Mock data 文件被 dev / test / Storybook 共用,单一来源 ## 与替代品对比 | | MSW | json-server | nock | jest.mock('axios') | |---|---|---|---|---| | 拦截层 | network (sw / node) | 真后端 | node only | 模块 mock | | 浏览器支持 | ✅ | ✅ | ❌ | ❌ | | 真发 HTTP | ✅ | ✅ | ❌(模拟) | ❌ | | 灵活程度 | 极高 | 中(REST 模板) | 中 | 高 | | Storybook 集成 | ✅ | 难 | 无 | 无 | MSW 全面胜出,是 2024 后的事实标准。 ## 踩过的坑 1. **Service Worker 没注册成功**:`npx msw init` 没把 mockServiceWorker.js 放对位置(应在 publicly served root)。生产 build 后路径变也会失效。 2. **MSW 拦截了真实生产请求**:忘了 `if (MODE === 'development')` 包裹 `worker.start()`。线上版本不能注入 MSW。 3. **Node fetch vs cross-fetch**:MSW 在 Node 18+ 拦截 global fetch; 老 Node 用 `node-fetch` 不被拦截,要装 `@mswjs/interceptors`。 4. **测试间 handler 泄漏**:忘 `afterEach(() => server.resetHandlers())`, 上个测试 override 的 handler 影响下个测试。 5. **WebSocket 不支持**:MSW 主要拦截 HTTP;WebSocket 用 [mock-socket](https://github.com/thoov/mock-socket) 或专门的 ws mock 库。
React 表单的痛点: - 每次输入触发整页 re-render(useState 模式) - 校验逻辑分散 - 类型推导 / 错误提示不一致 - 异步校验 / 提交状态难管 `react-hook-form` (RHF) + `zod` 组合是 2024 业界共识: RHF 用 ref 而非 state 避免重渲,zod 提供 schema-first 校验 + 类型推导。 ## 安装 ```bash npm i react-hook-form zod @hookform/resolvers ``` ## 5 行版 ```tsx import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' const schema = z.object({ email: z.string().email('邮箱格式不对'), password: z.string().min(8, '密码至少 8 位'), age: z.coerce.number().int().min(0).max(150), }) type FormData = z.infer<typeof schema> function LoginForm() { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<FormData>({ resolver: zodResolver(schema), defaultValues: { email: '', password: '', age: 18 }, }) const onSubmit = async (data: FormData) => { await fetch('/api/login', { method: 'POST', body: JSON.stringify(data) }) } return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('email')} placeholder="邮箱" /> {errors.email && <p>{errors.email.message}</p>} <input type="password" {...register('password')} /> {errors.password && <p>{errors.password.message}</p>} <input type="number" {...register('age')} /> {errors.age && <p>{errors.age.message}</p>} <button disabled={isSubmitting}>登录</button> </form> ) } ``` 注意: - `z.infer<typeof schema>` 自动推 FormData 类型,schema 改字段自动同步 - `register('email')` 把 input 注册到表单系统,不用 useState - 提交时 zod 校验,错的字段进 `errors` ## 性能:只渲染必要部分 ```tsx import { useFormState, useWatch } from 'react-hook-form' function EmailPreview({ control }) { // 只在 email 字段变了时重渲,整个表单不动 const email = useWatch({ control, name: 'email' }) return <div>预览: {email}</div> } ``` ## 嵌套 / 数组字段 ```ts const schema = z.object({ name: z.string(), emails: z.array(z.object({ address: z.string().email(), primary: z.boolean(), })).min(1), }) ``` ```tsx import { useFieldArray } from 'react-hook-form' const { fields, append, remove } = useFieldArray({ control, name: 'emails' }) return ( <> {fields.map((f, i) => ( <div key={f.id}> <input {...register(`emails.${i}.address`)} /> <input type="checkbox" {...register(`emails.${i}.primary`)} /> <button onClick={() => remove(i)}>×</button> </div> ))} <button onClick={() => append({ address: '', primary: false })}>加</button> </> ) ``` `useFieldArray` 优化动态列表场景,比手动 `useState<Email[]>` 性能好。 ## 复杂校验 ### 跨字段 ```ts const schema = z.object({ password: z.string().min(8), confirm: z.string(), }).refine(d => d.password === d.confirm, { message: '两次密码不一致', path: ['confirm'], }) ``` ### 异步(如检查用户名是否已存在) ```ts const schema = z.object({ username: z.string().min(3).refine(async (val) => { const r = await fetch(`/api/check-username?u=${val}`) return (await r.json()).available }, { message: '用户名已被占用' }), }) ``` zod 支持 async refine,RHF 自动 await。 ### transform / coerce ```ts const schema = z.object({ // <input type="number" /> 实际是字符串,z 转 number age: z.coerce.number().int(), // 字符串前后去空格 name: z.string().trim().min(1), // 把 "yes"/"no" 字符串变 boolean consent: z.string().transform(v => v === 'yes'), }) ``` ## 异步提交状态 ```tsx const { handleSubmit, formState: { isSubmitting, isSubmitSuccessful } } = useForm(...) return ( <form onSubmit={handleSubmit(onSubmit)}> ... <button disabled={isSubmitting}> {isSubmitting ? '提交中...' : '提交'} </button> {isSubmitSuccessful && <p>成功!</p>} </form> ) ``` ## 服务端错误回填 ```tsx const { setError } = useForm(...) const onSubmit = async (data) => { try { await api.create(data) } catch (e) { if (e.code === 'EMAIL_TAKEN') { setError('email', { type: 'manual', message: '邮箱已注册' }) } } } ``` ## 与 UI 组件库(Radix UI / shadcn / Mantine)配合 ```tsx import { Controller } from 'react-hook-form' <Controller control={control} name="country" render={({ field }) => <Select {...field} options={countries} />} /> ``` `Controller` 把不支持 ref 的受控组件包起来。 ## 共用 schema 到后端 zod schema 是纯 TypeScript,可以在 FastAPI / Express / Hono / tRPC 后端 共用。前后端用同一份校验,类型一致: ```ts // shared/schema.ts export const userSchema = z.object({ ... }) // frontend useForm({ resolver: zodResolver(userSchema) }) // backend (hono) import { zValidator } from '@hono/zod-validator' app.post('/api/user', zValidator('json', userSchema), (c) => { ... }) ``` ## 何时不用 RHF 简单 1-2 字段的表单直接 `useState` 就够,引入 RHF 反而麻烦。 3+ 字段 + 校验 + 异步提交时 RHF 收益最大。 ## 踩过的坑 - 忘了 `defaultValues`:第一次渲染 input value 是 undefined,React 报 "uncontrolled to controlled" 警告。永远显式给 defaultValues。 - `mode: 'onChange'` 让每次输入都校验 → 慢且打扰用户。默认 'onSubmit' 最稳;onBlur 是折中。 - 跨大版本升 RHF:v6 → v7 API 变化大。lockfile + 一次性升级。 - zod refine 异步:会让 onChange 校验变慢;尽量 onBlur 异步校验。