Onlook 可视化编辑器核心原理:AST 算法如何打通 DOM 与代码,精确定位组件实例
【免费下载链接】onlookThe Cursor for Designers • An Open-Source AI-First Design tool • Visually build, style, and edit your React App with AI项目地址: https://gitcode.com/GitHub_Trending/on/onlook
Onlook 是一款开源的 AI 优先(AI-First)可视化设计工具,让开发者/设计师直接在浏览器画布上选中、修改、排版 React 应用的任意元素。要做到"点哪改哪",编辑器必须回答一个关键问题:画布上渲染出来的某个 DOM 节点,究竟对应源码文件里的哪一行 JSX?本篇文章将围绕 ast/README.md 展开,完整讲解 AST(抽象语法树)算法的工作原理、在项目中的真实源码实现、以及它如何用"双份部分信息互补"的思路解决组件实例定位难题。读完你将理解:data-oid是什么、AST 索引如何构建、AstManager如何在运行时做 DOM 树与 AST 树的匹配,以及这些代码分别在仓库中的哪个位置。
问题的本质:DOM 树与 AST 树都只有"一半"信息
可视化编辑器的前端画布本质上是一个 iframe 中运行的 web 应用。编辑器能拿到的是:
- DOM 树:浏览器渲染出来的真实节点结构,包含每个元素的位置、样式、尺寸——但它不包含"实例"信息。当一个组件被复用时(比如
<Child/>在多个地方使用),渲染出来的 DOM 只是组件内部 JSX 的展开结果,你看不出这个<div>到底来自哪一次组件调用; - AST 树:由源码解析得到的语法树,包含完整的组件定义、JSX 标签、行号列号——但它不包含"树"信息,即不知道某个 JSX 元素在渲染时处于 DOM 树的哪一层、它的父元素是谁。
正如 README 所言:"The DOM tree does not have instance information and the AST does not have the tree information." 两份数据各自缺失一块,但两者的信息恰好互补:DOM 有树结构但没有实例来源,AST 有源码位置但没有渲染层级。Onlook 的做法是用一种"映射 + 上下求索"的算法把两者缝合起来。
从data-oid说起:连接 DOM 与源码的锚点
算法能成立的前提是每个元素身上都有唯一标识。在 Onlook 的代码索引体系中,这个标识就是 JSX 元素上的data-oid属性。在源码 packages/parser/src/template-node/map.ts 中,遍历 AST 时会读取每个 JSX 元素已有的 oid:
const existingOid = getExistingOid(path.node.openingElement.attributes); if (!existingOid) { return; } const oid = existingOid.value;也就是说,createTemplateNodeMap只为那些已经带data-oid的元素建立索引,并将其作为Map<string, TemplateNode>的 key(oid 指向对应的模板节点)。渲染时,框架层会把这些属性注入 DOM,于是画布中的每个 DOM 节点上都带有data-oid,运行时就可以用document.querySelector('[data-oid="..."]')之类的方式把 DOM 节点与索引条目一一对应起来。
算法总览:三步定位组件实例
README 给出了算法的主干,用Child与Parent两个组件演示:
// child.jsx export function Child () { return <div>// parent.jsx import { Child } from './child.jsx' export function Parent () { return ( <div><div>{ "parent": ["parent.jsx", "start and end location", "Parent"], "instance": ["parent.jsx", "start and end location", "Child"], "child": ["child.jsx", "start and end location", "Child"] }关键点:instance这个 oid 同样在索引里,它指向 parent.jsx 中<Child>export interface JsxElementMetadata extends TemplateNode { oid: string; code: string; }
索引的构建在rebuildIndex()中完成(code-fs.ts):列出所有 JSX 文件,按每批 10 个文件(BATCH_SIZE = 10)并发解析,对每个文件调用getAstFromContent得到 AST,再用createTemplateNodeMap产出oid → TemplateNode映射,最后把每个节点的源码片段(getContentFromTemplateNode按起止行列号截取)连同 oid 一起写入内存索引,并按projectId/branchId → oid → metadata的键做静态内存缓存与并发加载去重(index-cache.ts)。
第二步:沿 DOM 树下行,识别"实例"节点
遍历 DOM 树:
<div>interface LayerMetadata { rootNode: LayerNode; domIdToLayerNode: Map<string, LayerNode>; }它提供
domId → LayerNode的映射读写(getLayerNode/setMetadata/addNewMapping),并暴露layers/filteredLayers(后者会按当前选中的 frame 过滤)供图层面板使用。AstManager的get mappings()直接返回这个管理器。入口:
setMapRoot与updateMap每当帧初始化或 DOM 更新时,引擎把新的层级映射喂给
AstManager:setMapRoot(frameId, rootNode, layerMap):保存根节点与映射后,对整个树执行processNode;updateMap(frameId, newMap, domId):合并新增映射,并从指定domId对应的节点开始重新处理。
processNode用一个显式栈做 DFS 遍历(index.ts),对每个节点回调processNodeForMap。核心:
processNodeForMap与findNodeInstanceprocessNodeForMap(index.ts)对每个节点:- 要求节点有
oid,否则跳过; - 通过
editorEngine.frames.get(frameId)与branches.getBranchDataById(...)拿到当前分支的codeEditor; - 调用
codeEditor.getJsxElementMetadata(node.oid)查索引,得到该 oid 的元数据; - 从元数据读取
dynamicType(动态类型)与coreElementType(核心元素类型,如组件根节点、<body>标签),同步写回LayerNode并通知视图层frameData.view.setElementType(...); - 无论是否特殊类型,最终都会进入
findNodeInstance做实例定位。
findNodeInstance(index.ts)正是 README 算法第二步与第三步的落地:- 跳过
<body>标签;节点必须有父节点且父节点必须在映射中; - 查父节点 oid 的元数据,比较
parentMetadata.component !== metadata.component; - 若组件名不同,说明本节点是父组件使用的一个实例:收集父节点下所有 oid 相同的子节点
childrenWithSameOid,找到当前domId在其中的下标index; - 调用
getTemplateNodeChild(parentMetadata.code, metadata, index)(来自 packages/parser/src/template-node/map.ts),在父组件源码中按 index + 组件名匹配实例; - 命中后把
instanceId、component写回LayerNode,并通过updateElementInstance同步到视图; - 若未命中(例如实例隔着多层组件),则把父节点当作"新的原节点",向上递归继续查找(index.ts)——这正是"递归向上找实例"的实现,覆盖了组件多层嵌套的场景。
getTemplateNodeChild:第三步的匹配逻辑对应 README 第三步的代码在 map.ts。它拿到父组件源码后重新
getAstFromContent解析,然后traverse遍历所有JSXElement:const childName = (node.openingElement.name as T.JSXIdentifier).name; if (childName === child.component) { const instanceId = getOidFromJsxElement(node.openingElement); if (instanceId) { res = { instanceId, component: child.component }; } if (currentIndex === index || index === -1) { path.stop(); } currentIndex++; }即:只统计与目标组件同名的 JSX 子元素,用
currentIndex数到第index个即命中(index === -1表示不限定位置)。命中后返回该 JSX 元素自身的 oid——也就是data-oid="instance"那个实例标识。组件名的来源:
componentStackREADME 提到"组件名是 JSX 标签名或最近一个函数名"。在 map.ts 中,遍历 AST 时维护一个
componentStack:进入FunctionDeclaration、ClassDeclaration、VariableDeclaration(箭头函数组件)时压入声明名,退出时弹出;同时维护dynamicTypeStack记录节点是否处于map数组、三元/逻辑表达式等动态渲染上下文。最终在 helpers.ts 中:const component = componentStack.length > 0 ? componentStack[componentStack.length - 1] : null;取栈顶作为该 JSX 元素所属组件。动态上下文与组件根/
<body>标记则分别由getDynamicTypeInfo与getCoreElementInfo(map.ts)计算。完整调用链:一次画布点击的幕后流程
把上面所有部分串起来,当用户在画布上选中"Hi I'm Child"文本所在节点时,整个定位链路大致是:
DOM 节点 (data-oid="child") → AstManager.processNodeForMap(frameId, node) → branchData.codeEditor.getJsxElementMetadata("child") // 索引查询:child.jsx / Child → findNodeInstance: 父节点 contenteditable="false">【免费下载链接】onlookThe Cursor for Designers • An Open-Source AI-First Design tool • Visually build, style, and edit your React App with AI项目地址: https://gitcode.com/GitHub_Trending/on/onlook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考