Frappe Island 预设的构建工具链解析:从宿主 App 的依赖中加载 Vite、Tailwind 与 TypeScript
【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe
Frappe 的 desk island 子系统允许应用把 Vue 组件作为可独立挂载的"岛"(island)构建进自己的 SPA,而构建这些 island 的 Vite 预设(preset)本身却不携带任何工具链。本文围绕架构决策记录 ui/island/decisions/0005-the-preset-resolves-its-tooling-from-the-app.md,完整讲解"预设从被构建的 App 中解析自身工具链"这一设计:loadTools(root)如何在 App 的node_modules/.island/下生成再导出模块、为何采用此方案、版本一致性如何被锁定,以及被否决的三种替代方案及其原因。读完本文,你将理解 island 构建中 bare specifier(裸模块名)解析的本质,并能独立排查"构建工具链无法解析"类错误。
问题背景:预设自身不携带任何构建工具
Frappe 的 island 预设以源码形式随@framework/ui包发布,而 bench 通过相对路径链接(symlink)@framework/ui。这意味着:当预设源码中的import "vite"被执行时,Node 会基于导入方模块的"真实路径"(real path)来解析 bare specifier——也就是在 framework 检出目录旁寻找依赖,而 bench 从不会在该处安装前端依赖。
构建一个 island 需要一整套工具链:Vite、@vitejs/plugin-vue、Tailwind、autoprefixer、TypeScript,以及 frappe-ui 的图标解析器(icon resolver)。预设的选择是:不自己声明、不自己安装、不自己解析这些依赖,而是从"被构建的那个 App"处加载它们。决策记录原文如此概括:
The preset needs Vite,
@vitejs/plugin-vue, Tailwind, autoprefixer, TypeScript and frappe-ui's icon resolver to run a build. It loads each one from the app it builds.
这一思路贯穿整个 island 构建管线,具体实现在 ui/vite/island/index.js 与 ui/vite/island/tools.js 中。
决策核心:loadTools(root)生成再导出模块
决策的关键一行:
loadTools(root)writes a module into the app'snode_modules/.island/. The module re-exports every build-time dependency by name, and the preset imports it.
即:预设不直接import "vite",而是在 App 自己的node_modules/.island/目录下写入一个tools.mjs模块,该模块按名字再导出所有构建期依赖,然后预设再动态导入这个生成的文件。由此,bare specifier 的解析工作被完整交给 Node 自己的解析器,且解析的起点是App 的应用树(app's own tree)——App 的node_modules才是 Vite、Tailwind 和 frappe-ui 真正被安装的位置。
源码级实现:TOOLS 清单与生成逻辑
tools.js 中定义了预设所需的全部工具,共七项:
| 导出键 | 模块说明符 | 用途 |
|---|---|---|
vite | vite | island 构建的打包器 |
vue | @vitejs/plugin-vue | Vue 单文件组件编译插件 |
tailwindcss | tailwindcss | 样式扫描与生成 |
autoprefixer | autoprefixer | PostCSS 浏览器前缀 |
lucideIcons | frappe-ui/vite/lucideIconsPlugin | frappe-ui 的 lucide 图标解析 |
compilerSfc | vue/compiler-sfc | Vue SFC 编译器 |
typescript | typescript | 供 compiler-sfc 解析外部类型 |
loadTools(root)的实现要点(ui/vite/island/tools.js#L42-L71):
- 在
path.join(root, "node_modules/.island")目录下生成tools.mjs,每行形如export * as vite from "vite";; - 生成文件头部标注
// Generated by @framework/ui/vite/island. Do not edit.; - 通过
await import(pathToFileURL(file).href)动态导入生成文件; - 若导入失败,抛出带明确修复指引的错误:
island: the build tooling does not resolve from <root>: ...,并提示"Add the missing package to the frontend's devDependencies"。
生成目录选择node_modules/.island的原因在源码注释中说明:"Generated modules live where every other build artifact does"——Tailwind 配置生成(ui/vite/island/tailwind.js)也基于同一理由写入该目录,因为只有写入 App 的node_modules树内,配置文件中的 bare specifier(如frappe-ui/tailwind内部的tailwindcss/plugin)才能对 App 的依赖解析。
为什么必须"写文件"而不是"直接解析"
决策文档解释了深层的解析机制差异:
require.resolve读取的是require条件,因此拒绝 ESM-only 的子路径——frappe-ui/vite/lucideIconsPlugin正是其中之一;import.meta.resolve在 Node 未开启--experimental-import-meta-resolve时会忽略其 parent 参数,随后按预设自身的路径作答,毫无意义。
而"把文件写进 App 的应用树,再让 Node 解析"则把整个解析工作交给 Node 的原生解析器,其import条件能正确处理 ESM-only 子路径,也天然以 App 为解析根。这正是决策"Rejected: resolve each specifier by hand"一节否掉手工解析方案的根本原因。
版本一致性:由 App 的 lockfile 一锤定音
决策文档明确指出,这一设计还顺带解决了"哪个版本的依赖构建了 island"的问题:
The app's lockfile decides. The same lockfile builds the app's SPA, so an island and the app's own pages compile the same frappe-ui the same way.
- island 构建所用的 Vite、Tailwind、frappe-ui 等版本,完全由App 的
package.json+ lockfile决定; - 同一个 lockfile 也构建 App 的 SPA,因此island 与 App 自身页面用同一份 frappe-ui 源码、以同样的方式编译,不存在双份工具链、双份编译结果漂移的问题。
这也正是决策记录否决"framework declares the tooling"方案的核心论据:若把 Vite、Tailwind 等放进 framework 的apps/frappe/package.json,framework 将被迫安装一套自己永不运行的第二套前端工具链,并钉死所有 App 构建 island 的版本——一个使用更新版 Vite 的 App,会用一个版本构建 SPA、用另一个版本构建 island。
依赖声明:可选的 peerDependencies,安装时一次性提示
当生成模块中的一个 specifier 无法解析时,构建直接失败。错误信息会点名 App 的 root 路径与devDependencies作为修复方向:
A specifier that does not resolve fails the build. The error names the app's root and
devDependenciesas the fix.
@framework/ui在 ui/package.json 中将 Vite、@vitejs/plugin-vue、Tailwind、autoprefixer、TypeScript 等全部声明为optional peer dependencies(peerDependenciesMeta中标记optional: true)。效果是:使用方 App 只需在yarn install时收到一次提示,而不会被强制安装——因为 framework 自身并不运行这些工具,真正的宿主是 App。
与工具链包的对照:toolchain 目录
仓库内另有一个仅供框架侧自测的对照物:ui/vite/island/toolchain/package.json(包名@framework/island-toolchain)。其devDependencies声明了 Vite ^8.1.5、tailwindcss ^3.4.19、typescript ^5.9.3、frappe-ui 1.0.0-beta.55 等,且@framework/ui以link:../../..方式引用。需要区分:这是框架开发者验证预设的隔离工具链,而非 App 实际构建 island 所依赖的工具链——真实构建时,工具链一律来自 App 自己的node_modules。
从源码看后续加工:CommonJS 互操作与 TypeScript 注册
loadTools之后,构建管线对加载到的工具还有两处关键加工(ui/vite/island/tools.js#L73-L95):
interop互操作:Tailwind、autoprefixer、compiler-sfc 都是 CommonJS 模块。静态import x from "tailwindcss"会把module.exports绑定到x,而 namespace import 回答的是 namespace 对象,module.exports被放在其default上。因此interop = (module) => module.default ?? module负责统一取回真实导出。registerTypeScript:frappe-ui 的ImageGroupNodeView.vue写了defineProps<NodeViewProps>(),其中的类型来自@tiptap/vue-3。compiler-sfc 只有通过 TypeScript 编译器 API 才能解析该外部类型,而@vitejs/plugin-vue不会注册编译器,所以预设显式调用compilerSfc.registerTS(() => typescript);版本要求 TS >= 5,因为相关类型位于只有moduleResolution: bundler才能穿过的exportsmaps 之后。
这两处加工被 ui/vite/island/index.js 的islandContext(tools: await loadTools(root))与islandConfig消费:Vite 的build、@vitejs/plugin-vue、Tailwind PostCSS 插件、autoprefixer、lucideIconsPlugin均来自context.tools,证明"工具全部取自 App"不是文档中的孤证,而是构建管线的真实数据流。
被否决的替代方案:为什么三条路都不走
决策记录用三个小节完整记录了被否决的替代方案,这是理解该决策价值的关键:
方案一:手工逐个解析说明符(Rejected: resolve each specifier by hand)
用根植于 App 的require.resolve,或以 App 为 parent 的import.meta.resolve。失败原因如上文所述:
require.resolve读require条件,拒绝 ESM-only 子路径(frappe-ui/vite/lucideIconsPlugin即一例);import.meta.resolve除非开启--experimental-import-meta-resolve,否则忽略 parent 参数,只会按预设自身路径作答。
最终结论:让写入的文件把整个解析工作交给 Node 的解析器,是唯一干净的做法。
方案二:由 framework 声明工具链(Rejected: framework declares the tooling)
把 Vite、Tailwind 等放进apps/frappe/package.json,让预设自身路径即可解析。代价是 framework 安装了永不会运行的第二套前端工具链,并钉死所有 App 的 island 构建版本——App 的 SPA 与 island 将各用各的 Vite,破坏版本一致性。
方案三:由 App 把模块传进来(Rejected: the app passes the modules in)
即buildIslands({ vite, tailwindcss, ... })的显式传参形式。决策记录的评价是:"It is the same resolution, written out by every app"——每个 App 都要重复一遍同样的解析样板代码;更糟的是,传错模块的 App 要等进入 Rollup 内部才能发现问题,错误延迟且难以定位。而生成文件方案把"解析"这件事收口到一处,出错的 App 会在构建入口处立刻得到指名devDependencies的错误。
测试验证:verify.mjs 如何检验这套解析设计
ui/vite/island/tests/verify.mjs 是这套预设的集成验证脚本,直接体现了"预设跑在 App 自己的工具链上"这一约束:
- 它把
tests/fixture/暂存为一个一次性 bench 里的 App frontend; - 借用某个已执行过
yarn install的 App frontend 的node_modules(mirrorModules逐项符号链接,并让@framework/ui指向本 checkout),因为"预设构建在 App 自己的工具链上",fixture 本身不携带依赖; - 运行
buildIslands后校验:输出为 ESM、mount导出保留、无 bare import 残留、lucide 图标 SVG 被打进 bundle、两个入口共享 chunk、单份样式表含 preflight 与主题 token、超预算仍能注册 assets.json 等。
其中"无 bare import 残留"(bareImports(panel.text).length === 0)正是对"island 在浏览器端不依赖任何运行时解析"这一目标的验证,与本文所述"构建期工具链解析自 App"互为表里:构建期的解析交给 App 的 Node 解析器,运行期的解析则必须在打包时彻底完成。
实战排查指南:工具链解析失败怎么办
综合决策文档与 tools.js 的错误信息,当 island 构建报出 "the build tooling does not resolve from ..." 时,按以下顺序排查:
- 确认目标 App 的 frontend 目录已执行过
yarn install——node_modules必须存在且完整; - 对照
TOOLS清单检查 App 的devDependencies:vite、@vitejs/plugin-vue、tailwindcss、autoprefixer、typescript、frappe-ui、vue是否齐全;参考 ui/package.json 中@framework/ui声明的 peer 范围(如vite >=4、tailwindcss ^3.4.0、typescript >=5); - 注意 ESM-only 子路径:
frappe-ui/vite/lucideIconsPlugin必须通过 Node 的import条件解析,这正说明为何不能退回require.resolve手工解析方案; - 检查 lockfile 是否被手动改动——island 与 SPA 必须由同一 lockfile 决定版本,任何双 lockfile 的局面都会破坏一致性。
小结
"预设从 App 解析自身工具链"是一条围绕 Node 模块解析语义精心权衡的架构决策:它用"写入 App 应用树的再导出模块"绕开 bare specifier 解析根的问题,用"App 的 lockfile 决定版本"锁死 island 与 SPA 的编译一致性,用"optional peer dependencies + 构建期报错"把依赖缺失的反馈压缩到安装提示与一次清晰的构建错误。三种被否决方案(手工解析、framework 声明、App 传参)各自的缺陷——ESM 子路径拒绝、双工具链版本漂移、错误延迟到 Rollup 内部——反过来印证了最终方案在每个维度上的取舍。该决策与其余 island 决策共同构成 desk island 子系统(ui/island/decisions/README.md)的设计基石。
【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考