知识广场

按学科筛选:计算机科学 / 前端开发
清除筛选

«计算机科学 / 前端开发» 分类下共 52 篇帖子

CSS variables 做运行时主题:用户自选色彩 + 实时切换

## 起因 设计师要"用户能自选主色"——蓝色 / 紫色 / 红色 / 绿色等 5 个选项, 切换立刻全 UI 跟着变。 传统做法:每个主题独立 stylesheet,user 选 → 切换 `<link>` href。 重 + 慢 + 全 UI 闪一下。 CSS 变量(custom properties)让"颜色变量化",运行时改 root 上的变量值 → 所有引用立刻更新,无闪烁、无重新 load。 ## 基础 ```css :root { --color-primary: #2563eb; --color-primary-hover: #1d4ed8; --color-text: #1f2937; --color-bg: #ffffff; } button.primary { background: var(--color-primary); color: white; } button.primary:hover { background: var(--color-primary-hover); } ``` JS 改变量: ```js document.documentElement.style.setProperty('--color-primary', '#9333ea') // 所有用了 var(--color-primary) 的元素瞬间变紫 ``` ## 多主题 preset ```css :root { --color-primary: #2563eb; /* default 蓝 */ --color-primary-hover: #1d4ed8; } [data-theme="purple"] { --color-primary: #9333ea; --color-primary-hover: #7e22ce; } [data-theme="red"] { --color-primary: #dc2626; --color-primary-hover: #b91c1c; } [data-theme="green"] { --color-primary: #16a34a; --color-primary-hover: #15803d; } ``` 切换: ```js document.documentElement.setAttribute('data-theme', 'purple') ``` 零延迟切换。 ## 派生:HSL + alpha 灵活 把 primary 拆 H/S/L 分量存: ```css :root { --color-primary-h: 220; --color-primary-s: 90%; --color-primary-l: 56%; } button { background: hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l)); border: 1px solid hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l) / 0.3); } button:hover { /* hover 加深:l - 10% */ background: hsl(var(--color-primary-h) var(--color-primary-s) calc(var(--color-primary-l) - 10%)); } ``` 只改 hue 就换主题(不用每个 shade 独立定义): ```js root.style.setProperty('--color-primary-h', '270') // 紫 root.style.setProperty('--color-primary-h', '0') // 红 root.style.setProperty('--color-primary-h', '140') // 绿 ``` 省定义。 ## 用 color-mix(CSS Color 5) ```css button { background: var(--color-primary); } button:hover { background: color-mix(in srgb, var(--color-primary) 80%, black); } ``` `color-mix` 让浏览器自动算"加深 20%",不需要 hover-specific 变量。 Chrome 111+ / Safari 16.2+ / Firefox 113+ 支持。 ## 用户自定义主色:color picker ```tsx function ThemeCustomizer() { const [hue, setHue] = useState(220) useEffect(() => { document.documentElement.style.setProperty('--color-primary-h', hue.toString()) localStorage.setItem('theme-hue', hue.toString()) }, [hue]) return ( <div> <label>主色 hue</label> <input type="range" min="0" max="360" value={hue} onChange={e => setHue(+e.target.value)} /> <div style={{ background: `hsl(${hue} 90% 56%)`, width: 100, height: 30 }} /> </div> ) } ``` slider 拖动 → 全站颜色实时变。 保存到 localStorage → 下次访问还原。 ## CSS Color 5 高级 ```css :root { --color-primary: oklch(60% 0.2 250); } .text-on-primary { /* 自动算对比色 */ color: oklch(from var(--color-primary) calc(l > 50% ? 0 : 100%) 0 0); } ``` oklch 色彩空间感知更线性,主题派生更自然。 relative color syntax 让 "从 primary 派生 text" 一行写完。 Browser 支持 2024 大多 evergreen 已经 OK。 ## 暗色模式 ```css :root { --color-bg: white; --color-text: #111; } @media (prefers-color-scheme: dark) { :root { --color-bg: #0d1117; --color-text: #e6edf3; } } /* 手动覆盖 */ [data-mode="dark"] { --color-bg: #0d1117; --color-text: #e6edf3; } [data-mode="light"] { --color-bg: white; --color-text: #111; } ``` `<html data-mode="dark">` 强制;不设 attr 跟系统。 ## 防止 FOUC 如果初始化 theme 在 React 之后 → 闪一下默认主题再切。 解决:inline script 在 head 顶部立刻设: ```html <head> <script> (function() { const saved = localStorage.getItem('theme') if (saved) document.documentElement.setAttribute('data-theme', saved) })() </script> <link rel="stylesheet" href="/main.css"> </head> ``` 页面渲染前 theme attribute 已经在 → 没有 FOUC。 ## CSS 框架配合 Tailwind 用 CSS variables 已经标准化(v3.3+): ```js // tailwind.config.ts theme: { extend: { colors: { primary: 'hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l) / <alpha-value>)', }, }, } ``` ```html <button class="bg-primary text-primary-foreground">Click</button> <!-- bg-primary 自动用 CSS variable --> ``` 切换 variable → 所有 Tailwind class 立刻变。 ## 性能 CSS variable 修改是浏览器层面 reflow / repaint,非常快。 全站千个 element 用同一 variable 切换 < 1 frame(16ms)。 对比"重新 load stylesheet" 切换: - 重新解析 CSS(10-100ms) - 全 DOM repaint - 偶尔 flash variable 切换胜出。 ## 与 CSS-in-JS 对比 ```tsx // emotion / styled-components 风格 const StyledButton = styled.button` background: ${props => props.theme.primary}; ` <ThemeProvider theme={{ primary: 'blue' }}> <StyledButton>Click</StyledButton> </ThemeProvider> ``` CSS-in-JS theming: - 优点:JS 完整控制 + 静态 type - 缺点:bundle 大 + 运行时开销 + SSR 复杂 CSS variable: - 优点:原生 + 零运行时 + 兼容任何框架 - 缺点:无类型 + JS 改时要 string 我个人 2024 后偏向 CSS variable + Tailwind / shadcn 的方案, CSS-in-JS 用得少了。 ## 限制 / 注意 ### 1. variable 不能用在 @media 条件本身 ```css :root { --break: 768px; } /* ❌ 不行 */ @media (min-width: var(--break)) { ... } ``` @media 查询的值必须是 literal。 ### 2. variable 不能改 @keyframes ```css @keyframes slide { from { left: 0; } to { left: var(--target); } /* 这能用 */ } ``` keyframes 内部用 variable OK;但改 variable 后正在跑的动画不会重新算。 ### 3. 旧 IE / 老 Safari 不支持 IE 11 全不支持;Safari 9.1+ 支持但有 bug。 2024 这些已经不需考虑。 ## 我们的主题系统 ```css :root { /* 基础 hue 用户可调 */ --hue: 220; /* 派生:primary 系列 */ --color-primary-50: hsl(var(--hue) 90% 96%); --color-primary-100: hsl(var(--hue) 90% 90%); --color-primary-500: hsl(var(--hue) 90% 56%); --color-primary-700: hsl(var(--hue) 90% 36%); /* 派生:text on primary(自动选黑/白) */ --text-on-primary: white; /* 中性色 */ --color-gray-50: oklch(98% 0 0); --color-gray-500: oklch(50% 0 0); --color-gray-900: oklch(20% 0 0); } @media (prefers-color-scheme: dark) { :root { --color-primary-500: hsl(var(--hue) 80% 65%); /* 暗色下提亮 */ } } ``` 用户改 `--hue` → 整套主题色协调地变。 设计师 once 调好 hue 与各 shade 关系,用户只动 hue 不破坏视觉一致。 ## 踩过的坑 1. **`var(--x)` 没 fallback** → variable 没定义时整个声明无效: ```css color: var(--missing); /* 整个 color 不生效 */ color: var(--missing, black); /* fallback 到 black */ ``` 2. **JS setProperty 大小写敏感**:variable 名是 `--Color-Primary` 但 setProperty('--color-primary', ...) 设错变量。 3. **shadow DOM 隔离**:custom element 内部不继承 :root 变量。 需要在 shadow boundary 显式 forward。 4. **calc 不能跨单位**:`calc(var(--x) * 2px)` 错(var 已经带单位)。 存 unitless number + 在 calc 加 unit。 5. **打印模式**:`@media print` 没单独考虑主题 variable → 打印出来 是 default 颜色。专门 `@media print` 覆盖。

用 CSS 变量 + prefers-color-scheme 实现暗色模式(含手动切换)

暗色模式现在是基础体验。正确做法是用 CSS 变量统一所有颜色, 然后 `@media (prefers-color-scheme: dark)` 覆盖变量。 手动切换则用 `[data-theme="dark"]` 选择器。 ## 1. 颜色变量化 ```css :root { --bg: #ffffff; --card: #f8f9fa; --text: #1a1a1a; --text-soft: #6b7280; --border: #e5e7eb; --primary: #2563eb; --primary-soft: #eff6ff; } body { background: var(--bg); color: var(--text); } .card { background: var(--card); border: 1px solid var(--border); } ``` 任何颜色都引用变量,绝不写死 `color: #333`。 ## 2. 自动跟随系统(最少代码) ```css @media (prefers-color-scheme: dark) { :root { --bg: #0f172a; --card: #1e293b; --text: #f1f5f9; --text-soft: #94a3b8; --border: #334155; --primary: #60a5fa; --primary-soft: #1e3a8a; } } ``` 只这样:暗色模式自动跟系统设置变。 ## 3. 手动切换(覆盖系统设置) 加 `[data-theme]` 优先级: ```css :root { --bg: #fff; /* ... */ } @media (prefers-color-scheme: dark) { :root { --bg: #0f172a; /* ... */ } } [data-theme="dark"] { --bg: #0f172a; /* ... */ } [data-theme="light"] { --bg: #ffffff; /* ... */ } ``` JS: ```js function setTheme(theme) { // 'dark' | 'light' | 'system' if (theme === 'system') { document.documentElement.removeAttribute('data-theme') localStorage.removeItem('theme') } else { document.documentElement.setAttribute('data-theme', theme) localStorage.setItem('theme', theme) } } // 启动时还原 const saved = localStorage.getItem('theme') if (saved) document.documentElement.setAttribute('data-theme', saved) ``` ## 4. 避免 FOUC(First Of Unstyled Content) 如果上面的代码放在 `<script>` 模块末尾,会有一闪而过的错误主题。 解决:把"还原主题"提前到 `<head>` 顶部、inline 脚本: ```html <head> <script> (function() { var saved = localStorage.getItem('theme') if (saved) document.documentElement.setAttribute('data-theme', saved) })() </script> <!-- 后面才是 CSS --> </head> ``` inline 脚本同步执行,CSS 应用前主题已就位。 ## 5. 监听系统切换 用户用着用着把系统切到暗色——网页要立刻跟上: ```js const mq = window.matchMedia('(prefers-color-scheme: dark)') mq.addEventListener('change', e => { // 只在没设手动主题时跟随 if (!localStorage.getItem('theme')) { // CSS @media 会自动响应,这里不需要做什么,但如果你有 JS // 在用 mediaquery 判断当前主题,要刷新 updateChartTheme(e.matches ? 'dark' : 'light') } }) ``` ## 6. 图像 / iframe 内容怎么办 普通 `<img>` 没法跟随暗色。常用方案: ```html <picture> <source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)"> <img src="logo-light.svg" alt="Logo"> </picture> ``` SVG icon 是 CSS color 控制的(用 `currentColor`): ```html <svg class="icon" fill="currentColor">...</svg> ``` ```css .icon { color: var(--text); } ``` iframe 内容(地图、第三方嵌入)就只能自己看是否支持 `?theme=dark` 参数。 ## 7. 配色技巧 不是简单地"颜色取反",几个要点: - 暗色背景不要纯黑(#000),用 #0d1117 / #0f172a 这种"接近黑" 减少 contrast 疲劳 - 卡片背景比 body 稍亮(如 #161b22) - 文字 #e6edf3 而不是 #fff,避免高对比刺眼 - 主色 brand color 通常要在暗色下稍微 desaturate + lighten - shadow 在暗色下基本看不见,可以用 border 替代分隔 GitHub Dark / Tailwind slate 系列是优秀范本,直接借用色值。 ## 8. 给暗色添加缓动 切换时颜色突变很扎眼,加 transition: ```css :root { /* ... */ } body, .card, .button { transition: background-color .2s, color .2s, border-color .2s; } ``` 但别把 `transition: all` 加在所有元素上,会触发不必要的重排。 ## 踩过的坑 - 写死的 SVG 颜色 / inline style 是暗色模式的常见漏网之鱼。grep 整个 代码库 `#[0-9a-fA-F]{3,6}` 看哪些没用变量。 - 图表库 / 代码高亮通常自带主题,需要单独切。Pygments / Highlight.js 都有 light + dark theme 包。 - 系统暗色模式但用户手动选浅色——很多人不太会用,记得在 settings 里 加 "跟随系统 / 强制亮色 / 强制暗色" 三选项。 - 别忘了 favicon / `meta[name=theme-color]` 也支持 prefers-color-scheme: ```html <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)"> ```

CSS View Transitions API:原生页面切换动画(不再装 Framer Motion)

## 起因 要做"列表页 → 详情页"的过渡动画——卡片放大变成详情头图。 传统做法:装 Framer Motion / GSAP 写 layoutId 切换,几十行 JS。 `View Transitions API` 是 2024 年 Chrome 全面支持的浏览器原生功能, 两行 CSS + 一行 JS 就能让任何 DOM 切换变成 morph 动画。 ## 解决方案 ### 1. 最简单的同页面状态切换 ```html <style> ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.3s; } </style> <button id="toggle">切换</button> <div id="content">Hello</div> <script> document.getElementById('toggle').addEventListener('click', () => { if (!document.startViewTransition) { // fallback: 不支持的浏览器直接改 document.getElementById('content').textContent = Math.random().toString(36).slice(2, 7) return } document.startViewTransition(() => { document.getElementById('content').textContent = Math.random().toString(36).slice(2, 7) }) }) </script> ``` 效果:内容用平滑 crossfade 切换,而不是瞬间替换。 默认动画时长 0.25s。 ### 2. 给特定元素命名 → 跨页面 morph ```html <style> .product-card img { view-transition-name: product-hero; } .product-detail .hero { view-transition-name: product-hero; } </style> ``` 列表页的卡片图和详情页的头图都叫 `product-hero`。 切换页面时(用 startViewTransition 包裹)浏览器自动 morph 一个元素到另一个。 ```ts // 列表页点击跳详情时 function navigate(href) { if (!document.startViewTransition) { location.href = href return } document.startViewTransition(() => { // 这里切换 DOM;SPA 用 router push router.push(href) }) } ``` 效果:卡片图无缝放大到详情页大图位置,浏览器算 FLIP 动画。 **不需要 Framer Motion 的 layoutId**。 ### 3. SPA 路由集成 #### React Router 6.4+ ```tsx import { useNavigate } from 'react-router-dom' import { flushSync } from 'react-dom' function MyLink({ to, children }) { const navigate = useNavigate() return ( <a href={to} onClick={(e) => { e.preventDefault() if (!document.startViewTransition) { navigate(to) return } document.startViewTransition(() => { flushSync(() => { navigate(to) }) // flushSync 让 React 立刻渲染,view transition 才能比较新旧 DOM }) }} > {children} </a> ) } ``` #### SvelteKit `+layout.svelte` 加一行: ```svelte <script> import { beforeNavigate } from '$app/navigation' beforeNavigate(({ complete }) => { if (document.startViewTransition) { const transition = document.startViewTransition(() => complete) } }) </script> ``` #### Astro 直接 `<ViewTransitions />` 标签开启全站,无需自己 hook: ```astro --- import { ViewTransitions } from 'astro:transitions' --- <head> <ViewTransitions /> </head> ``` ### 4. 自定义动画 ```css ::view-transition-old(product-hero) { animation: 0.3s ease-out fade-out; } ::view-transition-new(product-hero) { animation: 0.4s ease-in fade-in; } @keyframes fade-out { to { opacity: 0; transform: translateY(-20px); } } @keyframes fade-in { from { opacity: 0; transform: translateY(20px); } } ``` 每个命名元素独立设动画。无名的全局用 `::view-transition-old(root)`。 ### 5. 同时多个 view-transition-name 详情页同时 morph 头图 + 标题 + 按钮: ```css .list .card .img { view-transition-name: hero-img; } .list .card .title { view-transition-name: hero-title; } .detail .hero { view-transition-name: hero-img; } .detail h1 { view-transition-name: hero-title; } ``` 浏览器同时为 hero-img 和 hero-title 做 morph,看起来三个元素 "飞到"详情页位置。 注意:**同一时间 viewport 内每个 view-transition-name 只能有一个元素**。 列表页和详情页的元素不会同时存在,但列表页有两张卡片都叫 `hero-img` 就出错。要给每张卡片不同 name: ```css .card-1 .img { view-transition-name: hero-1; } .card-2 .img { view-transition-name: hero-2; } ``` 或者用 CSS variable: ```css .card .img { view-transition-name: var(--vt-name); } ``` ```jsx <div class="card" style={{ '--vt-name': `card-${id}` }}> ``` ### 6. 检测支持 ```ts if ('startViewTransition' in document) { // 支持 } else { // 不支持,立刻切换(无动画) } ``` Chrome / Edge 111+ 全支持;Safari 18+ 支持单文档;跨文档(MPA)navigation 还要 Chrome 126+。Firefox 还在开发。 不支持时优雅降级:动画不出现,但不影响功能。 ## 实战 demo ```html <!DOCTYPE html> <html> <head> <style> body { margin: 0; font-family: sans-serif; } .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; } .card { background: white; border-radius: 8px; overflow: hidden; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.1); } .card img { width: 100%; height: 200px; object-fit: cover; } .detail { display: none; padding: 24px; } .detail.show { display: block; } .detail .hero { width: 100%; height: 400px; object-fit: cover; border-radius: 12px; } ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.3s; } </style> </head> <body> <div class="grid" id="grid"> <div class="card" data-id="1"> <img src="https://picsum.photos/seed/1/800/600" style="view-transition-name: hero-1"> <p>Card 1</p> </div> <div class="card" data-id="2"> <img src="https://picsum.photos/seed/2/800/600" style="view-transition-name: hero-2"> <p>Card 2</p> </div> </div> <div class="detail" id="detail"> <img class="hero" id="hero-img"> <button onclick="back()">← back</button> </div> <script> document.querySelectorAll('.card').forEach(card => { card.addEventListener('click', () => { const id = card.dataset.id const img = card.querySelector('img') // 详情页用同样的 view-transition-name const detailImg = document.getElementById('hero-img') detailImg.style.viewTransitionName = `hero-${id}` detailImg.src = img.src.replace('800/600', '1600/800') document.startViewTransition(() => { document.getElementById('grid').style.display = 'none' document.getElementById('detail').classList.add('show') }) }) }) function back() { document.startViewTransition(() => { document.getElementById('detail').classList.remove('show') document.getElementById('grid').style.display = 'grid' }) } </script> </body> </html> ``` 打开 Chrome 看,点 card 图片"飞"成大图 + 反向退出。零依赖。 ## 效果 - 列表 → 详情过渡从"瞬间切换"变"流畅 morph",体验提升明显 - bundle 减去 ~30KB(不装 Framer Motion) - 与现有 framework 集成只需 5-10 行 hook 代码 - 不支持的浏览器 graceful fallback 到无动画 ## 跨文档 transitions(MPA) Chrome 126+ 支持多页面 navigation 时的 view transition, 完全不需要 SPA。在两个 HTML 页面里 CSS 加: ```css @view-transition { navigation: auto; } ``` 浏览器 navigate 时自动应用 view transition。**SSR / 静态站也能有 SPA 般的过渡**。 ## 踩过的坑 1. **viewport 外的元素不参与**:如果列表卡片在视口外,detail 元素 morph 起点错误。让点击的卡片先 scrollIntoView 再触发 transition。 2. **同名元素冲突**:CSS 报"view-transition-name must be unique"。 不要在 React map 里所有 item 用同 name。 3. **transition 期间 click 失效**:transition 时整页冻结。复杂交互前 注意 transition duration 别太长。 4. **iOS Safari 18 之前不支持**:移动端覆盖度 2024 末期才到 80%+。 渐进增强是必须的。 5. **图片 src 切换 + view transition 并发**:浏览器对新 image 还没 下载完时 transition 显示空白。`<img>` 加 `decoding="sync"` 或 pre-cache 大图。

React 19 Streaming SSR + Suspense:渐进式首屏渲染

## 起因 传统 SSR(getServerSideProps): 1. 服务端拉所有数据 2. render 整个 HTML 3. 发给浏览器 慢的部分(如调 5 个 API)阻塞整个 response。用户看白屏几秒。 Streaming SSR 让服务端**边 render 边 flush**:先把 layout / shell 推 出去,慢部分用 `<Suspense fallback>` 标记 → 浏览器先显示 fallback → 慢部分 ready 后追加 HTML 替换 fallback。 React 18 引入,19 完善。Next.js App Router 默认就是 streaming。 ## 解决方案 ### Next.js 14+ App Router ```tsx // app/posts/[id]/page.tsx import { Suspense } from 'react' async function fetchPost(id) { await new Promise(r => setTimeout(r, 200)) // 200ms return { title: '...', body: '...' } } async function fetchRelated(id) { await new Promise(r => setTimeout(r, 2000)) // 2s(慢) return [{ ... }] } async function fetchComments(id) { await new Promise(r => setTimeout(r, 1500)) // 1.5s return [{ ... }] } export default async function Page({ params }: ...) { const post = await fetchPost(params.id) // 必须先等这个 return ( <article> <h1>{post.title}</h1> <p>{post.body}</p> <Suspense fallback={<div>loading related...</div>}> <Related id={params.id} /> </Suspense> <Suspense fallback={<div>loading comments...</div>}> <Comments id={params.id} /> </Suspense> </article> ) } async function Related({ id }) { const related = await fetchRelated(id) return <RelatedList items={related} /> } async function Comments({ id }) { const comments = await fetchComments(id) return <CommentList items={comments} /> } ``` 效果: - 200ms 后浏览器看到 article 标题 + body + 两个 "loading..." fallback - 1.5s 后 comments 替换 fallback - 2s 后 related 替换 fallback vs 传统 SSR 必须等 2s 才有任何内容。**首屏感知速度大幅提升**。 ## React 19 标准 API 不用 Next.js 也能 streaming SSR: ```tsx import { renderToReadableStream } from 'react-dom/server' import { Suspense } from 'react' async function handler(req) { const stream = await renderToReadableStream( <html> <body> <App /> </body> </html>, { bootstrapScripts: ['/main.js'], } ) return new Response(stream, { headers: { 'Content-Type': 'text/html' }, }) } ``` App 内 `<Suspense>` 包慢组件 → 自动 stream。 ## use() Hook(React 19) ```tsx import { use } from 'react' function Comments({ id }: { id: string }) { const comments = use(fetchComments(id)) // 直接调 promise! return <CommentList items={comments} /> } function Page({ params }) { return ( <Suspense fallback={<div>loading...</div>}> <Comments id={params.id} /> </Suspense> ) } ``` `use(promise)` 在 Suspense boundary 内 throw promise → React 等 resolve 后重渲。比 async 函数组件更简洁。 也能 use(context): ```tsx function Foo() { const theme = use(ThemeContext) // 替代 useContext return ... } ``` ## Partial pre-rendering(Next.js 14+ 实验性) 把"完全静态" + "完全动态" 之外加第三种:"骨架静态 + slot 动态": ```tsx export default function Page() { return ( <> <StaticHeader /> {/* build 时渲染,CDN cache */} <Suspense fallback={<Skeleton />}> <DynamicCart /> {/* 每请求 render */} </Suspense> </> ) } ``` CDN 立刻返回 static shell + skeleton,CDN 后端 stream 动态部分。 最优 LCP。 ## 与 client-side fetching 对比 ```tsx // 客户端 fetch(旧 SPA) function Comments({ id }) { const { data, isPending } = useQuery({ queryKey: ['comments', id], queryFn: () => fetchComments(id), }) if (isPending) return <Skeleton /> return <CommentList items={data} /> } ``` vs streaming SSR: | | client fetch | streaming SSR | |---|---|---| | TTFB | 极快(CDN) | 略慢(服务端等数据) | | FCP | 慢(要 JS hydrate + fetch) | 快(HTML 直接来) | | SEO | 差(爬虫不跑 JS) | 好 | | 客户端 bundle | 大 | 小(server component 不进 bundle) | | 复杂度 | 中 | 低(async function 直接写) | SEO / 首屏感知重 → streaming SSR。 交互重 / 后台 app → client fetch 仍合适。 ## 多服务串行 → 并行 ```tsx // ❌ 串行 async function Page() { const a = await fetchA() const b = await fetchB() const c = await fetchC() return <Component data={{ a, b, c }} /> } // 三个 fetch 加起来时间 // ✅ 并行 async function Page() { const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]) return <Component data={{ a, b, c }} /> } ``` 或者每个独立 Suspense 各自 stream: ```tsx function Page() { return ( <> <Suspense fallback={<...>}><A /></Suspense> <Suspense fallback={<...>}><B /></Suspense> <Suspense fallback={<...>}><C /></Suspense> </> ) } ``` 各自独立"快的先到"。 ## error boundary 跟 Suspense 配套 ```tsx <ErrorBoundary fallback={<Error />}> <Suspense fallback={<Loading />}> <SlowComponent /> </Suspense> </ErrorBoundary> ``` Suspense 处理 loading;ErrorBoundary 处理 throw。两者分工。 Next.js 14:每个文件夹放 `error.tsx`(自动 boundary)+ `loading.tsx` (自动 Suspense)。 ## 性能数据 我们一个商品详情页: | | TTFB | FCP | LCP | |---|---|---|---| | 传统 SSR | 2.4s | 2.4s | 2.4s | | Streaming + 3 Suspense | 200ms | 350ms | 1.8s | | + Partial pre-render | 50ms | 100ms | 1.6s | 用户感知差距巨大。Web Vitals 全绿。 ## 注意 / 限制 ### 1. async component 仅 server side ```tsx async function ServerComp() { // ✅ 只能在 server component 用 const data = await fetch(...) return <div>{data}</div> } ``` client component 要异步必须用 `use()`: ```tsx 'use client' function ClientComp({ promise }) { const data = use(promise) // ✅ return <div>{data}</div> } ``` ### 2. 静态 export 与 streaming 不兼容 `next export` 模式只有静态 HTML,没 server runtime,streaming 无意义。 ### 3. CDN cache streaming response 是 chunked transfer。Cloudflare 等 CDN 默认 **不缓存 chunked response**。要 cache 必须改回 fixed-length。 特性 trade-off。 ### 4. browser 历史问题 Safari 14 之前对 streaming HTML 表现不一致,第一屏可能闪。 现代浏览器都没事。 ## 调试 Network → response headers 看 `transfer-encoding: chunked` 确认 streaming 模式。 Chrome DevTools Performance → record load → 看 HTML chunks 到达 时间。 ## 与 Astro / Qwik 对比 Astro 默认是 server-render-with-islands(client 只 hydrate 交互 组件),Qwik 是 resumable(client 0 JS 启动),两者都侧重首屏速度。 React 19 streaming + RSC 是同方向但仍有 React runtime 开销。 极致 perf 试试 Qwik;React 生态成熟度 + RSC 仍是主流。 ## 踩过的坑 1. **顶层 await 阻塞 stream**:page.tsx 顶部 `await fetch(...)` 阻塞 所有 stream。把它移到子组件 + Suspense 才能 stream。 2. **Suspense 套了但没 fallback**:fallback 不会显示。永远写 `fallback={<X />}` 而非 `{...}`。 3. **client component import server lib**:bundle 暴大或运行时 error。 严格分 "use client" / server-only 边界。 4. **error boundary 在 Suspense 内** vs 外:内只接 stream 那段错; 外接 Suspense 自己 + 内部错。看意图分。 5. **dev 跟 prod 行为不同**:dev 没 cache + 较多 console,prod 体感 差异大。永远以 prod build 量真实体验。

Storybook 8:组件开发 / 文档 / 视觉回归一站搞定

Storybook 是组件开发的 IDE:在隔离环境里写组件 + 各种状态展示 + 自动文档 + 视觉回归测试。Storybook 8 升级了 Vite-first / 性能大涨。 ## 安装 ```bash npx storybook@latest init # 自动检测项目类型(React/Vue/Svelte 等),生成 .storybook/ 配置 ``` `package.json` 加 script: ```json { "scripts": { "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" } } ``` ## 第一个 story ```tsx // src/components/Button.tsx export function Button({ children, variant = 'primary', onClick }) { return <button className={`btn btn-${variant}`} onClick={onClick}>{children}</button> } // src/components/Button.stories.tsx import type { Meta, StoryObj } from '@storybook/react' import { Button } from './Button' const meta: Meta<typeof Button> = { title: 'Components/Button', component: Button, tags: ['autodocs'], // 自动生成文档页 argTypes: { variant: { control: 'select', options: ['primary', 'secondary', 'danger'], }, onClick: { action: 'clicked' }, }, } export default meta type Story = StoryObj<typeof Button> export const Primary: Story = { args: { children: 'Click me', variant: 'primary' }, } export const Secondary: Story = { args: { children: 'Secondary', variant: 'secondary' }, } export const Danger: Story = { args: { children: 'Delete', variant: 'danger' }, } ``` `npm run storybook` → 浏览器看到 Button 的 3 个变体 + interactive controls 可以实时改 args。 ## 2. Controls ```tsx argTypes: { size: { control: { type: 'range', min: 12, max: 32, step: 2 }, }, bg: { control: 'color' }, date: { control: 'date' }, } ``` Storybook UI 自动渲染滑块 / 颜色选择器 / 日期选择器。 ## 3. 自动文档 `tags: ['autodocs']` 让 Storybook 自动生成 Docs 页: - 组件描述(取自 JSDoc / TypeScript types) - Props 表格(取自 TypeScript types) - 所有 story 实例展示 ## 4. MDX 写富文档 ```mdx {/* src/components/Button.mdx */} import { Canvas, Story } from '@storybook/blocks' import * as ButtonStories from './Button.stories' # Button 按钮组件。 ## 何时使用 - 提交表单 - 触发关键操作 ## 示例 <Canvas of={ButtonStories.Primary} /> 注意:danger 按钮配合二次确认 modal 使用。 <Canvas of={ButtonStories.Danger} /> ``` MDX 让你混 Markdown + 实际可交互的 story。 ## 5. play 函数:交互测试 ```tsx import { userEvent, within, expect } from '@storybook/test' export const ClickHandling: Story = { args: { children: 'Click me' }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement) const btn = canvas.getByRole('button') await userEvent.click(btn) expect(args.onClick).toHaveBeenCalled() }, } ``` 打开这个 story Storybook 自动执行 play 函数 → 模拟点击 → 校验。 相当于把单元测试 + 视觉展示合一。 可以直接 `npm run test-storybook` 在 CI 里跑所有 play 函数。 ## 6. decorators:包一层 context ```tsx const meta: Meta = { decorators: [ (Story) => ( <ThemeProvider theme="light"> <div style={{ padding: 24 }}> <Story /> </div> </ThemeProvider> ), ], } ``` 每个 story 自动套 Provider + 内边距。 ## 7. globalTypes:主题 / 语言切换器 ```ts // .storybook/preview.ts export const globalTypes = { theme: { description: '主题', defaultValue: 'light', toolbar: { title: 'Theme', icon: 'circlehollow', items: ['light', 'dark'], }, }, } export const decorators = [ (Story, ctx) => ( <ThemeProvider theme={ctx.globals.theme}> <Story /> </ThemeProvider> ), ] ``` Storybook 顶部工具栏出现切换按钮,所有 story 同步主题。 ## 8. addon:a11y / viewport / measure ```bash npm i -D @storybook/addon-a11y @storybook/addon-viewport ``` `.storybook/main.ts`: ```ts export default { addons: [ '@storybook/addon-essentials', '@storybook/addon-a11y', // axe-core 自动 a11y 检查 '@storybook/addon-viewport', // 不同设备尺寸 ], } ``` 每个 story 自动跑 a11y audit;右上角设备切换看响应式。 ## 9. 视觉回归测试(Chromatic) ```bash npx chromatic --project-token=xxx ``` Chromatic(Storybook 母公司服务)每次 build 自动截图所有 story, 和上次对比,任何视觉变化提醒你确认。 替代方案:自托管 reg-suit / loki / Percy。 ## 10. 部署 storybook ```bash npm run build-storybook # 输出到 storybook-static/ # 上传任何静态托管(Vercel / Netlify / GitHub Pages / S3) ``` 设计师 / PM / 客户能直接看组件演示。 ## 11. 与 design token 把 Figma design tokens 导出 JSON → 写 Tailwind / Styled-Components 主题, 在 Storybook 里切换主题对比效果。Tokens Studio + Figma + Storybook 是 设计系统的成熟工作流。 ## 12. 何时不用 Storybook - 小项目(< 10 个组件):维护 stories 文件成本 > 收益 - 业务页面(不是可复用组件):直接在 app 里开发更快 - 团队不愿意写 stories:勉强引入只会废弃 适合:组件库开发 / 设计系统 / 跨团队共享组件场景。 ## 踩过的坑 - Story 文件忘了 default export meta:Storybook 不识别。 - 全局样式没在 preview.ts 里 import:story 看到的样式和 app 不一致。 `.storybook/preview.ts` 里 `import '../src/index.css'`。 - Storybook 8 + Vite 5 升级有 breaking change:`framework: '@storybook/react-vite'` 必须明确写。 - Chromatic 视觉测试对小动画 / 字体渲染差异敏感,可能 false positive。 配 `delay: 200` 等动画结束再截图。

HTMX:用 HTML 属性写动态交互(替代 1/3 SPA 场景)

## 起因 很多内部工具 / dashboard 不需要 React 那种"完整 SPA"——只是几个 "按钮 click → 加载片段 → 局部更新"。这些用 React + 后端 API 实施 是杀鸡用牛刀: - 后端要做 JSON API 而不是直接 render HTML - 前端要 setup React + router + 状态管理 - 部署两个东西 - 一改 backend schema 前后端都要同步 `HTMX` 是 14 KB 的 JS 库,让 HTML 元素**通过属性发 AJAX 请求 + 局部替换 DOM**。 后端继续 render HTML,前端就是"加几个 hx-* 属性"。 ## 最简单的例子 ```html <script src="https://unpkg.com/[email protected]"></script> <button hx-get="/api/hello" hx-target="#result"> 点我加载 </button> <div id="result"></div> ``` 后端(Django / Flask / Rails / 任意): ```python @app.get('/api/hello') def hello(): return HttpResponse('<p>Hello, World!</p>') ``` 按钮 click → 发 GET /api/hello → 服务端返 HTML 片段 → 插入 #result。 零 JS 业务代码。 ## hx 属性速查 ```html <!-- 哪种请求 --> <a hx-get="/path">...</a> <button hx-post="/path">...</button> <button hx-put="/path">...</button> <button hx-delete="/path">...</button> <!-- 替换什么 --> <button hx-get="/x" hx-target="#dest">...</button> <button hx-get="/x" hx-target="closest .card">...</button> <!-- 怎么替换 --> hx-swap="innerHTML" <!-- 默认:替换 target 的 innerHTML --> hx-swap="outerHTML" <!-- 替换 target 整个元素 --> hx-swap="beforebegin" <!-- 插 target 之前 --> hx-swap="afterend" <!-- 插 target 之后 --> hx-swap="delete" <!-- 删 target --> hx-swap="none" <!-- 不动 DOM(仅触发 side effect) --> <!-- 何时触发 --> hx-trigger="click" <!-- 默认 --> hx-trigger="keyup changed delay:500ms" <!-- input 改变 + 500ms --> hx-trigger="every 5s" <!-- 每 5 秒 poll --> hx-trigger="revealed" <!-- 进入视口(infinite scroll)--> hx-trigger="load" <!-- 元素 mount 后立刻 --> <!-- 传额外数据 --> <button hx-post="/like" hx-vals='{"post_id": 42}'> Like </button> <!-- form 自动收集 --> <form hx-post="/save"> <input name="title"> <button>save</button> </form> <!-- 自动把表单字段作 body --> <!-- loading indicator --> <button hx-get="/slow" hx-indicator="#spinner"> Load </button> <span id="spinner" class="htmx-indicator">⏳</span> <!-- 请求期间 .htmx-indicator 自动显示(CSS 控制) --> ``` ## 实战:To-do 列表(含增删改) ```html <ul id="todos"> <li>买菜 <button hx-delete="/todos/1" hx-target="closest li" hx-swap="delete">×</button></li> <li>遛狗 <button hx-delete="/todos/2" hx-target="closest li" hx-swap="delete">×</button></li> </ul> <form hx-post="/todos" hx-target="#todos" hx-swap="beforeend"> <input name="text" required> <button>add</button> </form> ``` 后端: ```python @app.post('/todos') def create(): text = request.form['text'] todo = Todo.objects.create(text=text) return HttpResponse(f'<li>{todo.text} <button hx-delete="/todos/{todo.id}" hx-target="closest li" hx-swap="delete">×</button></li>') @app.delete('/todos/<int:id>') def delete(id): Todo.objects.filter(pk=id).delete() return HttpResponse('', status=200) ``` 完整 CRUD < 30 行 HTML + 后端 model。 零 JavaScript 业务代码。 ## SPA-like:boost 让普通链接 / 表单变 AJAX ```html <body hx-boost="true"> <a href="/about">About</a> <!-- 自动变 hx-get="/about" + 替换 body --> <form action="/login" method="post"> <!-- 自动 hx-post --> ... </form> </body> ``` 整站 SPA 体验,无需为每个链接写 hx 属性。 浏览器 back / forward 自动 work(pushState)。 ## Infinite scroll ```html <div hx-get="/posts?page=2" hx-trigger="revealed" hx-swap="afterend"> Loading more... </div> ``` 最后那个 div 进入视口 → 自动加载下一页 + 插到 afterend。 后端返回的 HTML 末尾再放一个同样的 trigger,无限链。 ## Server-sent events / WebSocket ```html <div hx-ext="sse" sse-connect="/events" sse-swap="message"> 等通知... </div> ``` 服务端 push 时自动更新 div 内容。 ## Active search ```html <input type="search" hx-get="/search" hx-trigger="keyup changed delay:300ms" hx-target="#results"> <div id="results"></div> ``` 300ms debounce 后发请求;服务端返回结果 HTML 列表 → 替换 #results。 **"Google instant search" 体验,零 JS**。 ## 编辑表单:click → 变 input → save 后变回 ```html <div id="email-display"> {{ user.email }} <button hx-get="/users/me/edit-email" hx-target="#email-display" hx-swap="outerHTML"> 编辑 </button> </div> ``` 后端 GET /users/me/edit-email 返回: ```html <form hx-post="/users/me/email" hx-target="this" hx-swap="outerHTML"> <input name="email" value="{{ user.email }}"> <button>save</button> <button type="button" hx-get="/users/me/email-display" hx-target="closest form" hx-swap="outerHTML">取消</button> </form> ``` POST /users/me/email 处理后返回更新版 `#email-display`。 inline edit 模式,无 React 也优雅。 ## 复杂前端逻辑(少量 JS) HTMX 不替代所有 JS。需要复杂前端状态时配 Alpine.js / hyperscript: ```html <button x-data="{ count: 0 }" @click="count++"> Count: <span x-text="count"></span> </button> ``` Alpine.js 是 14 KB 的"轻量 Vue",跟 HTMX 同生态。 HTMX 管"跟服务器交互";Alpine 管"客户端局部状态"。 ## 与 SPA / React 对比 | | HTMX + 服务端 render | React SPA | |---|---|---| | bundle | 14 KB | 100+ KB | | 后端 | 返回 HTML | 返回 JSON API | | 前端代码量 | 极少 | 大 | | SEO | 自然好(HTML) | 要 SSR 才好 | | 适合 | 内部工具 / CMS / blog / 简单 CRUD | 复杂 SPA / 离线 / 极致交互 | | 团队 | 全栈 | 前后分离 | HTMX 适合: - 内部 dashboard / 后台 - Django / Rails / Phoenix 等 server-rendered framework 用户 - 不想维护两套 (frontend + backend API) 代码 - 中等复杂度 web app 不适合: - 极复杂客户端状态(编辑器 / IDE / 实时白板) - 离线优先 PWA - 需要超丝滑 transition / 动画 ## 实战 case 我们一个公司内部 admin tool 用 HTMX 重写: - 之前:React + REST API + 后端 + 部署两套 → 4 周 - 现在:Django + HTMX → 5 天 - 维护 1 套代码 - bundle 从 500 KB → 30 KB(Django 资源 + HTMX) - 内部用户没感觉差异(其实更快) 但绝不会用 HTMX 重写"复杂 SaaS dashboard"——那个还是 React。 工具论适配场景。 ## SSR framework with HTMX - **Django** + `django-htmx`:内置 helpers - **Flask** + Jinja2:原生适配 - **Rails 7** + Hotwire / Turbo(不是 HTMX 但理念类似) - **Phoenix** + LiveView(更强大但 Elixir 专属) - **Laravel** + Livewire(PHP 版 LiveView) 服务端渲染 + 轻量 JS 增强 是 2024 重新流行的方向。 ## 完整 demo: 文章 like 按钮 ```html <!-- post.html --> <article> <h1>{{ post.title }}</h1> <p>{{ post.body }}</p> <div id="like-section-{{ post.id }}"> {% include 'like_section.html' %} </div> </article> <!-- like_section.html --> <button hx-post="/posts/{{ post.id }}/like" hx-target="#like-section-{{ post.id }}" hx-swap="innerHTML" {% if user_liked %}disabled{% endif %}> {% if user_liked %}❤️ Liked{% else %}🤍 Like{% endif %} </button> <span>{{ post.likes_count }} likes</span> ``` ```python @app.post('/posts/<int:id>/like') @login_required def like(id): post = Post.objects.get(pk=id) Like.objects.get_or_create(user=request.user, post=post) return render(request, 'like_section.html', { 'post': post, 'user_liked': True, }) ``` 20 行代码完成"点赞 + 实时更新计数"。 React 版本至少 100 行 + 后端 API + 状态管理。 ## 踩过的坑 1. **hx-target="closest .row"** 找不到 → ".row" 必须是元素的祖先。 `find / next` 等其它选择器更明确。 2. **服务端忘记返 HTML fragment 而是返整个 page**:page 被插到 target 里 → 嵌套 html / body 一片乱。返 fragment template。 3. **CSRF token**:hx-post 默认不带 cookie / CSRF token。django-htmx 等 framework helper 自动加;纯 HTMX 配 `hx-headers='{"X-CSRFToken": "..."}'`。 4. **back button 不工作**:hx-boost 自动 pushState;自己写 hx-get 默认 不更 URL。要 history 工作加 `hx-push-url="true"`。 5. **debug 难**:DOM swap 后浏览器 inspector 不显示原 HTML。 开 HTMX debug:`htmx.logAll()` 看所有 request / response。

CSS 原生嵌套 + :has() + :where():2024 后不再需要 Sass 的几个功能

## 起因 写 CSS 多年装 Sass / Less / postcss-nesting 主要为了嵌套 + 父选择器 + mixin。但 2023 后浏览器原生支持: - **CSS Nesting**:原生嵌套,与 Sass 兼容语法 - **`:has()`**:父选择器(基于子元素状态匹配父元素) - **`:where()` / `:is()`**:选择器组合 + 控制优先级 - **CSS Layers (`@layer`)**:明确分层避免优先级地狱 新项目大多可以不引入 Sass。 ## 1. CSS Nesting ```css /* 旧:扁平 */ .card { padding: 16px; } .card .title { font-size: 18px; font-weight: bold; } .card .title:hover { color: blue; } .card.active { border: 2px solid blue; } .card.active .title { color: blue; } /* 新:嵌套(原生) */ .card { padding: 16px; & .title { /* & 显式指代父选择器,规范要求 */ font-size: 18px; font-weight: bold; &:hover { color: blue; } } &.active { border: 2px solid blue; & .title { color: blue; } } @media (max-width: 600px) { padding: 8px; } } ``` 要点: - `&` 是必须的(不像 Sass 可省) - 嵌套深度不要超过 3 层(可读性 / 编译 size) - @media / @supports 也能嵌套 浏览器支持:Chrome 112+ / Firefox 117+ / Safari 16.5+。 2024 中现代浏览器全支持。 ## 2. `:has()` —— 父选择器 CSS 历史上一直没法"父元素根据子元素状态变样式"。 `:has()` 终于解决: ```css /* 含图片的卡片样式不同 */ .card:has(img) { padding: 0; background: black; } /* 表单包含 invalid input 时整组红边框 */ form:has(input:invalid) { border: 1px solid red; } /* 卡片包含 .featured 时整张卡黄边 */ .card:has(.featured) { border: 2px solid gold; } /* "下个兄弟是 h2" 的段落加边距 */ p:has(+ h2) { margin-bottom: 24px; } /* 复杂:当 ul 包含 li.active 时 */ nav ul:has(li.active) { background: var(--accent-bg); } /* 没有图片的 article */ article:not(:has(img)) { text-align: center; } ``` 之前要靠 JS 加 class 解决的,现在纯 CSS。 **实际例子:自适应高亮表单** ```html <form> <label> Email <input type="email" required> </label> <label> Password <input type="password" required minlength="8"> </label> </form> ``` ```css label:has(input:invalid:not(:placeholder-shown)) { color: red; } label:has(input:valid) { color: green; } ``` 用户输入到无效状态时 label 立刻变红,不需要任何 JS。 ## 3. `:where()` —— 零优先级组合 `:where()` 把多个选择器组合,但**优先级为 0**: ```css /* :is() 优先级 = 最高的子选择器 (这里 #foo = 100) */ :is(.a, #foo) p { color: red; } /* :where() 优先级 = 0 */ :where(.a, #foo) p { color: red; } ``` 实战:写"基础重置"时希望可以被业务样式轻易覆盖: ```css /* 用 :where() 把所有标题清零,业务样式不需要 !important 就能改 */ :where(h1, h2, h3, h4, h5, h6) { margin: 0; font-weight: 400; } /* 业务样式:普通选择器优先级 1 > :where() 的 0 */ .title { font-weight: bold; } /* 生效 */ ``` 不再需要 `!important` 大战。 `:is()` 是同样组合但保留优先级,适合短化代码: ```css /* 旧 */ header h1, footer h1, main h1 { ... } /* 新 */ :is(header, footer, main) h1 { ... } ``` ## 4. `@layer` —— 显式优先级层 ```css /* base 最低 */ @layer base, components, utilities; @layer base { h1 { font-size: 2rem; } a { color: blue; } } @layer components { .btn { padding: 8px 16px; } .card { padding: 16px; } } @layer utilities { .text-center { text-align: center; } .hidden { display: none; } } ``` 后声明的 layer 总是覆盖前面的,**无视选择器优先级**。 意思: - `@layer base { h1 { ... } }` 的 h1(优先级 1) - 被 `@layer utilities { h1 { ... } }` 的同 h1(也是优先级 1)覆盖 ——因为 utilities 层在后 最强:第三方 CSS 引入时套个低优先级 layer: ```css @layer reset, vendor, base, components; @import url('https://cdn.example.com/some-lib.css') layer(vendor); /* 我的 base 总能覆盖 vendor 的所有 selector,无论它写了多复杂的 .x.y.z 选择器 */ ``` 终结"如何覆盖 Bootstrap 的样式"类问题。 ## 5. 用 PostCSS 转旧浏览器 旧浏览器(IE 11、老 Safari)支持差。生产建议加 PostCSS 转译: ```bash npm i -D postcss postcss-nesting postcss-preset-env ``` ```js // postcss.config.js export default { plugins: [ require('postcss-preset-env')({ stage: 2, features: { 'nesting-rules': true, 'has-pseudo-class': true, }, }), ], } ``` PostCSS 把 nesting / `:has()` 等转成等价旧 CSS,老浏览器也能用。 ## 6. 真实重构例子 之前用 Sass 写的组件: ```scss // Card.scss .card { padding: 16px; border-radius: 8px; background: white; &__title { font-size: 18px; font-weight: bold; } &__body { margin-top: 12px; } &--featured { border: 2px solid gold; .card__title { color: gold; } } } ``` 迁移到原生 CSS: ```css .card { padding: 16px; border-radius: 8px; background: white; & > .title { font-size: 18px; font-weight: bold; } & > .body { margin-top: 12px; } &.featured { border: 2px solid gold; & > .title { color: gold; } } } ``` 文件几乎一样,少装一个 Sass + sass-loader 依赖。 ## 效果 新项目: - 不再装 Sass / Less,CSS pipeline 简化 - `:has()` 替代了 5-6 处 JS 操控 class 的逻辑 - `@layer` 让多团队 CSS 协作不再撞车 - DevTools 调试 CSS(不再是编译后的"扁平"代码) ## 仍要用 Sass 的场景 CSS 原生还没有的: - **mixin** / `@function`:能写函数复用 - **运算 / 颜色函数**:CSS `color-mix()` 部分替代 - **partial / @use**:CSS `@import` 弱 如果只用嵌套 + 父选择器 → 原生 CSS 够;要重度逻辑还是 Sass / Stylus。 ## 踩过的坑 1. **`&` 不能省**:写 `.card { .title {} }` 在原生 CSS 里不工作 (但 Sass 工作)。必须 `& .title`。 2. **嵌套里的 type 选择器**: ```css .card { /* 旧 Sass 可以写 a {} 直接 */ a { color: blue; } /* CSS 也支持,但语义是 ".card a",跟 Sass 一致 */ } ``` 实际无差。 3. **`:has()` 性能**:浏览器要"反向"匹配,复杂 selector 可能慢。 单页面大量 `:has()` 时观察 performance。 4. **`@layer` 顺序很重要**:第一行 `@layer a, b, c` 定义顺序, 后续 `@layer a` 即使写在最后也属于第一层(低优先级)。 5. **PostCSS 转译错 `:has()`**:现有 polyfill 不完美(DOM 监听变化 性能差)。如果一定要老浏览器兼容,避免在频繁变化的元素上用。