Skip to content

源码定位插件

背景

我接手了一个公司 8 个月前的项目,由于上一个 coder 任务工期较短,很多组件都没有进行拆分复用,都是 cv 写的,导致项目中有很多重复的组件,不同页面功能相同的组件比比皆是,导致维护变得很困难,尤其是组件定位。

那我们平时是如何定位到组件在项目中的位置的呢? 一般的流程是:打开页面所在的路由路径,去  router  配置文件去找对应的页面路由,全局搜索一些关键字进行定位。

但是对于页面中组件套组件,这样找起来可太痛苦了,所以想到一个办法来解决这个问题:借助构建功能来帮助我们定位源码位置。(思路来源于: https://inspector.fe-dev.cn/ )

过程大致如下:

  1. 构建阶段(借助 vite 插件)
    • 通过插件的 transform 钩子可以识别到 .vue 文件
    • 对 vue 文件的根节点添加上路径标记(采用自定义属性,例如 code-path: '/xxx/xx.vue'
    • 对路径进行压缩,好处有:
      • 文件体积优化(如果只是在开发环境下,不影响):减小构建产物体积、减小网络传输开销
      • 运行时性能提升:加速 dom 操作,可以提升 dom diff 效率
      • 安全性:原始路径可能包含服务器目录结构(如src/admin/internal/),压缩后混淆路径可降低源码结构暴露风险,增强安全性
  2. 编译阶段(借助 vue 的 sfc 模版编译)
    • vue 模版编译接收到修改后的代码,通过 ast 识别带有自定义属性的元素,找到当前文件的所有根节点(vue3 可能有多个根节点),节点上判断是否有自定义属性,如果有就添加给节点的 class 上新增类名:code-locate-path-flag:xxx
  3. 运行时阶段(控制台预览/油猴脚本可视化查看)
    • 在控制台预览就不建议对路径进行压缩:直接在控制台元素那部分可以直接选中元素进行路径查看
    • 油猴脚本:识别页面上所有带特定 class 的元素,将其他解码出源路径,点击 Inspect 按钮,高亮所有标识元素

具体实现

1、安装依赖

  • lz-string:代码压缩
  • @vue/compiler-sfc:模版编译

2、编写 vite 插件

创建 plugin/code-locate.ts 文件,目的:在 vue 文件的根节点添加自定义属性,属性值为压缩后的路径

ts
import LZString from 'lz-string'
import { Plugin } from 'vite'

import {
  AttributeNode,
  ElementNode,
  NodeTypes,
  TransformContext
} from '@vue/compiler-core'
import { parse } from '@vue/compiler-sfc'

const CUSTOM_ATTR = 'code-locate-path'

// vite插件,给每个页面的根节点(template下的一级节点)添加自定义属性code-locate-path
export function CodeLocatePlugin({
  isScript = true
}: {
  isScript: boolean
}): Plugin {
  return {
    name: 'vite-plugin-code-locate',
    enforce: 'pre',
    transform(code: string, id: string) {
      if (!id.endsWith('.vue')) {
        return null
      }
      const { template } = parse(code).descriptor
      /**
        - template?.ast
        - template?.attrs:<template> 标签本身的属性(如 lang、src)
        - template?.content:原始的模板代码字符串,即 <template> 标签内的原始内容
        - template?.lang:模板使用的语言(如 pug、html),取自 <template lang="...">
        - template?.loc:记录模板在原文件中的位置信息,用于错误提示或代码映射。
            - start:起始位置(行、列、偏移量)。
            - end:结束位置(行、列、偏移量)。
            - source:原始内容字符串。
        - template?.map:模板的代码映射,用于生成源代码映射。
        - template?.src:外部模板文件的路径,取自 <template src="...">
        - template?.type:节点类型,vnode的type属性
         */
      if (template && template.ast && template.ast.children) {
        // type: NodeTypes.ELEMENT; 1
        /**
         * <template>
         *    <div></div>
         * </template>
         * rootElms获取的是div节点
         */
        let rootElms = template.ast.children.filter((child) => {
          return child.type === 1 // 1是元素节点
        })
        if (rootElms.length > 0) {
          // 依次处理每个根节点
          rootElms.forEach((rootElm) => {
            // console.log('[🚀code-locate-plugin] 已经找到根元素:', rootElm.tag)
            const tagString = `<${rootElm.tag}`
            const insertIndex =
              rootElm.loc.source.indexOf(tagString) + tagString.length
            const compressedPath = isScript ? LZString.compressToBase64(id) : id
            // console.log('[🚀code-locate-plugin] 原始路径', id)
            // console.log('[🚀code-locate-plugin] 压缩路径', compressedPath)

            // 添加自定义属性(进行字符串切割,中间添加属性)
            // LZString.compressToBase64 路径压缩
            const newSource = `${rootElm.loc.source.slice(
              0,
              insertIndex
            )} ${CUSTOM_ATTR}="${compressedPath}"${rootElm.loc.source.slice(
              insertIndex
            )}`
            // 替换原始内容
            code = code.replace(rootElm.loc.source, newSource)
          })
        }
      }
      return code
    }
  }
}

3、对 vue 模版编译处理

目的:在 vue 编译阶段,识别到根组件的自定义路径属性,然后将这个自定义路径属性添加到  class  中,这一步其实主要是便于给油猴脚本抓取数据用的

为什么不在 vite 的时候进行标记?因为 vue3 的组件可能是多个根节点,这样可以利用 AST 进行递归添加 class 属性,比 vite 在构建时方便。

ts
// 在vue对sfc模版编译阶段进行处理,给组件添加class来标记路径
// 分两种类型节点:1、vue文件根节点(直接复用自定义属性中的值)2、文件其他同级根节点
export function CodeLocateNodeTransform(
  node: ElementNode, // 当前处理的ast节点
  context: TransformContext
): void {
  /**
   * NodeTypes:
   *    ROOT=0
   *    ELEMENT=1
   *    IF_BRANCH=10:v-if、v-else-if、v-else 所在的条件分支节点
   * */
  // context.parent:当前节点的父节点
  if (node.type === 1 && context.parent) {
    // 处理根节点和带v-if的父节点
    if ([0, 10].includes(context.parent.type)) {
      context.parent.children.forEach((child) => {
        if (child.type === 1) {
          const customAttr =
            child &&
            child.props &&
            child.props.find((item) => item.name === CUSTOM_ATTR)
          // 自定义属性值
          const customAttrValue =
            customAttr &&
            'value' in customAttr &&
            customAttr.value &&
            customAttr.value.content
          if (customAttrValue) {
            // 统一处理,不区分组件类型
            addClass(node, customAttrValue, 'class')
          }
        }
      })
    } else if ((context.parent as ElementNode).props) {
      // 给同级根节点添加class类名
      const parentMarkAttr = (context.parent as ElementNode).props.find(
        /**
         * item类型
         * {
            type: 6,
            name: 'code-locate-path',
            value: {
              type: 2,
              content: 'PQVQzgpgTmwDYEMB2AXAlsgngV2AEQjAGsUB7AB2HKlIBNsBjdALwXVKQFooiALTTrTQBzNCgRxOvbAFtkwMFAbAAbmggB3WGF6kUa2hFKr1G4AAVsAIzhodAFQTEAkiggyAdCuwQgA=',
              ...
            },
            ...
        */
        (item) => item.name === CUSTOM_ATTR
      )
      // 获取到自定义属性值
      const parentMarkAttrValue =
        parentMarkAttr &&
        (parentMarkAttr as AttributeNode).value &&
        (parentMarkAttr as AttributeNode).value!.content
      if (parentMarkAttrValue) {
        // 为子组件添加标记
        addClass(node, parentMarkAttrValue, 'class')
      }
    }
  }
}

// 用于给元素添加类名
// 有类名就添加,没有就创建
function addClass(
  node: ElementNode,
  customAttrValue: string,
  className: string // attr为class的
) {
  const prefix = 'code-locate-path-flag'
  const addInfo = `${prefix}:${customAttrValue}`
  const classNode =
    node.props &&
    (node.props.find(
      (prop) => prop.type === 6 && prop.name === className
    ) as AttributeNode)
  /**
   * classNode: {
      type: 6, // NodeTypes.ATTRIBUTE 节点属性
      name: 'class',,
        value: {
          type: 2,
          content: 'btn-text',
          loc: { start: [Object], end: [Object], source: '"btn-text"' }
        },
        loc: ...
    }
  */
  if (classNode) {
    classNode.value!.content += ` ${addInfo}`
  } else {
    // 没有class属性,去手动构造节点属性
    node.props.push({
      type: 6, // ATTRIBUTE
      name: className,
      nameLoc: node.loc,
      value: {
        type: 2, // TEXT
        content: addInfo,
        loc: node.loc
      },
      loc: node.loc
    })
  }
}

4、使用 vite 插件和 vue 模版编译处理函数

ts
import { defineConfig } from 'vite'
import { CodeLocateNodeTransform, CodeLocatePlugin } from './plugin/code-locate'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          nodeTransforms: [CodeLocateNodeTransform]
        }
      }
    }), // 提供 Vue 3 单文件组件支持
    CodeLocatePlugin({ isScript: false })
  ]
})

5、油猴脚本

这个文件内容还是很多的,就说一些核心功能:

1、元素识别与查找

这部分代码负责在 DOM 中搜索带有 code-locate-path-flag: 前缀的类名,这些类名包含了压缩过的源文件路径。

js
// 返回code-locate-path-flag:的class值
function getCssMarkClass(element) {
  const classList = element.classList
  for (let i = 0; i < classList.length; i++) {
    const className = classList[i]
    if (className.startsWith('code-locate-path-flag:')) {
      return className
    }
  }
  return ''
}
// 返回class中包含code-locate-path-flag:的元素
function getCssMarkClassElement(root) {
  const allElements = root.querySelectorAll('*')
  // 过滤出 classList 中包含以 code-locate-path-flag: 开头的类的元素
  return Array.from(allElements).filter(function (element) {
    return Array.from(element.classList).some(function (className) {
      return className.startsWith('code-locate-path-flag:')
    })
  })
}

2、组件层次结构收集

这个函数从点击的元素开始向上遍历 DOM 树,收集所有带有标记的父元素,构建组件层次结构。

js
// 函数:收集从顶层到当前元素的 code-locate-path 属性列表
function collectCssMarkHierarchy(element) {
  const cssMarkList = []
  while (element) {
    const cssClassName = getCssMarkClass(element)
    if (cssClassName) {
      cssMarkList.push({ element, originMark: cssClassName })
    }
    element = element.parentElement
  }
  return cssMarkList
}

3、路径解码与显示

从类名中提取压缩的路径部分,然后使用 LZString.decompressFromBase64 解码还原为实际文件路径。

js
// 处理源码路径部分代码
cssMarkList.forEach((item) => {
  const tag = item.element.tagName.toLowerCase()
  try {
    const encodedPath = item.originMark.substring(prefix.length)
    const filePath = LZString.decompressFromBase64(encodedPath)
    decodedPaths.push({ tag, filePath })
  } catch (e) {
    console.error('解码路径失败:', e)
  }
})