很多人第一次看到 Odoo 网站编辑器,会觉得它像一个“后面又挂了一层前端”。
平时浏览网站时,页面很轻;一旦进入编辑模式,工具栏、拖拽能力、片段面板、弹窗和一堆编辑逻辑才突然出现。直觉上,这种体验背后往往意味着两件事:
- 资源不能一开始就全量塞进主页面;
- 编辑器运行环境又不能和普通前台页面完全混在一起。
Odoo 在这里的做法,不是简单的“动态 import 一下”,而是一整套运行时资产装配方案。顺着 addons/web/static/src/core/assets.js 和 addons/website/static/src/client_actions/website_preview/website_builder_action.js 往下看,会发现官方真正解决的是这个问题:
当一个重量级前端能力只在少数时刻需要时,怎么把它延迟到真正需要的那一刻,再精确地注入到正确的文档里。
这就是 LazyComponent、loadBundle() 和 targetDoc 选项存在的原因。
一、LazyComponent 解决的不是“懒”,而是“先确认资源,再实例化组件”
assets.js 里的 LazyComponent 很短,但意义很大。
它在 onWillStart 里先做两件事:
await loadBundle(this.props.bundle);- 再从
registry.category("lazy_components")里取真正的组件。
这个顺序非常关键。
很多框架里所谓懒加载,只是把组件代码晚一点下载。但 Odoo 这里把“组件出现”定义成一个更严格的契约:
- 不是代码文件拿到了就算结束;
- 而是相关 JS、CSS 乃至目标文档里的资源状态都到位后,组件才允许实例化。
所以 LazyComponent 的价值不是语法糖,而是把资源准备完成变成了渲染前提。
二、/web/bundle/<bundleName> 返回的不是脚本本身,而是资源清单
getBundle(bundleName) 不是直接下载一坨打包产物,而是去请求 /web/bundle/<bundleName>,再把结果拆成:
cssLibsjsLibs
这说明 Odoo 的 bundle 在运行时更像“描述文件”而不是“单个大包”。
它带来的收益很实际:
- 前端能明确知道这次要补哪些 CSS、哪些 JS;
- 是否注入样式、是否只补脚本,可以在
loadBundle()参数里控制; - 不同文档可以按同一份描述,装配各自需要的资源。
对网站编辑器来说,这尤其重要。因为编辑能力常常一部分属于主后台壳,一部分要真正落到预览 iframe 里。如果 bundle 只是个粗粒度黑盒,就很难做这种细分注入。
三、真正高明的地方在 targetDoc:资源可以进 iframe,而不只进主文档
loadBundle(bundleName, { targetDoc = document, css = true, js = true }) 是整套设计里最值得前端开发者记住的接口。
它意味着 Odoo 不是默认“所有脚本样式都进当前窗口”,而是承认一个复杂事实:
同一套前端能力,可能需要注入到另一个 document。
网站编辑器就是典型场景。
用户看到的是:
- 外面一层后台/预览壳;
- 里面一个网站页面 iframe;
- 编辑行为又要作用在 iframe 内的 DOM。
这时如果你把编辑器 CSS 和 JS 全塞到主文档:
- iframe 内看不到样式;
- 某些脚本找不到目标节点;
- DOM 测量与事件边界会混乱。
而 targetDoc 让 Odoo 可以把资源精确打到 iframe 的 document.head,让“编辑器运行在 iframe 里”这件事变得可操作。
四、缓存不是一层,而是“全局 + 每个文档”双层缓存
assets.js 里同时有:
globalBundleCacheassetCacheByDocument
很多人只要看到缓存就会说“防止重复加载”,但这里其实分了两类问题。
1)bundle 级缓存
同一个 bundle 的描述信息,不必每次重复请求 /web/bundle/...。
2)文档级资产缓存
同一个 JS/CSS URL 对某个 targetDoc 来说,一旦已经注入,就不要重复再打一次。
为什么一定要按 document 分开?
因为“主文档加载过”并不代表“iframe 文档也加载过”。
这就是网站编辑场景最容易踩坑的地方。很多自研系统懒加载做得半套,结果出现:
- 主页面缓存命中,但 iframe 其实没资源;
- 或者相反,错误地认为两个文档是一回事,最终导致编辑区行为不完整。
Odoo 这里用 WeakMap(targetDoc -> Map(url -> Promise)),本质上是在承认:资源加载状态属于具体文档,而不是只属于浏览器 tab。
五、computeBundleCacheMap() 很工程化:先看现实页面里已经有什么
whenReady(() => computeBundleCacheMap(document)) 这行很容易被忽略。
它会扫描 document.head 里现成的:
script[src]link[rel=stylesheet][href]
然后把这些 URL 直接标记进缓存。
这说明 Odoo 的资产系统并不天真。它没有假设“只有我自己插入过的资源才算已加载”,而是会先观察页面现实状态。
这在以下场景都很重要:
- 服务端模板已经先插入部分资源;
- 页面因为别的引导流程预热过某些文件;
- 页面恢复或嵌入时,head 里已有现成脚本样式。
这一步让运行时资产系统更像协调器,而不是单纯的 appendChild(script) 工具函数。
六、失败重试是网站编辑器可用性的保底,不是锦上添花
loadCSS() 和 loadJS() 都有重试逻辑,默认带:
- 重试次数;
- 固定延迟;
- 递增额外延迟。
为什么这里值得单独讲?因为懒加载最怕的不是“慢”,而是“点编辑时第一次失败”。
首屏失败用户可能刷新;编辑器失败,用户会直接觉得功能坏了。
对重量级 bundle 来说,临时网络抖动、资源服务器慢、浏览器抢占都可能导致偶发失败。Odoo 通过 Promise 缓存与失败后删缓存再重试,让“晚加载”不至于变成“脆弱加载”。
七、Website Builder Client Action 是这套机制的实际落点
在 website_builder_action.xml 里可以看到 LazyComponent 被直接用于网站编辑器组件;在 website_builder_action.js 里还能看到编辑模式会主动调用 loadAssetsEditBundle()。
这说明网站编辑器不是在初始 WebClient 启动时就绑死进来,而是在:
- 用户真的进入编辑场景;
- 状态判定需要编辑能力;
- 再补对应 bundle。
从架构角度看,这样做有三个明确收益:
1)普通访客首屏更轻
不是每个浏览网站的人都要背上编辑器成本。
2)编辑能力和展示能力边界更清楚
普通前台页面维持轻量;编辑态再追加复杂交互。
3)iframe 隔离更容易维持
因为 bundle 注入天然支持目标文档,编辑区与外层控制壳不会强行混写。
八、对二开最重要的启发:不要只想着“把 JS 文件引进来”
很多 Odoo 前端二开遇到网站编辑器问题时,思路还是:
- 组件注册了没;
- 模块 import 了没;
- 资源放进 manifest 了没。
这些当然重要,但还不够。
更关键的是先问四个问题:
- 这份能力属于主文档还是 iframe 文档?
- 它是首屏必须,还是进入某模式后再加载?
- 对应 bundle 的 CSS/JS 是否要分开控制?
- 失败时有没有可靠的重试与缓存语义?
如果这四个问题没想清楚,最常见的后果就是:
- 主界面有按钮,但 iframe 内样式没进;
- 代码在后台能 import,实际编辑区行为却不生效;
- 某些资源偶发加载失败,刷新后又“神奇恢复”。
这类 bug 往往不是业务逻辑错,而是资产注入边界错了。
九、一句话总结这套设计的核心价值
Odoo 的 LazyComponent + loadBundle + targetDoc 方案,本质上把网站编辑器这类重能力拆成了三步:
- 先声明能力属于哪个 bundle;
- 需要时再加载;
- 并且加载到正确的 document。
这比“所有东西首屏打平”复杂一些,但对 ERP + CMS 这种混合系统非常值。
因为它解决的不是简单性能优化,而是:
如何在一个同时拥有后台壳、前台页面和 iframe 编辑区的系统里,让复杂前端能力既晚出现、又准出现。
这正是 Odoo 网站编辑体验能兼顾轻量首屏与重度编辑的底层原因。
DISCUSSION
评论区