复现版本
@lobehub/editor@4.12.0
背景
在 LobeHub 的 page-agent 工具里需要在 server-side (Node) 跑 HeadlessEditor,期望复用 @lobechat/editor-runtime 里同一份 Lexical 调度逻辑(同时给 renderer 和 server 用)。落地时碰到两个上游问题,记录如下。
问题 1:主入口任何 value import 都在 Node 崩
只要在 Node 环境写
import { LITEXML_MODIFY_COMMAND } from '@lobehub/editor';
就会立刻 throw:
ReferenceError: document is not defined
❯ node_modules/.pnpm/@lobehub+editor@4.12.0_…/node_modules/@lobehub/editor/es/ReactSlashPlugin-DkEXwDgH.js:346:17
344| //#endregion
345| //#region node_modules/.pnpm/decode-named-character-reference@1.3.0/no…
346| const element = document.createElement(\"i\");
| ^
347| /**
348| * @param {string} value
❯ node_modules/.pnpm/@lobehub+editor@4.12.0_…/node_modules/@lobehub/editor/es/index.js:2:1
index.js 顶部直接 import { ... } from \"./ReactSlashPlugin-DkEXwDgH.js\",而那个 chunk 在模块顶层直接 document.createElement(\"i\")。
package.json 里写了 \"sideEffects\": [\"**/*.css\"],理论上 tree-shake 能掉所有 JS 模块的副作用,但 bundling 把 LITEXML_*_COMMAND 这种纯常量和 ReactSlashPlugin 打到了一起 / chunk 顶层就有 DOM 调用,bundler 没法消除。结果是:主入口 ESM 实际上不是 side-effect free,只要 evaluate 就会触发 DOM。
期望行为
主入口至少满足下面之一:
- 模块顶层不触碰
document / window,DOM 调用 lazy 到运行时
- 把
ReactSlashPlugin 等 DOM-only 的实现独立成 chunk,让纯常量 (LITEXML_*_COMMAND、type IEditor 等) 不会拖进来
- 主入口明确只为 browser,文档说明 Node 必须走
./headless,并把 server 需要的所有 symbol 都从 ./headless 导出(见问题 2)
问题 2:./headless 入口未导出 LITEXML_*_COMMAND
es/headless.js 内部其实定义了:
const LITEXML_MODIFY_COMMAND = createCommand(\"LITEXML_MODIFY_COMMAND\");
const LITEXML_APPLY_COMMAND = createCommand(\"LITEXML_APPLY_COMMAND\");
但模块底部 export 只有:
export { DEFAULT_HEADLESS_EDITOR_PLUGINS, HeadlessEditor, createHeadlessEditor };
d.ts 同样不导出这两个常量。
这导致 server 侧拿不到这两个 command 常量来对 headless.kernel 直接 dispatch,被迫只能走 headless.applyLiteXMLBatch(...) / headless.applyLiteXML({ action: 'apply', litexml }) 这种高层方法(不灵活、表达力弱于 dispatchCommand)。
期望行为
./headless 导出 LITEXML_MODIFY_COMMAND / LITEXML_APPLY_COMMAND(以及任何其他 server 侧需要 dispatch 的命令常量),让 server 能像 renderer 一样 kernel.dispatchCommand(LITEXML_*, payload)。
问题 3 (派生):pnpm 双副本下命令 identity 不一致
即便强行从主入口导入并在 Node 用 try/catch 绕开 document 报错,还有一层语义陷阱:
createCommand(name) 返回 { type: name } —— 同名调两次产生两个不同对象。
@lobehub/editor (主入口) 和 @lobehub/editor/headless 是两份独立 bundle,各自内部都 createCommand(\"LITEXML_APPLY_COMMAND\") —— pnpm 解析到的 module copy 不同时,两者的 LITEXML_APPLY_COMMAND 是不同对象。
后果:
import { LITEXML_APPLY_COMMAND } from '@lobehub/editor'; // 主入口的 symbol A
import { createHeadlessEditor } from '@lobehub/editor/headless'; // 内部注册的是 symbol B
const headless = createHeadlessEditor();
headless.kernel.dispatchCommand(LITEXML_APPLY_COMMAND, { litexml: ['<p>x</p>'] });
// dispatchCommand 返 false,无 handler 匹配,编辑器没动,没报错 —— 静默 no-op
这是相当隐蔽的 bug:工具调用流程返回 success,但内容没变,需要业务层加 hash diff 才能发现。
如果问题 2 修了(headless 也导出常量),并且约束 server 必须从 ./headless 导入,问题 3 在常见场景下也就消失了 —— 同一个 module 内部的 symbol 一致。
当前 workaround
我们在下游做了 strategy adapter:
// renderer 侧
runtime.setLiteXMLAdapter({
applyBatch: (editor, ops) => editor.dispatchCommand(LITEXML_MODIFY_COMMAND, ops),
applyReplace:(editor, litexml) => editor.dispatchCommand(LITEXML_APPLY_COMMAND, { litexml }),
});
// server 侧 —— 不导入主入口,走 headless 的高层方法绕过常量缺失 + identity 问题
runtime.setLiteXMLAdapter({
applyBatch: async (_e, ops) => { await headless.applyLiteXMLBatch(ops); },
applyReplace:async (_e, litexml) => { await headless.applyLiteXML({ action: 'apply', litexml }); },
});
能跑,但代码上多了一层不必要的间接。上游修了 1+2 之后这层 adapter 可以直接退役。
建议优先级
- P0:问题 1(任何 server-side / SSR / build-tool 静态分析场景都会踩到)
- P0:问题 2(让 server 能正常用 headless 而不用奇技淫巧)
- P1:问题 3(修了 1+2 之后基本消失,剩下的可以靠文档"server 必须只从 ./headless 导入"约束)
复现版本
@lobehub/editor@4.12.0背景
在 LobeHub 的 page-agent 工具里需要在 server-side (Node) 跑 HeadlessEditor,期望复用
@lobechat/editor-runtime里同一份 Lexical 调度逻辑(同时给 renderer 和 server 用)。落地时碰到两个上游问题,记录如下。问题 1:主入口任何 value import 都在 Node 崩
只要在 Node 环境写
就会立刻 throw:
index.js顶部直接import { ... } from \"./ReactSlashPlugin-DkEXwDgH.js\",而那个 chunk 在模块顶层直接document.createElement(\"i\")。package.json里写了\"sideEffects\": [\"**/*.css\"],理论上 tree-shake 能掉所有 JS 模块的副作用,但 bundling 把LITEXML_*_COMMAND这种纯常量和ReactSlashPlugin打到了一起 / chunk 顶层就有 DOM 调用,bundler 没法消除。结果是:主入口 ESM 实际上不是 side-effect free,只要 evaluate 就会触发 DOM。期望行为
主入口至少满足下面之一:
document/window,DOM 调用 lazy 到运行时ReactSlashPlugin等 DOM-only 的实现独立成 chunk,让纯常量 (LITEXML_*_COMMAND、type IEditor等) 不会拖进来./headless,并把 server 需要的所有 symbol 都从./headless导出(见问题 2)问题 2:
./headless入口未导出 LITEXML_*_COMMANDes/headless.js内部其实定义了:但模块底部 export 只有:
d.ts同样不导出这两个常量。这导致 server 侧拿不到这两个 command 常量来对
headless.kernel直接 dispatch,被迫只能走headless.applyLiteXMLBatch(...)/headless.applyLiteXML({ action: 'apply', litexml })这种高层方法(不灵活、表达力弱于 dispatchCommand)。期望行为
./headless导出LITEXML_MODIFY_COMMAND/LITEXML_APPLY_COMMAND(以及任何其他 server 侧需要 dispatch 的命令常量),让 server 能像 renderer 一样kernel.dispatchCommand(LITEXML_*, payload)。问题 3 (派生):pnpm 双副本下命令 identity 不一致
即便强行从主入口导入并在 Node 用 try/catch 绕开
document报错,还有一层语义陷阱:createCommand(name)返回{ type: name }—— 同名调两次产生两个不同对象。@lobehub/editor(主入口) 和@lobehub/editor/headless是两份独立 bundle,各自内部都createCommand(\"LITEXML_APPLY_COMMAND\")—— pnpm 解析到的 module copy 不同时,两者的LITEXML_APPLY_COMMAND是不同对象。后果:
这是相当隐蔽的 bug:工具调用流程返回 success,但内容没变,需要业务层加 hash diff 才能发现。
如果问题 2 修了(headless 也导出常量),并且约束 server 必须从
./headless导入,问题 3 在常见场景下也就消失了 —— 同一个 module 内部的 symbol 一致。当前 workaround
我们在下游做了 strategy adapter:
能跑,但代码上多了一层不必要的间接。上游修了 1+2 之后这层 adapter 可以直接退役。
建议优先级