引言
前端coder在刚接触一个新项目时是十分迷茫的,修改一个小 bug 可能要从路由结构入手逐级查找。 LocatorJS 提供了一种更便捷的方式,你只需要按住预设的快捷键选中元素点击,就可以快速打开本地编辑器中的代码,是不是非常神奇?
安装
访问 google 商店进行插件安装 地址
用法
本文以 MacOS 系统为例, Win系统可以用 Control 键替代 options键使用
LocatorJS 是 Chrome 浏览器的一个扩展程序,使用很便捷,只需要进行下面的三个步骤:
- 运行一个本地项目(本文以 LocatorJS源码 的 React 项目为例)
- 打开项目访问本地链接(例如:http://localhost:3348 )
- 按住键盘的 option 键(win系统是 control)后选中某一个元素并点击
这时候,就会跳出一个是否打开的提示,点击 "打开Visual Studio Code" 后 元素所在的本地代码就会通过你的 VsCode
(或者其他编辑器) 打开。是不是很神奇,那么它是怎么实现的呢?
原理解读
解读 Chrome 扩展程序,我们先打开 apps/extension/src/pages 路径,可以看到如下几个文件夹:
● Background 是放置后台代码的文件夹,本插件不涉及
● ClientUI 这里只有一行,引入了 @locator/runtime(本插件的核心代码)
● Content 放着插件与浏览器内容页面的代码,与页面代码一起执行
● Popup 文件夹下是点击浏览器插件图标弹出层的代码
4.1 解读 Content/index.ts
Content/index.ts 中最重要的代码是 injectScript
方法,主要做了两件事情,一个是创建了 Script 标签执行了 hook.bundle.js ,另一个是将 client.bundle.js 赋值给了 document.documentElement.dataset.locatorClientUrl
(通过 Dom 传值),其余代码是一些监听事件
ts
function injectScript() {
const script = document.createElement('script');
// script.textContent = code.default;
script.src = browser.runtime.getURL('/hook.bundle.js');
document.documentElement.dataset.locatorClientUrl =
browser.runtime.getURL('/client.bundle.js');
// This script runs before the <head> element is created,
// so we add the script to <html> instead.
if (document.documentElement) {
document.documentElement.appendChild(script);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
}
}
4.2 解读 hook.bundle.js
hook.bundle.js 是 hook 文件夹下的 index文件打包后的产物,因此我们去·看 apps/extension/src/pages/hook/index.ts 即可
ts
import { installReactDevtoolsHook } from '@locator/react-devtools-hook';
import { insertRuntimeScript } from './insertRuntimeScript';
installReactDevtoolsHook();
insertRuntimeScript();
● installReactDevtoolsHook 会确保你的 react devtools扩展已安装 (没安装就install一个,猜测是仅涉及使用 API 的轻量版(笔者未深究))
● insertRuntimeScript 会对页面生命周期做一个监听,尝试加载 LocatorJS 的 runtime
组件, 在 insertRuntimeScript()
中,看到了这两行:
ts
const locatorClientUrl = document.documentElement.dataset.locatorClientUrl;
delete document.documentElement.dataset.locatorClientUrl;
这个 locatorClientUrl
就是之前在 Content/index.ts
里传值的那个 client.bundle.js
,这里笔者简单说下,在尝试加载插件的方法 tryToInsertScript()
第一行判断如下:
ts
if (!locatorClientUrl) {
return 'Locator client url not found';
}
这行判断其实已经可以推测出 client.bundle.js
的重要性了,它加载失败,整个插件直接返回错误信息了。 回过头来看向 ClientUI 文件夹下的 index.tsx 文件:
ts
import '@locator/runtime';
至此,我们已经完成了 locatorJs
的加载逻辑推导,下一步我们讲揭开"定位器"的神秘面纱...
4.3 解读核心代码 runtime 模块
打开 packages/runtime/src/index.ts 文件
在这里我们看到不论是本地加载 runtime,还是浏览器加载扩展的方式都会去执行 initRuntime
initRuntime.ts
packages/runtime/src/initRuntime.ts
的initRuntime
这个文件中声明了一些全局样式,并用 shadow dom 的方式进行了全局的样式隔离,我们关注下底部的这几行代码即可:
ts
// This weird import is needed because:
// SSR React (Next.js) breaks when importing any SolidJS compiled file, so the import has to be conditional
// Browser Extension breaks when importing with "import()"
// Vite breaks when importing with "require()"
if (typeof require !== "undefined") {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { initRender } = require("./components/Runtime");
initRender(layer, adapter, targets || allTargets);
} else {
import("./components/Runtime").then(({ initRender }) => {
initRender(layer, adapter, targets || allTargets);
});
}
兼容了一下服务端渲染和 SolidJs 的引入方式,引入相对路径下的 ./components/Runtime
核心组件 Runtime.tsx
packages/runtime/src/components/Runtime.tsx
抽丝剥茧,我们终于找到了它的核心组件 Runtime
,这是一个使用 SolidJs
框架编写的组件,包含了我们选中元素时出现的红框样式,以及所有的事件:
我们重点关注点击事件 clickListener
,最后点击跳转的方法是 goToLinkProps
ts
export function goToLinkProps(
linkProps: LinkProps,
targets: Targets,
options: OptionsStore
) {
const link = buildLink(linkProps, targets, options);
window.open(link, options.getOptions().hrefTarget || HREF_TARGET);
}
采用逆推的方式,看 clickListener
事件里的 LinkProps
是怎样生成的:
ts
function clickListener(e: MouseEvent) {
...
const elInfo = getElementInfo(target, props.adapterId);
if (elInfo) {
const linkProps = elInfo.thisElement.link;
...
}
...
}
同样的方式,我们去看看 getElementInfo
怎么返回的(过程略过),我们以 react
的实现为例,打开 packages/runtime/src/adapters/react/reactAdapter.ts
, 查看 getElementInfo
方法
ts
export function getElementInfo(found: HTMLElement): FullElementInfo | null {
const labels: LabelData[] = [];
const fiber = findFiberByHtmlElement(found, false);
if (fiber) {
...
const thisLabel = getFiberLabel(fiber, findDebugSource(fiber)?.source);
...
return {
thisElement: {
box: getFiberOwnBoundingBox(fiber) || found.getBoundingClientRect(),
...thisLabel,
},
...
};
}
return null;
}
前面 goToLinkProps
使用的是 thisElement.link
字段, thisLabel
又依赖于 fiber
字段,等等! 这不是我们 react
玩家的老朋友 fiber
吗,我们查看一下生成它的 findFiberByHtmlElement
方法
ts
export function findFiberByHtmlElement(
target: HTMLElement,
shouldHaveDebugSource: boolean
): Fiber | null {
const renderers = window.__REACT_DEVTOOLS_GLOBAL_HOOK__?.renderers;
const renderersValues = renderers?.values();
if (renderersValues) {
for (const renderer of Array.from(renderersValues) as Renderer[]) {
if (renderer.findFiberByHostInstance) {
const found = renderer.findFiberByHostInstance(target as any);
console.log('found', found)
if (found) {
if (shouldHaveDebugSource) {
return findDebugSource(found)?.fiber || null;
} else {
return found;
}
}
}
}
}
return null;
}
可以看到,这里是直接使用的 window
对象下的 __REACT_DEVTOOLS_GLOBAL_HOOK__
属性做的处理,我们先打印一下 fiber 查看下生成的结构
惊奇的发现 _debugSource 字段里竟然包含了点击元素所对应本地文件的路径
我们到 goToLinkProps 方法里打印一下跳转的路径发现果然一致,只是实际跳转的路径加上了 vscode://
开头,进行了协议跳转。
真相解读,_debugOwner 是怎么来的
一路砍瓜切菜终于要接近真相了,回顾代码我们其实只需要搞懂 window.REACT_DEVTOOLS_GLOBAL_HOOK 是怎么来的以及它做了什么,就可以收工了。
-
_debugOwner 怎么来的?
_debugOwner
是通过 window.REACT_DEVTOOLS_GLOBAL_HOOK 根据 HtmlElement 生成的 fiber 得来的, 它是 React Devtools 插件的全局变量 HOOK,这就是为什么hook.bundle.js
要确保安装了 React Devtools -
REACT_DEVTOOLS_GLOBAL_HOOK 做了什么
它是通过 @babel/plugin-transform-react-jsx-source 实现的,这个 plugin 可以在创建 fiber 的时候,将元素本地代码的位置信息保存下来,以
_debugSource
字段进行抛出
总结
LocatorJs 的 React 方案使用 React Devtools 扩展的全局 Hook,由 @babel/plugin-transform-react-jsx-source
plugin 将元素所在代码路径写入 fiber 对象当中,通过 HtmlElement 查找到相对应的 fiber,取得本地代码的路径,随即可实现定位代码并跳转的功能。
结语
本文粗略的讲解了 LocatorJs 在 React 框架的原理实现,算是抛砖引玉,供大家参考。
篇幅原因,略过很多细节,感兴趣的朋友建议看看源码,结合调试学习
我是饮东,欢迎点赞关注,江湖再见