
Frappe Desk Island 架构决策解读mount 契约、宿主循环与统一构建的完整指南【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappeui/island/是 Frappe 中 Desk Island 子系统的核心它让应用以独立 ES Module 的形式把基于 Vue 与 frappe-ui 的组件岛屿挂载进 Desk 页面以及 CRM、Insights 这类 frappe-ui SPA的 Shadow DOM 中与宿主隔离运行。本文以 ui/island/decisions/README.md 及其索引的 13 份架构决策记录ADR为主体逐条拆解 island 子系统的设计边界——mount 契约、宿主循环、Vite 构建预设、样式与工具链归属——并对照ui/island/、ui/vite/island/、frappe/public/js/frappe/ui/island/中的真实源码帮助读者理解这套机制的每一个关键决策及其被拒绝的替代方案从而掌握应用自带运行时、框架只留一个接缝的架构哲学。决策文档的组织规则什么才配写进 decisions先看 ui/island/decisions/README.md 本身。它篇幅极短却定义了整个决策集的边界是阅读其余 13 份文档的元规则作用域本目录只收录 Desk Island 子系统的决策包括ui/island/下的 mount 契约、ui/vite/island/下的 Vite 预设、ui/island/host.js与ui/island/Island.vue中的宿主循环及其 Vue 宿主以及frappe/public/js/frappe/ui/island/中的 Desk 加载器。一份决策一个文件One decision per file每个决策文件是自洽的独立单元。收录判据一条决策只有当它存在迟早会有人再次提出的被拒绝替代方案时才值得写成 ADR一条只解释某一行代码的平台约束应当以注释的形式留在代码行旁而不是变成决策文档。也就是说ADR 记录的是有争议、有取舍的设计点而不是普通实现细节。篇幅约束每个决策文件控制在一屏以内Keep each file to one screen确保决策能被快速读完、引证。这个判据解释了为什么决策集中满是 Rejected: ... 小节——它们记录的正是那些看起来合理、实际被否掉的方案防止后人重复踩坑。决策一应用自备 island 运行时00010001-an-app-bundles-its-own-island.md 是整个子系统的地基决策。决策一个 island 就是一个 ES Module它自带自己的 Vue、自己的 frappe-ui 以及它 import 的每一个包。宿主的职责只有解析出模块的 URL、import 它。页面不需要为 island 预先加载任何东西——因为页面没有向 island 发布任何可链接的运行时。Ownership follows the code. The app writes the island, so the app builds it, weighs it and ships it.框架只保留一个极小的接缝seam一个名字、一个 URL 和mount(el, context)这个导出。接缝小到可以完整陈述宿主从ui_islandshook 读取 island 的 bundle 名从assets.json中读取name.island.js与name.island.cssimport 该模块并以宿主上下文和样式表 URL 调用mount。除此之外没有任何东西跨过这条边界。代价是重复同一页面来自两个应用的 island 会携带两份 Vue每份几百 kB。但每份各自正确——两个 Shadow Root 中的两个 Vue 应用不共享任何状态。被拒绝通过 import map 共享运行时。让框架构建一份 Vue、frappe-ui把 import map 写进 Desk 页面应用构建时保留裸模块标识符。这消除了重复但换来三个代价仓库间的顺序依赖应用必须等框架构建完才能构建每次框架发版整个站点的 island 都要等待重建注册必须决定 island 打包什么漏注册一个标识符文档中真实发生过漏掉frappe-ui/charts导致 island 打包了 762 kB 的 echarts构建不报任何错只有体积预算会提出异议框架反向依赖 frappe-ui为了构建运行时框架必须解析 frappe-ui 的入口、遍历其 import 图、调和版本、持有它从不 import 的包的补丁而 Desk 根本用不到这些。结论一句话An island that asks the page for Vue is an island the page can break. An island that bundles its own Vue breaks only on its own build.——向页面要 Vue 的 island 会被页面破坏自带 Vue 的 island 只会在自己的构建上出错。被拒绝Desk 把 Vue 暴露为全局变量像暴露 jQuery 那样暴露window.Vue。问题在于它只共享一个包而 frappe-ui 占 island 重量的 90%它的组件是源码、需要应用的 Tailwind 编译根本无法走全局变量这条路——island 依然要打包几乎所有东西而 Desk 却多了一个必须永久维护的全局。决策二mount 契约住在 frappe-ui而不是 Desk00060006-the-mount-contract-lives-with-frappe-ui.md 回答了谁负责把一个 Vue 组件变成 island。决策mountVueIsland完成全部转换工作——打开 Shadow Root、adopt 样式表、镜像宿主的主题、为 frappe-ui 的浮层在 root 内提供 portal 目标、返回update/unmount句柄。它随framework/ui发布由应用的构建编译进 island。契约放在 frappe-ui 一侧的原因很直接它每一行代码都在讲 Vue 和 frappe-ui——调用createApp、provideportalTargetKey、安装 memory router因为 frappe-ui 组件无条件调用useRouter()。推理 frappe-ui 的代码就应该待在把 frappe-ui 当作依赖的地方。Desk 一侧则留在 DeskDesk 加载器解析名字、组装 Desk 上下文宿主循环调用 island 的mount两边都不碰 VueDesk 的 bundle 保持原样。framework/ui/island同时导出hostKey和useHost——island 通过提供它的同一个模块读取宿主上下文应用无需再次声明这个 symbol。在源码中可以看到 ui/island/index.js 正是这样导出的export { mountVueIsland } from ./mount.js; export { hostKey, useHost } from ./context.js;而 ui/island/context.js 用Symbol.for(frappe:island-host)作为注入键并特意说明即使 island 携带了自己的一份本模块副本Symbol.for也能让useHost()读到宿主注入的上下文。useHost()返回空对象也是可用的上下文——每个字段都可选组件在没有宿主提供任何字段时如单元测试中依然能渲染。被拒绝Desk 随 bundle 提供 mount 契约frappe.ui.mount_vue_island。Desk 就得在自己的依赖里引入 Vue、vue-router、frappe-ui 来编译它所有 island 都会跑在 Desk 的副本上——这等于换一条路回到 0001 拒绝掉的共享运行时还把契约藏在了应用的类型检查器和打包器看不见的地方。被拒绝每个应用各写各的。Shadow Root 加createApp看似只有二十行但 portal 目标、Desk 页面控件之上的堆叠层级、root 内的主题属性、编译器的注释处理——每一个都是被发现过一次、错了还不报错的细节。只有一份实现第二个应用才能免费获得全部这些。决策三宿主循环只写一次两个宿主共用00080008-one-host-loop-two-hosts.md 定义了 island 与宿主的交互循环。决策Desk 是 island 的第一个宿主frappe-ui 应用CRM、Insights是第二个。两者对名字到已挂载 island的循环完全相同解析名字 → import 模块 → 检查导出 mount → 卸载占据目标的旧 island → 调用 mount → 持有跨重新挂载存活的句柄这段循环只写一次放在 ui/island/host.js。每个宿主只是它的薄包装。循环本身不 import 任何东西——不 import Vue、不 import frappe-ui、不 import frappe。每个宿主注入差异部分一个resolve(name)返回模块与样式表 URL以及 island 通过useHost()读取的上下文。Desk 加载器基于frappe.boot解析Island组件则调用frappe.utils.island.get_island_assets。分工原则Desk 保留 Desk 的东西——boot 注册表、Desk 上下文、frappe.ui.mount_islandAPI、热更新注册Island保留 Vue 的东西——生命周期与 props 监听。句柄是同步返回的所以循环同时拥有加载与挂载调用者从第一行起就持有句柄update和unmount在模块加载完成之前就可以调用。循环还携带那些各自被发现过一次、错了就不报错的规则重新挂载时保留调用者持有的句柄模块加载失败时屏幕上已存在的 island 保持原样不动上下文键是host而不是desk——在 CRM 托管的 island 内部上下文是 CRM 的。从 ui/island/host.js 的注释可以完整看到这个契约的用法import { mountIsland } from framework/ui/island/host; const island mountIsland(insights.dashboard, el, { resolve: (name) ({ js: /assets/…island.js, css: /assets/…island.css }), host: { locale, user, navigate }, props: { dashboard: sales, onNavigate: (route) router.push(route), onTitle: (title) (document.title title), }, }); island.update({ filters }); await island.ready; island.unmount();句柄update会合并进 mount 起始的 propsunmount会取消尚未完成的加载ready报告加载结果——island 上屏时 resolve 为句柄加载抛错时 reject取消的加载则 resolve因为什么都没失败。宿主内部用Map而不是WeakMap保存target - entry因为reloadChangedIslands必须遍历页面上存活的 island。被拒绝两个加载器各自带一份循环。循环约两百行两份拷贝会漂移——热重挂载的修复落在 Desk 的循环之后Vue 宿主就永远拿不到它。被拒绝Desk 把循环暴露到window调用frappe.ui.mount_island。它只在 Desk 页面上有效frappe-ui 应用是自有路由下的 SPA页面上根本没有 Desk bundle。这还会把 Vue 宿主绑死在 Desk 的解析和 boot 注册表上而它本该有 API。被拒绝Island直接调frappe.ui.mount_island。同样的耦合再加一步组件得为全局缺失准备 fallback——而它运行的每个页面都缺失。决策四island 接收 Vue 的 props 对象00090009-an-island-takes-vues-props-object.md 定义了宿主与 island 之间数据与事件的传输格式。决策数据键与on*监听键放在一个扁平对象里同行——正是h(Component, props)接受的形状。Desk 侧的真实调用示例frappe.ui.mount_island(insights.dashboard, el, { dashboard: sales, onNavigate: (route) frappe.set_route(route), onTitle: (title) frappe.utils.set_title(title), });mountVueIsland把这个对象原样交给h。update合并进它——这正是重渲染所做的。Island以 attribute 形式接收同一个对象除了name和context之外原样透传所以title、navigate在它上面像在任何组件上一样工作。形状没有翻译层就没有需要学习的规则懂 Vue 的调用者就懂这个 API组件作者读自己组件声明的 props 即可。被拒绝结构化袋子{ props, on, model }第一版形状。mount.js要把on.navigate转成onNavigateIsland.vue要把navigate转成on.navigateDesk 调用者还要写第三种形式——同一个想法三种翻译每一处都是名字写对了却到不了的地方。被拒绝驼峰监听名onUpdateTitle。读起来更好但永远不触发Vue 只驼峰化事件名中的连字符不处理冒号update:title解析为字面键onUpdate:title什么都匹配不上。而 Vue 解析不了的名字会静默失败——这是监听器出错最糟的方式。因此 island API 的事件名里不再有冒号0010 决策删掉了仅有的两个这条规则仍然约束宿主挂载的任何组件。决策五reka-ui 补丁让浮层读到 Shadow Root00070007-reka-ui-is-patched-to-read-the-shadow-root.md 记录了一个只有 Shadow DOM 里才会暴露的真实 bug。问题frappe-ui 的浮层Dialog、Popover、Select、Tooltip都是 reka-ui 的DismissableLayer。该层用ownerDocument.querySelectorAll查找其他层并在 document 上监听 dismiss 事件。这两条路都进不了 Shadow Rootdocument 上的查询看不到 island 里的层island 的事件到达 document 时被 retarget 到了 shadow host 上。结果在 dialog 之上打开的 popover 被判定为在 dialog 之外打开 popover 的那次点击反而关掉了底下的 dialog。决策ui/patches/reka-ui2.10.1.patch让该层读取它所在的树。getRootNode()在 island 内返回 shadow root在其他任何地方返回 document——所以 island 之外的行为不变。层同时监听自己的 root 与 document并忽略 document 上已经处理过的事件的 retarget 副本。补丁的应用方式很讲究reka-ui 是应用的依赖应用打包 frappe-ui见 0001所以应用在postinstall里应用补丁postinstall: patch-package patch-package --patch-dir node_modules/framework/ui/patches一份补丁服务所有 island 宿主。文件名中的版本号reka-ui2.10.1.patch就是它针对的版本patch-package 精确匹配该版本其他版本会告警——所以 reka-ui 升级会报告补丁而不是悄悄丢弃。文档还注明这个修复应当上溯上游unovue/reka-ui#1667发布修复的版本会让补丁退役。被拒绝把浮层 portal 到 document保留 reka-ui 不动让浮层渲染进body。浮层在 Shadow Root 之外、就在 island 样式表之外会以无样式渲染在 document 里复制一份样式表又重演了 Shadow Root 要防止的泄漏。被拒绝把修复留在框架里框架持有补丁并在自己安装时应用。框架不安装 reka-ui、不渲染任何浮层补丁放在一个没人证明它仍然适用的树里毫无意义。决策六应用把所有 island 一起构建00020002-an-app-builds-its-islands-together.md 定义构建粒度的决策。决策buildIslands接收整个 entry 列表跑一次Vite 构建。Rollup 会把两个 entry 共享的东西提升进两者共同 import 的 chunk每个 entry 写成name.island.hash.js旁边放一个chunks/目录。一个 island 自备 Vue 和 frappe-ui0001但在一个应用内部这份成本只付一次一起构建的两个 island 共享一个 Vue chunk、一个 frappe-ui chunk以及每个共同 import 的 helper 的 chunk——应用的第二个 island 只付出它独有部分的花销。budget的称重口径是浏览器加载的 entry 加上该 entry 静态 import 的 chunk再加大应用的样式表。这是读者为看到一个island 所等的字节数而不是磁盘上的字节数。动态 import 是 island 推迟的工作构建会报告它但不计入预算。chunk 不需要注册entry 用相对路径 import 它们base指向应用的 island 目录浏览器从已有的 entry 解析即可。被拒绝每个 entry 一次构建。每份都带完整的 Vue、frappe-ui 和应用 helper而且 0003 的 Tailwind 扫描需要一次一次性构建这会按 entry 数量成倍放大。它唯一换来的是每个 entry 一个输出目录、emptyOutDir安全但每个应用一个目录同样安全——应用的 island 构建是该目录下唯一的写者。被拒绝手写manualChunks。Rollup 已经知道两个 entry 会触及哪些模块手写清单是对同一事实的第二份描述一旦某 entry 停止 import 某个包就过时而且把切分固定在包粒度Rollup 本可以在模块粒度切分。决策七Tailwind 扫描模块清单而不是 glob00030003-tailwind-scans-the-module-list-not-a-glob.md 记录了样式生成中最隐蔽的 bug 形态。问题Tailwind 不会为它没扫描过的类生成规则而且不抛任何错——组件以无样式渲染看起来还像是一个设计选择。错误只有一种形状某样东西描述 bundle 里有什么Tailwind 扫描这份描述而另一样东西在组装 bundle。例如content列表只列.vue文件就会漏掉持有类字符串字面量的.tshelper只列应用自己的源码就会漏掉 frappe-ui——它的组件在每个 island 上都会应用类。决策预设扫描构建 bundle 所用的模块。做法是先跑一次不带样式表的构建保留模块清单丢弃输出。没有留给应用的content选项。清单就是 bundle 本身所以不可能和 bundle 漂移它覆盖应用源码和编译进去的每个依赖。island 的样式表是它 Shadow Root 里唯一的样式表所以必须为 bundle 应用的每个类都带一条规则。第二遍扫描大约耗时与第一遍相当——每个应用一遍而不是每个 island 一遍见 0002。被拒绝宽到安全的 glob。扫描应用全部源码让一个 island 的样式表从 32 kB 涨到 259 kB——多出来的是 island 不渲染的每个屏幕的类。被拒绝扫描构建产物 JS 找类名。找到同样的 bug 外加一堆噪音chunk 携带其组件编译后的 CSS 字符串转义的选择器读起来像类名而这些类一行之外就已定义。推导不覆盖的兜底检查完整构建下扫描清单由 bundle 推导两者不可能不一致但watch模式下会清单在启动时固定启动之后新增的 import 会带入没人扫描过的文件。因此预设会比较 bundle 的模块清单与扫描清单——若某文件在前者而不在后者预设使构建失败重启 watch 以扫描它。比较基于文件两边都是真实路径而非类扫描清单是预设自己写的所以是集合成员判断不需要 glob 引擎。决策八一个应用只发布一份 island 样式表00040004-an-app-ships-one-island-stylesheet.md 决定样式的组织粒度。决策构建为应用的所有 island 提取一份样式表。每个 entry 用自己的name.island.css键注册它宿主把同一个 URL 交给每个 islandmount 契约把一个CSSStyleSheet对象 adopt 进每个 Shadow Root。实现上就是cssCodeSplit: false 每个应用一份 Tailwind 配置 一张 sheet。这张 sheet 必须自足Shadow Root 不继承任何文档样式所以它带tailwind base即 preflight 和主题 token、components 和 utilities 层。预设会把:root、html、body重写为:host——Shadow Root 内没有任何元素能匹配前者。浏览器每个页面只获取和解析一次 sheet无论 island 有多少个adoptedStyleSheets共享解析后的对象而不是副本——一个 island 只为 sheet 付一次钱而不是每次挂载付一次。代价是 island 会携带它兄弟使用的类的规则utilities 压缩得很好整个应用的 island CSS 比按岛拆分时第二份 preflight 副本还小。被拒绝每个 island 一张样式表cssCodeSplit: true 每个 entry 一份 Tailwind 配置。preflight 和主题 token 是任何 island sheet 的地板会在每张里重复拆分还会把共享 chunk 的 CSS 放进独立文件宿主就得为每个 island 维护一份 sheet 列表mount 契约还要跨 island 保持顺序稳定。被拒绝框架样式表由每个 island 继承框架构建一张、在 island 自己的样式表之前 adopt。island 的类来自构建它的模块0003别处构建的 sheet 是从不同树扫描的那棵树没有的类在任何 island 上都没有规则而且没人报告。这又把框架拖回构建应用依赖的业务0001。决策九预设从应用解析自己的工具链00050005-the-preset-resolves-its-tooling-from-the-app.md 解决预设用什么版本的工具链。决策预设需要 Vite、vitejs/plugin-vue、Tailwind、autoprefixer、TypeScript 和 frappe-ui 的 icon resolver 来跑一次构建每个都从它构建的应用里加载。loadTools(root)把一个模块写进应用的node_modules/.island/该模块按名字 re-export 每个构建期依赖预设 import 它。原理Node 从 import 方模块的真实路径解析裸说明符。bench 用相对路径 linkframework/ui所以预设内部的import vite会在框架 checkout 旁边找依赖——而 bench 从不把前端依赖装在那里。写进应用自己树里的文件则按应用的依赖解析。生成的 Tailwind 配置放在那里也是同一原因。这同时决定了哪个版本构建 island应用的 lockfile 说了算。同一个 lockfile 构建应用的 SPA所以 island 和应用自己的页面以同样的方式编译同一份 frappe-ui。解析不了的说明符使构建失败错误信息会点名应用根目录和devDependencies作为修复framework/ui把所有这些声明为可选 peer dependency消费应用在安装时就会被告知一次。被拒绝手工逐个解析说明符require.resolve或以应用为 parent 的import.meta.resolve。require.resolve读取require条件会拒绝 ESM-only 子路径——frappe-ui/vite/lucideIconsPlugin正是其一import.meta.resolve除非 Node 带--experimental-import-meta-resolve否则忽略 parent 参数然后按预设自己的路径回答且不报告。写入文件的方式把整个工作交给 Node 的解析器。被拒绝框架声明工具链把 Vite、Tailwind 等放进apps/frappe/package.json。框架于是安装一套它永不运行的第二前端工具链并锁死所有应用 island 的构建版本——用新版 Vite 的应用会用一版构建 SPA、另一版构建 island。被拒绝应用把模块传进来buildIslands({ vite, tailwindcss, ... })。这是同样的解析、由每个应用手写一遍传错模块的应用要在 Rollup 内部才会发现。决策十page island 上报标题与操作00100010-a-page-island-reports-title-and-actions.md 处理填满一页的 island 怎么与宿主页头协作。决策page island 自带页面内容但仍然需要一个页头标题以及属于当前内容的操作。island 两者都知道因为它加载了文档但页头属于宿主——宿主本来就有用于每个其他页面的页头。所以page island 不携带页头而是上报页头会说的话titlestring或nullactionsAction[]。一个Action是{ label, icon? }加上onClick或href二者之一。onClick在 island 内运行。href是绝对或站内相对的 URL指向宿主应用之外的页面——action 上报它去哪由宿主决定应用外链接做什么例如在新标签页打开、用自己的惯用方式标记为离站操作。填不满一页的 island如 workspace 里的 widget两者都不上报没有页头的宿主两者都不绑定。Action字段是一个 island 需要的最小集合两个宿主都以菜单行展示它——新增字段是两个宿主共同做出的变更。每个宿主用上报结果设置自己的 chromeDesk 设置页面标题并填充页面菜单frappe-ui 应用填充它的LayoutHeader。island 不知道自己在哪个宿主里每个宿主的页头保持原生。两者都是普通事件Vue 宿主用title和actions绑定Desk 调用者传onTitle和onActions见 0009。被拒绝update:事件 v-modelupdate:title、update:actions。v-model:actions是:actions加update:actions而 island 两个 prop 都没声明——绑定把值传进一个忽略它的组件读起来像宿主与 island 共享状态。实际没有任何共享island 上报、宿主存储这正是普通事件表达的语义。这个糖还让Island不得不带一个过滤器去剥离所有update:形状的 attribute否则宿主绑定的值会作为多余 attribute 回流每次上报都通过update回响。被拒绝island 自己画页头。island 最先知道标题但每个宿主都已有页头——island 的页头会落在 Desk 的页头之下dashboard 页面不得不隐藏 Desk 的 page head 来腾地方代价是丢失面包屑、页面菜单和标题槽三者又作为上下文回到 island、由它再渲染一遍第二个宿主会把整个协商重来一次。被拒绝island 自己打开其他应用用window.open的onClick。那等于 island 替宿主选定了新标签页和目的地标签两者都该归宿主——Desk 菜单行和应用内页头读法不同只有宿主知道自己显示哪个SPA 宿主还被迫用 provider 标志告诉 island 别离开这是 island 替自己做不了的决策。href只陈述目的地、到此为止。被拒绝header: booleanprop。一个开关盖在两个设置下都错的设计上island 仍携带页头代码每个宿主仍要决策两条路径继续漂移。决策十一应用用doc_events认领 Desk 文档00110011-an-app-claims-a-desk-document-with-doc-events.md 让第三方应用接管 Desk 自绘的文档。决策绘制文档的 island 承载在__onload.island中形状为{name, props}。name是应用在ui_islands中声明的名字props是 island 的 props 对象。键不存在时Desk 自行绘制文档。应用在doc_events声明的onload处理器里设置这个键doc_events {Dashboard: {onload: someapp.desk.island.dashboard}} def dashboard(doc, methodNone): if doc.someapp_dashboard: doc.set_onload(island, {name: someapp.dashboard, props: {dashboard: doc.someapp_dashboard}})关键点Document.run_method(onload)已经会组合每个doc_events处理器frappe.desk.form.load对客户端获取的每个文档都会运行它——所以框架什么都不用加。Desk 页面和 chart widget 只读一个__onload键应用将来想绘制更多文档也无需框架改动。框架不读应用自己的任何字段——应用凭 Custom Field、命名规则或它持有的任何东西做决定。这个接缝与frappe.utils.island中的 island 注册表是分开的。注册表是通用的它把名字变成 bundle对文档一无所知并且是唯一知道某个 island 是否存在的地方。被拒绝每个文档一个dashboard_rendererhook。以文档命名的 hook方法接收文档、返回 island 或None框架跑每个应用的方法、校验答案、对冲突告警、把赢家写进__onload。这等于用更差的名字重造了doc_eventsDesk 绘制的每个文档都需要新 hook、新onload覆盖和hooks.py里的一行。校验与冲突代码守护的是一种只有返回它的应用才会弄错的形状——应用弄错时两种方案结果一样它的 island 不绘制。doc_events已经为两个应用的处理器排序第二个应用覆盖第一个正是告警描述的结果。决策十二Desk 页面本身可以是一个 island00120012-a-desk-page-can-be-an-island.md 把 island 从组件级推广到页面级。背景Desk 页面是一个带on_page_load的.js文件。想在一个 desk 路由上获得 Vue 屏幕的应用要写这个文件、在自己的前端构建 island、在hooks.py里声明、然后手工重复 mount、chrome、unmount。dashboard-view就是这样的页面——画任何东西之前就有六十行样板每条规则错了都不报错。决策Page长出type。当它是Frappe UI时由 island 绘制该页面。pageview.js构建页面、挂载 island、把页面路由之下的路由交给它、按 island 上报的内容设置页头0010。页面不携带脚本——load_assets读不到脚本就没有任何脚本到达客户端island 的 entry 若作为经典脚本 eval 会失败。页面注册自己的 islandget_ui_islands在ui_islandshook 旁边读取 Page 行所以 page island不需要 hook、不需要一行 Python。名字是app.page.page name两侧以相同方式推导page中缀避免与应用手工声明的名字冲突。starter 是两个文件放在页面 json 旁边page.island.js和page.vue由on_update代替其他类型获得的页面脚本一次性写出。blank是另一种 type今天存在的每个页面都是 blank——读者只分支Frappe UI所以没有任何迁移没有应用的导出 json 变动。被拒绝Route Type。路由工作有一张Route表行里命名Route Type只有Route Type命名代码——island 绘制的路由看起来像其中之一。但Route注册挂载点Desk 页面是 Desk 已经拥有的挂载点背后的内容不是同一件事而且路由模型还是一堆没有代码支撑的决策starter 等不起。被拒绝脚手架挂载 islandon_update写出调用frappe.ui.mount_island、写明 title/actions/unmount 的.jstype只是选了模板的旗标。每个页面从一份正确拷贝开始然后漂移——dashboard-view需要的路由竞争守卫是在页面写完之后才发现的任何脚手架页面都永远拿不到它这也让type变成页面如何被创建的记录而不是页面如何被绘制的事实。被拒绝页面在hooks.py里命名 bundle应用手写的注册表页面指向 bundle 名。一个页面各部分都在同一文件夹里却有三个地方要保持同步而 starter 恰恰会忘掉的那个。读取行给注册表增加一个来源而不是第二种解析名字的方式——加载器、get_island_assets、Island都不变。决策十三框架构建 page island00130013-framework-builds-page-islands.md 是 0005 的一个例外专为 page island 设立。问题island 用应用自己的工具链构建0005但 page island 不行——大多数带 Desk 页面的应用根本没有前端框架自己也一个都没有。按 0005 的规则starter 的第一步就是搭 Vite这成了与 island 毫无关系的全部门槛。决策一次构建、由框架为 bench 上每个 Frappe UI 页面执行。它的 vite root 是ui/vite/island/toolchain/——一个只装构建自身依赖的目录ui/vite/island/toolchain。这是对 0005 的修订而非推翻预设仍从自己的 root 解析工具链只是这一次 root 是框架的应用自己的 island 不受影响。所以 page island 用框架的 frappe-uiframework/ui编译不掺应用的东西。这是 starter 的应有形态而非待绕过的限制需要应用自有组件的页面已经超出了脚手架它会作为普通 island 进入应用前端。只构建一次而非每应用一次理由同 0002 与 0003bench 上所有 page island 共享一个 vue chunk 和一个 frappe-ui chunk扫描用的一次性首遍也只跑一次而不是每应用一次。after_app_build触发它——因为任何应用的构建都可能是加了页面的那个。输出是sites/assets/frappe/dist/page-island/而不是dist/island/一个构建拥有 assets.json 中指向自己目录的每个键框架构建和应用构建必须分目录否则会互相丢弃对方的键。发现逻辑读每个页面的 json 取名字和 type而不是读文件夹——文件夹名是 scrubbed 后的名字scrubbing 不可逆sales_dashboard既可能来自sales-dashboard也可能来自sales_dashboard构建选的名字必须与 Python 侧page_island_name选的一致。被拒绝应用构建自己的 page island0005 的规则照搬到页面。只对已有前端的应用有效——那是少数且永远不是框架。它要求的 setuppackage.json、lockfile、构建脚本、after_buildhook全在第一个页面渲染之前。应用的构建仍是毕业路径——需要应用自身代码的 island 本来就该待在那里。被拒绝把工具链装进ui/把依赖放进framework/ui自身、root 在那里。bench 用 symlink 把framework/ui链进每个消费应用Node 从模块真实路径解析——ui/node_modules里的vue和frappe-ui对每个链接该包的应用都是第二份副本这正是 README 点名的破坏 provide/inject、Vue 翻倍的东西兄弟目录永远不在从ui/src向上的查找路径上。被拒绝框架自建前端给apps/frappe一个真前端来构建 page island。那个前端正为 Desk 工作而引入现在再立一个日后就得合并或删除一个而且它会为一个只需六个包的构建背上整套 SPA 配置。决策全景一张图看懂 island 子系统的边界决策文件主题一句话结论0001运行时归属应用自带 Vue/frappe-ui框架只留名字 URL mount接缝0002构建粒度一次 Vite 构建所有 islandRollup 共享 chunk0003样式扫描Tailwind 扫描构建产出的模块清单而非 glob0004样式粒度每应用一张自足样式表adoptedStyleSheets共享解析0005工具链归属预设从应用的node_modules/.island/解析全部构建期依赖0006mount 契约mountVueIsland随 frappe-ui 发布Desk 不碰 Vue0007Shadow DOM 补丁reka-ui 补丁用getRootNode()读所在树应用postinstall应用0008宿主循环循环在host.js只写一次Desk 与Island都是薄包装0009数据契约数据与on*监听同走 Vue props 扁平对象无翻译层0010页面协作island 上报title/actions宿主以自己的 idiom 渲染 chrome0011文档认领doc_events的onload写入__onload.island框架零改动0012页面即 islandPage.type Frappe UI注册表直接读 Page 行0013页面构建框架在ui/vite/island/toolchain/一次构建所有 page island对照源码决策落地的真实位置上述决策不是纸面设计都可以在仓库中直接核对客户端契约入口ui/island/index.js 导出mountVueIsland、hostKey、useHost实现分布在 ui/island/mount.jsshadow root、样式 adopt、portal 目标、返回句柄、ui/island/context.jsSymbol.for(frappe:island-host)注入键、可空上下文、ui/island/theme.js宿主主题镜像。宿主循环ui/island/host.js 顶部长注释完整给出mountIsland的签名、resolve/host/props三参数、句柄的update/unmount/ready语义以及本模块不 import Vue、frappe-ui、frappe的约束。Vue 宿主ui/island/Island.vue负责生命周期与 props 监听0008 所述Vue 的东西。Vite 预设ui/vite/island 目录下的 index.js、tailwind.js、tailwind-scan.js、build-pages.js、tools.js、root-to-host.js 分别承载 0002–0005 与 0013 的构建逻辑ui/vite/island/toolchain 是 0013 指定的框架侧构建根目录。Desk 加载器frappe/public/js/frappe/ui/island/下的 index.js 与 loader.js 实现frappe.ui.mount_island、boot 注册表解析与热更新注册0008 所述Desk 的东西。reka-ui 补丁ui/patches/reka-ui2.10.1.patch 即 0007 决策所指的补丁文件。读者若要深入建议从 ui/island/host.js 的注释开始读它是宿主循环的活文档再对照 ui/island/decisions/README.md 的索引逐份阅读 13 个决策文件——每一份都短于一屏且都附有完整的拒绝理由这是理解这套架构取舍最快的路径。总结Frappe Desk Island 子系统用 13 条 ADR 回答了同一组问题的不同侧面运行时归谁、构建归谁、样式归谁、契约住在哪。贯穿始终的原则可以浓缩为三句话所有权跟随代码应用写的 island 由应用构建、称重、发布框架只拥有名字 → URL →mount这条最小的接缝。一份实现、多个宿主宿主循环只写一次ui/island/host.jsDesk 与 frappe-ui SPA 都是薄包装被拒绝的替代方案几乎全部死于第二份拷贝会漂移。Shadow Root 既是隔离也是代价样式自足、运行时自带、浮层需要补丁——每一项代价都对应一条 ADR而每条 ADR 的拒绝方案都记录着为什么不能更省。这套决策的边界维护方式本身也值得借鉴一份决策一个文件、只收录有被拒替代方案的决策、平台约束留在代码注释里、每份决策一屏读完——这让整个子系统在几年后依然能被快速理解和安全演进。【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考