Skip to main content
← 返回博客
Next.jsCSSPerformanceTurbopackSCSS

我如何用一个文档不全的 Turbopack 功能消除了 Next.js 16 中的 94 个渲染阻塞 CSS 文件

经过数天尝试每种方法——从 experimental.inlineCss 到 MutationObserver hack——我发现了 Turbopack Import Attributes,它解决了 Next.js App Router 中的渲染阻塞 CSS 问题。

发布于 2026年4月8日12 分钟阅读

问题

我们的应用(Promova)使用 Next.js 16 和 Landing Builder——一个 CMS 驱动的系统,从约 90 个不同的区块组件(hero、FAQ、定价、评论等)组装营销页面。架构使用 sectionRegistry.tsx 将区块名称映射到 next/dynamic() 调用:

sectionRegistry.tsx
// sectionRegistry.tsx — imports all 90 sections
export const sectionRegistry = {
  HeroSection: dynamic(() => import('./HeroSection/HeroSection')),
  FAQSection: dynamic(() => import('./FAQSection/FAQSection')),
  ReviewsSection: dynamic(() => import('./ReviewsSection/ReviewsSection')),
  // ... 87 more
}

单个着陆页只渲染 5-8 个区块。但 Lighthouse 显示:

Eliminate render-blocking resources
  94 CSS resources (~330 KB)
  Potential savings: 5,440 ms

为什么?Turbopack 将所有 90 个 import() 路径视为可达的,并为每个 SCSS 模块生成 <link rel="stylesheet">。即使从未在页面上渲染的区块也会将其 CSS 注入到 <head> 中。这是 Next.js App Router 的已确认的预期行为。CSS 不会为来自 Server Components 的 dynamic() 导入进行代码分割。维护者认为这是防止 FOUC 的有意权衡。不计划修复。

我尝试过的所有方法(以及为什么失败了)

我花了好几天时间尝试每一种我能找到的方法。以下是完整列表:

ApproachWhy it doesn't work
Split sectionRegistry into per-section filesCSS still loaded — #61066
experimental.inlineCss: trueInlines CSS on every SSR request — crashed our CMS under load
experimental.optimizeCss (Critters)Incompatible with streaming in App Router
CSS-in-JS rewriteRewriting 90 sections from SCSS to CSS-in-JS is not realistic
media="print" hackDoesn't work with CSS Modules in Turbopack — <link> tags are managed by the framework
Switch back to WebpackHas experiments.css options, but we're committed to Turbopack
Turbopack pluginsDon't exist — no plugin API
Turbopack CSS loadersNot supported — only JS output
SWC plugins for CSSSWC only processes JavaScript
Client Component wrapper for dynamic()Registry is a global constant — bundler sees all dependencies
Next.js middleware HTML rewriteMiddleware can't modify response body
Suspense + streaming with async importReact Float always pulls CSS into initial <head>
Suspense + 3s delayContent streams later, but CSS is in the initial <head> chunk
experimental.cssChunkingAlready default. Merges chunks but doesn't remove irrelevant CSS
Post-build Beasties/CrittersOnly for static export, not with ISR
npm libraries (next-critical, fg-loadcss)Pages Router only or abandoned (6+ years)
inlineCss per-route exclusionNot supported — all-or-nothing global flag
Turbopack as: 'raw' for SCSSReturns undefined instead of text
Turbopack rule for *.inline.module.scssTurbopack intercepts .module.scss before custom rules
Turbopack rule for *.inline.scssTurbopack intercepts .scss before custom rules
MutationObserver <script>Tested in dev, risky — CSS still loads as separate files, possible FOUC

inlineCss 的陷阱

Next.js 有一个 experimental.inlineCss 标志,将所有 <link rel="stylesheet"> 替换为内联 <style> 标签。听起来完美,对吧?

问题是:它是全部或没有。不能按路由启用。如果你有 SSR(force-dynamic)页面,每个请求都会重新构建所有内联 CSS。我们试过了——我们的 headless CMS 无法承受负载而崩溃了。

发现:Turbopack Import Attributes

在研究 Next.js 16.2 发布说明时,我发现了一个文档不足的功能:Turbopack Import Attributes。它允许你使用 TC39 with {} 语法为特定导入覆盖内置的打包器管道:

import { cssText, styles } from './hero.module.scss' with {
  turbopackLoader: '@promova/scss-to-inline-loader',
  turbopackAs: '*.js'
}

这告诉 Turbopack:"不要将此导入处理为样式表。通过我的自定义 loader 运行它,并将输出视为 JavaScript。"

这是关键洞察。我们的 loader 编译 SCSS 并将其导出为 JS 字符串,而不是让 Turbopack 生成阻塞渲染的 <link rel="stylesheet">。然后我们将其作为内联 <style> 标签直接注入组件中。结果:只有实际渲染的区块的 CSS 才会进入页面 HTML

解决方案

1. 自定义 Turbopack Loader

一个约 70 行的 Node.js 脚本,作为 yarn workspace 包(@promova/scss-to-inline-loader):

@promova/scss-to-inline-loader/index.js
const { createHash } = require('node:crypto')
const sass = require('sass')
const postcss = require('postcss')
const postcssModules = require('postcss-modules')
const path = require('path')

const SHARED_STYLES = path.resolve(__dirname, '../shared/styles')

// Same prependData as sassOptions.prependData in next.config.ts
const PREPEND_DATA = `
@use "sass:math" as math;
@use "sass:list" as list;
@use "${SHARED_STYLES}/_variables.scss" as *;
@use "${SHARED_STYLES}/_breakpoints.scss" as *;
@use "${SHARED_STYLES}/_mixins.scss" as *;
`

module.exports = function scssToInlineLoader(source) {
  const callback = this.async()
  processScss(source, this.resourcePath)
    .then((js) => callback(null, js))
    .catch((err) => callback(err))
}

async function processScss(source, resourcePath) {
  // 1. Compile SCSS → CSS (with global variables, mixins, breakpoints)
  const sassResult = sass.compileString(PREPEND_DATA + '\n' + source, {
    loadPaths: [path.dirname(resourcePath), SHARED_STYLES],
    style: 'compressed',
    sourceMap: false,
    url: new URL('file://' + resourcePath),
  })

  // 2. Scope class names with postcss-modules (CSS Modules compatible)
  let classNames = {}
  const result = await postcss([
    postcssModules({
      generateScopedName(name, filename) {
        const file = path.basename(filename, path.extname(filename))
          .replace('.module', '')
        const hash = createHash('md5')
          .update(filename + name)
          .digest('base64url')
          .slice(0, 6)
        return `${file}__${name}__${hash}`
      },
      getJSON(_, json) { classNames = json },
    }),
  ]).process(sassResult.css, { from: resourcePath })

  // 3. Export as JS module — same interface as CSS Modules
  return [
    `export const cssText = ${JSON.stringify(result.css)};`,
    `export const styles = ${JSON.stringify(classNames)};`,
    `export default styles;`,
  ].join('\n')
}

它的作用:styles——与标准 CSS Modules 相同的作用域类名映射。cssText——编译后的 CSS 字符串。

2. InlineStyle 组件

使用 React 19 内置的 <style href precedence> API 进行自动去重:

InlineStyle.tsx
export function InlineStyle({ css, id }: { css: string; id: string }) {
  return (
    <style href={id} precedence="default">
      {css}
    </style>
  )
}

React 19 保证:相同的 href → DOM 中只有一个 <style>

3. 逐组件迁移(每个区块约 6 行)

// BEFORE: Turbopack → <link rel="stylesheet"> (render-blocking)
import styles from './hero_section.module.scss'
HeroSection.tsx
// AFTER: Custom loader → JS module → inline <style> (non-blocking)
import { cssText, styles } from './hero_section.module.scss' with {
  turbopackLoader: '@promova/scss-to-inline-loader',
  turbopackAs: '*.js'
}
import { InlineStyle } from '@promova/scss-to-inline-loader/InlineStyle'

export function HeroSection() {
  return (
    <section className={styles.hero}>
      <InlineStyle css={cssText} id="hero-section" />
      {/* ... component JSX, className usage is identical */}
    </section>
  )
}

.module.scss 文件保持完全不变。无需重写 CSS。

为什么这比 inlineCss: true 更好

这是关键区别:

<code>inlineCss: true</code>Import Attributes + Loader
What gets inlinedALL CSS on the page (94 files)Only CSS for rendered sections (5-8 files)
SSR overheadCSS rebuilt on every requestCSS compiled at build time
Per-route controlNo (all-or-nothing)Yes (per-import)
SSR pages safetyRisky (overloads server)Safe (component-level)

使用 inlineCss: true,页面仍然会内联所有 94 个样式表。使用我们的方法,只有实际渲染的 CSS 才会进入 HTML

Turbopack 的坑:.module.scss 没有全局规则

我踩过的坑:你可能觉得可以在 next.config.ts 中添加 Turbopack 规则来全局应用 loader:

next.config.ts
// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
  rules: {
    '**/components/**/*.module.scss': {
      loaders: ['@promova/scss-to-inline-loader'],
      as: '*.js'
    }
  }
}

不要这样做。Turbopack 内置的 CSS 模块管道在应用自定义规则之前就拦截了 .module.scss 文件,导致:

FATAL PANIC: inner asset should be CSS processable

with {} 属性之所以有效,是因为它们在导入位置指示 Turbopack 完全绕过 CSS 模块管道。

结果

在 Landing Builder 中迁移了 127 个区块组件。生产构建已验证。

MetricBeforeAfter
Render-blocking CSS files940 (for landing sections)
CSS in HTML per page~330 KB (all sections)~20-40 KB (rendered sections only)
CSS delivery<link> (network request, blocking)<style> (inline, non-blocking)
Sass featuresFullFull (variables, mixins, nesting, @use)
CSS Modules scopingBuilt-inpostcss-modules (compatible)
Code change per section~6 lines (import + InlineStyle)

限制

  • 每个导入的 with {} 很冗长——每个导入需要 3 行额外代码。
  • 仅限 Turbopack——with {} 属性不受 Webpack 支持。
  • 类名哈希——我们的 loader 使用与 Turbopack 内置的不同的哈希算法。
  • HTML 大小增加——CSS 内联在 HTML 中,而不是作为单独的缓存文件。

何时使用

此技术在以下情况下最有效:

  1. 你有 registry/barrel 模式——一个文件导入许多组件,但每页只渲染几个
  2. 你使用 Turbopack——Import Attributes 是 Turbopack 特有的
  3. 你想要逐组件控制——不是全有或全无的标志
  4. 你的 SCSS 很复杂——变量、混入、断点、嵌套——全部支持
  5. 你无法使用 experimental.inlineCss——因为你有 SSR 页面或想要精细控制

如果你受到 Next.js App Router 中渲染阻塞 CSS 的影响——你不是一个人:

核心问题

  • #62485 — Render blocking CSS (maintainers: "expected behavior")
  • #61066 — Dynamic imports from Server Components are not code-split
  • #54935 — Server-side dynamic imports don't split client modules
  • #61574 — JS/CSS code splitting doesn't work as documented
  • #57634 — Add support for critical CSS inlining with App Router
  • #50300 — next/dynamic on server component does not build CSS modules

inlineCss 功能与问题

  • PR #72195 — experimental: css inlining (the implementation)
  • PR #73182 — Don't inline CSS in RSC payload for client navigation
  • #75648 — WhatsApp preview broken with inlineCss + Tailwind
  • #83612 — Turbopack wrong font URL with inline CSS

社区寻求解决方案

Turbopack CSS 缺陷

  • #68412 — Incorrect CSS modules load order with external components
  • #76464 — Turbopack sometimes strips SCSS imports
  • #82497 — SCSS module not loading when not-found.tsx is present
  • #88544 — Exporting Sass variables from CSS modules doesn't work with Turbopack

Promova 构建——一个服务数百万用户的语言学习平台。