查看原文
其他

code-inspector 源码浅析

子洋 子洋的摘星阁
2024-10-25

前言

code-inspector 是一个源代码定位插件,它可以通过点击页面上的 DOM 元素,自动启动 IDE 并将光标定位到对应 DOM 元素的源代码位置。

去年,一位同事曾向我推荐过这个插件,不过当时我已经非常熟悉项目目录结构了,所以没有觉得特别需要它。最近,社区的朋友又提起了code-inspector,正好我对其背后的实现机制感到好奇,遂深入研究了一番它的源码设计。

源码浅析

code-inspector 插件的实现过程中涉及到了大量的兼容适配,涵盖了跨平台、跨框架、跨编辑器等诸多方面。如果要一一细致分析,既不切实际也浪费时间,因此本文仅重点分析其核心机制。

实现原理

严格来讲 code-inspector 的实现原理和思路并不复杂,难点在于他考虑到了各种环境的兼容性。

我们本篇采用 vue + vite 环境进行讲解。

整体流程如下:

  • 首先 code-inspector 作为一个 vite / webpack 插件,在打包器收集依赖代码后,会将代码传递给插件,此时 code-inspector  可以拿到源码和文件路径。
  • 获得源码之后,插件使用 AST 解析库处理这些源码,将 DOM 元素的相关信息(如:文件路径、行号、列号)注入到每个 DOM 元素中。
  • 向客户端注入带有监听事件的 web component,用户通过触发这些事件来发起源码定位请求。
  • 利用 Node.js 启动一个 HTTP 服务,接收从浏览器端发送的请求。
  • 服务端收到请求后,通过 child_process.spawn 启动和控制 IDE,打开相应文件并定位到指定代码行。

目录设计

整个项目采用 monorepo 架构,源码都放在 packages 目录下

  • code-inspector-plugin 主要承担插件统一入口的作用,同时判断了是否是开发环境。
  • vite-pluginwebpack-plugin 主要实现了不同打包器的插件开发和适配。
  • core 是整个插件的核心逻辑,包含客户端逻辑和服务端逻辑。
.
├── LICENSE
├── README.md
├── assets ------------------------------- logo 文件
├── demos -------------------------------- 各个框架的 demo
├── docs --------------------------------- 文档目录
├── package.json 
├── packages ----------------------------- 源码实现
│   ├── code-inspector-plugin ------------ 插件入口
│   ├── core ----------------------------- 核心包: 服务端与客户端逻辑
│   ├── vite-plugin ---------------------- vite 插件
│   └── webpack-plugin ------------------- webpack 插件
└── pnpm-workspace.yaml

源码逻辑梳理

code-inspector-plugin 逻辑比较简单,只是判断了一下是否是环境和根据用户传入 bundler 类型调用不同的插件包,这里不再展开讲了,直接进行 vite-plugin 包的讲解。

以下是精简之后的 vite 插件代代码:

export function ViteCodeInspectorPlugin(options?: Options) {  
  const record: RecordInfo = {  
    port: 0,  
    entry: '',  
    nextJsEntry: '',  
    ssrEntry: '',  
  };  
  return {  
    name: PluginName,  
 // ... 省略 apply 逻辑
    async resolveId(id) {  
      // 返回客户端注入的虚拟模块 id
      if (id === ViteVirtualModule_Client) {  
        return `\0${ViteVirtualModule_Client}`;  
      }  
      return null;  
    },  
    load(id) {
   // 注入虚拟模块中的客户端代码
      if (id === `\0${ViteVirtualModule_Client}`) {  
        return getClientInjectCode(record.port, options);  
      }  
      return null;  
    },  
    async transform(code, id) {  
      if (id.match('node_modules')) {  
        return code;  
      }  
  
      // start server and inject client code to entry file  
      code = await getCodeWithWebComponent(options, id, code, record);  
  
      // ... 省略 jsx 与 svelte 等逻辑
  
      // vue  
      const isVue =  
        filePath.endsWith('.vue') &&  
        params.get('type') !== 'style' &&  
        params.get('raw') === null;  
      if (isVue) {  
        return transformCode({ content: code, filePath, fileType: 'vue' });  
      }  
    
      return code;  
    },  
  };  
}

虚拟模块

code-inspector 利用 vite 插件的虚拟模块添加了客户端的逻辑:load 函数 与 resolveId 部分

resolveId 钩子会在解析入口文件 和 遇到尚未加载解析的 id 时都会被调用,当遇到尚未加载的 id 时会调用 load 钩子。

load 钩子会返回一个虚拟模块代码,包含了整个客户端的逻辑。

transform 钩子每当解析到一个依赖文件时触发,同时中可以对源码进行修改,所以在这个阶段可以注入 import 虚拟模块的逻辑,当遇到没有加载模块时会触发 resolveId 钩子完成整个注入逻辑。

注入客户端代码

注入逻辑在 transform 中的 getCodeWithWebComponent 函数中。

export async function getCodeWithWebComponent(  
  options: CodeOptions,  
  file: string,  
  code: string,  
  record: RecordInfo  
{  
  // start server  
  await startServer(options, record);  
  //....省略其他逻辑
  recordNormalEntry(record, file);  
  //....省略其他逻辑
  
  // 注入 web component 组件代码  
  if (  
    (isJsTypeFile(file) &&  
      [record.entry, record.nextJsEntry, record.ssrEntry].includes(  
        getFilenameWithoutExt(file)  
      )) ||  
    file === AstroToolbarFile  
  ) {  
    if (options.bundler === 'vite') {  
      code = `import '${ViteVirtualModule_Client}';\n${code}`;  
    } else {  
      const clientCode = getClientInjectCode(record.port, {  
        ...(options || {}),  
      });  
      code = `${code}\n${clientCode}`;  
    }  
  }  
  return code;  
}

这个方法同时调用了 startServer 启用了 code-inspector 的后端接口服务

recordNormalEntry 会判断是否是入口文件,判断逻辑也很简单,直接通过判断是否是 js 文件和 record.entry 是否为空,当满足条件时将 record.entry 设置为这个文件名。

同样,底下的注入 web component 逻辑也是一样,判断是否是入口文件,当是入口文件时在文件头部加入 import '${ViteVirtualModule_Client}';\n${code} 使 vite 会加载虚拟模块完成 web component 的注入。

注入元素属性

transformCode 方法根据不同的框架进行了调用,我们这里直接看 transformVue 方法。

通过 @vue/compiler-domparse 方法将 vue 源码解析成 ast , 同时使用 transform 方法遍历所有 html 元素(包括 vue 组件 tag), 然后使用 MagicString 对源码进行修改:往元素上添加一个包含 文件路径、行号、列号等信息的属性。

import { parse, transform } from '@vue/compiler-dom';
//....省略其他导入

export function transformVue(content: string, filePath: string) {  
  const s = new MagicString(content);  
  
  const ast = parse(content, {  
    comments: true,  
  });  
  
  transform(ast, {  
    nodeTransforms: [  
      ((node: TemplateChildNode) => {  
        console.log(node)  
        if (  
          !node.loc.source.includes(PathName) &&  
          node.type === VueElementType &&  
          escapeTags.indexOf(node.tag.toLowerCase()) === -1  
        ) {  
          // 向 dom 上添加一个带有 filepath/row/column 的属性  
          const insertPosition = node.loc.start.offset + node.tag.length + 1;  
          const { line, column } = node.loc.start;  
          const addition = ` ${PathName}="${filePath}:${line}:${column}:${  
            node.tag  
          }"${node.props.length ? ' ' : ''}`;  
  
          s.prependLeft(insertPosition, addition);  
        }  
      }) as NodeTransform,  
    ],  
  });  
  
  return s.toString();  
}

启用服务

startServer 调用了 createServer 方法,同时判断了是否已经创建过了服务,避免重复创建。

export function createServer(callback: (port: number) => any, options?: CodeOptions) {  
  const server = http.createServer((req: any, res: any) => {  
    // 收到请求唤醒vscode  
    const params = new URLSearchParams(req.url.slice(1));  
    const file = decodeURIComponent(params.get('file'as string);  
    const line = Number(params.get('line'));  
    const column = Number(params.get('column'));  
    res.writeHead(200, {  
      'Access-Control-Allow-Origin''*',  
      'Access-Control-Allow-Methods''*',  
      'Access-Control-Allow-Headers''*',  
      'Access-Control-Allow-Private-Network''true',  
    });  
    res.end('ok');  
    // 调用 hooks    
    options?.hooks?.afterInspectRequest?.(options, { file, line, column });  
    // 打开 IDE    
    launchEditor(file, line, column, options?.editor, options?.openIn, options?.pathFormat);  
      
  });

上面 createServer 逻辑比较简单,通过 http.createServer 创建了一个服务,同时监听当前端口请求,当遇到请求时调用 lunchEditor 打开编辑器。

打开编辑器

这里就不看源码了,打开编辑器主要使用 nodejs 的 child_process.spawn 调用编辑器,同时根据编辑器的要求传入参数。

以下是 macOS 下打开 webstrom 并定位到指定位置的示例代码:

import child_process from 'child_process';

const webstormPath = '/Applications/WebStorm.app/Contents/MacOS/webstorm'
const line = '3'
const column = '10'
const filePath = '/Users/ziyang/.npmrc'

child_process.spawn(webstormPath, ['--line', line, '--column', column, filePath]);

需要注意的是,不同的平台(win, mac)路径和调用方式是不一样的,其次不同的编辑器参数也不一样,所以 code-inspector 做了很多兼容工作。

客户端逻辑

客户端主要是通过 lit 库实现了一个 web component,代码量比较大,我们就不细看了,这里主要看一个请求逻辑。

// 请求本地服务端,打开vscode  
trackCode = () => {  
  if (this.locate) {  
    const file = encodeURIComponent(this.element.path);  
    const url = `http://localhost:${this.port}/?file=${file}&line=${this.element.line}&column=${this.element.column}`;  
    // 通过img方式发送请求,防止类似企业微信侧边栏等内置浏览器拦截逻辑  
    const img = document.createElement('img');  
    img.src = url;  
  }  
  if (this.copy) {  
    const path = formatOpenPath(  
      this.element.path,  
      String(this.element.line),  
      String(this.element.column),  
      this.copy  
    );  
    this.copyToClipboard(path[0]);  
  }  
};

这里没有使用 xhr 或者 fetch 的方式请求,而是直接通过 img.src 的方式进行请求。

结语

通过学习 code-inspector 的源码,收获还是很多的。在这个过程中,我还特意研究了 pnpm 的 monorepo 配置、vite 插件开发流程、magic-string 库的使用等多个方面的技术。

当我准备本文时,发现原作者已在掘金发布了一篇功能介绍和原理解析的文章,里面的内容更加细致,是非常好的学习参考资料,链接放在文末。

相关连接

  • 原作者介绍+原理解析:https://juejin.cn/post/7326002010084311079
  • code-inspector 官网: https://inspector.fe-dev.cn/


修改于
继续滑动看下一个
子洋的摘星阁
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存