Frappe Island 预设的构建工具链解析:从宿主 App 的依赖中加载 Vite、Tailwind 与 TypeScript
2026/9/16 19:53:54 网站建设 项目流程

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 中定义了预设所需的全部工具,共七项:

导出键模块说明符用途
viteviteisland 构建的打包器
vue@vitejs/plugin-vueVue 单文件组件编译插件
tailwindcsstailwindcss样式扫描与生成
autoprefixerautoprefixerPostCSS 浏览器前缀
lucideIconsfrappe-ui/vite/lucideIconsPluginfrappe-ui 的 lucide 图标解析
compilerSfcvue/compiler-sfcVue SFC 编译器
typescripttypescript供 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 anddevDependenciesas the fix.

@framework/ui在 ui/package.json 中将 Vite、@vitejs/plugin-vue、Tailwind、autoprefixer、TypeScript 等全部声明为optional peer dependenciespeerDependenciesMeta中标记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/uilink:../../..方式引用。需要区分:这是框架开发者验证预设的隔离工具链,而非 App 实际构建 island 所依赖的工具链——真实构建时,工具链一律来自 App 自己的node_modules

从源码看后续加工:CommonJS 互操作与 TypeScript 注册

loadTools之后,构建管线对加载到的工具还有两处关键加工(ui/vite/island/tools.js#L73-L95):

  1. interop互操作:Tailwind、autoprefixer、compiler-sfc 都是 CommonJS 模块。静态import x from "tailwindcss"会把module.exports绑定到x,而 namespace import 回答的是 namespace 对象,module.exports被放在其default上。因此interop = (module) => module.default ?? module负责统一取回真实导出。
  2. 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 的islandContexttools: 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.resolverequire条件,拒绝 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_modulesmirrorModules逐项符号链接,并让@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 ..." 时,按以下顺序排查:

  1. 确认目标 App 的 frontend 目录已执行过yarn install——node_modules必须存在且完整;
  2. 对照TOOLS清单检查 App 的devDependenciesvite@vitejs/plugin-vuetailwindcssautoprefixertypescriptfrappe-uivue是否齐全;参考 ui/package.json 中@framework/ui声明的 peer 范围(如vite >=4tailwindcss ^3.4.0typescript >=5);
  3. 注意 ESM-only 子路径frappe-ui/vite/lucideIconsPlugin必须通过 Node 的import条件解析,这正说明为何不能退回require.resolve手工解析方案;
  4. 检查 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),仅供参考

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

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

立即咨询