首页
/ Next.js 集成 Fela 的通用样式渲染示例工程深度解析

Next.js 集成 Fela 的通用样式渲染示例工程深度解析

2026-09-06 19:02:25作者:瞿蔚英Wynne

技术指南以本仓库的示例应用 examples/with-fela 为主体,讲解如何在 Next.js 的 Pages Router 中用 Fela(一个原子化、高性能的 CSS-in-JS 引擎)替代内置的 styled-jsx,实现"通用样式"(Universal Styles)能力——即将首屏渲染所需的关键样式随 HTML 一同在服务端产出并注入 <head>,其余样式在客户端按需补齐。读完本文,你将掌握 Custom DocumentrenderPageenhanceApp 定制原理、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:除了 nextreact@^18.2.0react-dom@^18.2.0 之外,核心是 Fela 生态的四个包:

  • fela:核心库,提供 createRenderer
  • fela-dom:浏览器端 DOM 渲染器(用于客户端挂载与动态样式);
  • fela-preset-web:Web 环境常用插件的组合 preset(前缀、厂商前缀、fallback 值、px 自动补全等);
  • react-fela:React 绑定层,提供 RendererProviderFelaComponentuseFela 等 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-felaRendererProvider 把 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.jsMyApp),返回一个增强后的新组件。这里以 props 透传的方式把 renderer 作为普通 prop 附加进去,于是渲染 App 时 MyApp 就能从 props 中取到服务端的 renderer。之所以必须走 enhanceApp 而非在 _document.jsrender() 里直接包 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) 才拿得到完整结果。

renderToNodeListreact-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(经由 FelaProviderprops.renderer),确保首屏类名与客户端类名保持一致、避免重复注册。这是理解代码时最重要的心智模型。
  • 样式位置:Fela 首屏样式最终以 initialProps.styles 合并进文档的 <head>,这是文档开头所述"serve the required styles for the first render within the HTML"的直接落地。

小结:一张数据流图

整个方案可以浓缩为一条闭环:

  1. _document.jsgetInitialProps 为本次请求创建全新 Fela renderer;
  2. 通过重写 renderPage + enhanceApp,把 renderer 以 prop 形式注入 _app.js
  3. FelaProviderRendererProvider 使 renderer 在整个组件树上下文可见;
  4. 页面组件用 FelaComponent / useFela 注册规则,样式被记入当前 renderer;
  5. 渲染结束后,Document.getInitialPropsstyled-jsx 原生样式与 renderToNodeList(renderer) 产出的 Fela <style> 节点合并,一并写入 HTML 的 <head>
  6. 浏览器端收到含关键样式的完整首屏 HTML,后续新增样式由客户端 renderer 动态接管。

这套"Custom Document + renderPage.enhanceApp + 服务端渲染器隔离"的架构,不只是 Fela 专属——它同样适用于绝大多数需要 SSR 样式收集的 CSS-in-JS 库(如 styled-components、emotion 在 Pages Router 下的经典接入模式),因此本示例也是理解 Next.js 扩展点机制的一把钥匙。

登录后查看全文
热门项目推荐
相关项目推荐