深入解读 @lexical/eslint-plugin:用 ESLint 规则守护 Lexical 的 $function 约定
【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical
Lexical 是一个强调可靠性与可扩展性的文本编辑器框架,其 API 的核心约定是"$ 前缀函数"(如$getRoot、$createTextNode)只能在editor.update()、editorState.read()等受限上下文中调用。本文以 packages/lexical-eslint-plugin/README.md 为骨架,结合该包源码与测试,系统讲解如何通过@lexical/eslint-plugin在 ESLint 7~10+ 中自动强制这一约定,涵盖安装、Legacy/Flat 双配置体系、rules-of-lexical规则的四个可扩展匹配器,以及三种带 autofix 的违规修复场景。读完本文,你将能独立完成插件的接入、自定义匹配规则,并理解其底层 AST 分析原理。
插件定位:把"约定"变成"可执行的规则"
Lexical 要求所有直接读写 EditorState 的 API 都以$开头,例如$getRoot()、$createTextNode()。这一命名约定的意义在于:以$为前缀的函数只能在editor.update()或editorState.read()提供的上下文中被调用,否则无法保证读取到的是最新、最稳定的状态。@lexical/eslint-plugin的作用,就是把这条靠人脑记忆的"软约定"转化为构建期即可发现的硬性错误。
从 插件入口 src/index.ts 的类型定义可以看出,该插件目前暴露两条规则:
@lexical/rules-of-lexical:核心规则,强制$function命名约定(type: 'suggestion',支持自动修复);@lexical/no-document-in-dom-methods:禁止在节点的createDOM/updateDOM/exportDOM/$decorateDOM等 DOM 方法中直接使用全局document,并提供将document自动替换为$getDocument()的修复(type: 'problem',为 Shadow DOM / iframe 场景的安全性设计)。
两条规则的实际注册位于 LexicalEslintPlugin.js,插件的peerDependencies要求eslint >= 7.31.0、typescript >= 5.2(TypeScript 为可选 peer),见 package.json。
安装与版本兼容
文档明确指出该插件同时支持 ESLint 7、8、9 与 10+,且同时支持旧版.eslintrc(legacy)与新版eslint.config.js(flat)两种配置格式。前提是项目已安装 ESLint,然后安装插件:
npm install @lexical/eslint-plugin --save-devESLint 9+(Flat Config)
ESLint 9 起 flat config 成为默认,ESLint 10+ 强制要求使用 flat config。在eslint.config.js中直接引入插件自带的推荐配置即可:
import lexical from '@lexical/eslint-plugin'; export default [ // ... other configs lexical.configs['flat/recommended'] ];ESLint 7-8(Legacy Config)
ESLint 7 或 8 使用传统.eslintrc格式,通过extends继承推荐配置:
{ "extends": [ // ... "plugin:@lexical/legacy-recommended" ] }注意:从 LexicalEslintPlugin.js 源码可以看到,
recommended与all目前都是legacy-recommended、legacy-all的别名,而flat/recommended与flat/all指向同一份 flat 配置。也就是说,all与recommended目前内容完全一致,文档声明它们将在未来版本中迁移为独立的 flat config。
无论哪种配置形态,内置配置默认都将@lexical/rules-of-lexical设为'warn'(警告级别),并非直接报错;需要更强约束时建议手动配置为'error'。
自定义配置
ESLint 9+(Flat Config)
import lexical from '@lexical/eslint-plugin'; export default [ { plugins: { '@lexical': lexical }, rules: { '@lexical/rules-of-lexical': 'error' } } ];ESLint 7-8(Legacy Config)
{ "plugins": [ // ... "@lexical" ], "rules": { // ... "@lexical/rules-of-lexical": "error" } }两种写法的本质一致:在plugins中注册名为@lexical的插件,然后以@lexical/rules-of-lexical为键配置规则级别。
高级配置:四个可扩展的匹配器
rules-of-lexical的大多数启发式逻辑都可以通过规则选项扩展。下面这份示例展示了每个选项的默认实现,仅供参考,不建议直接照抄——因为当你配置这些选项时,它们是与默认实现以 "OR" 逻辑合并的,默认实现无法被覆盖。
匹配器的取值规则(源码见 buildMatcher.js):
- 字符串若以
"^"或"("开头,则被当作正则表达式字面源使用; - 否则按精确匹配处理(内部会被转义并包裹
^...$); - 字符串也可以直接替代字符串数组传入。
ESLint 9+(Flat Config)
import lexical from '@lexical/eslint-plugin'; export default [ { plugins: { '@lexical': lexical }, rules: { '@lexical/rules-of-lexical': [ 'error', { isDollarFunction: ['^\\$[a-z_]'], isIgnoredFunction: [], isLexicalProvider: [ 'parseEditorState', 'read', 'registerCommand', 'registerNodeTransform', 'update' ], isSafeDollarFunction: ['^\\$is'] } ] } } ];ESLint 7-8(Legacy Config)
{ "plugins": [ // ... "@lexical" ], "rules": { // ... "@lexical/rules-of-lexical": [ "error", { "isDollarFunction": ["^\\$[a-z_]"], "isIgnoredFunction": [], "isLexicalProvider": [ "parseEditorState", "read", "registerCommand", "registerNodeTransform", "update" ], "isSafeDollarFunction": ["^\\$is"] } ] } }isDollarFunction
- Base case:
/^\$[a-z_]/
定义$function约定:默认情况下,任何以$开头后跟小写拉丁字母(或下划线)的函数都被视为 Lexical 的$函数。如果你所在代码库有第二套约定——比如非拉丁字符开头,或需要纳入内部前缀(如"^INTERNAL_\\$"),可以在此补充。注意该选项只能"追加"模式,无法移除默认的$前缀识别。
从 rules-of-lexical.js 的BaseMatchers常量可以看到源码中四个匹配器的默认值即文档所示;compileMatchers会调用buildMatcher(BaseMatchers[k], parseMatcherOption(context, k)),将默认值与用户配置逐项 OR 合并。
isIgnoredFunction
- Base case:无
匹配这些模式的函数将被排除在分析之外:它们内部可以调用 Lexical$函数,但自身不会被认定为$函数,也不会因此被要求重命名。
isLexicalProvider
- Base case:
/^(parseEditorState|read|registerCommand|registerNodeTransform|update)$/
这类函数允许其函数参数内部使用 Lexical$函数。这正是"$函数只能在update/read等上下文中调用"的规则入口:当分析器遇到以这些名字(无论作为成员表达式editor.update、editorState.read还是独立调用)传出的回调时,会将该回调标记为安全上下文。
isSafeDollarFunction
- Base case:
/^\$is/
这类$函数被认为是在任何地方都可以安全调用的——通常它们是无状态运行时类型检查(如$isElementNode、$isTextNode),不依赖任何外部状态。因此即使不在update/read上下文中,调用它们也不会触发规则。在源码的CallExpression处理中,matchers.isSafeDollarFunction(calleeName)命中的调用会直接进入ignoreSet跳过分析。
规则的底层工作机制
理解了四个匹配器之后,再看 rules-of-lexical.js 的实现,就能明白整条规则的执行脉络:
- 遍历函数定义:对
FunctionDeclaration、FunctionExpression、ArrowFunctionExpression进入时入栈、退出时出栈,构成一个"当前函数栈"(funStack)。函数名通过 getFunctionName.js 与 getParentAssignmentName.js 解析,后者还支持const $fun = useCallback(() => {}, [])这种高阶 Hook 赋值场景,从useCallback/useMemo的父节点反推函数名; - 跳过安全上下文:当栈顶函数命中
isDollarFunction/isIgnoredFunction/isLexicalProvider,或调用命中isSafeDollarFunction时,节点被加入ignoreSet跳过分析;类的方法体(ClassBody)整体也会被跳过——这正是文档中"$函数可以从类方法中调用"这一合法示例成立的原因; - 触发报告:当在一个非安全上下文的函数内检测到对
$函数的调用,且该外层函数自身名字不满足$约定时,规则以rulesOfLexicalReport消息上报:"{{ callee }} called from {{ caller }}, without $ prefix or read/update context",并附带重命名建议(suggestion)与自动修复(fix); - 防重复上报:通过
reportedSet保证同一个函数多次调用$函数时只报告一次(只需重命名一次)。
值得一提的细节:规则对成员表达式(如editor.update、editorState.read)的处理只关心方法名本身(getFunctionNameIdentifier会取MemberExpression的property),所以文档特别说明"heuristic only considers the method name"。
合法用法(Valid Examples)
文档给出三类合法场景,均对应上述机制:
1.$函数可以调用其他$函数
function $namedCorrectly() { return $getRoot(); }外层函数$namedCorrectly自身满足$约定(命中isDollarFunction),因此整个函数体被跳过分析。
2.$函数可以在下列方法提供的回调中调用(启发式只考虑方法名):
editor.updateeditorState.readeditor.registerCommandeditor.registerNodeTransform
function validUsesEditorOrState(editor) { editor.update(() => $getRoot()); editor.getLatestState().read(() => $getRoot()); }这些方法名命中isLexicalProvider,其回调参数被标记为安全上下文。
3.$函数可以在类方法中调用
class CustomNode extends ElementNode { appendText(string) { this.appendChild($createTextNode(string)); } }类方法体(ClassBody)在分析时整体入ignoreSet跳过,因此类方法内部调用$函数是合法的。
违规场景与自动修复(Invalid Examples)
这是rules-of-lexical最有价值的部分:不仅报告错误,还能通过--fix自动修复。文档提供了三种典型违规场景。
场景一:普通函数重命名(Rename autofix)
function invalidFunction() { return $getRoot(); } function $callsInvalidFunction() { return invalidFunction(); }Autofix:函数被加上$前缀重命名,同时本模块内对该名字的所有引用也会一并被替换:
function $invalidFunction() { return $getRoot(); } function $callsInvalidFunction() { return $invalidFunction(); }底层实现中,fixer 会通过getIdentifierVariable解析函数对应的作用域变量,遍历variable.references将所有引用标识符统一替换为建议名(见 rules-of-lexical.js 中的renameIdentifier逻辑)。
场景二:导出函数重命名并保留旧名(Rename & deprecate autofix)
export function exportedInvalidFunction() { return $getRoot(); }Autofix:导出函数被加上$前缀重命名,同时保留旧名字的导出并标记为废弃——因为自动重命名引用只限于本模块作用域,模块外的引用无法被同步修改,因此需要旧名作为向后兼容的过渡:
export function $exportedInvalidFunction() { return $getRoot(); } /** @deprecated renamed to {@link $exportedInvalidFunction} by @lexical/eslint-plugin rules-of-lexical */ export const exportedInvalidFunction = $exportedInvalidFunction;对应源码中的getExportDeclaration会识别export function与export const foo = () => {}两种导出形态,并通过renameExportText生成带@deprecatedJSDoc 注释的兼容导出。
场景三:重命名与作用域冲突(Rename scope conflict)
import {$getRoot} from 'lexical'; function InvalidComponent() { const [editor] = useLexicalComposerContext(); const getRoot = useCallback(() => $getRoot(), []); return (<button onClick={() => editor.update(() => getRoot())} />); }这里getRoot包装了$getRoot(),但名字不符合$约定。注意InvalidComponent是组件函数(isHookFunctionIdentifier检测 Hook 命名),而const getRoot = useCallback(...)是典型的 Hook 赋值——规则通过getParentAssignmentName正确识别出该函数名为getRoot。
Autofix:建议名$getRoot已经与导入的$getRoot冲突(会遮蔽已有变量),因此规则自动附加下划线后缀:
import {$getRoot} from 'lexical'; function InvalidComponent() { const [editor] = useLexicalComposerContext(); const $getRoot_ = useCallback(() => $getRoot(), []); return (<button onClick={() => editor.update(() => $getRoot_())} />); }命名冲突检测在getSuggestName中实现:它从函数所在作用域向上遍历(scope.upper),若建议名$getRoot已被作用域中的变量占用,则返回$getRoot_。另外从getFirstSuggestion可以看到更精细的命名策略:小写开头直接加$前缀;PascalCase名称(如InvalidFunction)会转为$invalidFunction(首字母小写);其他情况退化为$_前缀。
附带规则:no-document-in-dom-methods
除核心的rules-of-lexical外,插件还附带@lexical/no-document-in-dom-methods规则。该规则禁止在以下 DOM 生命周期方法中直接引用全局document:
createDOMupdateDOMexportDOM$decorateDOM
违规时提供 autofix,将document替换为$getDocument()(源码见 no-document-in-dom-methods.js)。$getDocument()会返回节点实际所在的文档,从而保证在 Shadow DOM 或 iframe 等非主文档环境中也能正确操作,避免误用顶层全局document。需要注意:autofix 只替换标识符本身,不会自动补充 import,这是文档与源码中均明确提示的边界。
测试验证与使用建议
运行集成测试
插件仓库内置了跨 ESLint 版本的集成测试(文档命令行在仓库根目录执行):
node packages/lexical-eslint-plugin/__tests__/integration-test.js根据 integration-test.mjs 的实现,该脚本会验证:
- ✓ ESLint 8 + 传统
.eslintrc.json配置; - ✓ ESLint 10 + flat
eslint.config.js配置; - ✓ Legacy 配置名别名(
recommended与legacy-recommended)。
测试通过pnpm dlx eslint@8/pnpm dlx eslint@10临时拉起不同版本的 ESLint,不修改package.json或pnpm-lock.yaml;对 ESLint 8 还会显式设置ESLINT_USE_FLAT_CONFIG=false以避免 flat config 探测干扰。测试夹具位于 fixtures 目录,分别对应eslint8-legacy、eslint8-legacy-deprecated与eslint10-flat三套独立环境。
单元测试与文档一致性
值得强调的是,该插件的单元测试 rules-of-lexical.test.ts 中有一个特殊设计:测试会直接读取本包 README.md,从### Valid Examples与### Invalid Examples章节中解析代码块,动态构造RuleTester用例来运行——也就是说,文档中每个合法/违规示例都经过真实 ESLint 规则引擎的验证,保证"文档即测试"。这使得上面列出的所有示例都具有可复现性。
实践建议
- 从 warning 起步:内置
recommended配置默认是warn级别,适合先在 CI 中观察存量代码的违规情况,再逐步过渡到error; - 善用
--fix:三种 autofix(重命名、重命名+废弃导出、冲突加后缀)都能安全自动执行,建议在提交前运行 ESLint fix 让工具完成机械性改造; - 按需扩展匹配器:如果团队有
INTERNAL_$之类的内部前缀或特殊 provider 方法,通过isDollarFunction/isLexicalProvider等选项增量追加即可,无需修改插件源码; - 配合 Shadow DOM 场景:如果你的编辑器需要跑在 iframe 或 Shadow DOM 中,同时启用
no-document-in-dom-methods规则,并在 autofix 后手动补充$getDocument的导入。
小结
@lexical/eslint-plugin把 Lexical 最核心、也最容易出错的$function调用约定,变成了可在 CI 中强制执行的自动化检查:它同时兼容 ESLint 7~10+ 的 Legacy 与 Flat 配置体系,核心规则rules-of-lexical通过四个可扩展匹配器覆盖函数命名、provider 上下文与安全类型检查函数,并提供重命名、导出兼容与冲突规避三种自动修复策略;no-document-in-dom-methods则守护 Shadow DOM / iframe 环境下的 DOM 操作安全。结合其"文档即测试"的工程实践,这套插件既是 Lexical 开发者日常开发的贴身 lint 工具,也是一个学习如何编写高质量 ESLint 插件的绝佳范例。
【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考