Next.js 集成 Fela 的通用样式渲染示例工程深度解析
技术指南以本仓库的示例应用 examples/with-fela 为主体,讲解如何在 Next.js 的 Pages Router 中用 Fela(一个原子化、高性能的 CSS-in-JS 引擎)替代内置的 styled-jsx,实现"通用样式"(Universal Styles)能力——即将首屏渲染所需的关键样式随 HTML 一同在服务端产出并注入
<head>,其余样式在客户端按需补齐。读完本文,你将掌握 CustomDocument与renderPage的enhanceApp定制原理、Fela renderer 在服务端/客户端间的贯通方式,以及一套可复制运行的完整接入代码。
为什么需要"通用样式"渲染方案
Next.js 默认使用 styled-jsx 作为样式方案,它在服务端渲染时会自动收集组件内样式并输出到 HTML,天然支持"首屏样式随文档下发"。但当你选择 Fela 这类 CSS-in-JS 库时,服务端不会自动注入样式,页面首帧可能出现闪烁(FOUC,Flash of Unstyled Content),或样式在客户端重复计算造成性能浪费。
本示例要解决的核心问题正是:让 Fela 生成的首屏样式在服务端被收集,并以静态 <style> 标签的形式放进 HTML 的 <head> 中,再让客户端接管剩余交互产生的新样式。文档开宗明义地说明了这一点:
"We can serve the required styles for the first render within the HTML and then load the rest in the client."
具体做法是扩展 <Document /> 组件,在服务端渲染时把 Fela renderer 产生的样式序列化后注入 <head>,这正是 Next.js 官方提供的面向 CSS-in-JS 场景的高级定制入口。
工程结构与运行方式
该示例是标准的 Pages Router 应用,目录结构如下:
examples/with-fela/
├── pages/
│ ├── _app.js # 应用根组件:注入 RendererProvider
│ ├── _document.js # 自定义 Document:服务端收集并注入 Fela 样式
│ └── index.js # 示例页面:用 react-fela 编写组件样式
├── FelaProvider.js # 兼容性 Provider 封装(renderer 可空可传)
├── getFelaRenderer.js # 创建 Fela renderer(含 webPreset 插件)
├── package.json
└── README.md
对应的依赖清单见 package.json:除了 next、react@^18.2.0、react-dom@^18.2.0 之外,核心是 Fela 生态的四个包:
fela:核心库,提供createRenderer;fela-dom:浏览器端 DOM 渲染器(用于客户端挂载与动态样式);fela-preset-web:Web 环境常用插件的组合 preset(前缀、厂商前缀、fallback 值、px自动补全等);react-fela:React 绑定层,提供RendererProvider、FelaComponent、useFela等 API。
一键启动示例
示例可通过 create-next-app 的 --example 参数直接引导(以 examples/with-fela 为模板):
# npm
npx create-next-app --example with-fela with-fela-app
# 进入目录后
npm run dev # 对应 scripts.dev: next
npm run build # 对应 scripts.build: next build
npm run start # 对应 scripts.start: next start
也可以直接在本仓库的 examples/with-fela 目录中执行 pnpm install && pnpm dev 原地运行观察效果。运行后访问首页,可在浏览器"查看源代码"中看到 Fela 生成的首屏样式已经以内联 <style> 形式出现在 <head> 中。
关键源码逐步拆解
1. 创建 renderer:getFelaRenderer.js
getFelaRenderer.js 是整个链路的起点,它导出一个工厂函数,负责实例化 Fela renderer:
import { createRenderer } from "fela";
import webPreset from "fela-preset-web";
export default function getRenderer() {
return createRenderer({
plugins: [...webPreset],
});
}
fela-preset-web 展开后是一组按序执行的插件(典型的 Fela 插件机制),包括:
- 前缀插件:自动为
display: flex等属性补充-webkit-等浏览器前缀; - fallback 值插件:支持
fontFamily: ["Arial", "sans-serif"]这类多值降级写法,按顺序输出; - px 自动补全插件:对
0以外的数值属性自动追加px单位(需数字语义明确); - extend / 嵌套等增强插件:支持样式对象内的组合语法。
2. Provider 封装:FelaProvider.js
FelaProvider.js 是一个兼容层:在组件树根部通过 react-fela 的 RendererProvider 把 renderer 注入 React 上下文。其特殊之处在于 renderer 来源支持两种:
const fallbackRenderer = getFelaRenderer();
// ...
const renderer = this.props.renderer || fallbackRenderer;
return (
<RendererProvider renderer={renderer}>
{this.props.children}
</RendererProvider>
);
- 如果从
props.renderer拿到了服务端创建的那个 renderer,就使用它(客户端首帧沿用服务端已经渲染出的规则,避免重复创建); - 如果没有传入(例如纯客户端场景),则退回模块级
fallbackRenderer。
这种"prop 优先、兜底新建"的设计,保证了组件树无论在服务端还是客户端、无论 renderer 是否由上层显式注入,都能拿到可用的 renderer。
3. 应用根组件:pages/_app.js
_app.js 接收来自 Document 层的 renderer prop 并把它交给 FelaProvider:
import FelaProvider from "../FelaProvider";
function MyApp({ Component, pageProps, renderer }) {
return (
<FelaProvider renderer={renderer}>
<Component {...pageProps} />
</FelaProvider>
);
}
export default MyApp;
这里的 renderer prop 并不是页面路由自带注入的,而是由下一步 _document.js 中的 enhanceApp 在渲染 App 时以高阶组件的方式手动传入的。这形成了完整的单向数据流:Document 创建 renderer → 通过 enhanceApp 注入 App → App 传给 FelaProvider → 所有页面组件通过上下文访问。
4. 核心:pages/_document.js 服务端样式收集与注入
_document.js 是"通用样式"得以实现的关键。Next.js 只会在服务端渲染 _document.js,因此在这里执行样式收集,天然保证产出的是首屏 HTML 所需的关键 CSS:
import Document, { Html, Head, Main, NextScript } from "next/document";
import { renderToNodeList } from "react-fela";
import getFelaRenderer from "../getFelaRenderer";
export default class MyDocument extends Document {
static async getInitialProps(ctx) {
const renderer = getFelaRenderer();
const originalRenderPage = ctx.renderPage;
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) => <App {...props} renderer={renderer} />,
});
const initialProps = await Document.getInitialProps(ctx);
const styles = renderToNodeList(renderer);
return {
...initialProps,
styles: [...initialProps.styles, ...styles],
};
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
该文件内部包含了三个协同工作的步骤,逐一剖析如下。
第一步:在执行渲染前创建 renderer
const renderer = getFelaRenderer();
renderer 在 getInitialProps 内部创建,而不是模块顶层,是为了保证每次请求都能得到全新的、未被污染的状态容器。Fela 的 renderer 内部维护着一张规则表(rule registry),服务端渲染结束后页面组件可能会注册新的动态规则,如果复用单例会造成跨请求的状态串扰。每次请求新建、用完即弃是服务端 CSS-in-JS 的常见正确姿势。
第二步:重写 renderPage 并通过 enhanceApp 注入 renderer
const originalRenderPage = ctx.renderPage;
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) => <App {...props} renderer={renderer} />,
});
这是理解本示例的枢纽。Next.js 官方在 Custom Document 文档 中明确说明:自定义 renderPage 属于高级能力,仅在 CSS-in-JS 这类库需要支持服务端渲染时才需要(内置 styled-jsx 无需此操作)。
enhanceApp 接收当前的 App 组件(即 _app.js 的 MyApp),返回一个增强后的新组件。这里以 props 透传的方式把 renderer 作为普通 prop 附加进去,于是渲染 App 时 MyApp 就能从 props 中取到服务端的 renderer。之所以必须走 enhanceApp 而非在 _document.js 的 render() 里直接包 Provider,是因为页面真正渲染必须发生在 Main 内部、且发生在 getInitialProps 的整个流程里——只有通过重写 renderPage 才能在 React 组件树真正渲染时就让 Provider 生效,这样组件树中的 useFela / FelaComponent 才能注册到正确(服务端)的 renderer 上,样式规则也才会被记录。
ctx 对象与 getInitialProps 的标准上下文一致,只是额外增加了 renderPage(见 Custom Document 文档 的相关说明)。
第三步:父类收集 + Fela 样式合并注入
const initialProps = await Document.getInitialProps(ctx);
const styles = renderToNodeList(renderer);
return {
...initialProps,
styles: [...initialProps.styles, ...styles],
};
执行顺序上必须先 await Document.getInitialProps(ctx) 让页面组件树真正渲染一遍,这样 renderer 中才会积累出本页所有规则,随后 renderToNodeList(renderer) 才拿得到完整结果。
renderToNodeList 是 react-fela 提供的服务端序列化 API:它把 renderer 中已注册的规则输出为 React 元素节点数组(每个规则对应一个 <style> 节点)。Next.js 的 Document.getInitialProps 返回结果中本就带有 styles 字段(默认包含 styled-jsx 收集到的样式),这里将其与 Fela 产出的节点数组合并、拼接,最终由框架统一渲染进 _document.js 中 <Head /> 的位置。render() 方法保持标准的 Html / Head / Main / NextScript 结构,无需手动改动——注入是经由 getInitialProps 返回值自动完成的。
于是首个 HTML 响应中就携带了内联的 Fela 关键样式;浏览器端后续再触发的新规则则交给 fela-dom 在客户端动态处理,形成"服务端产出首屏、客户端接管增量"的完整闭环。
5. 消费侧:用 react-fela 编写组件样式
pages/index.js 展示了两种官方推荐的组件样式写法:
方式一:FelaComponent(声明式),适合静态样式:
const Container = ({ children }) => (
<FelaComponent
style={{
maxWidth: 700,
marginLeft: "auto",
marginRight: "auto",
lineHeight: 1.5,
}}
as="div"
>
{children}
</FelaComponent>
);
方式二:useFela + 规则函数(Hook 式),适合依赖 props 的动态样式。Fela 的规则是"输入 props 输出样式对象"的纯函数:
const textRule = ({ size, theme }) => ({
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
fontSize: size,
color: "#333",
});
function Text({ size = 16, children }) {
const { css } = useFela({ size });
return <p className={css(textRule)}>{children}</p>;
}
useFela({ size }) 的入参会被当作 props 传入规则函数,css(textRule) 返回生成好的原子类名,Fela 会将该规则注册进上下文中的 renderer。无论哪种写法,规则最终都会落到 renderer 中——若此刻是服务端首屏渲染,就会被 _document.js 收集进 HTML;若发生在客户端交互阶段,则由 fela-dom 即时插入样式表。
6. 从 Fela 反观 styled-jsx 的默认路径
为了理解为何需要如此繁琐的桥接,可以对照同目录下其它更简单的示例:Next.js 内置 styled-jsx 时,样式收集由框架自动完成,无需重写 renderPage。而一旦采用第三方 CSS-in-JS 方案,就落入了官方文档指出的"仅 CSS-in-JS 场景需要的 renderPage 定制"范畴。本示例正是该高级路径的最小、完整范本:两个自定义文件(getFelaRenderer.js + FelaProvider.js)、两个 Pages Router 特殊文件(_app.js + _document.js)即构成全部改动面,其余页面组件可无感编写样式。
适配与限制说明
- 面向 Pages Router:
_document.js/_app.js的定制机制属于 Pages Router(pages/目录),本示例即基于该路由模型;若使用 App Router(app/目录)则需要fela配合其它 SSR 注入机制(如基于流式渲染的自定义方案),不能照搬本示例文件。 - 服务端与客户端 renderer 分离:服务端渲染时每次请求新建 renderer(
getFelaRenderer按需调用),客户端则复用由 Document 下发的同一 renderer(经由FelaProvider的props.renderer),确保首屏类名与客户端类名保持一致、避免重复注册。这是理解代码时最重要的心智模型。 - 样式位置:Fela 首屏样式最终以
initialProps.styles合并进文档的<head>,这是文档开头所述"serve the required styles for the first render within the HTML"的直接落地。
小结:一张数据流图
整个方案可以浓缩为一条闭环:
_document.js的getInitialProps为本次请求创建全新 Fela renderer;- 通过重写
renderPage+enhanceApp,把 renderer 以 prop 形式注入_app.js; FelaProvider用RendererProvider使 renderer 在整个组件树上下文可见;- 页面组件用
FelaComponent/useFela注册规则,样式被记入当前 renderer; - 渲染结束后,
Document.getInitialProps将styled-jsx原生样式与renderToNodeList(renderer)产出的 Fela<style>节点合并,一并写入 HTML 的<head>; - 浏览器端收到含关键样式的完整首屏 HTML,后续新增样式由客户端 renderer 动态接管。
这套"Custom Document + renderPage.enhanceApp + 服务端渲染器隔离"的架构,不只是 Fela 专属——它同样适用于绝大多数需要 SSR 样式收集的 CSS-in-JS 库(如 styled-components、emotion 在 Pages Router 下的经典接入模式),因此本示例也是理解 Next.js 扩展点机制的一把钥匙。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00