Storybook Next.js 框架实战:用 staticDirs 让 next/font/local 本地字体在故事中正确加载
2026/9/18 11:45:37 网站建设 项目流程

Storybook Next.js 框架实战:用 staticDirs 让 next/font/local 本地字体在故事中正确加载

导读

在 Storybook 的@storybook/nextjs(Webpack)与@storybook/nextjs-vite框架中,next/font/google开箱即用,但next/font/local的本地字体文件需要额外一步staticDirs映射配置,否则字体资源无法被解析到。本文基于 Storybook 官方文档nextjs-image-static-dirs配置片段及其所在文档 Next.js (Webpack) 框架指南 展开,完整给出staticDirs的四种写法(CSF 3 /defineMain× JS / TS)、from/to的语义细节,并结合仓库源码解释「为什么必须做这一步」——Babel 插件改写next/font导入、Webpack loader 生成相对路径的@font-face的完整机制,同时覆盖next/font在 Storybook 中不支持的选项与 CI 环境下的字体请求 mock 方案。读完后你能在 Next.js + Storybook 项目中独立配置并排查本地字体加载问题。

一、问题背景:next/font 在 Storybook 中的支持边界

Next.js 的 next/font 在 Storybook 中是部分支持的,两个子包的支持程度不同(见 Next.js 框架指南):

模块支持情况需要做什么
next/font/google开箱即用,完整支持什么都不用做
next/font/local部分支持必须定义src属性,并通过staticDirs告诉 Storybook 字体目录的位置

使用next/font/local时的关键约束:

  1. 必须显式定义src属性(next/font/local不像next/font/google那样可以靠 font family 名称推断来源);
  2. src的路径相对于调用字体 loader 函数的那个文件所在的目录解析。

例如某个组件这样定义本地字体:

import localFont from 'next/font/local'; const localRubikStorm = localFont({ src: './fonts/RubikStorm-Regular.ttf' });

此时字体文件的实际位置由「组件文件所在目录 +./fonts/...」决定。但 Storybook 在构建时生成的字体 CSS 中url()是相对项目根的,如果该字体目录不在 Storybook 静态资源的服务范围内,浏览器就加载不到字体文件。这正是staticDirs配置要解决的问题。

仓库中的官方模板故事 Font.tsx 同时演示了next/font/googleRubik_Puddles)与next/font/locallocalFont({ src: '/fonts/RubikStorm-Regular.ttf' })) 在故事中以className/style/variable三种方式使用的完整形态,可作参考。

二、核心配置:staticDirs 映射字体目录

staticDirs是 Storybookmain.js|ts配置项(见 staticDirs 文档),用于声明一组静态文件目录。使用配置对象写法时包含两个字段:

  • from相对于.storybook目录的路径,即字体目录在磁盘上的真实位置;
  • to相对于 Storybook 执行上下文的路径(通常是项目根目录),即字体被服务到浏览器时的 URL 前缀。

因此,对于上面「组件位于src/components/、字体位于src/components/fonts/」的例子,.storybook目录下的配置应为:

export default { // ... staticDirs: [ { from: '../src/components/fonts', // 相对 .storybook 目录:指向真实字体目录 to: 'src/components/fonts', // 相对项目根:生成 CSS 中 url() 的解析前缀 }, ], };
// Replace your-framework with nextjs or nextjs-vite import type { StorybookConfig } from '@storybook/your-framework'; const config: StorybookConfig = { // ... staticDirs: [ { from: '../src/components/fonts', to: 'src/components/fonts', }, ], }; export default config;

如果使用 Storybook 的defineMain(CSF Next 🧪)写法,配置等价如下(TypeScript 与 JavaScript 版本相同,仅扩展名不同):

// Replace your-framework with nextjs or nextjs-vite import { defineMain } from '@storybook/your-framework/node'; export default defineMain({ // ... staticDirs: [ { from: '../src/components/fonts', to: 'src/components/fonts', }, ], });
// Replace your-framework with nextjs or nextjs-vite import { defineMain } from '@storybook/your-framework/node'; export default defineMain({ // ... staticDirs: [ { from: '../src/components/fonts', to: 'src/components/fonts', }, ], });

要点提醒:

  • 注释中的your-framework需替换为实际框架包名:Webpack 版填@storybook/nextjs,Vite 版填@storybook/nextjs-vite
  • fromto的参照系不同(前者是.storybook/,后者通常是项目根),这是最容易写错的地方;如果两者指向不一致,生成的url()前缀会错位,字体 404;
  • 该配置是通用能力:任何需要通过staticDirs暴露的静态资源(图片、字体等)都适用同一写法,next/font/local只是其中最典型的使用场景(参见 staticDirs 完整文档 及 静态文件说明)。

三、原理深挖:为什么必须映射 staticDirs

从源码结构看,@storybook/nextjs框架对next/font的处理分为三步,理解这三步就能明白staticDirs的必要性。

3.1 Babel 插件:把 next/font 导入改写成 loader 调用

Babel 插件 TransformFontImports 会拦截对next/font/localnext/font/google的导入,把形如:

import localFont from 'next/font/local'; const myFont = localFont({ src: './my-font.woff2' });

的代码,改写为携带完整参数的 loader 导入:

import myFont from 'storybook-nextjs-font-loader?{filename: "src/example.js", source: "next/font/local", props: {"src": "./my-font.woff2"}}!next/font/local';

可以看到,组件文件名(filename)和src属性(props.src)都作为 loader 参数被传递了下去

3.2 Webpack loader:基于「调用方所在目录」拼接字体 URL

loader 入口 storybook-nextjs-font-loader.ts 根据source判断是 google 还是 local 字体,local 字体走 get-font-face-declarations.ts。其中关键逻辑是:

// 组件文件所在目录(相对 rootContext),作为 src 的拼接基准 const parentFolder = swcMode ? dirname(join(getProjectRoot(), options.filename)).replace(rootContext, '') : dirname(options.filename).replace(rootContext, ''); // ... const localFontPath = join(parentFolder, localFontSrc).replaceAll('\\', '/'); return `@font-face { font-family: ${id}; src: url(.${localFontPath}); ... }`;

也就是说,loader 会生成类似src: url(./src/components/fonts/RubikStorm-Regular.ttf)@font-face规则,其 URL 是相对 Storybook 执行根的相对路径,同时把该 CSS 注入页面<head>(见 set-font-declarations-in-head.ts),并导出与 Next.js 一致的className/style/variable对象。

3.3 结论:字体文件必须真实存在于该 URL 下

url(./src/components/fonts/...)最终由浏览器按 URL 向 Storybook 的静态资源服务请求文件。如果字体目录没有被staticDirs声明,Storybook 就不会把src/components/fonts拷贝/服务到构建输出中,url()解析到的就是 404。所以「from/to映射」本质上是在告诉 Storybook:把这个字体目录服务到 loader 生成的那个相对 URL 下

这条链路也可以从 loader 的注册方式得到印证:configureNextFont.ts 在 Webpack 配置中把storybook-nextjs-font-loader挂为 loader(SWC 模式下直接对target.css规则追加 loader),确保上面生成的 CSS 能被正确注入。

注:以上基于 Babel 模式的转换链路来自仓库源码;在 SWC 模式下 loader 通过importQuery解析同样的参数(path/import/arguments),机制一致。

四、next/font 在 Storybook 中的不支持项

以下next/font能力在 Storybook 中尚未支持(可能被忽略或强制覆盖),写故事时需知晓(来源:Next.js 框架指南):

  • next.config.js中的 font loaders 配置支持;
  • fallback选项;
  • adjustFontFallback选项;
  • preload选项——被忽略,Storybook 以自己的方式加载字体;
  • display选项——被忽略,所有字体均以display: block加载,以保证 Storybook 能正确加载字体。

这些特性「可能会在未来的版本中提供」,当前版本请以本文档为准。

五、CI 场景:mock 掉 Google Fonts 的网络请求

next/font/google虽然开箱即用,但构建过程中它会真实请求 Google Fonts。在 CI 流水线上,偶发的网络失败可能导致整个 Storybook 构建失败。官方建议通过环境变量NEXT_FONT_GOOGLE_MOCKED_RESPONSES指向一个 mock 模块(该机制由 Next.js 本身提供),例如在 CI workflow 中:

- uses: chromaui/action@latest env: #👇 the location of mocked fonts to use NEXT_FONT_GOOGLE_MOCKED_RESPONSES: ${{ github.workspace }}/mocked-google-fonts.js with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} token: ${{ github_TOKEN }}

mock 文件以「请求的 Google Fonts CSS URL」为 key,值为完整的@font-face声明文本:

//👇 Mocked responses of google fonts with the URL as the key module.exports = { 'https://fonts.googleapis.com/css?family=Inter:wght@400;500;600;800&display=block': ` @font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: block; src: url(https://fonts.gstatic.com/s/inter/v12/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZJhiJ-Ek-_EeAmM.woff2) format('woff2'); unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; } /* more font declarations go here */ `, };

key 必须与next/font/google实际发起的 CSS 请求 URL完全一致(包含subsetsweightdisplay等参数的编码结果),否则 mock 不生效。

六、进一步阅读

  • 配置片段原文:nextjs-image-static-dirs.md(本文静态配置示例的出处,对应 nextjs.mdx 中「Next.js font optimization → staticDir mapping」一节);
  • staticDirs的通用 API 说明(字符串数组 / 配置对象两种写法):main-config-static-dirs.mdx;
  • @storybook/nextjs框架完整文档(next/image、路由 mock、nextjs参数命名空间、export-mocks等 API):get-started/frameworks/nextjs.mdx;
  • 框架侧字体处理源码:src/font/babel/index.ts、src/font/webpack/loader/storybook-nextjs-font-loader.ts、src/font/webpack/loader/local/get-font-face-declarations.ts;
  • 官方模板故事(google + local 字体的三种用法演示):template/stories/Font.tsx。

小结

场景需要做的事
next/font/google无需配置;CI 中建议用NEXT_FONT_GOOGLE_MOCKED_RESPONSESmock 请求
next/font/local显式传src(相对组件文件目录),并在main.js|ts中用staticDirs: [{ from, to }]把字体目录映射到与src一致的相对 URL

记住两个参照系——from相对.storybook/to相对项目根——再对照源码中 loader 生成url(./<to 前缀>/<字体文件名>)的行为,就能快速定位本地字体在 Storybook 中不显示的根因。

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询