UnoCSS Inspector 在 Next.js 中的落地:用 @unocss/postcss + Devframe 在纯 Next.js 应用中托管 UnoCSS 检查器
【免费下载链接】unocssThe instant on-demand atomic CSS engine.项目地址: https://gitcode.com/GitHub_Trending/un/unocss
本篇以仓库示例 examples/inspector-next 及其 README 为主体,讲解如何在不引入 Vite 的前提下,通过@unocss/postcss让 UnoCSS 在 Next.js 中生成样式,再用@devframes/next把 UnoCSS Inspector 作为开发期工具面板(devframe)挂载到 Next.js 应用自身的路由上。读完本文,你将掌握这条「PostCSS + Devframe」集成链路的工作原理:catch-all 路由如何构建独立 UnoCSS 上下文、一次性扫描如何工作、以及一次性解锁码等安全机制从何而来。
1. 这个示例解决什么问题
UnoCSS 生态中最常见的 Inspector 宿主是 Vite(通过unocss插件挂载)。但在纯 Next.js 项目中,UnoCSS 通常通过 PostCSS 集成 接入,应用本身并不存在一个可被插件直接驱动的 UnoCSS 插件上下文。examples/inspector-next演示的正是这条替代路径:
- 样式生成走
@unocss/postcss,由 Next.js 内置的 CSS 处理管线触发; - Inspector 的 SPA 与 RPC 服务由 Next.js 应用的一个 catch-all 路由托管,宿主是第三方框架层
@devframes/next(README 中称之为 devframe 托管方案); - 全程不涉及 Vite。
README 明确强调这一点:
Next.js app using UnoCSS through
@unocss/postcss, hosting the inspector with@devframes/next— no Vite involved.
2. 快速上手
仓库给出的运行方式非常简单(README):
pnpm install pnpm dev然后打开http://localhost:3000/__unocss/,并输入Next dev 终端中打印的一次性解锁码才能进入 Inspector。
README 还给出了一个关键限制说明:
Note: the standalone context scans the project once at startup — restart the dev server (or re-save the route file) to re-scan after adding new utilities.
即:独立上下文只在启动时扫描项目一次,新增工具类后需要重启 dev server(或重新保存路由文件触发重建)才会重新扫描。这一点在 5 节的源码分析中能看到具体成因。
3. 项目结构与关键文件
示例项目结构紧凑,核心文件各司其职:
| 文件 | 职责 |
|---|---|
| postcss.config.mjs | 注册@unocss/postcss插件并指定content扫描范围 |
| uno.config.ts | UnoCSS 配置:presetWind3(dark: 'media')与btnshortcut |
| next.config.ts | 通过withDevframe包装 Next 配置,并将 devframe 相关包设为外部依赖 |
| tsconfig.json | 配置@/*路径别名(@/uno.config因此可被路由直接 import) |
app/%5F_unocss/[[...path]]/route.ts | Inspector 的 catch-all 路由(目录名%5F是_的 URL 编码写法) |
| app/page.tsx | 演示页,使用原子类与btnshortcut |
| app/globals.css | 仅一行@unocss all;作为 UnoCSS 的 CSS 入口 |
依赖方面(package.json):生产依赖是next 16.1.2/react 19.2.3,开发依赖中通过 monorepolink:协议引用了@unocss/core、@unocss/postcss、@unocss/inspector、@unocss/preset-wind3与unocss,以及 devframe 宿主包@devframes/next。
3.1 样式生成侧:PostCSS 配置
postcss.config.mjs 完整内容:
const config = { plugins: { '@unocss/postcss': { content: ['./app/**/*.{html,js,ts,jsx,tsx}'], }, }, } export default configcontent字段限定工具类提取范围为app目录下的源码文件;app/globals.css 中的@unocss all;指令则告诉插件在此处输出全部生成的原子类。页面 app/page.tsx 中实际使用的类如h-screen flex flex-col items-center、op-60、hover:op-50,以及btn这个 shortcut(展开为px-4 py-2 rounded bg-sky-600 text-white hover:bg-sky-700 cursor-pointer,定义于 uno.config.ts),都会进入 Inspector 可检查的范围。
uno.config.ts中还有一个值得注意的工程细节——注释明确写道:
// Import presets from their own packages (instead of the `unocss` root // export) so the Next bundler doesn't pull in Vite-only transformers const config: UserConfig = { presets: [ presetWind3({ dark: 'media', }), ], shortcuts: { btn: 'px-4 py-2 rounded bg-sky-600 text-white hover:bg-sky-700 cursor-pointer', }, } export default config即:从@unocss/preset-wind3而不是unocss根包导入 preset,避免 Next 打包器把仅适用于 Vite 的 transformer 一并打进 bundle。
4. 核心机制:catch-all 路由托管 Inspector
整个方案的枢纽是路由文件app/%5F_unocss/[[...path]]/route.ts,完整源码:
import process from 'node:process' import { createDevframeNextHandler } from '@devframes/next/single' import { createStandaloneInspectorDevframe } from '@unocss/inspector/devframe' import config from '@/uno.config' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' // Build a standalone UnoCSS context by scanning the project files once at // startup, then host the inspector devframe from this catch-all route. // The RPC WebSocket runs on a side-car port advertised through // `/__unocss/__connection.json`, gated by devframe's one-time-code auth // (the code is printed in the Next dev terminal). const handlerPromise = createStandaloneInspectorDevframe({ root: process.cwd(), // Pass the config inline (`configFile: false` skips the file loader, // which doesn't play well with the Next bundler) config: { ...config, configFile: false }, patterns: ['app/**/*.{ts,tsx,html}'], // (cast: pnpm may instantiate devframe twice across the linked monorepo) }).then(({ definition }) => createDevframeNextHandler(definition as any)) export async function GET(request: Request) { return (await handlerPromise).fetch(request) }逐段拆解这段代码的设计意图:
runtime = 'nodejs'与dynamic = 'force-dynamic':路由必须运行在 Node.js runtime 且禁用静态缓存——因为 Inspector 需要在服务端持有内存中的 UnoCSS 上下文,并处理 WebSocket/RPC 动态请求,不能被边缘 runtime 或静态预渲染接管。handlerPromise是模块级单例:createStandaloneInspectorDevframe(...)的 Promise 在模块加载时就创建,所有GET请求共享同一个 handler。这保证了「启动时扫描一次」只发生一次,后续的 SPA 静态资源、RPC 连接都复用同一个 devframe handler。内联配置而非文件路径:
config: { ...config, configFile: false }直接把uno.config.ts以对象形式传入,并置configFile: false跳过文件加载器。源码注释解释了原因:Next 的打包器对运行时读取配置文件这条路径不友好(bundling 后import.meta.url等路径语义会被改写),因此改为在编译期把配置对象内联进 bundle,再由@unocss/inspector的上下文创建逻辑消费。patterns收窄扫描范围:['app/**/*.{ts,tsx,html}']只覆盖 Next.js 的app目录(对比 5 节中createStandaloneInspectorDevframe的默认 patterns 会扫全部常见源码扩展名)。as any强制断言的原因:源码注释写明「pnpm may instantiate devframe twice across the linked monorepo」——在 monorepo link 场景下,devframe包可能被实例化两份,导致类型不兼容,故对definition做断言。README 中提到的 side-car 架构:RPC WebSocket 运行在一个独立端口(side-car),客户端通过
/__unocss/__connection.json获取连接信息,且受 devframe 的「一次性码」鉴权保护——这就是打开http://localhost:3000/__unocss/后需要输入终端打印的解锁码的原因。
5. 源码纵深:createStandaloneInspectorDevframe做了什么
路由调用的createStandaloneInspectorDevframe实现在 packages-integrations/inspector/src/devframe.ts,这是理解整个方案行为边界的关键:
export async function createStandaloneInspectorDevframe(options: StandaloneInspectorOptions = {}): Promise<UnocssInspectorDevframe> { const { root = process.cwd(), config, defaults = {}, patterns = DEFAULT_PATTERNS, } = options const ctx = createContext(config, defaults) await ctx.updateRoot(root) await ctx.ready const files = await glob(patterns, { cwd: root, absolute: true }) await Promise.all(files.map(async (file) => { try { const code = readFileSync(file, 'utf-8') await ctx.extract(code, file) } catch {} })) await ctx.flushTasks() return createInspectorDevframe(ctx) }从源码结构看,它的工作流程是:
- 创建独立上下文:
createContext(config, defaults)依据传入的(内联)配置构建一个不与任何 bundler 绑定的 UnoCSS 插件上下文,updateRoot定位项目根目录(示例中即process.cwd(),与 postcss 的扫描根一致)。 - 一次性全量扫描:用
tinyglobby对patterns做 glob,逐文件readFileSync+ctx.extract(code, file)提取工具类,最后ctx.flushTasks()等待提取任务完成。这里没有任何 watch/fs 监听逻辑——这正解释了 README 中「新增工具类后需重启 dev server 重新扫描」的说明:standalone 模式是快照式的。 - 包装为 devframe:
createInspectorDevframe(ctx)把上下文包装成可挂载的 devframe 定义(id: 'unocss'、basePath: '/__unocss/'),供 Next 宿主通过createDevframeNextHandler转为 Next.js 路由 handler。
StandaloneInspectorOptions的完整选项(devframe.ts):
| 选项 | 说明 |
|---|---|
root | 要扫描的项目根目录,默认process.cwd() |
config | UnoCSS 配置对象或配置文件路径;省略时自动从 root 加载 |
defaults | 传给上下文的配置默认值(UserConfigDefaults) |
patterns | 提取工具类的 glob 模式,相对于 root |
未显式指定patterns时,默认的DEFAULT_PATTERNS(devframe.ts)覆盖了绝大多数前端源码扩展名,并排除构建产物:
const DEFAULT_PATTERNS = [ '**/*.{html,vue,svelte,astro,jsx,tsx,js,ts,mdx,md,marko,pug,elm,php,phtml}', '!**/node_modules/**', '!**/dist/**', '!**/.next/**', '!**/.nuxt/**', '!**/.output/**', ]示例项目主动把patterns收窄为app/**/*.{ts,tsx,html},与@unocss/postcss的content范围保持同一语义(app目录),使 Inspector 看到的工具集合与页面实际生成的 CSS 一致。
5.1 变更信号如何到达 Inspector 客户端
同一文件中,createInspectorDevframe还展示了 devframe 的通信设计(devframe.ts):devframe 定义声明了@devframes/service-shiki服务用于服务端语法高亮(覆盖 css/html/js/ts/vue/jsx/tsx),并在setup阶段注册 Inspector 的 RPC 函数与一个名为changes的 devframe shared state。源码注释解释了选择 shared state 而非自定义广播的理由:
Change signals ride a devframe shared state (
changes) rather than custom broadcasts: every host holds one, mutating it bumps a revision the client watches, and a reconnecting client gets the latest snapshot for free.
即任何宿主变更都会 bumprevision,客户端监视该值刷新;断线重连的客户端则免费获得最新快照。返回值中的notifyModuleUpdated/notifyConfigChanged/notifyInvalidated三个信号函数供宿主在模块热更、配置重载、CSS 失效时通知已连接的 Inspector——在 standalone 模式下这些信号虽少被触发(没有 bundler 热更事件),但机制本身保持一致。
此外,resolveClientDist()(devframe.ts)处理了 Next/Turbopack 改写import.meta.url的问题:优先使用模块相对路径定位 Inspector SPA 的构建产物,失败时回退到经由消费项目node_modules中@unocss/inspector/package.json反查dist/client。
6.next.config.ts:让 devframe 宿主正确工作
next.config.ts 完整内容:
import type { NextConfig } from 'next' import { withDevframe } from '@devframes/next/single' const nextConfig: NextConfig = { // Keep the devframe host out of the bundle — its optional MCP adapter // imports peer packages that are lazily loaded at runtime only serverExternalPackages: ['devframe', '@devframes/next', '@unocss/inspector'], } // Sets `skipTrailingSlashRedirect` so the inspector SPA's relative assets // resolve under /__unocss/ export default withDevframe({ ...nextConfig })两处配置都带有明确注释,对应两个真实的坑:
serverExternalPackages:devframe、@devframes/next、@unocss/inspector三个包被声明为服务端外部依赖,不参与 bundle——devframe 宿主的可选 MCP adapter 会懒加载 peer 包,若被打进 bundle 会在构建/运行时出问题。withDevframe包装:它设置了skipTrailingSlashRedirect,保证 Inspector SPA 的相对资源路径能正确解析在/__unocss/前缀下(与 devframe 定义中的basePath: '/__unocss/'对应),否则 Next 的尾斜杠重定向会破坏 SPA 静态资源的相对引用。
7. 与 Vite 版 Inspector 示例的对照
仓库同时提供 examples/inspector-vite 作为对照:Vite 集成下 Inspector 由unocss插件直接挂载,享有文件系统热更信号;而本文的 Next.js 方案以「一次性快照扫描 + devframe 路由托管」换取了对非 Vite 构建栈的支持。二者的能力取舍可以概括为:
- Vite 集成:上下文由 bundler 插件持续驱动,模块热更与工具类变化实时反映;
- 本文的 standalone 集成:上下文启动时扫描一次,无热更信号,代价是修改工具类后需重启 dev server 重新扫描(README 已明确说明此限制);换取的是任何能跑 Node runtime 路由的 Next.js 应用都能原生托管 Inspector。
8. 小结
examples/inspector-next展示了一条完整的非 Vite 链路:@unocss/postcss负责样式生成(postcss.config.mjs +@unocss all;),createStandaloneInspectorDevframe负责构建独立 UnoCSS 上下文并一次性扫描(devframe.ts),createDevframeNextHandler+withDevframe负责把 devframe 挂进 Next 路由(next.config.ts),而 catch-all 路由(app/%5F_unocss/[[...path]]/route.ts)以模块级单例 Promise 承载全部请求。理解了这套机制,你可以在任何纯 Next.js 项目中复刻「PostCSS 出样式、应用自身路由出 Inspector」的组合,并正确应对内联配置、serverExternalPackages、尾斜杠解析与「启动时扫描一次」这几个关键约束。
【免费下载链接】unocssThe instant on-demand atomic CSS engine.项目地址: https://gitcode.com/GitHub_Trending/un/unocss
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考