知识广场
按学科筛选:计算机科学 / 前端开发
«计算机科学 / 前端开发» 分类下共 52 篇帖子
测试工具几年一换,2024 后的主流: - **单元测试 / 组件测试**:Vitest(替代 Jest,与 Vite 一体) - **E2E 测试**:Playwright(替代 Cypress,多浏览器 + 并发) - **可视化组件库 / VRT**:Storybook + Chromatic(可选) ## Vitest 安装 ```bash npm i -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event ``` `vitest.config.ts`: ```ts import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], coverage: { provider: 'v8', reporter: ['text', 'html'], }, }, }) ``` `src/test/setup.ts`: ```ts import '@testing-library/jest-dom/vitest' ``` `package.json`: ```json { "scripts": { "test": "vitest", "test:run": "vitest run", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage" } } ``` ## 第一个组件测试 ```tsx // Counter.tsx import { useState } from 'react' export function Counter() { const [n, setN] = useState(0) return <button onClick={() => setN(n + 1)}>count: {n}</button> } // Counter.test.tsx import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Counter } from './Counter' test('increments on click', async () => { render(<Counter />) expect(screen.getByRole('button')).toHaveTextContent('count: 0') await userEvent.click(screen.getByRole('button')) expect(screen.getByRole('button')).toHaveTextContent('count: 1') }) ``` 注意: - `screen.getByRole` 优先于 `getByTestId`(更符合用户视角) - `userEvent` 比 `fireEvent` 更真实(模拟键盘 / 焦点) ## query 优先级 按 testing-library 官方推荐: 1. `getByRole` (button, link, heading) 2. `getByLabelText` (form fields) 3. `getByText` (text content) 4. `getByDisplayValue` (input current value) 5. `getByAltText` / `getByTitle` 6. `getByTestId` (最后选项) ## mock 模块 / 函数 ```ts import { vi } from 'vitest' vi.mock('./api', () => ({ fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }), })) // 或单个函数 const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) ``` ## 测异步 ```tsx import { render, screen, waitFor } from '@testing-library/react' test('loads user', async () => { render(<UserCard id="1" />) expect(screen.getByText(/loading/i)).toBeInTheDocument() await waitFor(() => { expect(screen.getByText('Alice')).toBeInTheDocument() }) }) // 或者用 findBy (自动 await) const el = await screen.findByText('Alice') ``` ## MSW:mock 网络请求 ```bash npm i -D msw ``` ```ts // src/mocks/handlers.ts import { http, HttpResponse } from 'msw' export const handlers = [ http.get('/api/user/:id', () => HttpResponse.json({ id: '1', name: 'Alice' })), http.post('/api/login', async () => HttpResponse.json({ token: 'x' })), ] ``` ```ts // setup.ts import { setupServer } from 'msw/node' import { handlers } from '../mocks/handlers' const server = setupServer(...handlers) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) ``` 测试代码里直接调真实 fetch,MSW 在网络层拦截返回 mock 数据。 比 mock `fetch` 函数干净得多。 ## Playwright 安装 ```bash npm init playwright@latest # 选项:TypeScript / 是否要 GitHub Actions / 是否装浏览器 ``` 生成的目录: ``` tests/ example.spec.ts playwright.config.ts ``` ## 第一个 E2E ```ts // tests/login.spec.ts import { test, expect } from '@playwright/test' test('user can log in', async ({ page }) => { await page.goto('/login') await page.getByLabel('Email').fill('[email protected]') await page.getByLabel('Password').fill('secret') await page.getByRole('button', { name: 'Sign in' }).click() await expect(page).toHaveURL(/\/dashboard/) await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible() }) ``` 跑: ```bash npx playwright test npx playwright test --ui # 交互模式(推荐开发用) npx playwright test --debug # 单步调试 ``` ## 多浏览器 `playwright.config.ts`: ```ts projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, { name: 'mobile', use: { ...devices['iPhone 13'] } }, ] ``` `npx playwright test --project=mobile` 只跑特定 project。 ## 自动等待 / locator Playwright 的 locator 自带 auto-wait: ```ts await page.getByText('Save').click() // 等元素出现 + 可点击 + 视口内 → click ``` 不需要写 `waitForSelector` / sleep。 ## 截图 / 视频 / trace(调试神器) ```ts // playwright.config.ts use: { trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', } ``` 测试失败时: ```bash npx playwright show-report # 浏览器打开 HTML 报告,看到失败时的截图 / 视频 / 一帧帧的 DOM 快照 ``` trace viewer 让你"时间穿越"看测试每一步页面状态——比 console.log 强 100 倍。 ## CI 集成 ```yaml # .github/workflows/test.yml - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20' } - run: npm ci - run: npx playwright install --with-deps chromium - run: npm run test:unit - run: npx playwright test - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/ ``` ## 性能基准 - 单元测试:每个 ~10ms,1000 个测试几秒跑完 - Playwright:每个 1-5s(浏览器启动 + 实际操作),用 `workers: 4` 并发 ## Vitest vs Jest Vitest 优势: - 与 Vite 共享配置(plugins / alias) - 启动快得多(< 1s 冷启 vs Jest 几秒) - ESM-native - Watch 模式更快(Vite 增量 build) 迁移:API 几乎与 Jest 兼容,`jest.fn()` → `vi.fn()`。 ## 踩过的坑 - `import.meta.env` 在 Vitest 里要在 setup 里 mock:测试时 env 是 test。 - Playwright 测真 API 时数据库被污染:用 `test.beforeEach` 清表,或单独 test environment + Docker 起独立 DB。 - userEvent 漏 `await`:操作没真发生测试通过,假绿。`await userEvent.click(...)` 必须 await。 - Playwright 第一次跑要下载浏览器(~300MB),CI 用 `--with-deps` 装系统 lib 否则跑不起来。
## 起因 老办法做 responsive: ```css .card { padding: 1rem; } @media (min-width: 768px) { .card { padding: 2rem; display: flex; } } ``` 依据**viewport** 调整。 但很多组件不关心 viewport,它关心**自己父容器多大**。 例:card 组件放 sidebar(300px)时纵向布局,放主区(800px)时横向。 media query 帮不上忙 → 同 viewport 不同位置要不同样式。 **Container queries**(CSS 2023+ 主流支持)解决:组件根据**容器 自己大小**调样式。 ## 基本用法 父元素声明 containment: ```css .sidebar { container-type: inline-size; container-name: sidebar; } .main { container-type: inline-size; } ``` 子元素 query 容器: ```css .card { padding: 1rem; display: block; } @container (min-width: 500px) { .card { padding: 2rem; display: flex; gap: 1rem; } } ``` card 在 sidebar (300px) 内 → block。 同 card 在 main (800px) 内 → flex。 ## containment type - `inline-size`:监听宽(最常用) - `size`:宽 + 高 - `normal`:不监听(默认) ```css .parent { container-type: inline-size; } ``` 注意:`container-type` 让元素成为 containment context,影响 layout 计算(不一定能在所有元素用)。 ## named container ```css .sidebar { container-type: inline-size; container-name: sidebar; } .main { container-type: inline-size; container-name: main; } @container sidebar (min-width: 400px) { ... } @container main (max-width: 600px) { ... } ``` 按 container 名 query,避免 ambiguity。 ## query unit (cqw / cqh) ```css .card { font-size: 5cqi; /* container inline-size 的 5% */ padding: 2cqi; } ``` - `cqi`:container inline-size 1% - `cqb`:container block-size 1% - `cqw` / `cqh`:absolute width/height (less common) 字号跟容器大小成正比,缩放友好。 ## 实战 example:可复用 card ```css .card { container-type: inline-size; border: 1px solid #ddd; border-radius: 8px; } .card-inner { padding: 1rem; } .card-thumb { width: 100%; aspect-ratio: 16/9; } @container (min-width: 400px) { .card-inner { display: grid; grid-template-columns: 150px 1fr; gap: 1rem; } .card-thumb { width: 150px; aspect-ratio: 1; } } @container (min-width: 700px) { .card-inner { grid-template-columns: 200px 1fr; } .card-thumb { width: 200px; } } ``` 同一个 `.card` 在任何容器里自适应,不需要 media query 协调。 ## media query 仍有用 container query 不是 100% 替代 media query: - **页面级布局**(sidebar/main 切换)→ media query - **组件内适应** → container query - **基于设备特性(hover / touch)** → media query - **prefers-color-scheme** → media query 混用: ```css @media (prefers-color-scheme: dark) { .card { background: #222; } } @container (min-width: 500px) { .card { display: flex; } } ``` ## style query (实验) ```css @container style(--theme: dark) { .card { background: black; } } ``` 依据自定义 prop 值变样式。Chrome 111+,Safari 18+。 还在 stabilizing。 ## 浏览器支持 Chrome 105+ / Safari 16+ / Firefox 110+ → 主流浏览器从 2022 末 / 2023 全支持。 2026 视角:可以默认用,老 browser 用 `@supports` fallback: ```css @supports not (container-type: inline-size) { /* fallback to media query */ } ``` ## 与 CSS-in-JS 对比 CSS-in-JS(styled-components / Emotion)经常通过 prop 控制样式: ```jsx <Card variant={width > 500 ? 'wide' : 'narrow'} /> ``` JS 测宽 → re-render → 改样式。复杂 + JS 阻塞。 container query 纯 CSS,浏览器原生计算 → 更高效 + 简洁。 ## 实际项目效果 我重构一个 dashboard,从 media query / JS measure 改 container query: - 删 200+ 行 JS layout logic - CSS 简化(不需要 `at-768`, `at-1024` 等命名) - 同组件在 modal / sidebar / 主区 都能 work - bundle 小 5KB 最大改善:组件**真正可复用** —— 不需要为不同上下文写变体。 ## 与 grid auto-fit / minmax 对比 ```css /* grid 自适应 N 列 */ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } ``` grid `auto-fit` 已经覆盖一些 container query 用例。 但 grid 是 layout 内自适应;container query 是组件内部样式。 ## 踩过的坑 1. **`container-type: inline-size` 副作用**:让元素 layout 隔离, 某些 height 计算变化。布局突变 → 检查父子。 2. **嵌套 container**:子 container 的 query 默认查最近的 named container,不指定容易混乱。明确 `container-name`。 3. **inline element 不能 container-type**:必须 block / inline-block 或者 display 至少能 contain。 4. **`@container` 在 nested rule 内**:CSS nesting 里写时注意顺序。 5. **devtool 难调**:Chrome devtools 显示 container query 没 media query 那么直观。仔细看 box model。
Service Worker 是 PWA 的核心:拦截网络请求,按策略返回缓存 / 网络 / 兜底页面, 让网页在无网时也能用。下面写一个最简单但完整的离线缓存层。 ## 1. 注册 ```js // main.js if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js', { scope: '/' }) .then(reg => console.log('SW registered:', reg.scope)) .catch(err => console.error('SW failed:', err)) }) } ``` `scope: '/'` 让 SW 拦截整个站点。SW 文件必须从 **同源 root 路径** 提供 (不能放 CDN)。 ## 2. SW 主体 ```js // public/sw.js const VERSION = 'v3-2026-05-23' const PRECACHE = `precache-${VERSION}` const RUNTIME = `runtime-${VERSION}` const PRECACHE_URLS = [ '/', '/index.html', '/assets/main.css', '/assets/main.js', '/offline.html', ] // 安装:预缓存 shell 资源 self.addEventListener('install', event => { event.waitUntil( caches.open(PRECACHE) .then(cache => cache.addAll(PRECACHE_URLS)) .then(() => self.skipWaiting()) // 立刻激活,不等老 SW 退出 ) }) // 激活:清理老版本缓存 self.addEventListener('activate', event => { event.waitUntil( caches.keys() .then(keys => Promise.all( keys.filter(k => k !== PRECACHE && k !== RUNTIME) .map(k => caches.delete(k)) )) .then(() => self.clients.claim()) ) }) // fetch:按策略响应 self.addEventListener('fetch', event => { const { request } = event if (request.method !== 'GET') return // 只缓存 GET const url = new URL(request.url) if (url.origin !== self.location.origin) return // 不管跨域 // 1. 导航请求 → 网络优先,失败回退到 offline.html if (request.mode === 'navigate') { event.respondWith( fetch(request).catch(() => caches.match('/offline.html')) ) return } // 2. 静态资源(hash 文件名)→ 缓存优先(永久) if (url.pathname.match(/\.(css|js|woff2|png|svg|ico)$/)) { event.respondWith( caches.match(request).then(cached => { if (cached) return cached return fetch(request).then(response => { if (response.ok) { const clone = response.clone() caches.open(RUNTIME).then(c => c.put(request, clone)) } return response }) }) ) return } // 3. API 请求 → 网络优先,失败用缓存 if (url.pathname.startsWith('/api/')) { event.respondWith( fetch(request).then(response => { if (response.ok) { const clone = response.clone() caches.open(RUNTIME).then(c => c.put(request, clone)) } return response }).catch(() => caches.match(request)) ) return } }) ``` ## 3. offline 页 ```html <!-- public/offline.html --> <!DOCTYPE html> <html> <head><meta charset="utf-8"><title>离线</title></head> <body> <h1>当前离线</h1> <p>请检查网络后重试。已缓存的页面仍然可以打开。</p> </body> </html> ``` ## 4. 三种缓存策略速查 | 策略 | 适用 | 实现 | |---|---|---| | Cache-first | 静态资源(hash 文件名)、字体 | match → 否则 fetch + cache | | Network-first | HTML / API(要新鲜数据) | fetch → 失败用 cache | | Stale-while-revalidate | 头像、缩略图(旧的也能用) | match 立刻返回 + 异步 fetch 更新 | 实现 stale-while-revalidate: ```js caches.match(request).then(cached => { const fetched = fetch(request).then(response => { if (response.ok) { caches.open(RUNTIME).then(c => c.put(request, response.clone())) } return response }) return cached || fetched // 有 cache 立刻给,同时后台更新 }) ``` ## 5. Manifest(让浏览器认你是 PWA) ```json // public/manifest.json { "name": "My App", "short_name": "MyApp", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#2563eb", "icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } ] } ``` ```html <link rel="manifest" href="/manifest.json"> <meta name="theme-color" content="#2563eb"> ``` 满足 PWA 安装条件(SW + manifest + HTTPS + 图标),Chrome 地址栏会出现 "安装应用"图标。 ## 6. 版本更新与"重启提示" ```js // main.js navigator.serviceWorker.register('/sw.js').then(reg => { reg.addEventListener('updatefound', () => { const nw = reg.installing nw.addEventListener('statechange', () => { if (nw.state === 'installed' && navigator.serviceWorker.controller) { // 新版本可用 if (confirm('有新版本,立即刷新?')) { window.location.reload() } } }) }) }) ``` ## 7. 调试 Chrome DevTools → Application → Service Workers: - "Update on reload" 勾上,每次 F5 强制 SW 重新安装 - "Bypass for network" 临时禁用 SW - "Unregister" 卸载(开发期常用) Cache 内容在同面板的 Cache Storage 看。 ## 8. Workbox 手写 SW 没问题,但生态成熟后用 Google 的 Workbox: ```bash npm i -D vite-plugin-pwa workbox-window ``` `vite-plugin-pwa` 配几行就生成 SW + manifest + precache,并处理"新版本提示" 的边界。生产推荐。 ## 踩过的坑 - SW 必须 HTTPS(localhost 例外)。HTTP 网站永远 register 不上。 - 改了 SW 文件浏览器要等"老 SW 没有 client" 时才激活新版;测试期间 关掉整个 tab 才生效,反复测试很烦——用 `self.skipWaiting()` + `self.clients.claim()` 强制立刻接管。 - SW 缓存了一个 redirect 响应后,整个站点行为奇怪。`Cache.put()` 不要存 `response.redirected` 的 response。 - 缓存条目 LRU 上限是浏览器决定的(Chrome 总配额几百 MB-几 GB); 超额时按时间删除最老条目,缓存可能突然失效。
## 起因 E2E 测试两个主流: - **Cypress**:先发优势,DX 好,2017+ - **Playwright**(Microsoft,2020+):更快 / 更全 / 现代 新项目都选 Playwright,下面对比。 ## 写法 ```ts // Playwright import { test, expect } from '@playwright/test'; test('login', async ({ page }) => { await page.goto('/login'); await page.fill('[name=email]', '[email protected]'); await page.fill('[name=password]', 'pass'); await page.click('button[type=submit]'); await expect(page.locator('h1')).toHaveText('Dashboard'); }); ``` ```js // Cypress describe('login', () => { it('logs in', () => { cy.visit('/login'); cy.get('[name=email]').type('[email protected]'); cy.get('[name=password]').type('pass'); cy.get('button[type=submit]').click(); cy.contains('h1', 'Dashboard'); }); }); ``` API 风格差异: - Playwright:async/await 标准 JS - Cypress:chained command,自定义 promise-like Cypress chain 直观但 debug 复杂。Playwright async 更标准。 ## 浏览器支持 | | Playwright | Cypress | |---|---|---| | Chromium | ✅ | ✅ | | Firefox | ✅ | ✅ | | WebKit (Safari) | ✅ | ❌(实验) | | Mobile emulation | ✅ | 部分 | | 并发跨浏览器 | ✅ | 部分 | Playwright **WebKit 支持** 是杀手:Safari bug 能在 CI 测出(不能跑 真 Safari 但 WebKit engine 同一个)。 ## 性能 我们一个项目 200 E2E 测试: | | Cypress | Playwright | |---|---|---| | 全跑 | 12 min | 4 min | | parallel (4 worker) | 5 min | 1.5 min | | 启动 (first test) | 8s | 2s | Playwright 3x 快主要因为: - 真正并行(多 worker,单 process 多 context) - WebSocket 协议直连(vs Cypress iframe-based) - 默认 headless 优化 Cypress 并行要付费云服务 (Cypress Cloud) 或者多 CI runner。 Playwright 单进程并行免费。 ## auto-wait ```ts // Playwright await page.click('button'); // 自动等元素可点 await expect(page.locator('h1')).toHaveText('done'); // 自动 retry until match ``` ```js // Cypress cy.get('button').click(); // 自动 retry cy.contains('h1', 'done'); // 自动 retry ``` 两者都有 auto-wait,行为相似。 Playwright 的 `expect().toBeVisible()` 等 matcher 配合 retry 内置。 ## test isolation Cypress:每 test 在新 iframe 跑(同 browser)。 Playwright:每 test 在新 BrowserContext(独立 cookie / storage)。 Playwright context 隔离更彻底,并行更安全。 ## fixture / setup ```ts // Playwright test.beforeEach(async ({ page }) => { await page.goto('/login'); }); // 共享 page state (storageState) test.use({ storageState: 'auth.json' }); ``` ```js // Cypress beforeEach(() => cy.visit('/login')); // 共享 login Cypress.Commands.add('login', () => { ... }); cy.login(); ``` Playwright `storageState` 让"登录一次" 然后多 test 复用 session → 速度 +30%。Cypress 类似 `cy.session()`。 ## debug Playwright: - VS Code 扩展:UI mode 一键 step through - trace viewer:自动录制每步 DOM + network + screenshot - `--debug` 启动 Inspector ```bash npx playwright test --ui # 交互 UI npx playwright show-trace ... # 看失败 trace ``` Cypress: - GUI mode 一直是核心(time-travel debugger) - 录像 / 截图 两者 debug 体验都好,Playwright trace viewer 后来居上更详细。 ## codegen ```bash npx playwright codegen example.com ``` 打开浏览器 → 你点 / 输 → Playwright 自动生成代码。 新人写第一个测试快。 Cypress 也有 Studio 但不如 Playwright codegen 成熟。 ## visual regression Playwright 内置 snapshot: ```ts await expect(page).toHaveScreenshot('home.png'); ``` Cypress 要装插件(cypress-image-snapshot)。 ## API testing Playwright 也能测 API: ```ts const response = await request.post('/api/users', { data: {...} }); expect(response.status()).toBe(201); ``` Cypress 也有 `cy.request()`。 两者都行,API 测试不是主战场。 ## component testing Cypress component testing 较早(2021+)。 Playwright 1.30+ 也有 component testing(experimental)。 实际:我们用 vitest + Testing Library 做 component test, Playwright 只跑 full E2E。 ## CI 集成 ```yaml # Playwright GitHub Actions - run: npx playwright install --with-deps - run: npx playwright test - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/ ``` 失败自动上传 HTML report + trace + screenshot → review 方便。 Cypress: ```yaml - run: npx cypress run ``` 跟 Cypress Cloud 集成更好(recorded video / dashboard),但要付费。 ## flake 处理 E2E 测试 flake(不稳定)是普遍问题。 Playwright retries: ```ts // playwright.config.ts export default { retries: process.env.CI ? 2 : 0, }; ``` Cypress 也支持 retry。 flake 治本: - locator 用 stable selector(test-id 而不是 CSS class) - 等明确条件(不是 sleep) - isolate test(不依赖前一 test state) ## locator best practice ```ts // bad: brittle page.locator('.btn-primary:nth-child(3)'); // good page.getByRole('button', { name: 'Submit' }); page.getByTestId('submit-button'); ``` Playwright `getByRole` / `getByText` / `getByLabel` 跟 RTL 一致。 accessibility-friendly + 稳定。 ## 选择决策 - **新项目** → Playwright(性能 + 跨浏览器 + 免费并行) - **老 Cypress 项目 + 测试稳** → 不必迁 - **macOS Safari 是 target** → Playwright(WebKit) - **团队偏好 GUI debug** → Cypress 仍胜 我新项目 100% Playwright。 ## 真实迁移 case 某客户项目 Cypress 200 测试,CI 跑 15 分钟,flaky。 迁 Playwright: - 1 周转换 + 调优 - CI 时间 → 4 分钟(4 worker parallel) - flake rate 从 5% → < 1% - 没付 Cypress Cloud 钱(每月 $75) 主要工作: - API 对应(cy.get → page.locator) - custom command → fixture / helper - 调整等待条件(用 expect with auto-retry) ## 踩过的坑 1. **CI 没装 browser dependencies**:`npx playwright install` 也要 `--with-deps`(Linux 系统依赖)。 2. **fixture 滥用**:把太多 setup 塞 fixture → test 慢。balance。 3. **`waitForLoadState('networkidle')`**:永远不到 idle(持续 polling) → 超时。改 `domcontentloaded` 或 specific selector。 4. **trace 文件大**:每 test 一个 zip 几 MB → CI 100 test 100 MB artifact。只 retain failure trace(`trace: 'retain-on-failure'`)。 5. **headed mode 与 headless 行为差**:极少数 case 一致性问题(如 focus / window size)。CI 主要 headless,dev 偶尔 headed debug。
## 起因 新人 React 开发普遍熟"useState / useEffect / useMemo / useCallback" 名字 但用法常踩坑。下面是我帮 review code 时反复指出的 6 个点。 ## 1. Hooks 只能在 React 函数 top-level 调 ```tsx // ❌ function MyComp({ user }) { if (user) { const [name, setName] = useState(user.name) // 错!hook 在 if 里 } return ... } // ✅ function MyComp({ user }) { const [name, setName] = useState(user?.name ?? '') // hook 在 top level,逻辑在 hook 之后 ... } ``` 为什么:React 用 hook 调用**顺序** 来匹配 state。条件调用 → 顺序变 → state 错位。 eslint-plugin-react-hooks 的 `rules-of-hooks` 规则自动检查。 **必须开**。 ## 2. useEffect 不是 "componentDidMount" 新人常用法: ```tsx useEffect(() => { fetchData().then(setData) }, []) // 类似 componentDidMount ``` 问题: - React Strict Mode 在 dev 双调用 → effect 跑两次 - 切 prop 不重 fetch - cleanup 经常漏写 更深的问题:**useEffect 是同步外部系统**,不是 lifecycle hook。 ```tsx // useEffect 真正的用途 useEffect(() => { // 同步外部:subscribe DOM event / WebSocket / 第三方库 const sub = thirdPartyLib.subscribe(handler) return () => sub.unsubscribe() // cleanup 必须 }, [handler]) ``` 数据获取应该用 React Query / SWR / RSC,不是 useEffect。 事件订阅 / 第三方库初始化才用 useEffect。 ## 3. dependency array 不能撒谎 ```tsx function MyComp({ userId }) { useEffect(() => { fetchUser(userId).then(setUser) }, []) // ❌ 用到 userId 但没写依赖 → 切 userId 不 refetch } ``` eslint `exhaustive-deps` 规则强制: ```tsx useEffect(() => { fetchUser(userId).then(setUser) }, [userId]) // ✅ ``` 不要为了"骗" linter 写 `// eslint-disable-next-line`。 真要省 refetch 改其它策略(debounce / 单独 ref)。 ## 4. 不要在 useEffect 里 setState 然后依赖那个 state ```tsx function MyComp() { const [count, setCount] = useState(0) useEffect(() => { setCount(c => c + 1) // ❌ 触发 effect 重跑 → setState → 无限循环 }, [count]) } ``` 修正取决于意图: ```tsx // 只初始化一次 useEffect(() => { setCount(initialCount) }, []) // 但更好的是用 useState 初值 // 派生 state 用 useMemo 或直接计算 const doubled = count * 2 // 不需要 state ``` 90% "我有个 state 依赖另一个 state" 都该用 useMemo / 直接计算。 ## 5. useMemo / useCallback:默认不要用 ```tsx function MyComp({ items }) { const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), [items]) return <span>{total}</span> } ``` `reduce` 100 个 item 极快(毫秒级)。 `useMemo` 本身的 hook 开销 + 依赖比较 > 计算成本。 **实际更慢**。 useMemo 真的有用的时候: - 计算确实贵(profile 显示 > 16ms) - 结果作为 React.memo 子组件的 prop - 结果作为另一个 useEffect 的依赖(保持稳定) ```tsx // ✅ 派生值传给 memoized 子组件 const filtered = useMemo(() => items.filter(complex), [items, filter]) return <MemoedList items={filtered} /> // ✅ 给 useEffect 稳定依赖 const config = useMemo(() => ({ url, timeout }), [url, timeout]) useEffect(() => { subscribe(config) }, [config]) ``` 不传 React.memo / 不进 useEffect deps,useMemo 多余。 React 19 + React Compiler 会自动决定哪些 memo,写代码时不用关心。 ## 6. setState 是异步的,但批量也异步 ```tsx const [count, setCount] = useState(0) function handleClick() { setCount(count + 1) setCount(count + 1) setCount(count + 1) // count 只 +1,不是 +3 console.log(count) // 仍是 0(这次 render 的 count) } ``` 原因: 1. React 同一 event 内 batch setState(多次合并成一次 render) 2. `count` 是闭包捕获的旧值 修正:函数式 setState ```tsx function handleClick() { setCount(c => c + 1) setCount(c => c + 1) setCount(c => c + 1) // count +3 } ``` `c => c + 1` 每次拿到最新 state。 读最新值(罕见用例): ```tsx function handleClick() { setCount(c => c + 1) // 这一行后 count 仍是旧值(要等下次 render) // 要立刻拿新值用 ref } ``` 要立刻执行更新后效果用 `flushSync`(React 18+): ```tsx import { flushSync } from 'react-dom' flushSync(() => { setCount(c => c + 1) }) // 这里 DOM 已经更新到新 count ``` 性能差,少用。 ## 7. ref vs state ```tsx // state:用作 UI 输出 const [count, setCount] = useState(0) // ref:用作"persistence 但不触发 render" const timerRef = useRef<NodeJS.Timer | null>(null) const renderCountRef = useRef(0) ``` ```tsx useEffect(() => { timerRef.current = setInterval(...) return () => clearInterval(timerRef.current) }, []) ``` 何时用 ref: - DOM ref(input focus) - 长生命周期 mutable(timer / WebSocket / observer) - 计数器 / debounce token(不需要 re-render) 何时用 state: - 视图反映的数据 经验:**只要 UI 不看这个值就用 ref**。 ## 8. custom hook:纯函数 + 命名 use 开头 ```tsx function useDebounce<T>(value: T, delay: number): T { const [debounced, setDebounced] = useState(value) useEffect(() => { const t = setTimeout(() => setDebounced(value), delay) return () => clearTimeout(t) }, [value, delay]) return debounced } // 用 function Search() { const [input, setInput] = useState('') const debouncedInput = useDebounce(input, 500) useEffect(() => { search(debouncedInput) }, [debouncedInput]) } ``` custom hook 内部调其它 hook → 受 hook rules 约束。 命名必须 `use` 开头 linter 才识别 + 启用规则检查。 ## 9. 控制 vs 非控制 input ```tsx // 控制(推荐):state 是真相 const [val, setVal] = useState('') <input value={val} onChange={e => setVal(e.target.value)} /> // 非控制:DOM 是真相 const ref = useRef<HTMLInputElement>(null) <input defaultValue="hello" ref={ref} /> const value = ref.current?.value ``` 控制:每次 input 触发 re-render(小开销,大表单累积)。 非控制:性能好但 React 不知道 value,复杂表单要混用。 react-hook-form 用非控制 + ref 拿值,避开 re-render,是高性能表单 首选。 ## 10. 经典反模式:把 hook 当 class state ```tsx // ❌ 一堆 useState const [name, setName] = useState('') const [email, setEmail] = useState('') const [phone, setPhone] = useState('') const [age, setAge] = useState(0) ``` 或者: ```tsx // ❌ 一个 object state(更新很麻烦) const [form, setForm] = useState({ name: '', email: '', phone: '', age: 0 }) setForm({ ...form, name: 'Alice' }) // 容易漏字段 ``` 用 useReducer 或外部 state(Zustand / Jotai / react-hook-form): ```tsx const [form, dispatch] = useReducer(formReducer, initialForm) ``` 复杂状态用专门工具。 ## 调试 hooks React DevTools → Components → 选组件 → Hooks 一栏。 能看到每个 hook 当前值。注意 useReducer / useMemo 显示有限。 ## 总结 | 反模式 | 该做的 | |---|---| | useEffect 做 fetch | React Query / SWR / RSC | | useEffect 依赖 state 触发更新 | 直接派生 / useMemo | | 无脑 useMemo / useCallback | 默认不用,profile 后按需 | | dependency array 写 [] 撒谎 | 实事求是 + lint | | 闭包 setState 用旧值 | `setX(prev => ...)` | | 一堆 useState 平铺 | useReducer / 第三方 store | | 在 if / loop 调 hook | 顶层调用,逻辑在内部分支 | 熟悉这些后写 React 体感顺很多 + bug 少很多。 ## 踩过的坑 1. **strict mode 双调 effect**:dev 模式 effect 跑两次。cleanup 必须 做对,否则 subscribe / setTimeout 等"两个跑半个" 状态。 2. **useEffect 跑两次拉两次 API**:dev 干扰开发体验。 `<StrictMode>` 内层包不要去 / 或者用 React Query 等自动 dedup。 3. **dep 数组的 object / function 每次新引用**: ```tsx useEffect(..., [{ x: 1 }]) // 每次都"新对象" ``` 触发 effect 每次都跑。用 stable ref 或 useMemo。 4. **custom hook 名没 use 开头**:lint 不识别 → 规则不生效 → 隐藏 bug。 5. **React 19 / Compiler 期望写法**:未来 useMemo / useCallback 大多 不需要。养成"先简洁后优化" 的习惯。
CSS transition / keyframes 能解决简单动画,复杂的(列表重排、组件进入/离开、 gesture、SVG morph)就力不从心。Framer Motion 是 React 生态的事实标准动画库, API 简洁,自动 GPU 加速。 ## 安装 ```bash npm i framer-motion ``` ## 1. 最简单的进入动画 ```tsx import { motion } from 'framer-motion' <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }} > Hello </motion.div> ``` 任何 HTML 元素加 `motion.` 前缀即可。 ## 2. 进入 + 离开(AnimatePresence) ```tsx import { motion, AnimatePresence } from 'framer-motion' <AnimatePresence> {visible && ( <motion.div key="modal" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} > Modal </motion.div> )} </AnimatePresence> ``` `AnimatePresence` 在子元素被 React 卸载时延迟卸载,让 `exit` 动画完成。 必须给每个直接子元素显式 `key`。 ## 3. 列表动画(重排 / 添加 / 删除) ```tsx <AnimatePresence> {items.map(item => ( <motion.li key={item.id} layout // 自动 FLIP 动画 initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 20 }} > {item.text} </motion.li> ))} </AnimatePresence> ``` `layout` 让元素位置变化时自动 morph(FLIP 算法)。 增删 / 排序列表时无需手动算位移。 ## 4. variants:复用动画状态 ```tsx const variants = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 }, } <motion.div variants={variants} initial="hidden" animate="visible"> ... </motion.div> // 父子联动 const parent = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.1 } }, } const child = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 }, } <motion.ul variants={parent} initial="hidden" animate="visible"> {items.map(i => <motion.li key={i.id} variants={child}>{i.text}</motion.li>)} </motion.ul> ``` `staggerChildren: 0.1` 让子元素一个接一个延迟 0.1s 进入,列表波浪效果。 ## 5. 手势:hover / tap / drag ```tsx <motion.button whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} > Click </motion.button> <motion.div drag dragConstraints={{ left: -100, right: 100, top: 0, bottom: 0 }} dragElastic={0.5} whileDrag={{ scale: 1.1 }} > 拖我 </motion.div> ``` `drag` 自动处理触摸 / 鼠标拖动。`dragConstraints` 限制范围。 ## 6. transition 选项 ```tsx <motion.div animate={{ x: 100 }} transition={{ duration: 0.5, ease: 'easeOut', // 'linear' | 'easeIn' | 'easeInOut' | 'circIn' | ... // 或弹簧物理 type: 'spring', stiffness: 100, damping: 10, }} /> ``` 弹簧(spring)参数比 duration 更自然,是 Framer Motion 默认。 ## 7. scroll-triggered 动画 ```tsx import { motion, useScroll, useTransform } from 'framer-motion' function Hero() { const { scrollYProgress } = useScroll() const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0]) const y = useTransform(scrollYProgress, [0, 1], [0, -200]) return <motion.h1 style={{ opacity, y }}>Title</motion.h1> } ``` `useTransform` 把 0-1 的 scrollYProgress 映射到任意值范围,做视差效果。 ## 8. layout 高级:跨组件共享 ```tsx import { motion } from 'framer-motion' // 小卡片 <motion.div layoutId="card-1" onClick={open}> <img src="thumb.jpg" /> </motion.div> // 弹出全屏 {open && ( <motion.div layoutId="card-1" className="fullscreen"> <img src="full.jpg" /> </motion.div> )} ``` 两个不同位置的元素 `layoutId` 相同 → Framer 自动 morph 从一个位置到另一个。 "卡片展开"效果一行写完。 ## 9. SVG morph ```tsx <motion.path d={pathData} initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 2 }} /> ``` `pathLength: 0 → 1` 让 SVG 路径"画出来"效果。 ## 10. 性能注意 Framer Motion 默认用 transform / opacity(GPU 友好)。不要动 width / height / top / left(CPU 重排)。 ```tsx // ❌ width 变化触发 layout <motion.div animate={{ width: 200 }} /> // ✅ transform scale <motion.div animate={{ scaleX: 2 }} /> ``` ## 11. 与 Tailwind / CSS-in-JS `motion.div` 接受所有 HTML props,class / style 正常写: ```tsx <motion.div className="bg-blue-500 rounded p-4" whileHover={{ scale: 1.05 }} /> ``` ## 12. 替代方案 - **react-spring**:物理为主,API 不同 - **gsap**:传统 JS 动画,强大但非 React-first - **Motion One**:framer-motion 作者的"无 React 依赖"版 React 项目就用 Framer Motion,最省事。 ## 踩过的坑 - AnimatePresence 子元素必须有 `key`:忘了 key 看不到 exit 动画。 - 直接给 `<div>` 加 motion 属性不工作:必须用 `motion.div`。 - 大量元素同时 layout 动画 → 卡。`layout` 只给真正需要 morph 的元素加。 - prefers-reduced-motion:尊重用户系统设置,给敏感动画加: ```tsx const reduce = useReducedMotion() <motion.div animate={{ x: reduce ? 0 : 100 }} /> ```
## 起因 每次 `npm run build` 后要手动跑 `node generate-sitemap.js` 生成 sitemap.xml 然后放进 dist/。容易忘 → 部署的 sitemap 是过期的。 把它做成 Vite plugin,build 时自动跑。学一次 plugin 编写 = 解锁 "任意 build-time 自动化"。 ## Vite plugin 基础 Vite plugin 是基于 Rollup plugin API + Vite 扩展。最小例子: ```ts // vite-plugin-hello.ts import type { Plugin } from 'vite' export default function helloPlugin(): Plugin { return { name: 'hello', buildStart() { console.log('build started') }, closeBundle() { console.log('build finished') }, } } ``` 用: ```ts // vite.config.ts import { defineConfig } from 'vite' import hello from './vite-plugin-hello' export default defineConfig({ plugins: [hello()], }) ``` `buildStart` / `closeBundle` 等是 Rollup hook。Vite 加了一些自己的 (`configResolved` / `transformIndexHtml` 等)。 ## 实战:自动生成 sitemap 需求:build 后扫描 `src/routes/**/*` 提取所有路由 → 生成 `dist/sitemap.xml`。 ```ts // vite-plugin-sitemap.ts import type { Plugin } from 'vite' import { glob } from 'glob' import path from 'node:path' import fs from 'node:fs/promises' interface Options { baseUrl: string outputDir?: string routesGlob?: string } export default function sitemapPlugin(opts: Options): Plugin { return { name: 'sitemap', apply: 'build', // 只在 build 时跑(dev 不需要) async closeBundle() { const outDir = opts.outputDir ?? 'dist' const routes = await scanRoutes(opts.routesGlob ?? 'src/routes/**/*.{tsx,ts}') const xml = generateXml(opts.baseUrl, routes) const outPath = path.resolve(outDir, 'sitemap.xml') await fs.writeFile(outPath, xml, 'utf-8') console.log(`[sitemap] wrote ${routes.length} URLs to ${outPath}`) }, } } async function scanRoutes(globPattern: string): Promise<string[]> { const files = await glob(globPattern) return files.map(file => { // src/routes/about.tsx → /about // src/routes/blog/[slug].tsx → /blog/:slug (skip dynamic) let p = file.replace(/^src\/routes/, '').replace(/\.(tsx|ts)$/, '') if (p.endsWith('/index')) p = p.replace(/\/index$/, '/') if (p.includes('[')) return null // dynamic route skip return p || '/' }).filter(Boolean) as string[] } function generateXml(baseUrl: string, urls: string[]): string { const now = new Date().toISOString() const urlEntries = urls.map(u => ` <url> <loc>${baseUrl}${u}</loc> <lastmod>${now}</lastmod> <changefreq>weekly</changefreq> </url>`).join('') return `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urlEntries} </urlset>` } ``` 用: ```ts import sitemap from './vite-plugin-sitemap' export default defineConfig({ plugins: [ sitemap({ baseUrl: 'https://example.com' }), ], }) ``` `npm run build` 完自动看到 `dist/sitemap.xml`。 ## 关键 Vite hooks ### apply ```ts apply: 'build' // 只 build 时跑 apply: 'serve' // 只 dev server 跑 // 默认两者都跑 ``` ### configResolved ```ts configResolved(config) { console.log('mode:', config.mode) console.log('outDir:', config.build.outDir) } ``` 读完整 config 后调。常用于读 outDir / base / 模式自适应。 ### transformIndexHtml ```ts transformIndexHtml: { order: 'pre', handler(html, ctx) { return html.replace('<head>', `<head>\n <link rel="prefetch" href="/critical.js">`) }, } ``` 修改 `index.html` 模板。常用于 inject 第三方 script / meta tag。 ### transform(修改 source 文件) ```ts transform(code, id) { if (id.endsWith('.md')) { return { code: `export default ${JSON.stringify(parseMarkdown(code))}`, map: null, } } } ``` 把 `.md` 文件转成 JS module。`?raw` `?url` 等 query 处理也在这里。 ### load / resolveId ```ts resolveId(id) { if (id === 'virtual:my-module') return id } load(id) { if (id === 'virtual:my-module') { return `export const value = 42` } } ``` 虚拟模块:`import { value } from 'virtual:my-module'`。 不存在的文件但 plugin 生成内容。 ### configureServer (dev only) ```ts configureServer(server) { server.middlewares.use('/api/_health', (req, res) => { res.end('ok') }) } ``` dev server 加 middleware。例如 mock API endpoint。 ### handleHotUpdate ```ts handleHotUpdate(ctx) { if (ctx.file.endsWith('.md')) { ctx.server.ws.send({ type: 'full-reload' }) return [] } } ``` 自定义 HMR 行为。`.md` 改了让浏览器 full reload。 ## 更复杂:内容 transformer 把所有 `.svg` import 转成 React component: ```ts import { transform } from 'esbuild' export default function svgrPlugin(): Plugin { return { name: 'svgr-light', async transform(code, id) { if (!id.endsWith('.svg')) return const svg = await fs.readFile(id, 'utf-8') const component = ` import React from 'react' export default function Svg(props) { return ${svg.replace('<svg', '<svg {...props}')} } ` const result = await transform(component, { loader: 'jsx' }) return result.code }, } } ``` ```tsx import Logo from './logo.svg' <Logo width="40" /> ``` 实际生产用 `@svgr/rollup` plugin 即可,自己写说明原理。 ## generateBundle ```ts generateBundle(opts, bundle) { // 修改最终 bundle 中的 chunk / asset bundle['manifest.json'] = { type: 'asset', fileName: 'manifest.json', source: JSON.stringify({ ... }), } } ``` 往 `dist/` 加额外文件(不一定是 sitemap,可以是 license 文件 / manifest / 报告)。 ## Plugin 开发流程 ```bash mkdir vite-plugin-mine && cd vite-plugin-mine npm init -y npm i -D vite typescript # src/index.ts # tests/ npm publish ``` 发到 npm 任何人能用。 或者 monorepo 里 `packages/vite-plugin-mine` 本地引用。 ## 注意 ### Plugin 顺序 ```ts plugins: [ pluginA(), // 先跑 pluginB(), pluginC(), // 后跑 ] ``` 某些 hook(transform)按顺序串行。明确 plugin 间依赖。 `order: 'pre'` / `'post'` 调整单 hook 顺序: ```ts { name: 'mine', transform: { order: 'pre', handler(code, id) { ... } } } ``` ### 性能 每个文件 transform 都跑你的 plugin → 大项目 build 慢。 filter 早 return: ```ts transform(code, id) { if (!id.endsWith('.special')) return // ... } ``` 或者更优雅用 `filter`: ```ts transform: { filter: { id: /\.special$/ }, handler(code, id) { ... } } ``` ### dev vs build 某些 hook 只在 build 跑(generateBundle / writeBundle / closeBundle)。 dev 模式没 bundle 概念。 要 dev 也响应文件改动用 `configureServer` + watcher。 ## 实战 plugin 库参考 读 source 学到更多: - `@vitejs/plugin-react`:JSX transform + HMR - `vite-plugin-pwa`:service worker 注入 - `vite-plugin-svgr`:SVG → React component - `unocss/vite`:原子 CSS transform 都是几百-千行代码,看完心里有数怎么写复杂 plugin。 ## 我们的实际 plugin 例子 ```ts // 收集 build 时所有 import "use server" 函数 → 生成 server actions 清单 export default function serverActionsPlugin(): Plugin { const actions: string[] = [] return { name: 'collect-server-actions', transform(code, id) { if (code.includes("'use server'")) { actions.push(id) } }, generateBundle() { this.emitFile({ type: 'asset', fileName: 'server-actions.json', source: JSON.stringify(actions, null, 2), }) }, } } ``` build 后 `dist/server-actions.json` 给后端读 → 注册 RPC 端点。 ## 踩过的坑 1. **`transform` 返回 string 不带 source map**:sourcemap 丢失 → 调试 时 stack trace 指向编译后代码。永远 return `{ code, map }`。 2. **path.resolve 在 Windows 反斜杠**:跨 OS 用 `path.posix` 或 `path.normalize`。 3. **hook 异步未 await**:plugin 完成前 build 已经结束 → 文件没生成。 `async` hook 必须 await 完。 4. **dev 模式 plugin 改动 cache 没失效**:vite hot reload 时改 plugin 要重启 dev server。或者 `--force` 让 vite 不用 cache。 5. **plugin 间冲突**:A 改 .md 转 JS,B 也改 .md 加 frontmatter parser, 顺序错就乱。明确 order + 测试。
## 起因 做了一个工具站,用户反馈"每次都要打开浏览器 → 输 URL,能不能像 app 一样固定在桌面?" PWA(Progressive Web App)让任何网站能被 "添加到主屏幕"作为独立 app 启动,而不需要写 React Native / Flutter。 满足 PWA 安装条件: 1. HTTPS(localhost 例外) 2. valid `manifest.webmanifest` 3. service worker(至少注册) 4. 192×192 + 512×512 PNG icon ## 最小步骤 ### 1. icons 用 [Real Favicon Generator](https://realfavicongenerator.net/) 或者 imagemagick 自己生成: ```bash convert source.png -resize 192x192 icon-192.png convert source.png -resize 512x512 icon-512.png convert source.png -resize 192x192 -background none -gravity center \ -extent 192x192 icon-192-maskable.png # maskable 留 padding ``` 放 `public/icons/`。 ### 2. manifest.webmanifest `public/manifest.webmanifest`: ```json { "name": "My Tool App", "short_name": "MyTool", "description": "在线小工具", "start_url": "/?source=pwa", "display": "standalone", "orientation": "portrait", "theme_color": "#2563eb", "background_color": "#ffffff", "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-192-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" } ], "shortcuts": [ { "name": "新建", "url": "/new", "icons": [{ "src": "/icons/new.png", "sizes": "96x96" }] } ] } ``` 要点: - `display: standalone` 让安装后启动是无浏览器 UI 的独立窗口 - `start_url: /?source=pwa` 加 source 参数便于分析 PWA 启动量 - `maskable` icon:Android 自适应图标,让系统裁圆角不丢内容 - `shortcuts`:长按 app 图标显示快捷菜单(如"新建文档") ### 3. 链接到 HTML ```html <head> <link rel="manifest" href="/manifest.webmanifest"> <meta name="theme-color" content="#2563eb"> <link rel="apple-touch-icon" href="/icons/icon-192.png"> <!-- iOS --> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-title" content="MyTool"> </head> ``` iOS Safari 不完全遵循 web manifest,需要 `apple-*` meta 兼容。 ### 4. Service Worker(让"可安装"条件满足) 最简版: ```js // public/sw.js self.addEventListener('install', e => { self.skipWaiting() }) self.addEventListener('activate', e => { e.waitUntil(self.clients.claim()) }) self.addEventListener('fetch', e => { // 不做任何拦截,纯透传 }) ``` ```js // main.js if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') }) } ``` 这就够 Chrome 把"安装"图标显示在地址栏。 ### 5. 加离线兜底页(推荐) ```js // public/sw.js const CACHE = 'v1' const OFFLINE_URL = '/offline.html' self.addEventListener('install', e => { e.waitUntil( caches.open(CACHE).then(c => c.addAll(['/', OFFLINE_URL, '/icons/icon-192.png'])) ) self.skipWaiting() }) self.addEventListener('activate', e => { e.waitUntil(self.clients.claim()) }) self.addEventListener('fetch', e => { if (e.request.mode === 'navigate') { e.respondWith( fetch(e.request).catch(() => caches.match(OFFLINE_URL)) ) } }) ``` `public/offline.html`: ```html <!DOCTYPE html> <html> <head><meta charset="utf-8"><title>离线</title></head> <body> <h1>当前没有网络</h1> <p>请检查网络连接后重试。</p> </body> </html> ``` 断网时用户打开 PWA 看到"离线"页而不是浏览器报 ERR_INTERNET_DISCONNECTED。 ### 6. 自定义安装按钮 ```js let deferredPrompt = null window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault() deferredPrompt = e document.getElementById('install-btn').style.display = 'block' }) document.getElementById('install-btn').addEventListener('click', async () => { if (!deferredPrompt) return deferredPrompt.prompt() const { outcome } = await deferredPrompt.userChoice console.log('user choice:', outcome) deferredPrompt = null document.getElementById('install-btn').style.display = 'none' }) ``` 控制安装提示的时机(如用户用了 5 分钟后才显示),比浏览器默认弹的 体验好。 ## 配合 Vite ```bash npm i -D vite-plugin-pwa ``` ```ts // vite.config.ts import { VitePWA } from 'vite-plugin-pwa' export default defineConfig({ plugins: [ VitePWA({ registerType: 'autoUpdate', manifest: { name: 'My Tool', short_name: 'MyTool', theme_color: '#2563eb', icons: [/* ... */], }, workbox: { globPatterns: ['**/*.{js,css,html,png,svg,woff2}'], runtimeCaching: [ { urlPattern: /^https:\/\/api\./, handler: 'NetworkFirst', options: { cacheName: 'api', expiration: { maxAgeSeconds: 60*60*24 }}, }, ], }, }), ], }) ``` vite-plugin-pwa 用 Workbox 自动生成 manifest + service worker, 含 precache / runtime cache / 更新策略。 ## 效果 - 用户打开后 Chrome 地址栏显示 ⊕ 安装按钮 - 安装后桌面 / 应用列表里有 app icon - 启动 app 是独立窗口(无浏览器地址栏 / 标签) - 断网时显示"离线页"而不是浏览器错误 - iOS / Android / 桌面 Chrome / Edge 都支持 - 不需要 App Store 审核 ## 验证 Chrome DevTools → Application → Manifest: - 看 manifest 是否被正确解析 - icon 预览 - "Installability" 一栏告诉你不满足哪些条件 Lighthouse → PWA 类别也可以跑评分。 ## 推送通知(可选) ```js // 申请权限 const perm = await Notification.requestPermission() if (perm === 'granted') { const sub = await navigator.serviceWorker.ready .then(reg => reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapidPublicKey, })) // 把 sub 发给后端,后端用 web-push 库发送 } ``` iOS Safari 16.4+ 才支持 web push(且要求"添加到主屏幕"后)。 ## 踩过的坑 1. **manifest URL 写错**:`<link rel="manifest" href="/manifest.json">` 但文件叫 `manifest.webmanifest`。404 没声音,PWA 静默不可安装。 DevTools → Application 检查。 2. **HTTP**:service worker 不工作,安装条件不满足。localhost 是例外, 生产必须 HTTPS。 3. **icon 路径错**:相对路径 vs 绝对路径混乱。manifest 里推荐绝对 路径 `/icons/...`。 4. **iOS 安装后地址栏没了,分享 / 复制 URL 不便**:iOS PWA 没"分享 到浏览器"按钮。给 app 内自己加分享按钮(`navigator.share()`)。 5. **service worker cache 把新版本卡住**:用户装了 PWA 后总用老版本。 `registerType: 'autoUpdate'` + 检测到新版后提示用户刷新。
原生 `WebSocket` 没有自动重连、没有心跳检测、断网时 send 直接丢。 生产用都得自己包一层。下面是一个 50 行的实用实现。 ## 1. 用法目标 ```ts const ws = new ResilientWS('wss://api.example.com/ws') ws.on('message', (msg) => console.log(msg)) ws.on('open', () => console.log('connected')) ws.on('close', () => console.log('disconnected')) ws.send({ type: 'subscribe', channel: 'orders' }) // 如果当前断开,先排队;连上后自动发出去 ``` ## 2. 实现 ```ts type Listener<T> = (data: T) => void interface ResilientWSOptions { reconnectMaxDelay?: number // 默认 30s heartbeatInterval?: number // 默认 25s heartbeatMessage?: string // 默认 'ping' } export class ResilientWS { private url: string private ws: WebSocket | null = null private listeners = new Map<string, Set<Listener<any>>>() private queue: any[] = [] private retries = 0 private heartbeatTimer?: number private reconnectTimer?: number private opts: Required<ResilientWSOptions> private alive = true constructor(url: string, opts: ResilientWSOptions = {}) { this.url = url this.opts = { reconnectMaxDelay: opts.reconnectMaxDelay ?? 30000, heartbeatInterval: opts.heartbeatInterval ?? 25000, heartbeatMessage: opts.heartbeatMessage ?? 'ping', } this.connect() } private connect() { if (!this.alive) return this.ws = new WebSocket(this.url) this.ws.onopen = () => { this.retries = 0 this.emit('open') this.startHeartbeat() // 重发排队的消息 this.queue.forEach(m => this.ws!.send(m)) this.queue = [] } this.ws.onmessage = (e) => { // 服务端 pong 不要传给用户 if (e.data === 'pong') return try { this.emit('message', JSON.parse(e.data)) } catch { this.emit('message', e.data) } } this.ws.onclose = () => { this.stopHeartbeat() this.emit('close') this.scheduleReconnect() } this.ws.onerror = (e) => { this.emit('error', e) // 让 onclose 触发重连 } } private scheduleReconnect() { if (!this.alive) return // 指数退避:1s, 2s, 4s, 8s, ... 上限 reconnectMaxDelay const delay = Math.min( 1000 * Math.pow(2, this.retries), this.opts.reconnectMaxDelay ) // 加 ±20% 抖动,避免多客户端同时重连打爆服务端 const jittered = delay * (0.8 + Math.random() * 0.4) this.retries++ this.reconnectTimer = window.setTimeout(() => this.connect(), jittered) } private startHeartbeat() { this.heartbeatTimer = window.setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(this.opts.heartbeatMessage) } }, this.opts.heartbeatInterval) } private stopHeartbeat() { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer) } send(data: any) { const text = typeof data === 'string' ? data : JSON.stringify(data) if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(text) } else { this.queue.push(text) } } on(event: string, fn: Listener<any>) { if (!this.listeners.has(event)) this.listeners.set(event, new Set()) this.listeners.get(event)!.add(fn) return () => this.listeners.get(event)?.delete(fn) } private emit(event: string, data?: any) { this.listeners.get(event)?.forEach(fn => fn(data)) } close() { this.alive = false this.stopHeartbeat() if (this.reconnectTimer) clearTimeout(this.reconnectTimer) this.ws?.close() } } ``` ## 3. 心跳的作用 许多防火墙 / 反代会在空闲连接 60-300 秒后悄悄断(不发 close 帧)。 客户端 ws 的 readyState 还是 OPEN,但 send 出去服务端永远收不到。 每 25 秒主动发个 `ping` 字符串,服务端回 `pong`,强制流量保持连接活跃。 服务端配合: ```python # FastAPI 示例 @app.websocket('/ws') async def ws(websocket: WebSocket): await websocket.accept() while True: msg = await websocket.receive_text() if msg == 'ping': await websocket.send_text('pong') continue # 业务处理 ``` ## 4. 指数退避 + 抖动 重连立即重试会把服务端打爆: - 1 秒后,2 秒后,4 秒后,8 秒后... - 上限 30 秒,避免太久不重连 加随机抖动(jitter)避免"惊群"——大量客户端同时断线后同时重连。 ## 5. 离线 / 在线监听 浏览器有 `online` / `offline` 事件: ```ts window.addEventListener('online', () => { // 用户网络恢复,立刻重连(不必等当前 backoff timer) ws.forceReconnect() }) window.addEventListener('offline', () => { console.log('network offline') }) ``` 加到 ResilientWS 里: ```ts constructor(...) { ... window.addEventListener('online', () => { if (this.ws?.readyState !== WebSocket.OPEN) { this.retries = 0 if (this.reconnectTimer) clearTimeout(this.reconnectTimer) this.connect() } }) } ``` ## 6. visibilitychange:tab 被切到后台 ```ts document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { // tab 重新可见,确认连接还活着 if (this.ws?.readyState !== WebSocket.OPEN) ws.forceReconnect() } }) ``` ## 7. React Hook 封装 ```tsx import { useEffect, useState } from 'react' export function useWebSocket<T>(url: string) { const [messages, setMessages] = useState<T[]>([]) const [connected, setConnected] = useState(false) const [send, setSend] = useState<(d: any) => void>(() => () => {}) useEffect(() => { const ws = new ResilientWS(url) ws.on('open', () => setConnected(true)) ws.on('close', () => setConnected(false)) ws.on('message', (m) => setMessages(prev => [...prev, m])) setSend(() => (d: any) => ws.send(d)) return () => ws.close() }, [url]) return { messages, connected, send } } ``` ## 8. 选择:原生 WebSocket vs Socket.IO - **原生 + ResilientWS(上)**:协议轻量,控制完整 - **Socket.IO**:自带 fallback (polling) + room / namespace / ack, 但流量大,跨语言客户端少 - **SockJS**:纯 ws fallback - **Centrifugo**:高性能 ws 服务端 + 多语言客户端 后端 Python 用 FastAPI / Django Channels / Sanic 都好; Node 用 `ws` 库 / `socket.io`。 ## 9. 鉴权 WebSocket 没有标准 Authorization header(浏览器 API 不让设)。常见解: 1. **Token 作为 query**:`wss://...?token=xxx`。简单但 token 进 URL log。 2. **首条消息发 token**:连上后立刻 `ws.send({ auth: token })`, 服务端验证后才允许其它消息。 3. **Cookie**:浏览器自动带 Cookie,服务端从 Cookie 取 session。 跨域要 CORS 配。 ## 10. 调试 Chrome DevTools → Network → WS 标签: - 看每条消息的方向 / 内容 / 时间 - 看连接打开 / 关闭事件 - 看 ping / pong 是否正常 ## 踩过的坑 - 旧的 React Strict Mode 双调用 useEffect → 连两个 ws。要么用 ref 保存实例,要么 cleanup 函数里 close。 - 关掉 tab 时浏览器不会等 ws.close() 完成 → 服务端看到的是异常断开。 服务端不要 assume 客户端会优雅退出。 - 重连 storm:服务端挂了 → 1k 客户端同时重连 → 服务端起来又被打挂。 加抖动 + 上限 + 服务端限流。 - 心跳间隔 < 反代超时;nginx 默认 60s,心跳 25s 安全。
## 起因 Vue 2 用 Options API 多年: ```vue <script> export default { data() { return { count: 0 }; }, computed: { doubled() { return this.count * 2; }, }, methods: { inc() { this.count++; }, }, mounted() { console.log('mounted'); }, }; </script> ``` 清晰简单。但复杂组件痛点: - 同 feature 的 data / method / watcher 分散在不同 section - 跨组件复用 logic 难(mixin 有命名冲突) - TS 类型推导弱 Vue 3 Composition API + `<script setup>`: ```vue <script setup> import { ref, computed, onMounted } from 'vue'; const count = ref(0); const doubled = computed(() => count.value * 2); function inc() { count.value++; } onMounted(() => console.log('mounted')); </script> ``` 更接近 React Hook 思路。重构 6 个月经验: ## 主要变化 ```vue <!-- Options API --> <template><button @click="inc">{{ count }}</button></template> <script> export default { data() { return { count: 0 }; }, methods: { inc() { this.count++; } }, }; </script> <!-- Composition API --> <template><button @click="inc">{{ count }}</button></template> <script setup> import { ref } from 'vue'; const count = ref(0); function inc() { count.value++; } </script> ``` - `data()` → `ref()` / `reactive()` - `methods` → 普通 function - `computed` → `computed()` - `watch` → `watch()` / `watchEffect()` - lifecycle → `onMounted` / `onUnmounted` 等 - props → `defineProps()` - emit → `defineEmits()` ## ref vs reactive ```ts const count = ref(0); // primitive count.value++; console.log(count.value); const state = reactive({ count: 0, name: '' }); // object state.count++; state.name = 'bob'; ``` `ref` 需 `.value` 访问(template 中自动 unwrap)。 `reactive` 用 Proxy 包对象,访问无 .value。 主流偏好:**全用 ref**(一致性,避免混乱)。 对 object 用 ref({ ... })。 ## composable (Hook 等价) 跨组件复用 logic: ```ts // composables/useCounter.ts import { ref } from 'vue'; export function useCounter(initial = 0) { const count = ref(initial); const inc = () => count.value++; const dec = () => count.value--; return { count, inc, dec }; } // 任意组件 <script setup> import { useCounter } from '@/composables/useCounter'; const { count, inc } = useCounter(); </script> ``` 跟 React custom hook 概念一致,命名 `useXxx`。 替代 Vue 2 mixin(没命名冲突 + 类型推导好)。 ## TS 类型自动 ```vue <script setup lang="ts"> const props = defineProps<{ title: string; count?: number; }>(); const emit = defineEmits<{ (e: 'update', value: number): void; }>(); // props.title typed string // emit('update', 42) typed </script> ``` vs Options API: ```vue <script> export default { props: { title: { type: String, required: true }, count: Number, }, emits: ['update'], }; </script> ``` TS-style 简洁 + 类型自动。 ## reactivity 注意 ```ts const state = ref({ count: 0 }); // ❌ destructure 失 reactive const { count } = state.value; count++; // 不更新 view // ✅ toRefs const stateRefs = toRefs(state.value); const { count } = stateRefs; count.value++; ``` destructure reactive object 是常见坑。解决用 `toRefs`。 ## watch vs watchEffect ```ts // watch(显式 source) watch(count, (newVal, oldVal) => { console.log(`count changed: ${oldVal} → ${newVal}`); }); // watchEffect(auto-track deps) watchEffect(() => { console.log(`count: ${count.value}`); }); ``` watch 像 React useEffect with explicit dep。 watchEffect 自动追踪用到的 ref。 ## lifecycle ```ts import { onMounted, onUnmounted, onUpdated, nextTick } from 'vue'; onMounted(async () => { await fetchData(); }); onUnmounted(() => { cleanup(); }); ``` 直观跟 Options API mapping。 ## provide / inject (Context) ```ts // 父 import { provide, ref } from 'vue'; const theme = ref('dark'); provide('theme', theme); // 子(任意深度) import { inject } from 'vue'; const theme = inject('theme'); ``` 跟 React Context 类似。typed key: ```ts import { InjectionKey } from 'vue'; const themeKey: InjectionKey<Ref<string>> = Symbol(); provide(themeKey, theme); const theme = inject(themeKey); // typed Ref<string> ``` ## 项目迁移经验 Vue 2 → Vue 3 大型项目: 1. Vue Compat(Vue 2/3 兼容层)渐进迁移 2. component 一个一个改 Composition API(同 file 内可以 Options 跟 Composition 共存暂时) 3. mixin → composable 4. global API(Vue.use → app.use) 我们 100 component 项目 1 个月迁完。 ROI: - 代码量降 20-30%(去掉 boilerplate) - 复杂组件可读性大幅提升 - TS 类型推导显著强 ## 仍能用 Options API Vue 3 兼容 Options API(permanent)。 没必要强迫迁。新 component 用 Composition,老的稳定就放。 ## 与 React Hook 对比 | | Vue Composition | React Hook | |---|---|---| | reactivity | Proxy (auto) | manual setState | | effect deps | 自动 track | 手动 dep array | | 模板 | SFC (`<template>`) | JSX | | 性能 | 默认细粒度 | 默认 re-render component | | 学习 | 中 | 中(dep array 陡) | Vue reactivity 更"魔法" + 不用记 dep。 React 更显式但 boilerplate 多。 ## script setup 是关键 只用 Composition API 但没 `<script setup>` → 仍要 `setup()` function + return。冗余。 `<script setup>`(SFC 顶部)让所有 top-level binding 自动可用 template。 减少 50%+ boilerplate。 强烈推荐每 Vue 3 component 都用。 ## 与 Nuxt 3 Nuxt 3 是 Vue 3 的 meta-framework(Next.js 类比)。 auto-import composable / page-based routing / SSR 等。 新 Vue 项目几乎都 Nuxt 3。 ## 真实 case:复杂 form 老 Vue 2 form 组件(800 行 Options API): - 30+ field,复杂校验 - 跨 step state - watcher 一堆 迁 Composition + Pinia + Zod: - 300 行(-60%) - 校验集中 schema 内 - step 切换 logic 用 composable 复用 - TS 类型保证 最大改进:思路清晰。一个 file 内"这 feature 的所有相关 code 在一起" (不是 data / methods / computed / watch 跳跃读)。 ## 踩过的坑 1. **forgot .value**:`if (count > 5)` 永远 truthy(ref 对象 truthy)。 `count.value > 5`。template 内不需要 .value 容易混。 2. **reactive deep limit**:reactive 是浅 → 嵌套 plain object 不 trigger update。`ref(deepObject)` 或者 `reactive` 递归。 3. **lifecycle order**:onMounted 内调子组件 ref → 子还没 mount。 await nextTick() 或 onMounted nested。 4. **props 解构丢 reactivity**:`const { title } = defineProps()` title 是 snapshot。`const props = defineProps(); props.title` 保 reactive。Vue 3.5+ 支持解构保留 reactivity。 5. **TS 推导慢**:复杂 generic component 编译变慢。简化 generic 或 拆分 component。
## 起因 React 写多了开始审美疲劳:useState 一个数字、useEffect 一个 fetch、 useMemo 一堆 callback...... 想试试别的范式。Svelte 5 的卖点是 "编译时把响应式翻译成命令式代码"——bundle 极小,写法更接近原生 JS。 跟一个小项目(个人博客 + 评论系统)试了一周。 ## 装 ```bash npm create vite@latest myblog -- --template svelte-ts cd myblog npm i npm run dev ``` 或者 SvelteKit(带 SSR / routing / API routes): ```bash npx sv create myblog ``` ## 第一个组件 ```svelte <!-- Counter.svelte --> <script lang="ts"> let count = $state(0) let doubled = $derived(count * 2) function increment() { count++ } </script> <button onclick={increment}> {count} (doubled: {doubled}) </button> <style> button { padding: 8px 16px; border-radius: 4px; } </style> ``` `$state` 和 `$derived` 是 Svelte 5 引入的 "runes"——显式标记响应式 变量。 跟 React 对比: ```tsx const [count, setCount] = useState(0) const doubled = useMemo(() => count * 2, [count]) return ( <button onClick={() => setCount(c => c + 1)}> {count} (doubled: {doubled}) </button> ) ``` Svelte 优势: - `count++` 直接改,无需 setter - `doubled` 自动追踪依赖,无需 deps 数组 - `<style>` scoped 内置 - 模板更接近 HTML,无 className 之类 bundle 大小:上面 Svelte 组件编译后约 1KB;React 等价组件需要 React + ReactDOM ~45 KB。 ## $effect: 副作用 ```svelte <script> let count = $state(0) $effect(() => { console.log(`count changed to ${count}`) document.title = `Count: ${count}` }) </script> ``` `$effect` 类似 React useEffect 但**自动追踪**用到的响应式变量, 不需要 deps 数组。`count` 一变就重跑。 ## 父子通信:$props / $bindable ```svelte <!-- Child.svelte --> <script lang="ts"> let { name, count = $bindable(0) } = $props<{ name: string count: number }>() </script> <input bind:value={count} /> <p>{name}: {count}</p> ``` ```svelte <!-- Parent.svelte --> <script> let n = $state(0) </script> <Child name="counter" bind:count={n} /> <p>Parent sees: {n}</p> ``` `bind:` 是 Vue v-model 的等价物。`$bindable` 让 prop 双向。 ## 列表渲染 ```svelte <script> let items = $state([1, 2, 3]) function add() { items.push(items.length + 1) // ✅ 直接 push,Svelte 5 能追踪 } </script> {#each items as item, i (item)} <div>{i}: {item}</div> {/each} <button onclick={add}>add</button> ``` `(item)` 是 key(类似 React key)。 `{#each}` / `{#if}` / `{#await}` 是 Svelte 模板语法。 ## 异步:{#await} ```svelte <script> let promise = fetch('/api/users/1').then(r => r.json()) </script> {#await promise} <p>loading...</p> {:then user} <p>{user.name}</p> {:catch err} <p>error: {err.message}</p> {/await} ``` 直接在模板里处理 promise,不需要 useState + useEffect。 ## SvelteKit:File-based routing + SSR ``` src/routes/ ├── +page.svelte # / ├── about/+page.svelte # /about ├── posts/ │ ├── +page.svelte # /posts │ ├── +page.server.ts # 数据预取(server-only) │ └── [id]/+page.svelte # /posts/:id ``` ```ts // src/routes/posts/+page.server.ts export async function load() { const posts = await db.posts.findMany() return { posts } } ``` ```svelte <!-- src/routes/posts/+page.svelte --> <script> let { data } = $props() </script> {#each data.posts as post} <a href="/posts/{post.id}">{post.title}</a> {/each} ``` `load` 在服务端跑,结果通过 `data` prop 传给页面。 类似 Next.js getServerSideProps 但更轻量。 ## API routes ```ts // src/routes/api/posts/+server.ts import { json } from '@sveltejs/kit' export async function GET() { const posts = await db.posts.findMany() return json(posts) } export async function POST({ request }) { const data = await request.json() const post = await db.posts.create({ data }) return json(post, { status: 201 }) } ``` 文件即 API endpoint。 ## 状态管理 不需要 Redux / Zustand。直接 `$state` 在共享模块里: ```ts // src/lib/auth.svelte.ts export const auth = $state({ user: null as User | null, }) export async function login(email, pw) { const r = await fetch('/api/login', ...) auth.user = await r.json() } ``` 任何组件 import 后修改 `auth.user`,所有用到的地方自动更新。 ## bundle 大小对比 同一个 todo app: | | bundle (gzipped) | |---|---| | React + Redux | 78 KB | | React + Zustand | 52 KB | | Vue 3 | 41 KB | | **Svelte 5** | 11 KB | Svelte 编译模式让框架本身的运行时极小。 ## 性能 benchmark [js-framework-benchmark](https://krausest.github.io/js-framework-benchmark/) 显示 Svelte 在多数操作上比 React 快 1.5-3 倍(虽然 React 19 + compiler 之后差距缩小)。 ## 何时选 Svelte - 个人项目 / 小团队 / 喜欢简洁 - bundle size 关键场景(嵌入式 widget / PWA) - 已有 web 基础,不想被框架抽象绑死 何时选 React: - 大生态需求(组件库 / 招聘 / 大公司支持) - 已有 React 团队 - React Native 跨端 ## 缺点 1. **生态相对小**:组件库 / 工具链 / 教程都比 React 少 2. **招聘难**:Svelte 工程师比 React 少很多 3. **Svelte 5 runes 模式刚出**:4 → 5 迁移有小痛 4. **大型应用案例少**:Apple Music 等用了但还不算主流 ## 一周体验感受 - 写起来明显比 React 顺手(无 useState 仪式、无 deps 数组焦虑) - 编译后产物小到惊喜 - 但生态查个稍复杂的库选择少 / 文档少 - 对于个人博客 / 小工具站推荐;上生产前评估团队 / 长期维护成本 ## 踩过的坑 1. **Svelte 4 教程 / 文档不适用 Svelte 5**:`export let foo` → `let { foo } = $props()`, `$:` → `$derived`/`$effect`。教程要看 5.x。 2. **`$state` 必须在 script setup 顶层**:不能在条件 / 函数里。 3. **数组 / 对象的响应性**:`items.push(...)` Svelte 5 能追踪, 但深层嵌套 `items[0].nested.value = ...` 仍要小心。深层用 store。 4. **SSR + 浏览器 API**:`window.localStorage` 在 SSR 时报错。 `if (browser) { ... }` 包起来或 `onMount` 里访问。 5. **VSCode 插件**:要装官方 Svelte for VSCode + 语言服务器。 IntelliSense / 跳定义都依赖。
## 起因 要做一个 a11y 合规的 dropdown menu:键盘 navigation / focus trap / ARIA roles / Escape 关闭 / 点外面关闭 / 上下方向键循环 / Home/End 键 跳第一项末项 / 输入字符直接跳到对应项 / ... 每个细节单独写都 30-50 行 JS。完全无 bug 实施一个 dropdown 要 1 天。 不抽象怎么办——5 个组件就要 1 周。 Radix UI Primitives 把这些"a11y 行为正确" 的组件做成了 React component, **不带样式**,你用 Tailwind / CSS / 任何 styling 方案套外观。 ## 装 ```bash npm i @radix-ui/react-dropdown-menu npm i @radix-ui/react-dialog npm i @radix-ui/react-tooltip npm i @radix-ui/react-popover # 每个组件独立 package,按需装 ``` 或者一次性装常用的: ```bash npm i @radix-ui/themes # 带默认主题的全套 ``` ## Dropdown Menu 例子 ```tsx import * as DropdownMenu from '@radix-ui/react-dropdown-menu' function UserMenu() { return ( <DropdownMenu.Root> <DropdownMenu.Trigger asChild> <button>菜单 ↓</button> </DropdownMenu.Trigger> <DropdownMenu.Portal> <DropdownMenu.Content className="bg-white shadow-lg rounded-md p-1 min-w-[180px]" sideOffset={4} > <DropdownMenu.Item className="px-3 py-2 rounded hover:bg-gray-100 outline-none cursor-pointer" onSelect={() => navigate('/profile')} > 个人主页 </DropdownMenu.Item> <DropdownMenu.Item className="px-3 py-2 rounded hover:bg-gray-100" onSelect={() => navigate('/settings')} > 设置 </DropdownMenu.Item> <DropdownMenu.Separator className="h-px bg-gray-200 my-1" /> <DropdownMenu.Item className="px-3 py-2 rounded hover:bg-red-50 text-red-600" onSelect={logout} > 退出登录 </DropdownMenu.Item> </DropdownMenu.Content> </DropdownMenu.Portal> </DropdownMenu.Root> ) } ``` **没写任何 keyboard handler / focus trap / ARIA**,但 Radix 内部 全做好了: - ↑↓ 切换项目 - Home / End 跳首末 - Enter 选中 - Escape 关闭 - 点外面关闭 - 输入字符跳到对应项 - aria-expanded / aria-haspopup / aria-orientation 等 ARIA 全配 - focus 进入 / 离开管理 - screen reader 报"opened menu, 3 items, item 1 of 3" 业务代码只关心"我有哪些菜单项 + 点击做什么"。 ## Dialog 例子(合规 modal) ```tsx import * as Dialog from '@radix-ui/react-dialog' function DeleteConfirm({ onDelete }) { return ( <Dialog.Root> <Dialog.Trigger asChild> <button className="btn-danger">删除</button> </Dialog.Trigger> <Dialog.Portal> <Dialog.Overlay className="fixed inset-0 bg-black/50" /> <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg max-w-md"> <Dialog.Title className="text-lg font-bold">确认删除</Dialog.Title> <Dialog.Description className="text-sm text-gray-500 mt-1"> 此操作不可撤销。 </Dialog.Description> <div className="mt-4 flex gap-2 justify-end"> <Dialog.Close asChild> <button>取消</button> </Dialog.Close> <button onClick={onDelete} className="btn-danger">删除</button> </div> </Dialog.Content> </Dialog.Portal> </Dialog.Root> ) } ``` 内置: - focus trap(Tab 不出对话框) - ESC 关闭 - 点 overlay 关闭 - 自动 focus 第一可聚焦元素 - 关闭后焦点回到 trigger - 背景 inert / aria-hidden 屏蔽 - title / description 自动关联到对话框 aria-labelledby / aria-describedby 合规模态 5 行配置。 ## Tooltip / Popover ```tsx import * as Tooltip from '@radix-ui/react-tooltip' <Tooltip.Provider delayDuration={200}> <Tooltip.Root> <Tooltip.Trigger asChild> <button>?</button> </Tooltip.Trigger> <Tooltip.Portal> <Tooltip.Content className="bg-black text-white p-2 rounded text-sm" sideOffset={4}> 这是一段提示 <Tooltip.Arrow className="fill-black" /> </Tooltip.Content> </Tooltip.Portal> </Tooltip.Root> </Tooltip.Provider> ``` Tooltip 之类需要全局 Provider 控制 hover delay 一致。 ## Accordion ```tsx import * as Accordion from '@radix-ui/react-accordion' <Accordion.Root type="single" collapsible className="w-[400px]"> <Accordion.Item value="item-1"> <Accordion.Header> <Accordion.Trigger className="w-full text-left"> 什么是 React Hooks? </Accordion.Trigger> </Accordion.Header> <Accordion.Content> Hooks 是 React 16.8 引入的特性... </Accordion.Content> </Accordion.Item> <Accordion.Item value="item-2"> ... </Accordion.Item> </Accordion.Root> ``` `type='single'` 同时只展开一个;`type='multiple'` 多个。 键盘 Tab / Space / Enter 展开收起,箭头切项。 ## Switch / Checkbox / RadioGroup ```tsx import * as Switch from '@radix-ui/react-switch' <Switch.Root className="w-11 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-blue-500" checked={isDark} onCheckedChange={setIsDark} > <Switch.Thumb className="block w-5 h-5 bg-white rounded-full shadow translate-x-0.5 data-[state=checked]:translate-x-5 transition" /> </Switch.Root> ``` 完整 keyboard support + ARIA + 适合 screen reader 的 label 关联。 ## asChild 模式 很多 Radix 组件支持 `asChild`: ```tsx <DropdownMenu.Trigger asChild> <button>Click</button> {/* 把 trigger 的行为应用到这个 button */} </DropdownMenu.Trigger> // vs 默认(包一层 button) <DropdownMenu.Trigger>Click</DropdownMenu.Trigger> ``` `asChild` 让你用自己的元素(甚至自定义 styled component)作为 trigger, 不强制额外 DOM。React Aria / Headless UI 也类似。 ## 跟 shadcn/ui 关系 shadcn/ui = "Radix Primitives + Tailwind 样式 + 复制粘贴" 的预设。 直接: ```bash npx shadcn-ui@latest add dropdown-menu dialog tooltip ``` 代码进你的 `components/ui/`,底层就是 Radix。 省了"自己包样式" 这一步。 ## 跟 Headless UI / React Aria 对比 | | Radix UI | Headless UI | React Aria (Adobe) | |---|---|---|---| | 风格 | 较细粒度 primitive | 简洁 | 极完整(也最重) | | 组件数 | ~30 | 10+ | ~50 | | Tailwind 友好 | ✅ | ✅(同公司) | 中性 | | 学习曲线 | 中 | 低 | 中-高 | | a11y 严谨度 | 高 | 高 | 极高 | | bundle | 中(按需) | 小 | 中-大 | shadcn 默认用 Radix;个人偏好用 Headless UI(Tailwind 同公司)也很 好。 ## 实际效果 我们一个 React app 把所有 dialog / dropdown / popover / tooltip 改 Radix: - 之前自己实现 + 半成品 → bug 多 + 不一致 - 改 Radix 后 a11y 自动达标(axe-core lint 0 violation) - bundle 增加 ~30KB(5-6 个 primitive) - 团队 review focus 从"键盘怎么操作" → 只看业务逻辑 ## 几个建议 1. **不要从 Radix 自己包 100 个 wrapper**:用 shadcn 把 Radix + Tailwind 集成代码丢进自己 repo 直接改更灵活 2. **状态管理交给 Radix**:除非需要受控 (controlled mode),让 Radix 内部管 `open` state 3. **portal 默认开**:菜单 / dialog 进 portal 避免 overflow / z-index 父元素影响 4. **className 接受**:所有 primitive 都接 className + style, 尽情用 Tailwind / CSS Modules ## 踩过的坑 1. **DropdownMenu 不能 nested**:嵌套菜单要用 `DropdownMenu.Sub`, 不是再嵌一个 Root。 2. **Dialog 内嵌 Tooltip 不显示**:z-index / portal 父子关系问题。 Tooltip 也 portal 出去。 3. **`asChild` + Link**:React Router `<Link>` 已经是 `<a>`, 配 `asChild` 时不要重复包 `<a>`。 4. **server side rendering**:Radix 用 useId / 假 portal,SSR 时 小心 hydration mismatch。包 dynamic import / suppress hydration warning。 5. **bundle 一个 component 拉一堆 dep**:每个 Radix component 拉 `@radix-ui/react-primitive` 等 shared util。多组件用同样 base tree-shake 后实际不重复,但单独看 size 比想象大。
任何在主线程跑超过 50ms 的 JS 都会让交互掉帧。常见 culprit: - 大 JSON parse / stringify - markdown 渲染、syntax highlight - 客户端排序 / 过滤万条数据 - 图像 / 视频 / WASM 解码 正确做法:放 Web Worker。下面是从零到 production 的最小路径。 ## 1. 经典 Worker(自己管字符串) ```js // main.js const worker = new Worker('/worker.js') worker.onmessage = e => console.log('got:', e.data) worker.postMessage({ type: 'sort', payload: bigArray }) ``` ```js // worker.js self.onmessage = e => { if (e.data.type === 'sort') { const sorted = e.data.payload.sort((a, b) => a - b) self.postMessage(sorted) } } ``` 毛病:字符串 type、回调地狱、worker 文件路径在打包工具里难管。 ## 2. Vite + 模块 Worker(推荐) Vite 原生支持 `?worker` 后缀: ```ts // worker.ts self.onmessage = (e: MessageEvent<{ items: number[] }>) => { const sorted = e.data.items.sort((a, b) => a - b) self.postMessage(sorted) } export {} // 标记为 module ``` ```ts // main.ts import MyWorker from './worker.ts?worker' const worker = new MyWorker() worker.postMessage({ items: [3, 1, 2] }) worker.onmessage = e => console.log(e.data) ``` Vite 会把 worker.ts 单独打包,部署后是独立 .js 文件。 ## 3. Comlink:把 worker 当普通对象用 每次 postMessage / onmessage 写起来很烦。Comlink 把消息通信封装成 "调远程方法": ```bash npm i comlink ``` ```ts // worker.ts import { expose } from 'comlink' const api = { sort(items: number[]) { return items.sort((a, b) => a - b) }, async process(data: Row[]) { // 任意复杂同步 / 异步逻辑 return data.map(transform).filter(predicate) }, } export type Api = typeof api expose(api) ``` ```ts // main.ts import { wrap, Remote } from 'comlink' import Worker from './worker.ts?worker' import type { Api } from './worker' const worker = new Worker() const api: Remote<Api> = wrap<Api>(worker) // 用起来像普通对象 const sorted = await api.sort([3, 1, 2]) const processed = await api.process(rows) ``` 代码主线非常清晰,类型完整。Comlink 内部还是 postMessage,但你不需要管。 ## 4. Transferable:避免大数据拷贝 `postMessage` 默认深拷贝传过去。对 ArrayBuffer / ImageBitmap / OffscreenCanvas 可以用 transfer,把所有权直接交给 worker(O(1) 操作): ```ts // main const buf = new Float32Array(10_000_000).buffer worker.postMessage({ buf }, [buf]) // buf 这边变成 length=0,不能再用 // worker self.onmessage = e => { const arr = new Float32Array(e.data.buf) // ... 处理 self.postMessage({ result: arr.buffer }, [arr.buffer]) } ``` 对于几十 MB 以上的数据,transfer vs 拷贝差距可以从几秒到 0。 ## 5. SharedArrayBuffer:多 worker 共享同一块内存 需要服务端发 COOP / COEP header: ``` Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp ``` 否则现代浏览器禁用 `SharedArrayBuffer`(Spectre 缓解)。 ```ts const shared = new SharedArrayBuffer(1024 * 1024) const view = new Int32Array(shared) worker1.postMessage({ buf: shared }) worker2.postMessage({ buf: shared }) // 三方都看到同一块内存;用 Atomics.* 同步 ``` ## 6. Worker Pool CPU 任务很多时一个 worker 不够,开 N 个: ```ts class Pool<T extends object> { private workers: Remote<T>[] private next = 0 constructor(n: number, factory: () => Remote<T>) { this.workers = Array.from({ length: n }, factory) } call<K extends keyof T>(method: K, ...args: any[]) { const w = this.workers[this.next++ % this.workers.length] return (w[method] as any)(...args) } } const pool = new Pool( navigator.hardwareConcurrency || 4, () => wrap<Api>(new Worker()) ) await pool.call('sort', items) ``` navigator.hardwareConcurrency 是浏览器报的核心数,通常等于物理核数。 ## 7. 不要把 React 状态搬进 Worker worker 没有 DOM,没法 React。它的工作是 **纯计算 + 返回数据**。 返回数据后主线程拿到才能 setState / 渲染。 ## 8. 调试 Chrome DevTools 的 Sources 面板能看到 worker 上下文(左侧 frames 列表), 断点、step、查变量都正常。 ## 踩过的坑 - worker 内不能用 `window`、`document`、`localStorage`(IndexedDB 可以); 共享代码模块要避免引用这些。 - Worker 启动有 50-100ms 开销。一次性短任务(< 10ms)放主线程更快。 - worker.terminate() 立即杀死 worker(不让它清理)。优雅退出应让 worker 接收一个 "shutdown" 消息后 close。 - Safari 对 module worker 支持比较晚(15.4+),需要确认目标用户。 Vite 编译目标设 `es2017` 时可能自动 fallback 到 classic worker,注意区分。
## 起因 要把一个英文站点国际化到中文 + 日文。 "naive"做法:`if (lang === 'zh') return '你有 ' + n + ' 条消息'`。 但碰到: - 复数:英文 "1 message" vs "5 messages";俄文 4 种复数形式 - 日期:1/2/2024 vs 2024-01-02 vs 2024年1月2日 - 货币:$1,234.56 vs ¥12,345.6 - 嵌入组件:`'<b>张三</b> 发了 <a href=...>3 条评论</a>'` 怎么拆? ICU MessageFormat 是 Unicode 联盟标准,处理这些场景。`react-intl` (FormatJS)是 React 的标准 ICU 实现。 ## 解决方案 ### 装 ```bash npm i react-intl ``` ### 顶层 Provider ```tsx import { IntlProvider } from 'react-intl' const messages = { en: { 'msg.greeting': 'Hello, {name}!' }, zh: { 'msg.greeting': '你好,{name}!' }, ja: { 'msg.greeting': '{name}さん、こんにちは!' }, } function App() { const [locale, setLocale] = useState('en') return ( <IntlProvider locale={locale} messages={messages[locale]}> <MyApp /> </IntlProvider> ) } ``` ### 用 `<FormattedMessage>` 或 `intl.formatMessage` ```tsx import { FormattedMessage, useIntl } from 'react-intl' function Greeting({ user }) { return ( <h1> <FormattedMessage id="msg.greeting" values={{ name: user.name }} /> </h1> ) } // 或 hook function Title({ name }) { const intl = useIntl() const text = intl.formatMessage({ id: 'msg.greeting' }, { name }) return <title>{text}</title> } ``` ### ICU MessageFormat 复数 ```ts const messages = { en: { 'comments.count': '{count, plural, =0 {No comments} one {1 comment} other {# comments}}' }, zh: { 'comments.count': '{count, plural, =0 {暂无评论} other {# 条评论}}' }, ru: { 'comments.count': '{count, plural, =0 {Нет комментариев} one {# комментарий} few {# комментария} many {# комментариев} other {# комментариев}}' }, } <FormattedMessage id="comments.count" values={{ count: 5 }} /> ``` `#` 自动替换成数字。 不同语言的复数规则: - 英文:one (1) / other (其它) - 中文:other - 俄文:one / few / many / other - 阿拉伯:zero / one / two / few / many / other 库里有 CLDR 数据自动应用。 ### Select(按值选不同文案) ```ts 'user.role': '{role, select, admin {管理员} editor {编辑} viewer {访客} other {未知}}' ``` ### 嵌入 React 组件 ```tsx const messages = { zh: { 'notif.commented': '<b>{user}</b> 评论了你的 <a>帖子</a>', }, } <FormattedMessage id="notif.commented" values={{ user: 'Alice', b: chunks => <strong>{chunks}</strong>, a: chunks => <a href={`/posts/${id}`}>{chunks}</a>, }} /> // 输出:<strong>Alice</strong> 评论了你的 <a href="/posts/1">帖子</a> ``` 翻译里的标签会调对应 function。完全保留 React 组件 + 事件 + 链接。 ### 日期 / 时间 / 货币 ```tsx import { FormattedDate, FormattedTime, FormattedNumber } from 'react-intl' <FormattedDate value={new Date()} year="numeric" month="long" day="numeric" /> // en: January 15, 2024 // zh: 2024年1月15日 // ja: 2024年1月15日 <FormattedTime value={new Date()} hour="numeric" minute="numeric" /> <FormattedNumber value={1234.5} style="currency" currency="USD" /> // $1,234.50 <FormattedNumber value={1234.5} style="currency" currency="CNY" /> // ¥1,234.50 <FormattedNumber value={0.85} style="percent" /> // 85% ``` 底层 `Intl.DateTimeFormat` / `Intl.NumberFormat`,浏览器原生。 ### 相对时间 ```tsx import { FormattedRelativeTime } from 'react-intl' <FormattedRelativeTime value={-2} unit="hour" /> // en: 2 hours ago // zh: 2 小时前 ``` ### 翻译文件管理 每个 locale 一个 JSON: ``` src/locales/ ├── en.json ├── zh.json └── ja.json ``` ```json // en.json { "msg.greeting": "Hello, {name}!", "comments.count": "{count, plural, =0 {No comments} one {1 comment} other {# comments}}" } ``` 按需加载: ```tsx async function loadMessages(locale: string) { return (await import(`./locales/${locale}.json`)).default } function App() { const [locale, setLocale] = useState('en') const [messages, setMessages] = useState({}) useEffect(() => { loadMessages(locale).then(setMessages) }, [locale]) return <IntlProvider locale={locale} messages={messages}>...</IntlProvider> } ``` 不同语言不打进 main bundle,按需 lazy load。 ### 提取翻译 key ```bash npx @formatjs/cli extract 'src/**/*.{ts,tsx}' --out-file lang/en.json --format simple ``` 扫描代码里所有 `FormattedMessage` / `formatMessage` 调用,提取 key + defaultMessage。翻译团队基于这个文件翻译。 ### 用 Crowdin / Lokalise / POEditor 把 en.json 上传 → 译员翻译 → 下载 zh.json / ja.json。 专业翻译工具有翻译记忆 / 协作 / 校对功能。 ### TypeScript 类型安全 ```bash npm i -D @formatjs/cli ``` ```ts // auto-generated.ts type Messages = | 'msg.greeting' | 'comments.count' // ... declare module 'react-intl' { interface FormattedMessageProps { id: Messages } } ``` 写错 id 编译报错。 ## 实战 tip ### 1. defaultMessage ```tsx <FormattedMessage id="msg.greeting" defaultMessage="Hello, {name}!" values={{ name }} /> ``` `defaultMessage` 在 dev 时显示(避免 key 缺失看到 `[msg.greeting]`), 在 extract 时被收进 source 文件。 ### 2. 不要 concat 字符串 ```tsx // ❌ 永远不要 <>{intl.formatMessage({ id: 'a' })} {intl.formatMessage({ id: 'b' })}</> // ✅ 一条 message 一句话 'msg.fullName': '{first} {last}' // 在 message 里组合 ``` 某些语言(日文)语序不一样,concat 永远不能正确翻译。 ### 3. 上下文 (context) 同样英文 "Open" 可能是按钮 / 状态 / 动词,翻译不同: ```json "button.open": "Open", "status.open": "Open", ``` 加注释让译员理解: ```tsx <FormattedMessage id="button.open" description="Button to open a file picker" defaultMessage="Open" /> ``` extract 出来 description 给译员看。 ## 效果 - 3 种语言(en/zh/ja)全覆盖 + 复数 + 日期 + 货币正确 - 翻译团队用 Crowdin 协作,开发不参与翻译细节 - bundle 按 locale 分割,每个 locale 增加 ~20KB - 加新语言只需添 JSON + locale 列表 + 设置 default 即可 ## 替代品 - **i18next**(react-i18next):更老牌、生态大、社区资源多 - **lingui**:Babel macro 转译,运行时几乎无开销 - **next-intl**:Next.js App Router 原生集成 i18next 与 react-intl 哪个好争论不断。我选 react-intl 因为 ICU 标准 + FormatJS 团队(Intl 提案推动者)维护。 ## 踩过的坑 1. **复数规则用错语法**:英文写 `{count} comments` 而不是 ICU `{count, plural, ...}`。后者才能让翻译灵活。 2. **直接 hard-code 日期格式**:`new Date().toLocaleDateString('en')` 只能英文。永远 `FormattedDate` 才跟随当前 locale。 3. **RTL 语言(阿拉伯 / 希伯来)布局**:仅翻字符串不够,CSS 也要 适配。`dir="rtl"` + `logical properties`(margin-inline-start 而非 margin-left)。 4. **lazy load 时第一次渲染缺译文**:fallback 显示 `[msg.greeting]` 是糟糕体验。加载完成前用 default locale 兜底,或显示骨架。 5. **翻译里有 HTML 注入风险**:译员误打了 `<script>` 进去会被解析。 ICU `<tag>` 必须在代码里映射;不在映射里的会被原样输出(HTML 实体 escape)。
媒体查询(@media)按视口尺寸响应式。但同一个组件可能放在不同宽度的 容器里(侧栏窄 / 主区宽 / 仪表盘卡片各种尺寸),媒体查询不知道 组件实际可用宽度。 Container Queries(容器查询)让组件按 **父容器** 尺寸响应式。 2023 后全 evergreen 浏览器支持,可以放心用。 ## 1. 启用容器 ```css .card-container { container-type: inline-size; /* 或者 size(同时观察宽 + 高) */ container-name: card; /* 可选,命名容器便于精确引用 */ } ``` `container-type: inline-size` 让浏览器观察这个元素的宽度变化, 开销小(不需要观察高度)。 ## 2. 容器查询 ```css @container card (min-width: 400px) { .card-title { font-size: 1.5rem; } .card-image { display: block; } } @container card (min-width: 600px) { .card { display: grid; grid-template-columns: 200px 1fr; } } ``` `@container card` 引用前面命名为 "card" 的容器。 不写名字也行:`@container (min-width: 400px)` 用最近的祖先容器。 ## 3. 完整例子:自适应卡片 ```html <div class="grid"> <div class="card-wrap"> <article class="card"> <img src="thumb.jpg" alt=""> <div> <h3>标题</h3> <p>描述...</p> </div> </article> </div> <div class="card-wrap"> <article class="card">...</article> </div> </div> ``` ```css .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; } .card-wrap { container-type: inline-size; } .card { display: flex; flex-direction: column; gap: 8px; } .card img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } /* 容器够宽时改横向布局 */ @container (min-width: 360px) { .card { flex-direction: row; } .card img { width: 120px; aspect-ratio: 1; flex-shrink: 0; } } ``` 视口窄时卡片竖排;视口宽时容器宽度 > 360px,自动横排。 **同一组件 + 同一 CSS 在不同上下文行为不同**。 ## 4. 容器查询单位 CSS 出了 `cqw` / `cqh` / `cqi` / `cqb` 等单位,相对于容器尺寸: ```css .card-title { font-size: clamp(1rem, 4cqi, 2rem); } ``` `4cqi` = 4% 容器 inline 尺寸。容器越宽字体越大,但限制在 1-2rem 之间。 ## 5. style queries(实验性) 按容器的某个 CSS 自定义属性查询: ```css .theme-dark { --mode: dark; container-type: normal; } @container style(--mode: dark) { .card { background: #1e1e1e; color: #fff; } } ``` 适合切主题不影响组件 HTML 结构。仍是实验阶段,部分浏览器支持。 ## 6. 与媒体查询配合 媒体查询管页面布局(侧栏开关、导航形态),容器查询管组件内部。 分工清晰: ```css /* 媒体查询:响应视口 */ @media (max-width: 800px) { .layout { grid-template-columns: 1fr; } .sidebar { display: none; } } /* 容器查询:响应组件可用空间 */ @container card (min-width: 400px) { .card { ... } } ``` ## 7. 命名 vs 匿名容器 匿名(不写 container-name)查询最近的祖先 container: ```css .parent { container-type: inline-size; } @container (min-width: 500px) { .child { ... } } ``` 命名让你跨层级精确引用: ```css .page { container: page / inline-size; } .card { container: card / inline-size; } @container page (min-width: 1000px) { /* 引用 page 容器 */ } @container card (min-width: 400px) { /* 引用 card 容器 */ } ``` ## 8. polyfill / 回退 老浏览器不支持时降级: ```css .card { /* 默认(窄屏 / 不支持时的样子) */ flex-direction: column; } @container (min-width: 360px) { .card { flex-direction: row; } } /* 或者 @supports 兜底 */ @supports not (container-type: inline-size) { /* 不支持容器查询的浏览器用媒体查询近似 */ @media (min-width: 600px) { .card { flex-direction: row; } } } ``` ## 9. 性能 `container-type: inline-size` 让浏览器为这个元素建立 containment context。 开销很小(不重排不重绘),但避免无脑给所有元素加。 通常每个独立组件根加一个就好。 `container-type: size`(同时观察宽 + 高)更贵些,因为元素的高度 通常由内容决定,会创建一个潜在的"无限循环"风险。 ## 10. 实际收益 之前没容器查询时,常见 hack: - 给容器加 class(`.card--wide` / `.card--narrow`)→ 业务代码要知道布局 - ResizeObserver + JS 控制 → 跨框架不一致 + 性能差 - 多套 CSS 类按 prop 切换 → 难维护 容器查询是这些痛点的官方解。组件 truly self-contained。 ## 11. 工具支持 Tailwind CSS v3.4+ 有 container queries plugin: ```html <div class="@container"> <div class="@md:flex @lg:grid">...</div> </div> ``` UI 库(shadcn / Mantine)渐渐采纳。 ## 踩过的坑 - 自己引用自己:`.card { container-type: inline-size; }` 然后 `@container (min-width: ...) .card { width: ... }` —— 改 width 会 触发容器尺寸变化 → 触发条件再判断 → 死循环。浏览器有保护但视觉上抖。 - 父子嵌套容器名相同:第二个 container-name 覆盖第一个,意外查询。 跨层 query 务必命名清楚。 - height container query 慎用:必须 `container-type: size` 且容器有 确定高度(不能完全由内容撑开)。 - 把 container-type 加到 body 上 → 全局影响,性能可能下降。粒度 控制在组件根元素。