Frappe Island 的 Props 契约:为什么一个 Island 直接接收 Vue 的 props 对象
【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe
导读
本文基于 frappe 仓库ui/island/decisions/下的架构决策记录(ADR),深入解析 Island 系统的核心数据契约:宿主(Host)向 Island 传递数据与监听器时,采用的就是 Vue 的 props 对象本身——数据和on*监听器平铺在同一个扁平对象里,与h(Component, props)的入参完全一致。读完本文,你将理解frappe.ui.mount_island与<Island>组件为何能"零翻译"地共享同一套传参语义、update如何实现 Island 的增量刷新,以及为什么事件名中不能出现冒号。
Island 是什么:Shadow Root 后面的 Vue 组件
在 frappe 的 Island 架构中,一个 Island 是运行在 Shadow Root 内部的 Vue 组件。宿主页面(host)与 Island 之间存在一条清晰边界:
- 宿主(host)把数据与监听器(listeners)交给 Island;
- Island 通过
mount挂载、渲染,并通过普通事件向上报告; - Shadow Root 双向隔离 CSS,
mountVueIsland负责打开 shadow root、adopt 样式表、镜像宿主的主题,并为 frappe-ui 的浮层提供 portal 目标(详见 ADR 0006:mount 契约随 frappe-ui 发布)。
既然数据与监听器要"一起旅行",那么它们以什么形状打包?这就是本 ADR 要回答的问题。
决策:一个扁平的 props 对象
决策结论非常直接:数据键与on*监听器键放在同一个扁平对象中,这正是h(Component, props)的入参形状。
frappe.ui.mount_island("insights.dashboard", el, { dashboard: "sales", onNavigate: (route) => frappe.set_route(route), onTitle: (title) => frappe.utils.set_title(title), });以这个调用为例:
dashboard是数据 prop;onNavigate、onTitle是监听器,命名规则是on+ 事件名(首字母大写)。
mountVueIsland:原样交给h
在 ui/island/mount.js 中,mountVueIsland把整个对象原封不动地交给h:
const currentProps = shallowRef({ ...props }); const app = createApp({ name: "FrappeIsland", render: () => h(component, currentProps.value), });注意这行render: () => h(component, currentProps.value)——props 对象没有被重命名、拆分或重新组织,直接进入h的第二个参数。组件作者声明什么 props,宿主就能传什么 props;onNavigate会以 Vue 标准的事件监听机制解析到组件的emit("navigate")上。
update:合并即重渲染
update的语义同样简单:合并(merge)。在 mount.js 中:
update(next) { if (destroyed) return; currentProps.value = { ...currentProps.value, ...next }; },currentProps是shallowRef,替换其.value触发重渲染;新值 = 旧值展开 + 新 props 展开。这就是"一次 re-render 所做的全部事情"——不重建组件、不重挂载 shadow root,只更新 props。
<Island>组件:同一对象当作属性
Vue 侧宿主<Island>组件(ui/island/Island.vue)采用同一套语义,只是换成了模板写法:
<Island name="insights.dashboard" :dashboard="dashboard" :context="{ user, locale, navigate }" @title="title = $event" @actions="actions = $event" @navigate="router.push($event)" />组件是"透明"的:除name与context之外,所有属性都是 Island 的 props 对象,逐字传递给它的组件。@title、@navigate与普通组件上的事件绑定行为一致。实现上,islandProps计算属性把 attrs 里除class、style之外的键全部收集起来(Island.vue):
const islandProps = computed(() => Object.fromEntries(Object.entries(attrs).filter(([key]) => key !== "class" && key !== "style")) );而class/style停留在宿主元素上,inheritAttrs: false防止它们意外落入 Island。
为什么要"没有翻译层"
这个形状的核心理念是:形状本身没有翻译层,所以没有需要学习的规则。一个懂 Vue 的调用者天然懂这个 API(h(Component, props)的入参就是它);一个组件作者阅读组件声明的 props 就能知道能传什么。任何"额外规则"都会成为拼写正确但传不到位的出错点。
被否决的方案一:结构化对象{ props, on, model }
这是最早尝试的形状。问题在于它需要三处翻译:
mount.js要把on.navigate转成onNavigate;Island.vue要把@navigate转回on.navigate;- Desk 侧的调用者又写了第三种写法。
同一个想法存在三种翻译,而每一次翻译都是一个"名字写对了但传不到"的出错点。此外,{ props, on, model }是一种"我们自己的语法"——任何新来者都无法从 Vue 知识中推断它。一个 Idea 出现三种拼写,本身就是设计失败的信号。
被否决的方案二:camelCase 监听器名onUpdateTitle
这个方案"读起来更顺眼",但它永远不会触发。原因在 Vue 的底层事件解析规则:
Vue 会把事件名中的连字符(hyphen)驼峰化,但不会处理冒号(colon)。
update:title会原样解析为字面量键"onUpdate:title",仅此而已。结果是一个 Vue 无法解析的名字,而无法解析的监听器失败时不会有任何报错——这是监听器出错最糟糕的方式:静默失效,调用者无从排查。
冒号彻底退出 Island API
作为配套措施,ADR 0010:页面 Island 上报标题与动作 移除了页面 Island API 中仅有的两个冒号事件(update:title、update:actions被否决,改为普通事件title、actions)。但"事件名不得含冒号"这条规则依然约束任何宿主挂载的组件——这正是 props 对象必须原样交给h的原因:任何自作主张的重写都可能制造出 Vue 无法解析的事件名。
普通事件 vsv-model:为什么 Island 上报用@title而非update:title
顺带说明 0010 中的相关决策:v-model:title在 Vue 中的展开是:title加@update:title,而 Island 根本不声明title这个 prop,于是绑定会向一个忽略它的组件传值,并暗示"宿主与 Island 共享状态"。实际上没有任何东西被共享——Island 上报,宿主存储。普通事件@title干净地表达了这一方向性。而且放弃update:形状还省掉了<Island>的一个过滤逻辑:update:形态的绑定会把宿主绑定的值当作 stray attribute 回传下来,导致每次上报都通过update回显一遍。
这个契约落在哪里:host loop 与两个宿主
props 契约之所以能"一处定义、两处复用",是因为它定义在共享的宿主循环(host loop)中。ADR 0008:一个宿主循环,两个宿主 规定:解析名字 → import 模块 → 校验导出mount→ 卸载占位 → 调用mount→ 持有可跨重挂载的 handle,这段逻辑只存在于 ui/island/host.js,Desk 与 frappe-ui 应用各自只是它的薄封装。
host.js 的文档注释里明确重申了这条契约(host.js):
props是 Vue 的 props 对象:数据键与on*监听器键在同一个扁平对象中,正如h(Component, props)所接收的那样。事件名中的连字符或冒号必须保持引号形式——写"onUpdate:modelValue",而不是onUpdateModelValue,后者 Vue 永远不会解析。
Desk 侧:frappe.ui.mount_island
Desk 的封装在 frappe/public/js/frappe/ui/island/loader.js:
function mount_island(name, el, props = {}) { return mountIsland(name, el, { resolve: resolve_island, host: build_desk_context(), props, }); }调用者传的就是 props 对象,Desk 额外注入host与styles。build_desk_context()自动组装locale、timezone、user、base_url与navigate(路由到frappe.set_route)等环境上下文(loader.js),所有字段都是纯数据或 desk 调用,不含任何 Vue 值——因为 desk 的 bundle 与 Island 跑在各自独立的 Vue 副本上。
Vue 侧:<Island>组件的 props 监听
<Island>的islandProps变化由watch捕获,通过handle?.update(next)推过 shadow 边界(Island.vue)。由于useAttrs()返回的 proxy 在父组件每次渲染时都会刷新,它用sameProps做逐键比较,避免每次父渲染都向 shadow root 推送一次冗余更新。
错误处理:静默失败是监听器最糟糕的失败方式
贯穿两个被否决方案的一条主线是失败的可诊断性:
- 结构化对象需要翻译,翻译处可能"名字对但传不到";
- camelCase 监听器名可能"从不触发且无任何报告"。
这个契约的设计哲学因此非常明确:props 对象原样交给h,让 Vue 自己的解析规则成为唯一的真理来源。调用者写出的事件名是否有效,取决于 Vue 是否认识它;认识则触发,不认识则……虽然仍然可能静默,但至少失败点收敛到了"Vue 的事件解析规则"这一个可学习、可文档化的位置,而不是散布在各宿主自造的翻译逻辑里。
小结
- Island 的宿主契约 =Vue props 对象:数据键与
on*监听器键平铺一个扁平对象,与h(Component, props)的入参一致; mountVueIsland把它原样交给h,update通过合并触发重渲染,<Island>把除name/context外的属性原样传递;- 该契约定义在共享的 ui/island/host.js 宿主循环中,Desk 的
frappe.ui.mount_island与 Vue 的<Island>只是两种宿主语法; - 事件名中的冒号是禁区:Vue 只驼峰化连字符、不处理冒号,
update:title只会解析成永远不触发的"onUpdate:title"; - 两个被否决方案(结构化
{ props, on, model }、camelCase 监听器名)的共同教训:任何一层翻译都是一个名字拼对了却传不到的出错点,而"没有规则可学"本身就是这个 API 最大的优点。
想继续深入,可以按决策编号阅读 ui/island/decisions/ 系列:0001(应用自打包 Island)、0006(契约随 frappe-ui 发布)、0008(单一宿主循环)、0010(页面 Island 的 title/actions 上报),以及宿主循环的实现 ui/island/host.js 与 Desk 装载器 frappe/public/js/frappe/ui/island/loader.js。
【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考