ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

styled-components 缓冲样式注入:客户端通过 useInsertionEffect 提交样式,保障并发渲染安全

2026/9/18 21:10:26 拓冰建站 浏览量
styled-components 缓冲样式注入:客户端通过 useInsertionEffect 提交样式,保障并发渲染安全 styled-components 缓冲样式注入客户端通过 useInsertionEffect 提交样式保障并发渲染安全【免费下载链接】styled-componentsFast, expressive styling for React. Server components, client components, streaming SSR, React Native—one API.项目地址: https://gitcode.com/gh_mirrors/st/styled-components导读本篇文章讲解 styled-components 客户端样式注入机制的架构变更样式规则不再在 React 渲染过程中同步写入文档而是改为在useInsertionEffect插入效应中、组件提交之后写入。这一机制同时解决了三个问题——被丢弃的并发渲染不再残留 CSS 规则、已提交的更新必然拿到自己的规则、以及阻塞更新行为与并发模式完全一致。本文将以 .changeset/buffered-injection-default.md 的变更说明为主体结合仓库源码StyledComponent.ts、WebStyle.ts、Sheet.ts与专项测试BufferedInjection.test.tsx从原理、实现到验证给出完整解读。一、变更背景样式注入的时机问题在 React 应用中CSS-in-JS 库需要回答一个关键问题样式规则应该在什么时机写入文档传统实现是渲染期同步写入在组件渲染render过程中一旦计算出样式就立刻调用insertRule或写入style标签。这种做法的优点是直观、首屏即可见但在 React 18 的并发渲染模型下暴露出两个缺陷被丢弃的渲染会留下脏规则。并发渲染中一次渲染可能因为优先级被抢占、组件挂起suspend或抛错而被丢弃discarded但渲染期内已经写入的 CSS 规则却无法回滚造成文档中出现实际从未被提交的样式。类名与规则写入的强耦合。类名class name必须在渲染期确定元素才能在首次绘制时带上正确的class但规则写入是否也必须绑定渲染期这正是本次变更要解耦的点。本次变更changeset 类型为minor即向后兼容的新行为给出的答案清晰明确Client styles inject throughuseInsertionEffect. Class names are still resolved during render so elements get the rightclasson the first paint, but the stylesheet write runs in the insertion effect after React commits.即类名解析仍发生在渲染期保证首帧 class 正确样式表写入延后到 React 提交之后的插入效应中执行。二、核心变更类名渲染期解析规则提交后写入2.1 两步走的职责分离变更的核心是把生成样式与写入样式拆成两个独立阶段对应 WebStyle.ts 中的三个方法generate()WebStyle.ts在渲染期执行编译规则集、计算类名但不向样式表写入任何规则只把结果缓存在暂存规则provisional rules中inject()WebStyle.ts负责真正的 CSSOM 写入按继承链逐级调用insertRulesflush()WebStyle.tsinject(sheet, generate(...))一步完成是同步路径的入口。/** Compute the inheritance plan and write any new rules to the sheet in one call. */ flush(executionContext, styleSheet, compiler): string { return this.inject(styleSheet, this.generate(executionContext, styleSheet, compiler)); }generate()的源码注释明确写着Produce per-level compiled CSS without writing any rules to the tag生成编译后的 CSS 但不向标签写入任何规则类名在生成阶段即被确定并返回从而保证元素在首帧就能拿到正确的class。2.2 StyleInjector提交后的写入入口客户端路径中写入动作被封装在 StyledComponent.ts 的StyleInjector内部组件里通过React.useInsertionEffect触发function StyleInjector({ generated, sheet, webStyle }) { // getServerSnapshot 在 renderToString 下运行getSnapshot 在 createRoot 下运行 const isServerRender React.useSyncExternalStore(subscribeNoop, getFalse, getTrue); if (__DEV__ isServerRender) { warnOnce( buffered-ssr-without-sheet, client styles inject through useInsertionEffect, which does not run during renderToString / renderToStaticMarkup. Wrap the tree in ServerStyleSheet#collectStyles so styles flush during render. ); } React.useInsertionEffect(() { webStyle.inject(sheet, generated); }); return null; }这里有两个值得注意的设计点useInsertionEffect的语义恰好匹配需求。它比useLayoutEffect更早执行在 DOM 变更后、布局与绘制前是 React 官方为 CSS-in-JS 提供的专用注入时机——既保证提交后的样式在绘制前可见又避免渲染期写入带来的并发脏数据问题。开发环境下的 SSR 探测。由于useInsertionEffect在renderToString/renderToStaticMarkup中不会执行代码用useSyncExternalStore的getServerSnapshot与getSnapshot差异来区分jsdom 下未包 ServerStyleSheet 的 SSR与正常客户端挂载从而精准地在开发期给出警告提示开发者用ServerStyleSheet#collectStyles包裹组件树而不是误报。2.3 暂存规则provisional rules同一 JS 轮次内的字节共享把写入延后到提交期会引入一个新问题同一渲染中多个相同类的兄弟组件、以及被丢弃的竞争渲染如何在尚未写入的情况下共享编译结果Sheet.ts 用一张按轮次存活的暂存表解决provisionalRules是一个Mapstring, string[]键为id \0 name值是该类名的编译字节。关键设计Sheet.ts 注释Turn-scoped compiled rules keyed byid\0name. The first generate for a name compiles and stores here; same-turn siblings reuse the bytes so a discarded claimer cannot leave a committed follower with an empty payload. Dropped on a microtask so a discarded concurrent render cannot pin a name past the turn that claimed it.配合的三个方法claimNameForId(id, name)Sheet.ts为当前 JS 轮次认领类名第一个认领者返回true并负责编译同轮次的后续认领返回false直接复用暂存字节。该表通过queueMicrotask在微任务中清空确保被丢弃的并发渲染不会把类名钉过它所属的那一轮。stashProvisionalRules(id, name, rules)Sheet.ts认领者编译完成后存入字节。getProvisionalRules(id, name)Sheet.ts跟随者follower读取认领者留下的字节。服务器端server为 true则跳过暂存机制直接同步 flush——因为同步路径在同一调用栈内完成注册轮次记账只会白白分配内存Sheet.ts 注释。三、并发渲染安全丢弃的渲染不写入提交的更新必拿到规则3.1 写入发生在提交之后由于 CSSOM 写入被移入useInsertionEffect而插入效应只在React 真正提交commit的组件树上执行因此被丢弃的并发渲染不会留下规则无论渲染因错误边界抛错、因Suspense挂起被中断、还是被更高优先级的更新抢占只要该渲染最终未提交其插入效应就不会运行文档中也就不会残留它生成的样式。已提交的更新必然获得规则即使同一类名有一个更早的、被丢弃的并发尝试提交的那次渲染仍然会执行自己的插入效应把规则写入文档。3.2 继承链与被丢弃的认领者并发场景中最微妙的是继承链styled(Base)场景基类组件Base在抛出错误的边界内认领了类名并完成了编译但随后整个边界被丢弃而边界外的扩展组件Extended正常提交。此时 Extended 的inject()会沿继承链逐级写入——WebStyle.inject遍历generated.levelsWebStyle.ts只要某个 level 携带了样式表缺失的规则就写入因此作为跟随者携带了基类字节的扩展组件会把基类规则一并写入文档。对应的测试用例writes base rules when a discarded claimer held the base of an inheritance chainBufferedInjection.test.tsx验证了这一点边界内Base /与Thrower /同时渲染边界外是Extended /最终断言 CSS 中同时包含基类的rgb(20, 20, 20)与扩展的font-weight: 700。3.3 类名缓存的兜底另一个边界是被丢弃的并发尝试已经把generate()结果写进了渲染缓存。测试injects after a discarded concurrent attempt whose className was cachedBufferedInjection.test.tsx构造了一个在startTransition中挂起、随后恢复的更新第一次渲染写入rgb(1, 1, 1)开启挂起后在 transition 中重渲染rgb(14, 14, 14)——该尝试被丢弃此刻断言文档中不包含新颜色恢复渲染并提交后断言文档中包含rgb(14, 14, 14)。这验证了渲染缓存命中 首次提交的组合路径即使类名已在缓存中跳过编译提交时的插入效应仍会执行写入。四、阻塞更新非并发模式行为一致变更说明中特别强调Blocking updates (concurrent features off) behave the same: styles apply on commit, not mid-render.即当应用没有开启任何并发特性不使用startTransition、useDeferredValue、并发渲染等普通的setState阻塞更新同样遵循提交时写入的规则。useInsertionEffect并不依赖并发模式——它在每次提交后、绘制前都会同步执行因此样式仍然在浏览器绘制之前落盘不会出现无样式内容闪烁FOUC行为语义在并发与阻塞模式下完全统一开发者不需要为两种模式编写两套逻辑。测试applies styles for blocking updates without startTransitionBufferedInjection.test.tsx通过普通rerender验证了rgb(2, 2, 2)→rgb(3, 3, 3)两次更新后 CSS 均正确写入。五、SSR 与 RSC仍走渲染期同步 flush插入效应在服务器端不会执行因此ServerStyleSheetSSR 与 React Server ComponentsRSC仍然保持原有的渲染期同步 flush 语义ServerStyleSheetSSR and React Server Components still flush during render, where insertion effects do not run.5.1 ServerStyleSheet 的同步输出在服务端样式必须随 HTML 一起输出否则客户端无法拿到服务端渲染组件的样式。使用方式不变——用ServerStyleSheet#collectStyles包裹组件树然后通过getStyleTags()获取style标签import { renderToString } from react-dom/server; import { ServerStyleSheet } from styled-components; const sheet new ServerStyleSheet(); try { const html renderToString(sheet.collectStyles(App /)); const styleTags sheet.getStyleTags(); // 渲染期间同步收集到的样式 // 将 styleTags 注入到 HTML 的 head 中一起下发 } finally { sheet.seal(); }对应测试ServerStyleSheet SSR still flushes synchronously (no insertion effects)BufferedInjection.test.tsx断言renderToString后sheet.getStyleTags()已包含渲染组件写入的颜色规则——注意此时客户端路径的插入效应尚未也不可能运行。5.2 RSC 的渲染期注册RSC 侧由rscFlushStyledComponent.ts负责调用webStyle.generate()生成继承链的编译 CSS但不向 tag 写入同时为每个新类名调用styleSheet.registerName()注册使得相同组件/属性的重复渲染跳过编译。生成的levels随后交给内联style输出。5.3 常见陷阱与开发期警告一个典型的误用是在jsdom/Node 环境IS_BROWSER为 true 或类似环境下直接调用renderToString而未使用ServerStyleSheet。此时默认 sheet 走缓冲路径useInsertionEffect永不执行样式会静默丢失。测试jsdom renderToString without ServerStyleSheet writes no CSS and warnsBufferedInjection.test.tsx验证了两点CSS 未写入 控制台出现包含ServerStyleSheet的警告。这正是 2.2 节中useSyncExternalStore探测的用途正常客户端挂载createRoot不会触发该警告测试createRoot mount does not warn about ServerStyleSheetBufferedInjection.test.tsx而 SSR 无 sheet 场景会精准提示。六、专项测试行为契约的完整覆盖BufferedInjection.test.tsx 是该变更的行为契约覆盖了从正常挂载到并发边界的全部场景是理解机制的最佳入口测试主题验证点类名渲染期生成L16-L24渲染完成后元素带有sc-类名且至少两个 class基类 自身提交后写入L26-L32render后 CSSOM 中出现对应规则阻塞更新L34-L44普通rerender的多次更新均正确写入渲染期未写入L61-L84用探针组件在渲染体内读取 CSSOM断言此刻不含新规则、提交后包含丢弃渲染不写入L86-L115错误边界内抛错最终文档不含该规则keyframes 顺序L117-L132keyframes出现在引用它的组件规则之前同轮次去重L134-L205三个同 class 兄弟组件insertRule仅调用一次认领者丢弃、跟随者提交L207-L239边界内的认领者被丢弃后边界外的兄弟仍写入规则并发挂起后恢复L241-L286被丢弃的并发尝试不写入恢复提交后写入继承链基类规则L307-L347丢弃的认领者持有基类字节时提交的扩展组件代为写入其中dedupes same-class siblings within one commitL134-L159与claims a shared class only once among same-class siblingsL161-L179尤其值得注意它们通过 spy 断言所有实例先于任何效应运行而完成生成、认领只发生一次、两次为跟随者直接验证了 2.3 节暂存机制的轮次内去重语义。七、对开发者的影响与迁移建议API 完全不变。styled.div、css、keyframes、ServerStyleSheet的用法均无变化本变更是内部注入时机的调整普通应用可无感升级。依赖 React 18 的useInsertionEffect。这是 React 为 CSS-in-JS 提供的标准注入时机意味着 styled-components 的客户端运行时已原生适配 React 18 及以上的提交模型。SSR 用户无需改动但要注意无 ServerStyleSheet 的 renderToString这一老陷阱现在开发环境下会得到更明确的警告文案指引使用collectStyles。并发渲染的收益使用startTransition、Suspense、useDeferredValue的应用将不再出现被丢弃渲染残留样式的脏数据问题这在 3.1-3.3 节的测试中已有充分验证。如需深入阅读实现建议按以下路径追踪调用链入口 StyledComponent.ts 的StyleInjector→ 生成阶段 WebStyle.ts 的generate()→ 暂存与写入 Sheet.ts 的claimNameForId/inject→ 服务端路径 ServerStyleSheet.tsx 与rscFlush。【免费下载链接】styled-componentsFast, expressive styling for React. Server components, client components, streaming SSR, React Native—one API.项目地址: https://gitcode.com/gh_mirrors/st/styled-components创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考