知识广场
按学科筛选:计算机科学 / 前端开发
«计算机科学 / 前端开发» 分类下共 52 篇帖子
## 起因 SPA 路由切换 instant 但生硬: - click link → 内容直接换 - 没有 native app 那种"页面滑入"感觉 要做平滑过渡过去要用 framer-motion / GSAP / react-transition-group 等, JS 重 + 配置 + 难做"shared element transition"(同一元素跨页面渐变到 新位置)。 `View Transitions API`(Chrome 111+,Safari 18+)让浏览器原生做这事: ```js document.startViewTransition(() => { updateDOM(); // 你只管 update DOM }); // 浏览器自动 fade(默认) ``` 3 行启用,CSS 控制效果。 ## 基本 fade ```js function navigate(url) { if (!document.startViewTransition) { return loadPage(url); // fallback } document.startViewTransition(() => loadPage(url)); } ``` 浏览器拍 snapshot → DOM 改 → 自动 crossfade。 ## 自定义动画 CSS: ```css ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 300ms; } ::view-transition-old(root) { animation: slide-out 300ms; } ::view-transition-new(root) { animation: slide-in 300ms; } @keyframes slide-out { to { transform: translateX(-100%); } } @keyframes slide-in { from { transform: translateX(100%); } } ``` 浏览器把"老" 和"新" 都当独立的 pseudo-element animate。 slide 效果。 ## shared element transition 同一元素从 page A 滑到 page B 新位置: ```css /* page A */ .hero-image { view-transition-name: hero; } /* page B */ .detail-image { view-transition-name: hero; } ``` `view-transition-name` 相同的两个元素 → 浏览器自动 morph 过去(位置 + 大小)。 效果:list 页 thumb 点击后展开到 detail 页大图的位置 → 流畅滑动。 native app 这是常见效果,web 历来很难做。 ## 跨文档 (MPA) 也行 ```css /* old behavior: SPA only */ @view-transition { navigation: auto; } ``` 加这个 → 普通 `<a href>` 跳转也自动有 view transition。 MPA / 服务端渲染网站直接得到 SPA 般体验。 Chrome 126+ 支持,Safari 18+。 ## 实战 example:image gallery ```html <!-- list page --> <ul> <li><a href="/photo/1"><img src="thumb1.jpg" class="thumb-1"></a></li> <li><a href="/photo/2"><img src="thumb2.jpg" class="thumb-2"></a></li> </ul> <!-- detail page /photo/1 --> <img src="full1.jpg" class="thumb-1"> ``` ```css .thumb-1 { view-transition-name: photo-1; } .thumb-2 { view-transition-name: photo-2; } @view-transition { navigation: auto; } ``` 点 list 缩略图 → 滑到 detail 页大图位置(同 `view-transition-name` morph)。 50 行 CSS / 0 行 JS framework 出 native app 体验。 ## SPA framework 集成 SvelteKit: ```js // app.html / hook import { onNavigate } from '$app/navigation'; onNavigate((navigation) => { if (!document.startViewTransition) return; return new Promise((resolve) => { document.startViewTransition(async () => { resolve(); await navigation.complete; }); }); }); ``` Next.js (with App Router 14+) 计划 next-view-transitions package 集成。 Astro 4 内置 view transitions(最早接入的 SPA framework)。 ## 自定义动画 token ```css ::view-transition-group(*) { animation-duration: 200ms; animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } ::view-transition-old(*) { animation-name: fade-out; } ::view-transition-new(*) { animation-name: fade-in; } @keyframes fade-out { to { opacity: 0; } } @keyframes fade-in { from { opacity: 0; } } ``` `*` 是所有 view-transition-name,统一应用。 ## reduce motion ```css @media (prefers-reduced-motion: reduce) { ::view-transition-old(*), ::view-transition-new(*) { animation: none; } } ``` 尊重无障碍偏好,关闭动画。 ## fallback ```js async function navigate(url) { const update = () => loadPage(url); if (document.startViewTransition) { document.startViewTransition(update); } else { update(); } } ``` 老浏览器(Firefox 现在还不支持)→ 直接换不动画。 不需要 polyfill,平滑降级。 ## debug Chrome DevTools 有 view transition debugger: - Animation panel 看 frames - Performance panel 看 transition cost 慢的话查: - snapshot 渲染贵 - 动画太多并行 ## 性能 view transition 是 GPU 加速(compositor 层),60 fps 容易。 比 React state 切换 + CSS transition 效率高。 但 snapshot 大 element(整页)也有成本。 > 500ms 不算 view transition 适合场景。 ## 与 framer-motion 对比 | | View Transitions API | framer-motion | |---|---|---| | 配置 | CSS + 几行 JS | JS 重 | | 大小 | 0 (原生) | ~30 KB | | shared element | 简单 | 复杂 (AnimatePresence) | | 兼容 | Chrome / Safari (Firefox no) | 全部 | | 灵活度 | 中 | 极高 | simple fade / slide / shared element → View Transitions API。 复杂交互(drag / gesture / spring physics)→ framer-motion。 ## 真实 case:内容站 我们一个博客 + 文档站,加了 5 行 CSS(`@view-transition: navigation: auto` + hero 图 view-transition-name)。 效果: - 点击文章卡片 → 卡片 morph 到文章详情 header - 切换文档章节 → 内容 fade - 滑动手感像 native app - bundle 增加 0(纯 CSS / 浏览器原生) - code 改动几十行 用户反馈:"站点感觉变快了"(实际加载没变,但平滑过渡心理 perceived performance 提升)。 ## 不适合的场景 - 复杂物理动画(spring / momentum)→ 用 framer / GSAP - 需要 user gesture(drag / pinch)→ 用 framer - 极致性能场景(动画 60fps + 5+ 大元素并发) ## 踩过的坑 1. **`view-transition-name` 必须 unique**:两个元素同名同时存在 → 只 transition 一个。dynamic list 用 unique id 后缀。 2. **width/height 变化 morph 怪**:position absolute 大小变化时 transform 计算偏差。试 `width: auto` 与 `aspect-ratio` 配合。 3. **dark mode 切换不平滑**:toggle dark mode 时 view transition → crossfade 整页。慢设备卡。考虑 disable。 4. **图片 loading**:transition 时新图还没加载 → 闪。preload 关键 img。 5. **嵌套 transition**:transition 期间 trigger 另一 transition → undefined behavior。debounce / sequence。
React 新手最常踩的"过度优化"就是给所有函数 / 计算包 `useMemo` `useCallback`。 官方文档 2024 之后已经明确:**默认不要用,先 profile**。 但是有几个场景必须用。下面分清楚。 ## 默认不要用的理由 `useMemo` 本身要执行: 1. 调用 `useMemo` 创建 hook entry 2. 比较依赖数组(浅比较) 3. 决定是否复用 这些开销 > 大多数同步函数的执行成本。所以对一个"加两个数字"包 useMemo, 反而更慢。 ## 真正需要 useMemo 的场景 ### 场景 1:计算确实贵(毫秒级以上) ```tsx function ChartView({ rawData }: { rawData: Row[] }) { // 大量数据点排序 + 重计算 const processed = useMemo(() => { return rawData .map(row => ({ ...row, score: complexCalc(row) })) .sort((a, b) => b.score - a.score) .slice(0, 100) }, [rawData]) return <Chart data={processed} /> } ``` 判断标准:profile 看到这块 > 16ms(一帧)就有必要。 ### 场景 2:作为 `useEffect` / `useMemo` / `useCallback` 的依赖 ```tsx function Form({ schema }: { schema: Schema }) { // 不 memo 的话每次渲染 validator 都是新对象,下面 useEffect 每次都跑 const validator = useMemo(() => createValidator(schema), [schema]) useEffect(() => { validator.subscribe(handleValid) return () => validator.unsubscribe(handleValid) }, [validator]) } ``` 这是 useMemo 最常见的合理用途——稳定依赖。 ### 场景 3:传给 memoized 子组件 ```tsx const SearchResults = React.memo(function SearchResults({ items }: ...) { // ... }) function Page() { const [query, setQuery] = useState('') // items 每次都是新数组 → React.memo 浅比较失败 → 子组件每次都重渲染 // 加 useMemo 之后只有 query 变了才重新生成 items const items = useMemo(() => filterItems(allItems, query), [query, allItems]) return <SearchResults items={items} /> } ``` 如果子组件没 `React.memo` 包裹,这个 useMemo 完全无效——白干。 ## useCallback 类似 ```tsx function Parent() { const [count, setCount] = useState(0) // ❌ 没必要:onClick 每次都是新引用但 Button 没 memo const onClick = useCallback(() => setCount(c => c + 1), []) return <Button onClick={onClick} /> } ``` `useCallback` 仅在以下两种情况有意义: 1. 函数作为 `useEffect` / `useMemo` 的依赖 2. 函数传给 `React.memo` 的子组件 ## 反模式集合 ### ❌ 给基础类型包 useMemo ```tsx const sum = useMemo(() => a + b, [a, b]) // 直接: const sum = a + b // 一样快 ``` ### ❌ 包内联对象 / 数组(除非传给 memo 子) ```tsx const style = useMemo(() => ({ color: 'red' }), []) // 直接: const style = { color: 'red' } // 多创建一个对象,但 GC 几乎免费 ``` ### ❌ "为了未来扩展" 提前 useMemo 写代码时不要"也许将来子组件会 React.memo 所以现在先包"。等真有需要再包。 ## 测量工具 React DevTools → Profiler → Record。看哪个组件渲染 > 5ms 或者 "unnecessarily" 标红。 Chrome DevTools → Performance → Record。看主线程任务,> 50ms 就是 long task,需要优化。 ## React 19 的 React Compiler React 19+ 带的 React Compiler 会在编译期自动决定哪些值需要 memo, **届时手写 useMemo / useCallback 几乎可以全删**。 提前为这个未来准备: 1. 不要为了 memo 而引入"复杂的依赖追踪"。代码清晰更重要,性能用编译器 2. 现有的 useMemo / useCallback 可以保留,编译器会忽略它们 ## 一个完整反例 ```tsx // 全是反优化 function UserCard({ user }: { user: User }) { const name = useMemo(() => user.name, [user]) // ❌ const onClick = useCallback(() => alert(user.id), [user]) // ❌(没 memo 子) const style = useMemo(() => ({ padding: 12 }), []) // ❌ return ( <div style={style} onClick={onClick}> <strong>{name}</strong> </div> ) } ``` 清版: ```tsx function UserCard({ user }: { user: User }) { return ( <div style={{ padding: 12 }} onClick={() => alert(user.id)}> <strong>{user.name}</strong> </div> ) } ``` 后者更短、更快、更易读。 ## 踩过的坑 - 给 useMemo 的依赖数组写错(漏依赖 / 多依赖)是 React 最常见 bug, 装 `eslint-plugin-react-hooks` 让 `exhaustive-deps` 规则帮你查。 - 复杂表单的 onChange 用 useCallback 包并传给 memoized Input 子组件—— 看起来对,但 input 上的 onChange 引用是否稳定通常不是性能瓶颈。 先 profile 再优化。 - 把 `useState` 的 setter 写进 useCallback 的依赖数组:setter 永远稳定, 写了不影响但说明你没理解 setter 的行为。可以省略。
## 起因 React 组件里调 API 老方法: ```jsx useEffect(() => { setLoading(true); fetch('/api/posts') .then(r => r.json()) .then(setPosts) .finally(() => setLoading(false)); }, []); ``` - 没 cache,组件 mount 都重 fetch - 没 retry / refetch - 没共享:两个组件用同 endpoint 各 fetch 一次 - 没 stale-while-revalidate - 错误处理累 `TanStack Query` (前 React Query) / `SWR` 解决这些。 ## SWR (Vercel) ```jsx import useSWR from 'swr'; const fetcher = (url) => fetch(url).then(r => r.json()); function Posts() { const { data, error, isLoading } = useSWR('/api/posts', fetcher); if (isLoading) return <Spinner />; if (error) return <Error />; return <List items={data} />; } ``` 简单 API:URL = cache key,fetcher 函数任意。 ### 优势 - API 极简 - bundle 小(4 KB) - Vercel 友好(Next.js 推荐) - focus revalidation 默认(窗口聚焦自动 refetch) ### 劣势 - mutation API 较弱 - 复杂场景能力上限低 - 文档比 TanStack Query 少 ## TanStack Query ```jsx import { useQuery } from '@tanstack/react-query'; function Posts() { const { data, error, isLoading } = useQuery({ queryKey: ['posts'], queryFn: () => fetch('/api/posts').then(r => r.json()), }); if (isLoading) return <Spinner />; if (error) return <Error />; return <List items={data} />; } ``` API 类似但更"框架化"。 ### 优势 - 功能极完整(pagination / infinite / mutation / optimistic update) - devtools 强(专门 query inspector) - 框架无关(react / vue / solid / svelte 都有版本) - 大型项目首选 ### 劣势 - bundle 大(13 KB gzip) - 概念多(queryClient / mutation / invalidation) - 学习曲线高于 SWR ## 共享 cache 两者都按 key cache: ```jsx // 组件 A useSWR('/api/posts'); // 组件 B(同 URL) useSWR('/api/posts'); // → 只 fetch 一次,共享 data ``` TanStack Query 同理(按 queryKey)。 跨组件状态共享解决了,不需要 Redux 把 server state 塞 store。 ## stale-while-revalidate ``` 1. mount → 显 cached data (instant) 2. 后台 refetch 3. 新 data 到 → 静默 update ``` 用户感觉"立刻有数据" + 后台保持最新。 两者都默认 SWR 行为。 可配 `staleTime`:< staleTime 不 refetch(节省请求): ```jsx useQuery({ queryKey: ['posts'], queryFn, staleTime: 5 * 60 * 1000, // 5 min 内不重新 fetch }); ``` ## mutation (TanStack) ```jsx const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (newPost) => fetch('/api/posts', { method: 'POST', body: JSON.stringify(newPost) }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['posts'] }); }, }); function NewPostForm() { return <button onClick={() => mutation.mutate({ title: 'hi' })}>Save</button>; } ``` mutation 完后 invalidate `['posts']` query → 自动 refetch。 ## optimistic update ```jsx useMutation({ mutationFn: deletePost, onMutate: async (postId) => { await queryClient.cancelQueries({ queryKey: ['posts'] }); const prev = queryClient.getQueryData(['posts']); queryClient.setQueryData(['posts'], (old) => old.filter(p => p.id !== postId)); return { prev }; }, onError: (err, postId, ctx) => { queryClient.setQueryData(['posts'], ctx.prev); // rollback }, }); ``` UI 立刻显示删除 → API 失败 → 自动 rollback。 专业级 UX,TanStack Query 内置 helper。 SWR 也能做但要手动。 ## pagination ```jsx const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 1 }) => fetch(`/api/posts?page=${pageParam}`).then(r => r.json()), getNextPageParam: (lastPage) => lastPage.next_page, initialPageParam: 1, }); ``` `fetchNextPage` 加载下一页,data 累积。 infinite scroll 一行代码。 SWR 的 `useSWRInfinite` 类似但 API 略别扭。 ## prefetching ```jsx // hover 时预拉数据 function PostLink({ postId }) { const queryClient = useQueryClient(); return ( <a onMouseEnter={() => queryClient.prefetchQuery({ queryKey: ['post', postId], queryFn: () => fetch(`/api/posts/${postId}`).then(r => r.json()), })} > ... </a> ); } ``` 用户点链接前已经预 fetch → 点击 = 立刻有数据。 ## devtools TanStack Query devtools: ```jsx import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; <> <App /> <ReactQueryDevtools /> </> ``` floating 浮窗看所有 active query / state / cache / refetch。 debug 神器。 SWR devtools 弱很多。 ## 选择 - **简单项目 / 几个 query** → SWR - **复杂应用 / 多 mutation / 需 devtools** → TanStack Query - **Next.js + SWR 风格** → SWR - **跨框架** → TanStack Query(React/Vue/Svelte/Solid 都行) 我的默认是 TanStack Query(功能完整 + 不会后悔)。 ## 与 RTK Query 对比 RTK Query (Redux Toolkit): - Redux 圈子的查询库 - 跟 Redux store 集成深 - 学习曲线更陡 已经用 Redux → RTK Query 自然。 没用 Redux → TanStack Query / SWR 更轻。 ## Server Components 时代 React 19 + Next.js App Router 倾向 Server Components 直接 fetch: ```tsx // Server Component async function Posts() { const posts = await fetch('https://...').then(r => r.json()); return <List items={posts} />; } ``` server-side 数据获取不需要 query 库。 但 client-side mutation / interactive 数据还是需要 TanStack Query / SWR。 混用:server fetch 初始 → client query 后续更新。 ## 真实 case 我们一个 dashboard 用 TanStack Query: - 20+ query (各种 metric / list) - 10+ mutation - 大量 prefetch (hover 看 detail) - 自动 refetch on window focus(用户回来看最新) 效果: - delete useEffect / useState 50% - 用户体验显著(看不到 loading spinner,always cached) - bundle +13 KB but worth 之前手写 useEffect + Context:500 行 boilerplate。 TanStack Query:100 行 query / mutation definition。 ## 踩过的坑 1. **queryKey 写错**:写 string 而非 array → cache 不 share 或者错 share。永远 array:`['posts', filter, page]`。 2. **fetchOptions vs queryFn**:query 库不强制 fetch impl。 传 axios / ofetch 都行,但要在 queryFn 返 promise<data>。 3. **invalidation 过粗**:mutation 后 `invalidateQueries({ queryKey: ['posts'] })` invalidate 所有 posts*。可以更精确 `exact: true`。 4. **stale closure in mutation**:onSuccess 用 useState 值过时 → 用 functional update 或 ref。 5. **SSR hydration mismatch**:SSR data 跟 client first query 不一 致 → flicker。两者都有 hydration helper(HydrationBoundary 等)。
## 起因 新 landing page 在 Lighthouse Mobile 上跑只有 45 分(红色)。 boss 看到 PageSpeed Insights 截图骂街。 "哪些指标拉低分数?怎么改?" 系统化地走一遍。 ## 6 个关键指标 Lighthouse 综合分基于: | 指标 | 解释 | 目标 | |---|---|---| | **LCP** (Largest Contentful Paint) | 首屏最大元素显示时间 | < 2.5s | | **INP** (Interaction to Next Paint) | 用户交互响应延迟 | < 200ms | | **CLS** (Cumulative Layout Shift) | 累计布局偏移 | < 0.1 | | FCP (First Contentful Paint) | 任何内容首次显示 | < 1.8s | | Speed Index | 视觉填充速度 | < 3.4s | | TBT (Total Blocking Time) | 主线程被阻塞总时长 | < 200ms | LCP / INP / CLS 是 "Core Web Vitals",Google 排名因子。 ## 我做的 7 个改动 ### 1. 给关键图片加 `fetchpriority="high"` 和 preload ```html <head> <link rel="preload" as="image" href="/hero.jpg" fetchpriority="high"> </head> <body> <img src="/hero.jpg" fetchpriority="high" loading="eager" width="1200" height="600" alt="..."> </body> ``` 之前 hero 图被各种 CSS / JS 排到后面,LCP 4s。 preload + high priority 后 1.2s。 ### 2. 字体 preload + font-display: swap ```html <link rel="preload" as="font" type="font/woff2" href="/fonts/inter.woff2" crossorigin> <style> @font-face { font-family: 'Inter'; src: url('/fonts/inter.woff2') format('woff2'); font-display: swap; /* 字体没下来用 fallback,不阻塞渲染 */ } </style> ``` 之前自定义字体阻塞文字渲染 800ms。改后字体没到先用 system fallback。 ### 3. 关闭未使用的第三方脚本 PageSpeed 的 "Reduce unused JavaScript" 报告告诉我 Google Analytics + Hotjar + Intercom 注入了 280KB JS。 - Hotjar 临时关(运营暂时不需要) - Intercom 改 "页面停留 > 10s 才加载" - GA 切到 `gtag.js`(最小版) JS bundle 从 320 KB → 80 KB。 ### 4. CSS critical path:inline above-the-fold + defer 其余 ```html <head> <style> /* 首屏关键 CSS inline 进 HTML */ body{font-family:system-ui;...} .hero{...} .nav{...} </style> <link rel="preload" href="/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'"> <noscript><link rel="stylesheet" href="/main.css"></noscript> </head> ``` 之前 main.css 阻塞 render 600ms。inline critical CSS 后渲染立刻开始。 工具:`critters` / `critical` npm 包能自动提取 critical CSS。 ### 5. 图片用 AVIF / WebP + responsive srcset ```html <picture> <source srcset="/hero-800.avif 800w, /hero-1600.avif 1600w" sizes="100vw" type="image/avif"> <source srcset="/hero-800.webp 800w, /hero-1600.webp 1600w" sizes="100vw" type="image/webp"> <img src="/hero-800.jpg" alt="" width="1600" height="800" loading="eager" fetchpriority="high"> </picture> ``` AVIF 比 JPEG 小 50%+。手机用 800w 版本,平板用 1600w。 图片总下载量从 1.2 MB → 300 KB。 ### 6. 移除阻塞渲染的 JS(defer / async) ```html <!-- 之前 --> <script src="/analytics.js"></script> <!-- 阻塞 --> <script src="/vendor.js"></script> <!-- 阻塞 --> <!-- 现在 --> <script src="/main.js" defer></script> <!-- 等 HTML parse 完才执行 --> <script src="/analytics.js" async></script><!-- 不阻塞,下载完就跑 --> ``` - `defer`:HTML parse 完 + DOMContentLoaded 前按顺序执行 - `async`:下载完立刻执行,不保顺序 - 不带:阻塞 parser(最差) ### 7. CLS:所有 image / iframe 写 width/height ```html <!-- 错 --> <img src="/photo.jpg" alt=""> <!-- 加载完撑高页面 → 跳动 --> <!-- 对 --> <img src="/photo.jpg" width="800" height="600" alt=""> ``` 让浏览器在图片下载完前知道占位空间。CSS 用 `max-width:100%; height:auto` 保持响应式。 CLS 从 0.34 → 0.02。 ## 跑 Lighthouse Chrome DevTools → Lighthouse → 选 Mobile + Performance → Generate report. 或者命令行 CI 跑: ```bash npm i -D lighthouse @lhci/cli lhci autorun --upload.target=temporary-public-storage ``` `@lhci/cli` 能跑多次取中位数 + 上传报告 + PR 评论分数变化。 ## 结果 | | 改前 | 改后 | |---|---|---| | Performance | 45 | 95 | | LCP | 4.2s | 1.4s | | INP | 380ms | 120ms | | CLS | 0.34 | 0.02 | | TBT | 850ms | 110ms | | JS bundle | 320 KB | 80 KB | | 首屏图片 | 1.2 MB | 300 KB | ## 持续优化 ### CI 限红线 ```yaml # lighthouse.config.js module.exports = { ci: { assert: { assertions: { 'categories:performance': ['error', { minScore: 0.9 }], 'largest-contentful-paint': ['error', { maxNumericValue: 2500 }], }, }, }, } ``` LCP > 2.5s 的 PR 直接 fail。防止性能慢慢退化。 ### 真实用户数据:web-vitals + analytics ```js import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals' const send = (metric) => { navigator.sendBeacon('/analytics/vitals', JSON.stringify({ name: metric.name, value: metric.value, id: metric.id, })) } onCLS(send) onINP(send) onLCP(send) onFCP(send) onTTFB(send) ``` 收集真实用户的 web vitals 上报到自己的分析后端。Lighthouse 是合成测试, 真实用户在各种设备 / 网络下的体验才是终极目标。 ## 踩过的坑 1. **测试环境 vs 生产**:dev server 没 gzip / 没缓存 → 跑 Lighthouse 分数极低。永远在 staging / prod-like build 上跑。 2. **`fetchpriority="high"` 滥用**:所有 img 都设 high → 优先级失效。 只首屏关键 1-2 个图设。 3. **inline critical CSS 太多**:> 14 KB(一个 TCP 窗口)反而慢,因为 阻塞了 HTML 后续 stream。生产建议 < 10 KB。 4. **Hydration 慢拉低 INP**:React app 大 bundle hydrate 时主线程被 占。RSC + 把交互组件拆细减少 hydration scope。 5. **PSI mobile 分数比 Desktop 低很多**:移动用 throttled CPU + 慢 4G。 永远以 mobile 为准(用户主要在手机)。
图像是网页最大的字节来源。下面是一份能让 Lighthouse 分数从 60 涨到 95 的实战清单。 ## 1. 用对格式 - **AVIF**:2024 主流浏览器都支持,文件最小,复杂图比 WebP 还小 20-30% - **WebP**:兼容性更广(IE11 之外都行),编码快 - **JPEG**:照片,进度加载支持好 - **PNG**:透明背景、像素图 / icon - **SVG**:矢量图(logo、icon) 服务端按 Accept 头返回最佳格式: ```html <picture> <source srcset="hero.avif" type="image/avif"> <source srcset="hero.webp" type="image/webp"> <img src="hero.jpg" alt="Hero" width="1200" height="600" loading="lazy"> </picture> ``` 浏览器从上到下选第一个支持的。`<img>` 是兜底。 ## 2. 永远写 width / height ```html <img src="thumb.jpg" alt="" width="320" height="180"> ``` 让浏览器在加载图片前知道占位空间,避免 CLS(Cumulative Layout Shift)。 没写的图片加载完会"撑开"页面跳动。 CSS 控制实际尺寸: ```css img { max-width: 100%; height: auto; } ``` 但 HTML 的 width/height 用来告诉浏览器宽高比(aspect ratio),必加。 ## 3. responsive:不同视口不同尺寸 ```html <img src="hero-800.jpg" srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1600.jpg 1600w" sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw" alt="Hero" width="1600" height="800" > ``` `srcset` 提供多分辨率,`sizes` 告诉浏览器图片实际显示大小, 浏览器结合 device pixel ratio 选最优 src。 ## 4. lazy loading(原生属性) ```html <img src="below-fold.jpg" loading="lazy" alt="" width="..." height="..."> <iframe src="..." loading="lazy"></iframe> ``` `loading="lazy"` 让浏览器在元素进入视口前不加载。 首屏 / above-the-fold 的图片不要加(用 `loading="eager"` 默认)。 ## 5. decoding="async" ```html <img src="x.jpg" loading="lazy" decoding="async"> ``` 让浏览器异步解码图片,不阻塞主线程。 ## 6. priority hints 首屏关键图加 `fetchpriority="high"`: ```html <img src="hero.jpg" fetchpriority="high" loading="eager"> ``` 预加载 + 高优先级,关键内容更快出现。LCP(Largest Contentful Paint) 指标提升明显。 ## 7. preload 关键资源 ```html <link rel="preload" as="image" href="hero.jpg" imagesrcset="hero-800.jpg 800w, hero-1600.jpg 1600w" imagesizes="100vw"> ``` 页面 head 里 preload 首屏大图,浏览器立刻并行下载。 ## 8. CDN 上做实时转换 ``` https://your-cdn.example.com/image?src=foo.jpg&w=800&fmt=avif ``` CDN 收到请求后按参数生成对应格式 / 尺寸,缓存返回。 服务:Cloudinary、imgix、Cloudflare Images、Bunny.net、自己用 imgproxy 搭建。 ## 9. 工具:批量优化 ```bash # AVIF npm install -g sharp-cli sharp -i 'images/*.jpg' -o 'dist/' avif --quality 60 # WebP cwebp -q 80 input.png -o output.webp # JPEG 进度 + mozjpeg 优化 mozjpeg -quality 80 -progressive -outfile out.jpg in.jpg # PNG 优化 pngquant --quality=70-90 in.png # 有损但视觉无差 oxipng --opt 4 in.png # 无损 # SVG svgo -i in.svg -o out.svg ``` CI 阶段把 source 全转一遍。 ## 10. Next / Vite 框架内置 ### Next.js ```tsx import Image from 'next/image' <Image src="/hero.jpg" width={1200} height={600} alt="Hero" priority // 首屏 LCP placeholder="blur" /> ``` `next/image` 自动生成多分辨率 + AVIF / WebP + 懒加载。 ### Vite `vite-imagetools` plugin: ```ts import hero from './hero.jpg?w=400;800;1600&format=avif;webp;jpg&as=picture' <img {...hero} loading="lazy" /> ``` Build 时自动生成所有尺寸 / 格式 + 注入 srcset。 ## 11. 模糊占位符(LQIP) 预生成 base64 编码的极小图当占位: ```html <img src="data:image/jpeg;base64,/9j/..." /> <!-- 同时异步加载真图,加载完替换 --> ``` 或者用 BlurHash 算法(更紧凑,几十字节)。Next.js `placeholder="blur"` 自动做这个。 ## 12. SVG sprite(多个 icon) ```html <svg style="display:none"> <symbol id="icon-cart" viewBox="0 0 24 24"> <path d="..." /> </symbol> </svg> <!-- 使用 --> <svg><use href="#icon-cart" /></svg> ``` 一次下载所有 icon,按需引用,比 icon font 性能 / 灵活性都好。 ## 13. 验证 Lighthouse / WebPageTest 跑一遍: - LCP < 2.5s ✅ - CLS < 0.1 ✅ - 没有 "Properly size images" 警告 - 没有 "Serve images in next-gen formats" 警告 ## 14. 视频替代 GIF GIF 巨大 + 不可压缩。替代: ```html <video autoplay loop muted playsinline> <source src="anim.webm" type="video/webm"> <source src="anim.mp4" type="video/mp4"> </video> ``` 同一段动画 WebM 通常比 GIF 小 80-90%。 ## 踩过的坑 - 用 `<img src=` 但没写 `width/height` → CLS 0.3+ 直接红色。 - AVIF 编码慢(CPU 几秒一张):build 时缓存输出,不要每次重编码。 - responsive `srcset` 算错 `sizes` → 浏览器选了过大或过小的图。 开 DevTools 看 Network 实际请求的 URL 校验。 - "压缩太狠" → JPEG quality 60 以下肉眼可见 artifact。建议 70-85 甜点。
## 起因 每个 React 项目都装 `@floating-ui/react` 或 `popper.js` 解决 tooltip / dropdown / popover 的"跟着元素,但溢出视口时自动翻转" 这类位置计算。 代码体积 + 学习曲线 + 抽象层都有成本。 Chrome 125+(2024 中)开始支持 **CSS Anchor Positioning**, 原生 CSS 写定位浮动元素。Firefox / Safari 跟进中。 ## 解决方案 ### 最简单的 tooltip ```html <button class="btn">Hover me</button> <div class="tooltip">I am a tooltip</div> <style> .btn { anchor-name: --my-btn; /* 命名锚点 */ } .tooltip { position: absolute; position-anchor: --my-btn; /* 指定锚点 */ position-area: top; /* 在锚点上方 */ margin: 4px; } </style> ``` 效果:tooltip 自动定位到 btn 正上方 4px。 ### 自动翻转避免溢出 ```css .tooltip { position: absolute; position-anchor: --my-btn; position-area: top; /* 如果上方放不下,自动 fallback 到下方 */ position-try-fallbacks: --bottom; } @position-try --bottom { position-area: bottom; } ``` 视口顶部空间不够 tooltip → 自动跑到下面。Popper.js 的 "flip middleware" 原生实现。 ### 多 fallback ```css .tooltip { position: anchor: --my-btn; position-area: top; position-try-fallbacks: top right, bottom right, bottom left, top left; } ``` 按顺序尝试位置,第一个能完整显示的胜出。 ### dropdown menu ```html <button id="trigger" popovertarget="menu">Menu ↓</button> <menu id="menu" popover> <li><button>编辑</button></li> <li><button>删除</button></li> </menu> <style> #trigger { anchor-name: --trigger; } #menu { position-anchor: --trigger; position-area: bottom span-right; min-width: anchor-size(width); /* menu 至少跟 trigger 一样宽 */ margin: 4px 0; } </style> ``` `popover` 是 HTML 新属性(2023+),让任意元素能 toggle 显示, 配 anchor positioning 写下拉菜单几行搞定。 **完全无 JavaScript**。 ### popover API ```html <button popovertarget="dialog">Open</button> <div id="dialog" popover> <h2>对话框</h2> <button popovertarget="dialog" popovertargetaction="hide">关闭</button> </div> ``` `popover` 属性让元素自动有: - ESC 关闭 - 点击外部关闭(auto 模式) - 出现在 top layer(覆盖任何 z-index) - 焦点管理 ```css #dialog { /* 默认显示在浏览器中心 */ margin: auto; /* 进入动画 */ &:popover-open { animation: fade-in 0.2s; } } @keyframes fade-in { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } ``` modal 风格对话框: ```html <dialog id="modal"> <p>真的删除吗?</p> <button onclick="modal.close()">取消</button> <button>确认</button> </dialog> <button onclick="modal.showModal()">删除</button> ``` `<dialog>` 元素 + `showModal()` 自动焦点 trap + backdrop + ESC 关闭。 零依赖 modal。 ## 跟 Popper.js / floating-ui 对比 | | CSS anchor + popover | Popper.js / floating-ui | |---|---|---| | 浏览器支持 | Chrome/Edge 125+ / Safari/FF 跟进 | 全支持 | | bundle 影响 | 0 | 5-15 KB | | 灵活度 | 中(CSS 限制) | 高(JS 任意) | | 实施 | CSS only | JS hook + ref | | Modal trap | popover 自动 | 第三方库 | 2025-2026 浏览器覆盖率到位后可以全切。 **现在写新项目**:渐进增强——支持的浏览器用 CSS,老的退化用 JS 库。 ### 渐进增强 ```js if (CSS.supports('position-anchor: --x')) { // 啥都不做,CSS 处理 } else { // fallback 到 floating-ui await import('@floating-ui/dom').then(...) } ``` ## React 集成 只要 React 渲染对应 HTML: ```tsx function Tooltip({ children, label }) { return ( <> <span className="tooltip-target">{children}</span> <span className="tooltip-content" role="tooltip">{label}</span> </> ) } ``` ```css .tooltip-target { anchor-name: --tt; } .tooltip-content { position: absolute; position-anchor: --tt; position-area: top; } ``` 不需要 ref / 不需要 useState 控制位置。CSS 自动。 注意:每个 tooltip 实例需要唯一 anchor-name 否则冲突。 React 里: ```tsx const id = useId() <span style={{ anchorName: `--tt-${id}` }}>...</span> <span style={{ positionAnchor: `--tt-${id}` }}>...</span> ``` ## 实战:context menu 右键菜单: ```html <div id="cm-target">右键我</div> <menu id="ctxmenu" popover> <li><button>复制</button></li> <li><button>粘贴</button></li> <li><button>删除</button></li> </menu> <script> const target = document.getElementById('cm-target') const menu = document.getElementById('ctxmenu') target.addEventListener('contextmenu', (e) => { e.preventDefault() // 把 menu 定位到鼠标位置 menu.style.left = `${e.clientX}px` menu.style.top = `${e.clientY}px` menu.style.position = 'fixed' menu.showPopover() }) </script> ``` `showPopover()` API 触发显示。点外面 / ESC 自动关闭。 ## 与 dialog / modal 区别 | | dialog (showModal) | popover (showPopover) | |---|---|---| | 用法 | 阻塞性对话框 | 任意浮动元素 | | backdrop | ::backdrop 默认黑半透 | 无(popover=manual 时) | | inert | 背景内容自动 inert | 看模式 | | ESC | 自动关 | 自动关 | | 嵌套 | 多个 dialog 栈 | popover 也可栈 | 模态对话框用 `<dialog>`;非模态浮层用 `popover`。 ## 等待中的浏览器支持 ``` Chrome 125+ ✅ (2024-05) Edge 125+ ✅ Safari 17.4+ 部分 (popover 完整,anchor positioning 部分) Firefox 134+ anchor 部分 ``` 2026 末预计全支持。在那之前生产用 Popper.js 兜底; 个人项目 / Chrome 内部工具立刻可用。 ## 浏览器特性检测 ```css @supports (position-anchor: --x) { /* 新 API */ } @supports not (position-anchor: --x) { /* fallback:用 absolute + JS 控制 */ } ``` ## 我的实验感受 试用一周: - 简单 tooltip / dropdown:CSS 写 5 行 = floating-ui 50 行 - 复杂多步定位 / 动画 / 拖拽 → 还是 JS 灵活 - popover API 替代了 90% modal use case,不再需要 Headless UI Dialog 未来 1-2 年逐步迁移;现在新组件优先用 CSS 方案 + fallback。 ## 踩过的坑 1. **anchor-name 唯一性**:同 anchor-name 多个 target 时只第一个起效。 动态生成的组件用 useId 保证唯一。 2. **popover vs dialog 选错**:modal 阻塞场景仍是 dialog 的设计意图; popover 给非阻塞浮层。混用导致 UX 怪。 3. **position-try-fallbacks 顺序敏感**:写错顺序 fallback 选不优。 测视口各种尺寸验证。 4. **animation 不工作**:popover 进出动画需要 `:popover-open` + `@starting-style` 配 transition。CSS 语法新,文档要看 latest。 5. **Tailwind class 没原生支持**:要写自定义 utility。Tailwind v4 预计支持 anchor positioning utility。
90% 的模态框 / 抽屉组件不合规——键盘用户 / 屏幕阅读器用户用得很痛苦。 正确实现并不复杂,主要是几个细节都必须做对。 ## 一个合规模态必须做到 1. **打开时焦点自动移入** —— 屏幕阅读器才知道有新内容 2. **关闭时焦点回到触发按钮** 3. **Tab 焦点循环在模态内**(focus trap) 4. **ESC 关闭** 5. **背景内容不可滚动**(防 iOS 弹起键盘时背景滚走) 6. **背景 `aria-hidden` 或 `inert`**,屏幕阅读器不读背景 7. **`role="dialog"` + `aria-modal="true"` + `aria-labelledby`** ## React 实现 ```tsx import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' interface ModalProps { open: boolean onClose: () => void title: string children: React.ReactNode } export function Modal({ open, onClose, title, children }: ModalProps) { const dialogRef = useRef<HTMLDivElement>(null) const lastFocusRef = useRef<HTMLElement | null>(null) // 打开时保存当前焦点 + 把焦点移入对话框 useEffect(() => { if (!open) return lastFocusRef.current = document.activeElement as HTMLElement // 锁背景滚动 const prevOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' // 标记背景 inert(modern 浏览器) const root = document.getElementById('root') if (root) root.setAttribute('inert', '') // 找第一个可聚焦元素并聚焦 queueMicrotask(() => { const focusable = dialogRef.current?.querySelector<HTMLElement>( FOCUSABLE ) focusable?.focus() }) return () => { document.body.style.overflow = prevOverflow root?.removeAttribute('inert') lastFocusRef.current?.focus() } }, [open]) // ESC 关闭 useEffect(() => { if (!open) return const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [open, onClose]) // Tab focus trap useEffect(() => { if (!open) return const onKey = (e: KeyboardEvent) => { if (e.key !== 'Tab' || !dialogRef.current) return const els = dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE) if (els.length === 0) return const first = els[0] const last = els[els.length - 1] if (e.shiftKey && document.activeElement === first) { last.focus() e.preventDefault() } else if (!e.shiftKey && document.activeElement === last) { first.focus() e.preventDefault() } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [open]) if (!open) return null return createPortal( <div className="modal-backdrop" onClick={onClose}> <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" className="modal" onClick={e => e.stopPropagation()} > <h2 id="modal-title">{title}</h2> {children} <button onClick={onClose} aria-label="关闭对话框">×</button> </div> </div>, document.body ) } const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), ' + 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), ' + '[tabindex]:not([tabindex="-1"])' ``` ## CSS ```css .modal-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, .5); display: flex; align-items: center; justify-content: center; z-index: 1000; } .modal { background: #fff; border-radius: 8px; padding: 24px; max-width: 480px; width: 90vw; max-height: 90vh; overflow: auto; position: relative; } .modal h2 { margin-top: 0; } ``` ## 使用 ```tsx function App() { const [open, setOpen] = useState(false) return ( <> <button onClick={() => setOpen(true)}>打开</button> <Modal open={open} onClose={() => setOpen(false)} title="确认"> <p>真的要删除吗?</p> <button onClick={() => setOpen(false)}>取消</button> <button onClick={handleConfirm}>确认</button> </Modal> </> ) } ``` ## `inert` 属性 `inert` 是 2024 主流浏览器都支持的属性,给元素加上后该子树: - 不接受任何焦点 - 不响应任何鼠标点击 - 屏幕阅读器跳过 完美替代过去的 "set tabindex=-1 on every focusable" + `aria-hidden` hack。 老浏览器(IE / 老 Safari)回退到 `aria-hidden="true"` + 手动管理 tabindex, 但 2026 年基本没必要考虑。 ## 用现成的库 实现 1 个模态花一下午是 OK 的,做 5 种弹层组件就别造轮子了。 推荐: - **Radix UI**(无样式 primitives)—— 工业级 a11y 实现 - **Headless UI**(Tailwind 出品)—— 类似 - **react-aria**(Adobe)—— 更细粒度 直接用上面任何一个,配自己的样式。 ## 测试 ```bash npm i -D @axe-core/playwright ``` ```ts import { test } from '@playwright/test' import AxeBuilder from '@axe-core/playwright' test('modal has no a11y violations', async ({ page }) => { await page.goto('/') await page.click('text=打开') const results = await new AxeBuilder({ page }).analyze() expect(results.violations).toEqual([]) }) ``` axe-core 是事实标准的 a11y 自动测试工具。 ## 踩过的坑 - 没把焦点还回触发按钮 → 键盘用户按 ESC 后焦点跳到 body 开头,要重新 Tab 几十次回来。 - 用 `aria-hidden="true"` 包裹背景 + 没用 inert:iOS VoiceOver 会跳过 aria-hidden 但 Android TalkBack 仍能 "swipe to" 选中背景元素。 `inert` 两端都靠谱。 - focus trap 实现里查 focusable 元素,querySelector 一次太死板: 对话框内动态变化元素时要每次 keydown 时重新查(如上代码)。 - iOS Safari 上 `overflow:hidden` 锁滚不够稳,需要再加 `position: fixed; top: -${scrollY}px`。生产建议用 `body-scroll-lock` 库。
## 起因 一个生产 bug:某个组件渲染时 throw 了未捕获错误 → 整页白屏 → 用户刷新无效 → 截图发给客服。 React 默认行为:子组件 throw 没 catch → 整个组件树卸载 → 白屏。 正确做法:用 ErrorBoundary 局部捕获 + 显示"出错了"卡片 + 上报错误到 Sentry。 ## 解决方案 ### 1. 基础 ErrorBoundary ```tsx // components/ErrorBoundary.tsx import { Component, ReactNode } from 'react' interface Props { children: ReactNode fallback?: ReactNode onError?: (error: Error, info: { componentStack: string }) => void } interface State { hasError: boolean error: Error | null } export class ErrorBoundary extends Component<Props, State> { state: State = { hasError: false, error: null } static getDerivedStateFromError(error: Error): State { return { hasError: true, error } } componentDidCatch(error: Error, info: { componentStack: string }) { console.error('ErrorBoundary caught:', error, info) this.props.onError?.(error, info) } reset = () => { this.setState({ hasError: false, error: null }) } render() { if (this.state.hasError) { if (this.props.fallback) { return this.props.fallback } return ( <div role="alert" style={{ padding: 16, background: '#fee', borderRadius: 8 }}> <h3>出错了 😞</h3> <p>{this.state.error?.message}</p> <button onClick={this.reset}>重试</button> </div> ) } return this.props.children } } ``` ErrorBoundary 必须 class component(Hooks 不支持 componentDidCatch)。 ### 2. 用法:包裹"应该独立失败"的区域 ```tsx function Dashboard() { return ( <Layout> <ErrorBoundary> <PostList /> {/* 出错只这里降级 */} </ErrorBoundary> <ErrorBoundary> <RecommendedSidebar /> </ErrorBoundary> <ErrorBoundary> <CommentFeed /> </ErrorBoundary> </Layout> ) } ``` 任意一个组件挂了不影响其它,用户至少能用其它部分。 ### 3. 顶层兜底 ```tsx function App() { return ( <ErrorBoundary fallback={ <div style={{ padding: 40, textAlign: 'center' }}> <h1>应用出错</h1> <p>请刷新页面,或联系支持</p> <button onClick={() => location.reload()}>刷新</button> </div> } > <Router> <Routes>...</Routes> </Router> </ErrorBoundary> ) } ``` ### 4. Sentry 集成(生产必备) ```bash npm i @sentry/react ``` ```ts // main.tsx import * as Sentry from '@sentry/react' Sentry.init({ dsn: 'https://[email protected]/123', environment: import.meta.env.MODE, tracesSampleRate: 0.1, // 10% 性能采样 replaysSessionSampleRate: 0.1, // 10% 会话录制 replaysOnErrorSampleRate: 1.0, // 出错的 100% 录制 integrations: [ Sentry.browserTracingIntegration(), Sentry.replayIntegration({ maskAllText: false, blockAllMedia: true }), ], }) ``` 用 Sentry 的 ErrorBoundary 替代自己写的: ```tsx import * as Sentry from '@sentry/react' <Sentry.ErrorBoundary fallback={({ error, resetError }) => ( <ErrorCard error={error} onReset={resetError} /> )} showDialog // 弹用户反馈表单 > <PostList /> </Sentry.ErrorBoundary> ``` 任何 error throw → 自动上报 Sentry → 包含 stack trace + 浏览器版本 + URL + 用户 ID + session replay 视频回放。 ### 5. 异步错误:try/catch + Sentry.captureException ErrorBoundary 只抓 render / lifecycle 错误。**异步错误(fetch / setTimeout / Promise)抓不到**: ```tsx async function loadData() { try { const r = await fetch('/api/users') if (!r.ok) throw new Error(`status ${r.status}`) return await r.json() } catch (e) { Sentry.captureException(e) throw e // 让上层 React Query / useState 知道失败 } } ``` 或者 React Query 自动: ```tsx const { data, error } = useQuery({ queryKey: ['users'], queryFn: loadData, throwOnError: true, // throw 给最近的 ErrorBoundary }) ``` `throwOnError: true` 让 TanStack Query 把 error 抛给 ErrorBoundary, 两套机制串起来。 ### 6. 区分预期错误 vs 真 bug ```tsx // 预期错误:业务规则 async function transfer(amount) { const r = await fetch('/api/transfer', ...) if (r.status === 403) { throw new InsufficientBalanceError() // 不上报 Sentry } if (!r.ok) { throw new Error('transfer failed') // 上报 } } class InsufficientBalanceError extends Error { isExpected = true } // 包装 function captureIfUnexpected(e: Error) { if ((e as any).isExpected) return Sentry.captureException(e) } ``` Sentry 项目里大量"用户输错密码 401"的告警没价值,要区分。 ### 7. 全局未捕获错误 ```ts window.addEventListener('error', (event) => { Sentry.captureException(event.error) }) window.addEventListener('unhandledrejection', (event) => { Sentry.captureException(event.reason) }) ``` Sentry SDK 默认已经 hook 了这些,不需要自己写。 ### 8. Source maps 上传 生产 bundle 是 minified;Sentry 看 stack trace 全是 `a.b.c at xyz.js:1:1234`, 没法定位。Vite 配 sourcemap + 上传到 Sentry: ```ts // vite.config.ts import { sentryVitePlugin } from '@sentry/vite-plugin' export default defineConfig({ build: { sourcemap: true }, plugins: [ react(), sentryVitePlugin({ org: 'my-org', project: 'my-app', authToken: process.env.SENTRY_AUTH_TOKEN, }), ], }) ``` build 时自动上传 sourcemap 到 Sentry,error 在 dashboard 显示原始 TS 代码 + 行号。 ### 9. 用户反馈 ```tsx <Sentry.ErrorBoundary fallback={({ error, eventId }) => ( <div> <h3>出错了</h3> <p>已自动上报。错误 ID: <code>{eventId}</code></p> <button onClick={() => Sentry.showReportDialog({ eventId })}> 提供反馈 </button> </div> )} > ``` 用户能描述"我点什么按钮触发的",Sentry 把这个反馈跟 error 绑在一起。 ## 效果 - 白屏 bug 数从每月 10+ → 0(局部降级) - Sentry 收集到 95% bug,开发能主动修而不是等用户报 - session replay 让"为什么会出现这个 state" 一目了然 - 半年内 frontend error rate 从 2% → 0.3% ## 注意事项 ### 1. 不要包太细 每个 div 一个 ErrorBoundary:渲染开销 + 视觉细碎。 原则是"独立可用的功能块"为单位(侧栏 / 主内容 / 评论区)。 ### 2. PII 信息 Sentry 默认抓表单数据。可能包含密码、邮箱、信用卡。 ```ts Sentry.init({ beforeSend(event) { // 删 password 字段 if (event.request?.data) { delete event.request.data.password } return event }, }) ``` 或者 mask all replay:`replayIntegration({ maskAllText: true })`。 ### 3. quota Sentry 免费层每月 5k events。`tracesSampleRate: 0.1` 限性能采样。 真 error 全部上报;性能 / replay 采样。 ## 踩过的坑 1. **ErrorBoundary 必须是 class**:function component 用 React 19+ 的 `use()` 可以一定程度替代,但官方 ErrorBoundary 仍是 class。 2. **`getDerivedStateFromError` 不能调 setState**:用 return 新 state。 `componentDidCatch` 才能调副作用 / 上报。 3. **dev 模式 React 显示完整错误覆盖层**:不代表 ErrorBoundary 没工作。 生产 build 才看到 fallback。 4. **forgotten reset**:fallback 里写 "重试" 按钮要调 `resetError()`, 而不是 `setState({ hasError: false })`(state 在 boundary 里, 按钮在 fallback 里)。Sentry boundary 的 `resetError` prop 帮你 处理。 5. **SSR 时 ErrorBoundary**:getDerivedStateFromError 在 SSR 跑 → 服务端如果 catch 到错误就 render fallback HTML。客户端 hydrate 时如果不同会 mismatch warning。
## 起因 新项目要选前端 SSR framework。两个主流: - **Next.js**(React,Vercel):业界事实标准 - **SvelteKit**(Svelte,Vercel):更小 bundle,更直觉 最近用 SvelteKit 做一个新项目,Next.js 维护两个老项目。下面对比。 ## 文件结构 Next.js(app router): ``` app/ layout.tsx page.tsx blog/ [slug]/ page.tsx # /blog/:slug api/ posts/route.ts # GET/POST /api/posts ``` SvelteKit: ``` src/routes/ +layout.svelte +page.svelte # / blog/ [slug]/ +page.svelte # /blog/:slug +page.server.ts # server-side load api/ posts/+server.ts # GET/POST /api/posts ``` 类似的 file-based routing,约定不同。 ## 写法对比 ```tsx // Next.js page export default async function Page({ params }: { params: { slug: string } }) { const post = await db.posts.findFirst({ where: { slug: params.slug } }); return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.body }} /> </article> ); } ``` ```svelte <!-- SvelteKit +page.svelte --> <script lang="ts"> export let data; // 从 +page.server.ts load 函数来 let { post } = data; </script> <article> <h1>{post.title}</h1> {@html post.body} </article> ``` ```ts // +page.server.ts export async function load({ params }) { const post = await db.posts.findFirst({ where: { slug: params.slug } }); return { post }; } ``` SvelteKit 强制分离 server load 跟 view,Next.js app router 同 file (async component)。 SvelteKit 模板语法更接近 HTML(`{@html}` `{#if}` `{#each}`)。 React 是 JS-first(JSX)。 ## bundle size baseline blank page: - Next.js: ~85 KB JS(React runtime + Next router) - SvelteKit: ~10 KB JS(Svelte compile 到 vanilla JS) Svelte 是编译时框架,runtime 极小。 React 是 runtime 框架,最小也几十 KB。 实际复杂 app: - Next.js: 200-500 KB - SvelteKit: 50-200 KB 老旧移动设备 / 网络差地区差异显著。 ## 性能 SSR / 静态生成性能两者接近(看 hosting / CDN)。 客户端 hydration: - React: ~150ms typical page - Svelte: ~30ms Svelte 编译时已生成 DOM 更新代码,hydration 简单。 React 要重新跑 component tree 对比 virtual DOM。 ## state 管理 React:useState / useReducer / Zustand / Redux / Jotai 等。 Svelte 5(runes): ```svelte <script> let count = $state(0); let doubled = $derived(count * 2); $effect(() => { console.log(`count: ${count}`); }); </script> <button on:click={() => count++}>+1</button> <p>doubled: {doubled}</p> ``` `$state` / `$derived` / `$effect` 内置 reactive primitive。 不需要库。 跨组件用 store: ```ts // stores.ts import { writable } from 'svelte/store'; export const user = writable(null); // 组件 import { user } from './stores'; $user // 自动 subscribe ``` React 同样需求要 Zustand / Jotai / Context。 ## ecosystem | | Next.js | SvelteKit | |---|---|---| | UI 库 | 极多(Material / Chakra / shadcn 等等) | 中(Skeleton / SVeltestrap / shadcn-svelte) | | component snippets | 几万 | 几千 | | 招聘 React | 海量 | 中 | | 第三方教程 | 海量 | 中 | | Vercel / 部署 | 一等公民 | 一等公民 | React 生态优势大。 ## 部署 两者都 Vercel friendly(一键 git push 部署)。 Cloudflare Pages / Netlify / 自托管也都 OK。 SvelteKit 的 adapter 概念:同 codebase 部署到不同 platform: ```ts // svelte.config.js import adapter from '@sveltejs/adapter-cloudflare'; // or node / vercel / netlify export default { kit: { adapter: adapter() }, }; ``` Next.js 也行但 vendor lock 严重些。 ## 学习曲线 Svelte 新人:HTML + JS 像,3-5 天上手。 React 新人:JSX + hook 概念多,1-2 周上手。 但 React 后续生态 / TypeScript 集成成熟,深入资料无限。 Svelte 5 runes 是新(2024)变化大,老教程过时。 ## 实战 case:从 React 迁 Svelte 我们一个内部 admin tool 原 React (CRA),bundle 800KB。 迁 SvelteKit: - 3 周重写 - bundle 180KB - 加载快 2-3x - 代码行数 -40% 主要节省: - 不用 useEffect 注意 dep array - 表单写法简洁(`bind:value`) - 不用 Redux(store 几行) 但缺点: - 团队学习 - 某些 React 专用库(react-flow 之类)没 Svelte 等价 → 自己写或者用 web component ## React 仍胜的场景 - 大型团队 / 已有 React 经验 - React Native 共享 UI - 极丰富的第三方组件依赖 - Server Components(React 19+)+ Next.js 强大 ## SvelteKit 仍胜的场景 - 性能 critical(移动 / 老设备) - 小团队 + 偏 web 标准 - 内容站 / 静态化重 + SSR - 想少写代码 ## 部署一个 demo ```bash npm create svelte@latest my-app cd my-app && npm install npm run dev # localhost:5173 ``` ```bash npx create-next-app@latest my-app cd my-app && npm run dev # localhost:3000 ``` 两个都 5 分钟跑起来。 ## 与其它框架对比 - **Astro**:内容站王者,多框架混用,islands 架构。SSR 比 Next / SvelteKit 简单 - **Nuxt**(Vue):Vue 圈的 Next,类似哲学 - **SolidStart**:Solid.js 的 framework,性能极好 - **Remix**:React 路线,更"web 标准 first"(已并入 React Router 7) 新项目 2026 视角: - 内容站 → Astro - 应用 + 团队 React → Next.js - 应用 + 性能 / 简洁 → SvelteKit - 应用 + Vue 团队 → Nuxt ## 踩过的坑 1. **SvelteKit prerender 错配**:以为静态生成,结果运行时还跑 → 用 `export const prerender = true` 显式标记。 2. **server-only code 漏到 client**:API key 写在 +page.svelte 里 → bundle 到 client。server code 放 +page.server.ts / +server.ts。 3. **Next.js app router 学习陡**:server components / client components 边界要清。误用 hook 报错难懂。 4. **Svelte 5 vs 4 转换**:runes 是新,老 reactive `let count = 0; $: doubled = count * 2` 仍兼容但官方推 runes。教程乱。 5. **TypeScript 集成**:SvelteKit 比 Next 略弱(template 内的类型推断)。 严格项目要小心。
## 起因 要做一个商品轮播 + 滑到中间自动对齐 + 触屏滑动也丝滑。 传统做法用 swiper.js / react-slick 等库,bundle 几十 KB + 复杂 API。 `scroll-snap` CSS 几行原生实现,所有现代浏览器全支持,零 JS。 ## 1. 横向轮播 ```html <div class="carousel"> <div class="card">A</div> <div class="card">B</div> <div class="card">C</div> <div class="card">D</div> </div> <style> .carousel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; /* 横向 + 强制 snap */ scroll-padding: 16px; gap: 16px; padding: 16px; } .card { flex: 0 0 80%; /* 每个卡片占 80% 视口宽 */ scroll-snap-align: center; /* 滑动停在卡片中心 */ background: #eee; height: 200px; border-radius: 12px; } </style> ``` 效果: - 横向滑动 / swipe - 松手后自动对齐到最近卡片中心 - 触屏 momentum 滚动 + snap 一起 work - 鼠标 wheel / 键盘箭头都 work **完全 CSS**。 ## 2. `mandatory` vs `proximity` ```css scroll-snap-type: x mandatory; /* 强制:松手必停 snap 点 */ scroll-snap-type: x proximity; /* 接近:靠近 snap 点才 snap */ ``` `mandatory` 适合"看一个" 的轮播;`proximity` 适合"长列表偶尔对齐" 场景。 ## 3. snap-align 选项 ```css scroll-snap-align: start; /* 元素左边对齐 */ scroll-snap-align: center; /* 元素居中对齐 */ scroll-snap-align: end; /* 元素右边对齐 */ ``` ## 4. 全屏式纵向"翻页" ```html <main class="pages"> <section>第一屏</section> <section>第二屏</section> <section>第三屏</section> </main> <style> html { scroll-snap-type: y mandatory; } /* 整页 snap */ section { height: 100vh; scroll-snap-align: start; } </style> ``` 或者 `mandatory` 在 html 上: ```css html, body { height: 100%; scroll-snap-type: y mandatory; } section { scroll-snap-align: start; min-height: 100vh; } ``` 效果:滚轮 / 滑动按"屏" 翻页,落地页常用。 ## 5. snap-stop:必须 stop 在某些元素 ```css .important-card { scroll-snap-align: center; scroll-snap-stop: always; /* 不允许"飞越"这个元素 */ } ``` 普通卡片可以滚很远跳过;`always` 强制每个都停。 高 momentum 滑动时 prevent 飞过去。 ## 6. 进度指示器(dot / progress) scroll-snap 本身没有"当前第几页" 信息。要做 indicator dot: ```tsx function Carousel({ items }) { const ref = useRef<HTMLDivElement>(null) const [active, setActive] = useState(0) useEffect(() => { const el = ref.current if (!el) return const observer = new IntersectionObserver( (entries) => { entries.forEach(e => { if (e.isIntersecting) { const idx = Array.from(el.children).indexOf(e.target as HTMLElement) setActive(idx) } }) }, { root: el, threshold: 0.5 } ) Array.from(el.children).forEach(c => observer.observe(c)) return () => observer.disconnect() }, []) return ( <> <div ref={ref} className="carousel"> {items.map(item => <div key={item.id} className="card">{item.title}</div>)} </div> <div className="dots"> {items.map((_, i) => ( <span className={i === active ? 'active' : ''} /> ))} </div> </> ) } ``` `IntersectionObserver` 检测哪个卡片当前可见。 没 JS 也能 snap,只是没 indicator。 ## 7. 前进 / 后退按钮 ```tsx function Carousel({ items }) { const ref = useRef<HTMLDivElement>(null) function scroll(dir: number) { const el = ref.current if (!el) return const cardWidth = el.firstElementChild?.clientWidth ?? 0 el.scrollBy({ left: cardWidth * dir, behavior: 'smooth' }) } return ( <> <button onClick={() => scroll(-1)}>←</button> <button onClick={() => scroll(1)}>→</button> <div ref={ref} className="carousel"> {items.map(...)} </div> </> ) } ``` `scrollBy + smooth` 程序触发滚动 + snap 自动配合。 ## 8. 移动端 momentum iOS Safari 需要: ```css .carousel { -webkit-overflow-scrolling: touch; /* 老 iOS 启 momentum */ } ``` 现代 iOS 默认 momentum,但写上也无害。 ## 9. 隐藏 scrollbar 视觉上不要 scrollbar 但仍可滚: ```css .carousel { /* Firefox */ scrollbar-width: none; /* Chrome / Safari */ &::-webkit-scrollbar { display: none; } } ``` ## 完整示例:商品轮播 ```html <div class="product-carousel"> <div class="product">商品 1</div> <div class="product">商品 2</div> <div class="product">商品 3</div> <div class="product">商品 4</div> <div class="product">商品 5</div> </div> <style> .product-carousel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; gap: 12px; padding: 16px; scrollbar-width: none; } .product-carousel::-webkit-scrollbar { display: none; } .product { flex: 0 0 calc(50% - 18px); /* 移动端每屏 2 个 */ scroll-snap-align: start; background: white; border: 1px solid #ddd; border-radius: 8px; padding: 12px; } @media (min-width: 768px) { .product { flex: 0 0 calc(25% - 12px); /* 桌面每屏 4 个 */ } } </style> ``` 响应式 + snap + 美观 < 30 行 CSS。 ## 11. 跟 Swiper.js 对比 | | scroll-snap CSS | Swiper.js | |---|---|---| | bundle | 0 | 30-100 KB | | 学习成本 | 极低(CSS 属性) | 中(API + plugin) | | 灵活度 | 中(CSS 限制) | 极高(事件 / 插件) | | 动画 / parallax | 困难 | 简单 | | autoplay | 自己写 JS | 内置 | | nested swiper | 难 | 支持 | | 多 breakpoint | media query | 内置 | 简单轮播 / 卡片流 → scroll-snap。 复杂动画 / autoplay / parallax / 大量 feature → Swiper。 ## 12. 浏览器支持 scroll-snap 全 evergreen 浏览器支持(Safari 11+ / Firefox 39+ / Chrome 69+ / Edge 79+)。 **不需要 polyfill**。 老 IE 不支持但本来也没人 care。 ## 实战 use case 我们网站几处用 scroll-snap 后: - 删除 swiper.js 依赖 → bundle 减 60 KB - 触屏滑动体验跟原生 app 一致 - 维护成本 0(CSS 一直 work) 不适合: - 复杂 carousel(autoplay / parallax / cube transition) - 多嵌套 swiper ## 踩过的坑 1. **scroll-snap-type 在父,align 在子**:写反了不 work。 2. **flex item 没设 `flex: 0 0 ...`** → 自动收缩 → 看起来都对齐 但宽度不对。强制 `flex-shrink: 0`。 3. **mobile 触屏 snap 不灵敏**:低端 Android 偶尔 snap 慢。 设 `scroll-snap-stop: always` 让必停每个。 4. **snap 让锚点跳乱**:含 `#anchor` URL 时 snap 偶尔覆盖 scroll-to-anchor。 配 `scroll-padding-top` 给固定 header 留位置。 5. **iOS Safari momentum 衰减**:iOS 上 momentum 后才 snap,感觉 slightly delayed。这是 OS 行为,无解。
## 起因 要给 React 项目快速搭出"看着像样"的 UI。选择: - Material UI / Chakra:大、深度定制难、bundle 大 - Ant Design:大且企业风、设计语言强 - Headless UI / Radix:无样式、要自己写 CSS shadcn/ui 是 2023 后流行的新范式:**不是 npm 包**,而是 "复制粘贴 组件源码进你的项目"。底层用 Radix Primitives 做 a11y / 行为, Tailwind 做样式。你拥有源码,随便改。 ## 解决方案 ### 装 前提:React + Tailwind CSS 项目。 ```bash npx shadcn-ui@latest init # 交互式问几个问题: # Style: default / new-york # Base color: slate / gray / zinc / ... # CSS variables: yes ``` 生成 `components.json` 配置。 ### 加组件 ```bash npx shadcn-ui@latest add button npx shadcn-ui@latest add dialog npx shadcn-ui@latest add toast ``` 每个组件作为 `.tsx` 文件被复制到 `src/components/ui/`: ``` src/components/ui/ ├── button.tsx ├── dialog.tsx ├── toast.tsx └── ... ``` 打开 `button.tsx`: ```tsx import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center rounded-md text-sm font-medium ...", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border border-input bg-background hover:bg-accent", ghost: "hover:bg-accent", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-10 px-4 py-2", sm: "h-9 rounded-md px-3", lg: "h-11 rounded-md px-8", icon: "h-10 w-10", }, }, defaultVariants: { variant: "default", size: "default" }, } ) export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean } const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> ) } ) Button.displayName = "Button" export { Button, buttonVariants } ``` 这是**你**的代码。要加一个 `success` variant 直接改就行: ```tsx variant: { default: "...", success: "bg-green-600 text-white hover:bg-green-700", ... }, ``` 不像传统组件库要 fork 整个 repo 或者写 wrap。 ### 用 ```tsx import { Button } from '@/components/ui/button' <Button>Click me</Button> <Button variant="outline" size="lg">Outline</Button> <Button variant="success">Saved</Button> ``` ### Dialog 例 ```tsx import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' <Dialog> <DialogTrigger asChild> <Button variant="destructive">删除</Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>确认删除</DialogTitle> </DialogHeader> <p>真的要删除这条记录吗?</p> <DialogFooter> <Button variant="outline">取消</Button> <Button variant="destructive">确认</Button> </DialogFooter> </DialogContent> </Dialog> ``` 底层 Radix Dialog 已经处理好:focus trap、ESC 关闭、aria-modal、 overlay 点击关闭、滚动锁等所有 a11y 细节。 ### 配色:CSS variables `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%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; /* ... */ } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; /* ... */ } } ``` 改这些变量 → 整个组件库换色。dark mode `<html class="dark">` 自动切。 ### theming:从 [ui.shadcn.com/themes](https://ui.shadcn.com/themes) 抄 shadcn 官网有 themes 页面,挑色调 + 复制 CSS 变量 → 替换你的 globals.css。 3 秒换主题。 ### 组件清单 shadcn 现有 50+ 组件: - Button / Input / Label / Textarea / Select / Switch / Checkbox / Radio - Dialog / Sheet / Popover / Tooltip / HoverCard / ContextMenu / DropdownMenu / Menubar / NavigationMenu / Command (cmd-k palette) - Card / Badge / Avatar / Separator / Skeleton / Progress - Toast / Alert / AlertDialog - Table / DataTable - Tabs / Accordion / Collapsible - Calendar / DatePicker - Form (react-hook-form 集成) - Carousel / Resizable / Drawer 涵盖 95% web app 需求。 ### 数据表 + sorting + filter(DataTable) ```tsx import { useReactTable } from '@tanstack/react-table' // shadcn 用 TanStack Table 做底层 const table = useReactTable({ data: users, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), }) // 渲染部分 shadcn 提供的 Table 组件包 ``` 复杂表格 < 100 行 + 完全可定制。 ## 与传统组件库对比 | | shadcn/ui | MUI v5 | Ant Design | |---|---|---|---| | 引入 | 复制源码 | npm i | npm i | | 改样式 | 直接改 .tsx | sx prop / theme override | 复杂 token | | Bundle | 仅用到的组件 | 大(tree-shaking 不彻底) | 极大 | | Tailwind | ✅ 原生 | 需适配 | 需适配 | | 升级 | 自己控制 | 升级有 breaking risk | 升级有 breaking risk | | 设计风格 | 默认 minimal | Material | 企业风 | shadcn 的"复制源码"哲学是关键差异: - **优点**:完全掌控、零依赖、bundle 最小、修改简单 - **缺点**:组件库更新不会自动应用(要重新 `add` 覆盖) ## 效果 我们一个内部工具用 shadcn 重做,对比之前的 Material UI 版: - bundle 从 850 KB → 280 KB - 设计师对样式调整一句话就能改(不需要"懂 MUI theme") - 暗色模式切换 0 配置 - 新人 onboarding 时间减半(组件就在自己代码里,IDE 跳定义直接看) ## 与同生态组合 - **next-themes**:dark mode 切换 - **react-hook-form** + **zod**:表单(shadcn Form 组件直接集成) - **TanStack Query**:数据 - **TanStack Table**:表格 - **lucide-react**:icon - **sonner**:toast 替代品(更现代) 一套组合下来基本 cover 现代 React app 全部需求。 ## 踩过的坑 1. **第一次 init 必须有 Tailwind**:shadcn 强依赖 Tailwind utility classes。 非 Tailwind 项目要么先装 Tailwind,要么用 [tremor](https://tremor.so/) 等替代。 2. **`npx shadcn-ui add` 覆盖修改**:再次 add 同名组件会覆盖你改过的 版本。要么不再 add(自己维护),要么 git 改动检查。 3. **`asChild` 模式坑**:`<Button asChild><a href="/">link</a></Button>` 把样式应用到 child 上。新人不知道这个 pattern 时疑惑。 Radix Slot 的设计,多看几次就熟。 4. **CSS 变量值是 HSL 三段值不是颜色字符串**:`--primary: 220 90% 56%` 不是 `--primary: hsl(220, 90%, 56%)`。tailwind config 用 `hsl(var(--primary) / <alpha-value>)` 拼。 5. **monorepo 共享 components/ui**:复制到每个 app 都改 = 重复劳动。 抽到 `packages/ui` 共享,但 shadcn 自动 add 路径不知道。 手动维护 import 路径或写自己的 CLI wrapper。
TypeScript 的标准库带了几十个 utility type。熟练用能少写一半重复定义。 下面只挑实战频率最高的 20+ 个 + 5 个我自己常造的。 ## 内置常用 ### `Partial<T>` / `Required<T>` ```ts interface User { id: string; name: string; email: string } // 所有字段可选(PATCH 接口最常用) function update(id: string, patch: Partial<User>) {} update('1', { name: 'Alice' }) // OK // 所有字段必选 type FullUser = Required<Partial<User>> ``` ### `Pick<T, K>` / `Omit<T, K>` ```ts type UserPublic = Pick<User, 'id' | 'name'> // 只要这些字段 type UserPrivate = Omit<User, 'email' | 'password'> // 去掉这些字段 ``` ### `Record<K, V>` ```ts type StatusMap = Record<'pending' | 'done' | 'failed', number> // { pending: number; done: number; failed: number } type Cache<T> = Record<string, T> // 字典 ``` ### `Readonly<T>` / `ReadonlyArray<T>` ```ts function process(items: ReadonlyArray<string>) { items.push('x') // ❌ 编译错 } ``` ### `ReturnType<F>` / `Parameters<F>` ```ts function fetchUser(id: string): Promise<User> { ... } type UserResp = Awaited<ReturnType<typeof fetchUser>> // User type Args = Parameters<typeof fetchUser> // [string] ``` `Awaited<T>` 解 Promise 包装。 ### `Exclude<T, U>` / `Extract<T, U>` ```ts type Color = 'red' | 'green' | 'blue' | 'transparent' type Opaque = Exclude<Color, 'transparent'> // 'red' | 'green' | 'blue' type Warm = Extract<Color, 'red' | 'orange'> // 'red' ``` ### `NonNullable<T>` ```ts type StringOrNull = string | null | undefined type DefinitelyString = NonNullable<StringOrNull> // string ``` ### `Awaited<T>` ```ts type A = Awaited<Promise<Promise<number>>> // number ``` 递归解包 Promise。 ## 自定义常用 ### `DeepPartial<T>` — 递归 Partial ```ts type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T type Config = { a: { b: { c: number } } } type PartialConfig = DeepPartial<Config> // { a?: { b?: { c?: number } } } ``` 适合 patch 嵌套对象。 ### `DeepReadonly<T>` ```ts type DeepReadonly<T> = T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T ``` ### `PickRequired<T, K>` — 指定字段必选,其它保持 ```ts type PickRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] } interface Opts { name?: string; age?: number; email?: string } type WithName = PickRequired<Opts, 'name'> // { name: string; age?: number; email?: string } ``` `-?` 是 "去掉 optional 修饰"。 ### `Branded<T, B>` — 名义类型(防混淆) ```ts type Branded<T, B extends string> = T & { __brand: B } type UserId = Branded<string, 'UserId'> type PostId = Branded<string, 'PostId'> function getUser(id: UserId): User { ... } const uid = 'u1' as UserId const pid = 'p1' as PostId getUser(uid) // OK getUser(pid) // ❌ Type 'PostId' is not assignable to 'UserId' ``` 让基础类型(string / number)按业务含义区分,编译期阻止误用。 ### `ValueOf<T>` ```ts type ValueOf<T> = T[keyof T] const Status = { Active: 'active', Inactive: 'inactive' } as const type StatusVal = ValueOf<typeof Status> // 'active' | 'inactive' ``` ### `Mutable<T>` — 反向 Readonly ```ts type Mutable<T> = { -readonly [K in keyof T]: T[K] } ``` ### `XOR<T, U>` — 两选一不能同时 ```ts type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never } type XOR<T, U> = (Without<T, U> & U) | (Without<U, T> & T) type Login = XOR<{ email: string }, { phone: string }> // 必须传 email 或 phone,不能两个都传 ``` ## 高级:条件类型 + infer ```ts // 提取数组元素类型 type ArrayElement<T> = T extends (infer E)[] ? E : never type Item = ArrayElement<string[]> // string // 提取函数返回类型(重新实现 ReturnType) type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never // 提取 Promise 解析后类型(重新实现 Awaited) type Unpromise<T> = T extends Promise<infer V> ? V : T ``` `infer` 让你在条件类型里"声明并捕获"一个类型变量。 ## 字符串字面量类型 ```ts type CSSVar<S extends string> = `--${S}` type X = CSSVar<'primary'> // "--primary" type ToCamelCase<S extends string> = S extends `${infer A}-${infer B}${infer Rest}` ? `${A}${Uppercase<B>}${ToCamelCase<Rest>}` : S type C = ToCamelCase<'foo-bar-baz'> // "fooBarBaz" ``` 模板字面量类型 + infer 让 TS 能做一些"字符串编程"。但别滥用—— 复杂的会让编辑器卡 + 错误信息难懂。 ## 类型守卫 ```ts function isError(x: unknown): x is Error { return x instanceof Error } try { ... } catch (e) { if (isError(e)) { console.error(e.message) // TS 知道 e 是 Error } } ``` `x is Type` 让 TS 在 if 分支里 narrow 类型。 ## type vs interface 何时选 - 简单对象 / 可被扩展(库提供的):interface - 联合 / 交叉 / 字面量 / 复杂条件:type - 不知道选哪个:type(更通用) 性能上几乎无差。 ## 工具:type-fest [type-fest](https://github.com/sindresorhus/type-fest) 收集了 100+ 实用 utility type: ```bash npm i type-fest ``` ```ts import type { SetRequired, JsonValue, CamelCase } from 'type-fest' type User = { name?: string; age?: number } type WithName = SetRequired<User, 'name'> ``` 不要每个项目都自己实现 `DeepPartial`,引这个库直接用。 ## 踩过的坑 - `Partial<DeepFoo>` 不递归。要 `DeepPartial<...>`。 - `Exclude<T, U>` 看似"减去",但 U 必须是 T 的子集;不是的话什么都不去。 - `as Type` 强制断言不安全(编译器信你的)。能用类型守卫就用。 - 复杂 conditional type 报错时 TS 错误信息可达几十行。把复杂 type 拆 成中间命名 type,错误位置更清楚。
## 起因 公司项目演化: - 1 个 web app → 加 mobile app(React Native) - 加 admin dashboard - 加 marketing site - 加 design system 组件库 - 加 shared TS types / API client 多 repo 痛点: - shared code 要 publish package - 跨 repo PR 难 coordinate - 升级 dep 各 repo 跟不齐 monorepo 解决但 build / test / cache 是新挑战。 **Turborepo** / **Nx** 解决"哪些 task 要跑 + 缓存什么"。 ## Turborepo Vercel 收购,2022+。 ``` monorepo/ ├── apps/ │ ├── web/ # Next.js │ ├── mobile/ # React Native │ └── admin/ ├── packages/ │ ├── ui/ # shared component │ ├── api-client/ │ └── tsconfig/ ├── package.json ├── turbo.json └── pnpm-workspace.yaml ``` `pnpm-workspace.yaml`: ```yaml packages: - 'apps/*' - 'packages/*' ``` `turbo.json`: ```json { "tasks": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] }, "test": { "dependsOn": ["build"] }, "lint": {}, "dev": { "cache": false, "persistent": true } } } ``` `^build` = 先跑依赖 package 的 build。 ## 用法 ```bash pnpm turbo build # build 所有 package(按 dep order) pnpm turbo test # 同上 pnpm turbo dev # 跑所有 dev server pnpm turbo build --filter=web # 只 build web app + 它依赖 ``` 第一次跑 build:跑全部 task。 第二次跑 build(无改动):**全部 cache hit,零执行**。 ## cache 怎么工作 Turbo 把每 task 的: - input file content hash - env var - dependency hash → cache key。命中 cache:直接复用 output(dist/)。 ``` ✔ ui:build cached (3.2s saved) ✔ api-client:build cached ○ web:build finished (cached) 4.5s ``` 改 1 个 package 的 source → 它跟它的 dependent 重 build;其它仍 cache hit。 ## 远程 cache Turbo cache 默认本地。多人开发 / CI 同一计算重复跑。 remote cache: ```bash npx turbo login npx turbo link ``` 把 cache 推 Vercel cloud / 自托管 → 同事 pull 后 cache 命中(同 commit 他不必再跑 build)。 我们 6 人团队 CI 时间从 12 分钟 → 2 分钟(90% cache hit)。 ## Nx Nrwl 出,2017+。比 turbo 更老更全。 ```bash npx create-nx-workspace@latest myorg ``` Nx 提供: - task scheduling + cache(跟 Turbo 类似) - generator(一键 scaffold app / lib) - 依赖图可视化 - 多语言(不只 JS,也支持 .NET / Go via plugin) - consistent project structure 强制 `nx.json`: ```json { "targetDefaults": { "build": { "cache": true, "inputs": ["default", "^production"] } }, "tasksRunnerOptions": { "default": { "runner": "nx/tasks-runners/default", "options": { "cacheableOperations": ["build", "test", "lint"] } } } } ``` ```bash nx build web nx affected -t test # 只跑受影响 package nx graph # 浏览器看 dependency 图 ``` `nx affected` 是杀器:基于 git diff 判定哪 package 改了,只跑相关。 ## Turbo vs Nx | | Turbo | Nx | |---|---|---| | 设计 | 轻量 task runner | 全套 monorepo solution | | 学习曲线 | 低 | 中高 | | generator | 无 | 强(scaffold app / lib) | | 依赖图 UI | 无 | 强 | | 多语言 | JS only | JS + 插件 | | 远程 cache | Vercel / 自托管 | Nx Cloud / 自托管 | | 强约束 | 少 | 多("Nx way") | **Turbo**:你已有 monorepo + 想加 cache → 5 分钟接入。 **Nx**:从 0 开始 + 想 batteries-included → Nx。 我个人 Turbo 多(不喜欢被框架强约束)。 ## 内部 package 引用 ```json // apps/web/package.json { "dependencies": { "@myorg/ui": "workspace:*", "@myorg/api-client": "workspace:*" } } ``` `workspace:*` 让 pnpm 链接到本地 package(不发 npm)。 ```tsx // apps/web/app/page.tsx import { Button } from '@myorg/ui'; import { fetchPosts } from '@myorg/api-client'; ``` 跟普通 npm package 一样 import。 ## shared tsconfig ```json // packages/tsconfig/base.json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "strict": true, "moduleResolution": "Bundler" } } ``` ```json // apps/web/tsconfig.json { "extends": "@myorg/tsconfig/base", "compilerOptions": { ... } } ``` config 一处改 全 repo 受用。 ## CI 优化 ```yaml # GitHub Actions - uses: pnpm/action-setup@v2 - run: pnpm install - run: pnpm turbo build test lint --concurrency=4 ``` Turbo 跟 GH cache action 配合 → cache 远程拉。 PR 时间从 15 分钟 → 3 分钟(动几个 package 重 build,其余 cache)。 ## 真实 case 我们 monorepo 演化: ``` 最初: 单 Next.js 项目 ↓ 加 admin dashboard (Next.js) → 共享 component 想抽 ↓ 建 monorepo + pnpm workspace + Turbo ↓ packages/ui (shared component) packages/api-types (类型 + zod schema) packages/tsconfig apps/web, apps/admin, apps/marketing ↓ 后来加 packages/api-client (axios + types) ↓ team grow 到 8 人 → 加 remote cache ``` 效果: - 改 ui component → web + admin 一起测 - 共享 type 跨 frontend / backend(Node 后端也 import api-types) - CI 快 5x - 新成员 onboarding:clone 1 repo 全 setup ## 不要过早 monorepo 单 app + < 3 人 → 不必。 N app + shared code → monorepo + Turbo。 ## 与 yarn workspaces / npm workspaces pnpm 是 monorepo 最强: - 严格 dependency 隔离 - 链接速度快 - workspaces 原生 npm workspaces / yarn workspaces 也行但 pnpm 主流。 ## 踩过的坑 1. **依赖循环**:`@myorg/ui` 依赖 `@myorg/utils` → utils 依赖 ui → build 报错。`nx graph` / `turbo` 看依赖图理清。 2. **build output 没指定**:turbo 不知道 cache 啥 → cache miss 一直。 `"outputs": [".next/**", "dist/**"]` 必填。 3. **env 变量影响 cache**:env 改了应该 invalidate cache。 `"env": ["NODE_ENV"]` 配置。 4. **monorepo 大 后 IDE 慢**:100+ package 后 TS server 慢。 project references + 限 indexing scope。 5. **publish 仍麻烦**:内部 package 不 publish OK。但要 publish shared design system → changesets 工具帮 version + changelog。
## 起因 团队主要写 React,但接手一个 Vue 2 老项目。Vue 2 的 Options API(data/methods/ computed 分块)跟 React hooks 风格完全不一样,写起来不顺手。 升级到 Vue 3 + Composition API 后语义跟 React 接近多了,迁移 成本下降。下面记录我的"React 视角看 Vue 3"。 ## script setup 语法 Vue 3.2+ 的 `<script setup>` 是 Composition API 的语法糖: ```vue <script setup lang="ts"> import { ref, computed, watch, onMounted } from 'vue' const count = ref(0) const doubled = computed(() => count.value * 2) function increment() { count.value++ } watch(count, (newVal, oldVal) => { console.log(`count ${oldVal} → ${newVal}`) }) onMounted(() => { console.log('mounted') }) </script> <template> <button @click="increment">{{ count }} (doubled: {{ doubled }})</button> </template> ``` 对比 React: ```tsx const [count, setCount] = useState(0) const doubled = useMemo(() => count * 2, [count]) useEffect(() => { console.log(`count: ${count}`) }, [count]) useEffect(() => { console.log('mounted') }, []) return <button onClick={() => setCount(c => c + 1)}>{count}</button> ``` Vue 的 `ref(0)` ≈ React 的 `useState`。访问值要 `.value`(template 里 自动 unwrap)。`computed` ≈ `useMemo`。`watch` ≈ `useEffect` with deps。 ## ref vs reactive ```ts import { ref, reactive } from 'vue' const counter = ref(0) console.log(counter.value) counter.value = 10 const state = reactive({ count: 0, name: '' }) console.log(state.count) state.count = 10 ``` `ref` 适合基础类型;`reactive` 适合对象。 **重要**:reactive 不能解构(会失去响应性): ```ts const state = reactive({ count: 0 }) const { count } = state // ❌ count 不再响应 ``` 要解构必须 `toRefs`: ```ts import { toRefs } from 'vue' const { count } = toRefs(state) // ✅ ``` 为了避免这个坑,我直接全用 ref,复杂状态用 `ref({ count: 0, name: '' })`。 ## 组合函数(custom hook 等价物) 抽象逻辑成 `useXxx`: ```ts // composables/useCounter.ts import { ref, computed } from 'vue' export function useCounter(initial = 0) { const count = ref(initial) const doubled = computed(() => count.value * 2) const inc = () => count.value++ const dec = () => count.value-- return { count, doubled, inc, dec } } ``` 用: ```vue <script setup> import { useCounter } from '@/composables/useCounter' const { count, doubled, inc } = useCounter(10) </script> ``` 完全等价于 React custom hook。 ## defineProps / defineEmits(类型化 props / events) ```vue <script setup lang="ts"> const props = defineProps<{ user: { id: string; name: string } count?: number }>() const emit = defineEmits<{ (e: 'increment', value: number): void (e: 'reset'): void }>() function bump() { emit('increment', (props.count ?? 0) + 1) } </script> <template> <button @click="bump">{{ user.name }}: {{ count }}</button> </template> ``` 父组件: ```vue <MyButton :user="me" :count="5" @increment="handleInc" @reset="handleReset" /> ``` 类型完全跟着传。 ## v-model 双向绑定 React 强迫你手写 `value={x} onChange={...}`。Vue 的 v-model 是糖: ```vue <!-- 父 --> <MyInput v-model="email" /> <!-- 子 --> <script setup> const props = defineProps<{ modelValue: string }>() const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>() </script> <template> <input :value="modelValue" @input="emit('update:modelValue', $event.target.value)"> </template> ``` 简化版用 `defineModel`(Vue 3.4+): ```vue <script setup> const email = defineModel<string>() </script> <template> <input v-model="email"> </template> ``` 3 行干完。React 里至少 8-10 行。 ## Pinia:Vue 的状态管理 ```bash npm i pinia ``` ```ts // stores/auth.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' export const useAuthStore = defineStore('auth', () => { const user = ref<User | null>(null) const isLoggedIn = computed(() => user.value !== null) async function login(email: string, pw: string) { const r = await fetch('/api/login', { method: 'POST', body: ... }) user.value = await r.json() } function logout() { user.value = null } return { user, isLoggedIn, login, logout } }) ``` ```vue <script setup> import { useAuthStore } from '@/stores/auth' const auth = useAuthStore() function onSubmit() { auth.login(email.value, password.value) } </script> ``` 跟 Zustand 类似(但写法是 composition function 而非 set/get)。 ## 路由 ```bash npm i vue-router@4 ``` ```ts // router.ts import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('./views/Home.vue') }, { path: '/posts/:id', component: () => import('./views/Post.vue') }, ], }) export default router ``` ```vue <script setup> import { useRoute, useRouter } from 'vue-router' const route = useRoute() // /posts/42 → route.params.id = '42' const router = useRouter() function goHome() { router.push('/') } </script> ``` 完全镜像 React Router 的 useParams / useNavigate。 ## SSR / SSG: Nuxt 类似 React 的 Next.js,Vue 的元框架是 Nuxt: ```bash npx nuxi@latest init my-app ``` 文件系统路由、自动 import、SSR、API routes 等都是 Nuxt 的事,开箱即用。 ## Vue 比 React 强的几点 1. **template + script + style 三段式**:scoped CSS 内置, `<style scoped>` 自动加 hash,组件样式不互相污染 2. **响应式不需要写依赖数组**:`watch` 自动追踪。React `useEffect` 写错 deps 是常见 bug。 3. **v-for, v-if, v-show**:模板里写就够,不像 JSX 强 `.map()` / `&&` 4. **defineModel 简化双向绑定** ## React 比 Vue 强的几点 1. **更大生态**:Next.js、Remix、库选择更多 2. **TypeScript 更原生**:Vue 也支持但偶尔有些边角不丝滑 3. **更广泛的工作机会** ## 效果 我个人混着写下来: - Vue 项目代码量比同等 React 少 20-30% - 模板比 JSX 更适合"以视图为中心"的页面(marketing / 表单密集) - 复杂状态 / 复杂交互 React 生态成熟度高 - 个人选择:marketing / 后台管理 → Vue + Nuxt; 复杂 web app → React ## 踩过的坑 1. **reactive 解构丢响应性**:上面说过。新人最容易踩。 2. **template 里 ref 自动 unwrap**: ```vue <script setup> const count = ref(0) </script> <template>{{ count }}</template> <!-- 自动 .value --> ``` 习惯之后没事,刚转过来时反复 `{{ count.value }}` 多写 `.value`。 3. **`v-html` 直接 innerHTML**:XSS 隐患。任何用户输入永远不用 v-html。 4. **Vue 3 跟 Vue 2 写法不通用**:老 Vue 2 项目升 3 是 breaking change, 不是 React 16 → 17 那种平滑升级。 5. **Pinia store 在 setup() 外用要 createPinia 先注入**: `main.ts` 必须 `app.use(createPinia())`,否则报 "no active pinia"。
## 起因 老项目 JS(或松散 TS)想拧紧 strict mode: - `noImplicitAny` - `strictNullChecks` - `strictFunctionTypes` - `noImplicitThis` - `strictPropertyInitialization` - `alwaysStrict` 一次性全开 → 几千 type error → 团队崩。 渐进 rollout 是正解。 ## strict 全开 ```json { "compilerOptions": { "strict": true, // 等价于全开上面 6 个 } } ``` 新项目直接 strict。 ## 老项目分步骤 ```json { "compilerOptions": { "strict": false, "noImplicitAny": true, "strictNullChecks": false, ... } } ``` 一个一个加,每个加完跑全 ts check + 修。 推荐顺序: 1. `noImplicitAny`(多数 type 自动推或 explicit) 2. `strictNullChecks`(最大改动,但最值) 3. `strictFunctionTypes` 4. 其它 ## strictNullChecks 影响 ```ts // 关 strictNullChecks function getName(user) { // user: any return user.name; // 没事 } getName(null); // 没报,但运行时崩 // 开 function getName(user: User | null) { return user.name; // ❌ Object is possibly 'null' } function getNameSafe(user: User | null) { return user?.name; // ✅ } ``` 每函数 explicit handle null。 最大价值:编译期 catch null pointer 问题。 ## 渐进:per-file strict `@ts-strict` 风格 comment / 工具: ```ts // @ts-check // @ts-strict ``` 或者用 `ts-strict-plugin`、单 file `// @ts-expect-error` 标技术债。 ## TypeScript per-file 更激进:拆 tsconfig 多个 project: ```json // tsconfig.strict.json { "extends": "./tsconfig.json", "compilerOptions": { "strict": true }, "include": ["src/strict/**"] } ``` `src/strict/` 下严格,其余宽。 新代码进 strict,老代码慢慢迁。 ## 工具帮迁移 - `ts-migrate`(Airbnb 出):批量 JS → TS + 加 `// @ts-expect-error` - `typescript-strict-plugin`:渐进 strict per-file ## 渐进示例 ``` Week 1: 装 TS, allowJs: true, 跑 baseline Week 2: noImplicitAny on, 修 (100 error) Week 3: 把 critical paths 改 strict (per-file) Week 4: strictNullChecks on, 修 (500 error,多数小) Month 2: 全 strict ``` ## 常见迁移 pattern ```ts // 之前 function process(data) { if (data.user.address.city) { ... } } // 改 1: 类型 interface User { address?: Address } interface Address { city?: string } function process(data: { user: User }) { if (data.user.address?.city) { ... } // optional chaining } // 改 2: 早返 function process(data: { user: User }) { const city = data.user.address?.city; if (!city) return; // city 类型 narrowed string } ``` optional chaining + early return 是最常用 strict-friendly pattern。 ## 第三方 lib 没 type ```ts // 装 @types npm install -D @types/lodash // 没 @types 的 declare module 'weird-lib' { export function doStuff(x: any): any; } ``` 最差情况 cast: ```ts const lib = require('weird-lib') as any; ``` 但 `as any` 会传染 → 限定到边界。 ## any vs unknown ```ts function parse(input: any) { // 危险 return input.value; // 不报,但运行时崩 } function parse(input: unknown) { // 强制处理 if (typeof input === 'object' && input && 'value' in input) { return input.value; // narrowed } } ``` `unknown` 是 type-safe 版 `any`。强制 narrow 后用。 新代码用 unknown 替代 any。 ## strictNullChecks 后的常见错 ```ts // 1. array access const arr: string[] = []; const first: string = arr[0]; // ❌ undefined if empty // noUncheckedIndexedAccess: true const first: string | undefined = arr[0]; // 2. delete const obj: { x?: number } = { x: 1 }; delete obj.x; // 必须先 optional // 3. JSON.parse const data: unknown = JSON.parse(s); // 不能直接 data.x → Zod / type guard ``` ## CI gate ```json // package.json "scripts": { "typecheck": "tsc --noEmit", "typecheck:strict": "tsc --project tsconfig.strict.json --noEmit" } ``` ```yaml # CI - run: pnpm typecheck - run: pnpm typecheck:strict # 只检查 strict files ``` PR 改动 strict file → 必须过 strict check。 慢慢"扩 strict 边界"。 ## 真实迁移 某老项目 (50k LOC JS): - 全开 strict 一次:3000 error,回归测试一周 - 改渐进:4 个月 - 月 1:装 TS + allowJs,0 改 → 跑通 - 月 2:noImplicitAny + 主要路径手加 type → 50% 文件 typed - 月 3:strictNullChecks,整修 → 1500 changes - 月 4:全 strict + tslint → strict - 期间也持续开发新 feature 迁完收益: - 生产 NullPointer error 降 80% - IDE auto-complete 准 - 重构信心大增 - 新人 onboarding 快(看类型就懂 API) ## skipLibCheck ```json { "skipLibCheck": true // 第三方 .d.ts 不 check } ``` 大型项目几乎必开。第三方 type 偶有小问题不卡你。 ## 不要追求 100% `any` 不是恶魔。 边界(unknown API / migration code / quick prototype)用 `any` 标 `// FIXME: type later`。 内部核心 type-tight 90% 已经收益大头。 ## 与 jsdoc TS 对比 ```js // JS file + JSDoc /** * @param {string} name * @returns {string} */ function greet(name) { return `Hi ${name}`; } ``` `// @ts-check` 让 TS check JSDoc 类型。 适合:不想转 .ts 但想要 type-check(如 lib / 小项目)。 中大型 → 直接 .ts。 ## 踩过的坑 1. **`Object.keys` 返回 string[]**:`Object.keys({a:1, b:2}).forEach((k) => obj[k])` 报 string can't index Foo。`(Object.keys(obj) as (keyof Foo)[])` cast。 2. **DOM null**:`document.querySelector('.x')` 返 Element | null。 `!` non-null assert 慎用。 3. **callback this**:strict 模式下 method as callback 失 `this`。 bind / arrow。 4. **enum 类型**:`enum X { A, B }` 编译有运行时 object。 `const X = { A: 'a', B: 'b' } as const; type X = (typeof X)[keyof typeof X]` 更轻。 5. **复杂 generic**:递归 / mapped type 编译慢。简化或拆。