当前位置:首页 > 域名

基于 webpack 实现点击 vue 页面元素跳转到对应的 vscode 代码

观众收益

通过本文,基于击你可以对 webpack 的现点 loader 和 plugin 有一个更清晰的认知,以及如何开发一个 loader 和 plugin,页面元素同时也穿插了一些 vue、跳转css、到对代码node 方面的基于击一些相关知识,扩充你的现点知识面。

效果

先上效果:

源码仓库

https://github.com/zh-lx/vnode-loaderhttps://github.com/zh-lx/vnode-plugin

前置知识

由于是页面元素开发 loader 和 plugin,所以需要对 loader 和 plugin 的跳转作用及构成需要有一些简单的理解。

loader

作用

loader 是到对代码 webpack 用来将不同类型的文件转换为 webpack 可识别模块的工具。我们都知道 webpack 默认只支持 js 和 json 文件的基于击处理,通过 loader 我们可以将其他格式的现点文件转换为 js 格式,让 webpack 进行处理。页面元素除此之外,跳转我们也可以通过 loader 对文件的到对代码内容进行一定的加工和处理。

构成

loader 本质上就是导出一个 JavaScript 函数,webpack 会通过 loader runner[1] 会调用此函数,然后将上一个 loader 产生的结果或者资源文件传入进去。网站模板

example:

// 同步 loader

/

**

 * @param { string|Buffer} content 源文件的内容

 * @param { object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 数据

 * @param { any} [meta] meta 数据,可以是任何内容

 */

module.exports = function (content, map, meta) {

  return someSyncOperation(content);

};

// or 

module.exports = function (content, map, meta) {

  this.callback(null, someSyncOperation(content), map, meta);

  return; // 当调用 callback() 函数时,总是返回 undefined

};

// --------------------------------------------------------------------------

// 异步 loader

module.exports = function (content, map, meta) {

  var callback = this.async();

  someAsyncOperation(content, function (err, result) {

    if (err) return callback(err);

    callback(null, result, map, meta);

  });

};

// or 

module.exports = function (content, map, meta) {

  var callback = this.async();

  someAsyncOperation(content, function (err, result, sourceMaps, meta) {

    if (err) return callback(err);

    callback(null, result, sourceMaps, meta);

  });

};

参考 api

https://webpack.docschina.org/api/loaders/

plugin

作用

拓展 webpack 功能,提供一切 loader 无法完成的功能。

构成

一个 plugin 由以下部分组成:

导出一个 JavaScript 具名函数或 JavaScript 类。在插件函数的 prototype 上定义一个 ​​apply​​ 方法。指定一个绑定到 webpack 自身的事件钩子[2]。处理 webpack 内部实例的特定数据。功能完成后调用 webpack 提供的回调。// 一个 JavaScript 类

class MyExampleWebpackPlugin {

  // 在插件函数的 prototype 上定义一个 `apply` 方法,以 compiler 为参数。

  apply(compiler) {

    // 指定一个挂载到 webpack 自身的事件钩子。

    compiler.hooks.emit.tapAsync(

      MyExampleWebpackPlugin,

      (compilation, callback) => {

        console.log(这是一个示例插件!);

        console.log(

          这里表示了资源的单次构建的 `compilation` 对象:,

          compilation

        );

        // 用 webpack 提供的插件 API 处理构建过程

        compilation.addModule(/* ... */);

        callback();

      }

    );

  }

}

module.exports = MyExampleWebpackPlugin;

compiler 和 compliation

webpack plugin 开发中有两个重要的概念:compiler 和 compliation。

plugin 类中有一个 ​​apply​​ 方法,其接收 compiler 为参数, compiler[3] 在 webpack 构建之初就已经创建,云服务器并且贯穿 webpack 整个生命周期,其包含了 webpack 配置文件传递的所有选项,例如 loader、plugins 等信息。

compilation[4] 是到准备编译模块时,才会创建 compilation 对象。其包含了模块资源、编译生成资源以及变化的文件和被跟踪依赖的状态信息等等,以供插件工作时使用。如果我们在插件中需要完成一个自定义的编译过程,那么必然会用到这个对象。

参考 api

https://webpack.docschina.org/api/plugins/

整体思路

要做到点击元素能够跳转 vscode,首先需要某种手段打开 vscode,借助一个 plugin 实现如下功能:打开 vscode:借助 react 封装的 launchEditor[5] 方法,可以识别各种编辑器并唤醒,原理是通过 node 的 child_process api 去启动 vscode点击元素时通知跳转:在本地启动一个 node server 服务,点击元素时发送一个请求,然后 node server 去触发跳转要能够跳转到 vscode 对应的代码行和列,需要知道点击的元素对应的源码位置,所以需要一个 loader,在编译上将源码的相关信息注入到 dom 上

实现过程

实现 vnode -loader

调试借助 loader-runner 调试

我们在开发 loader 的云南idc服务商过程中,往往需要打断点或者打印部分信息来进行调试,但是如果每次都启动 webpack,可能存在启动速度慢、项目文件太多需要过滤信息等诸多问题。这里我们可以借助前面提到的 loader runner[6] ,方便地进行调试。

​​loader-runner​​ 这个包中导出了一个名为 ​​runLoaders​​ 的方法,事实上 webpack 内部也是借助这个方法去运行各种 loader 的。它接收 4个参数:

resource:要解析的资源的绝对路径loaders:要使用的 loader 的绝对路径数组context:对 loader 附加的上下文readResource:读取资源的函数

在根目录下新建一个 ​​run-loader.js​​ 文件,填入如下内容,执行 ​​node ./run-loader​​ 指令即可运行 loader,并可以在 loader 源码中进行断点调试:

const {  runLoaders } = require(loader-runner);

const fs = require(fs);

const path = require(path);

runLoaders(

  {

    resource: path.resolve(__dirname, ./src/App.vue),

    loaders: [path.resolve(__dirname, ./node_modules/vnode-loader)],

    context: {

      minimize: true,

    },

    readResource: fs.readFile.bind(fs),

  },

  (err, res) => {

    if (err) {

      console.log(err);

      return;

    }

    console.log(res);

  }

);

在 vue-cli 中调试

由于我们是在 vue 项目中使用,所以为了配合 vue 的真实环境,我们通过 vue-cli 的webpack 配置来调试 loader。

新建 ​​.vscode/launch.json​​ 文件,添加如下内容,下面的内容指定了在 5858 端口,执行 ​​npm run debug​​ 命令启动一个 node 服务:

{

  // 使用 IntelliSense 了解相关属性。

  // 悬停以查看现有属性的描述。

  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387

  "version": "0.2.0",

  "configurations": [

    {

      "type": "node",

      "request": "launch",

      "name": "debug",

      "skipFiles": ["/**"],

      "runtimeExecutable": "npm",

      "runtimeArgs": ["run", "debug"],

      "port": 5858

    }

  ]

}

在 ​​package.json​​ 文件中添加如下命令:

{

  "name": "loader-test",

  "version": "0.1.0",

  "private": true,

  "scripts": {

    "serve": "vue-cli-service serve",

    "build": "vue-cli-service build",

    "lint": "vue-cli-service lint",

    "debug": "node --inspect-brk=5858 ./node_modules/@vue/cli-service/bin/vue-cli-service.js serve"

  },

  // ...

}

点击 vscode 的 debug,即可进行调试:

解析 template

我们要往 dom 上注入源码信息,所以首先需要获取 .vue 文件的 dom 结构。那我们就需要对 template 的部分进行解析,这里我们可以借助 @vue/compiler-sfc[7] 这个包去解析 .vue 文件。

import {  parse } from @vue/compiler-sfc;

import {  LoaderContext } from webpack;

import {  getInjectContent } from ./inject-ast;

/

**

 * @description inject line、column and path to VNode when webpack compiling .vue file

 * @type webpack.loader.Loader

 */

function TrackCodeLoader(this: LoaderContext, content: string) {

  const filePath = this.resourcePath; // 当前文件的绝对路径

  let params = new URLSearchParams(this.resource);

  if (params.get(type) === template) {

    const vueParserContent = parse(content); // vue文件parse后的内容

    const domAst = vueParserContent.descriptor.template.ast; // template开始的dom ast结构

    const templateSource = domAst.loc.source; // template部分的原字符串

    const newTemplateSource = getInjectContent(

      domAst,

      templateSource,

      filePath

    ); // 注入后的template部分字符串

    const newContent = content.replace(templateSource, newTemplateSource);

    return newContent;

  } else {

    return content;

  }

}

export = TrackCodeLoader;

我们对上面部分代码进行分析,首先我们导出了一个 ​​TrackCodeLoader​​ 函数,这是 vnode -loader 的入口函数,通过 ​​this​​ 对象,我们能拿到诸多 webpack 及源代码的相关信息。

看这一句代码:​​params.get(type) === template​​ ,对于 .vue 文件,vue-loader 会将其分解为多部分区交给其实现的解析器解析。例如现在有一个文件路径为 ​​/system/project/app.vue​​,vue-loader 会将其解析为三部分:

​​/system/project/app.vue?type=template&xxx​​:这部分作为 html 部分,将来由 vue 内置的 ​​vue-template-es2015-compiler​​ 去解析为 dom​​/system/project/app.vue?type=script&lang=js&xxx​​:这部分作为 js 部分,将来交给匹配了 webpack 配置的 ​​/.js$/​​ rule 的 ​​babel-loader​​ 等 loader 去处理​​/system/project/app.vue?type=style&lang=css&xxx​​:这部分作为 css,将来交给匹配了 webpack 配置的 ​​/.css$/​​ rule 的 ​​css-loader​​、​​style-loader​​ 等去处理

所以一个 vue 文件,实际上会多次经过我们这个自定义的 loader,而我们只需要对其 url 中 type 参数为 ​​template​​ 的那一次进行处理,因为只有此次的 template 部分代码最终会被有效处理为 dom。

然后我们将 .vue 文件的内容作为参数传给 ​​@vue/compiler-sfc​​ 中导出的 parse 函数,我们得到了一个对象,对象中有一个 descriptor 属性,我们通过打一个断点可以看到,里面包含了 template、script、css 等几部分的 ast 解析结果:

现在我们已经获取到了 template 结构的 ast,我们要做的就是将 .vue 文件的 content,其中的 ​​domAst.loc.source​​ 部分替换为注入了源码信息的 template 字符串。

template 的 ast 是一个树状结构,表示当前的 dom 节点,和我们注入源码信息有关的主要是以下几个属性:

type:当前的节点类型,为 1 时表示标签节点,为 2 时表示文本节点,为 6 时表示标签属性……这里我们只需要对标签节点进行注入,也就是说只需要对 ​​type === 1​​ 的 ast 节点进行处理。loc:当前节点在 vscode 中的信息,包括节点中在 vscode 中的源码信息、在 vscode 中起始和结束的行、列以及长度等。这一部分就是我们要注入的信息childern:对子节点进行递归处理注入源码信息

我们创建一个 ​​getInjectContent​​ 方法,将源码信息注入到 dom 中,​​getInjectContent​​ 接受三个参数:

ast:当前节点的 astsource:当前节点对应的源码字符串filePath:当前文件的绝对路径

在 dom 标签上注入行、列、标签名和文件路径等相关的信息:

export function getInjectContent(

  ast: ElementNode,

  source: string,

  filePath: string

) {

  // type为1是为标签节点

  if (ast?.type === 1) {

    // 递归处理子节点

    if (ast.children && ast.children.length) {

      // 从最后一个子节点开始处理,防止同一行多节点影响前面节点的代码位置

      for (let i = ast.children.length - 1; i >= 0; i--) {

        const node = ast.children[i] as ElementNode;

        source = getInjectContent(node, source, filePath);

      }

    }

    const codeLines = source.split(\n); // 把行以\n划分方便注入

    const line = ast.loc.start.line; // 当前节点起始行

    const column = ast.loc.start.column; // 当前节点起始列

    const columnToInject = column + ast.tag.length; // 要注入信息的列(标签名后空一格)

    const targetLine = codeLines[line - 1]; // 要注入信息的行

    const nodeName = ast.tag;

    const newLine =

      targetLine.slice(0, columnToInject) +

      ` ${ InjectLineName}="${ line}" ${ InjectColumnName}="${ column}" ${ InjectPathName}="${ filePath}" ${ InjectNodeName}="${ nodeName}"` +

      targetLine.slice(columnToInject);

    codeLines[line - 1] = newLine; // 替换注入后的内容

    source = codeLines.join(\n);

  }

  return source;

}

实现 vnode-plugin

node server 唤醒 vscode

我们通过 ​​http.createServer​​ ,创建一个本地的 node 服务,然后通过 ​​protfinder​​ 这个包,从 4000 端口开始寻找一个可用的接口启动服务。node 的本地服务接收 ​​file​​、​​line​​ 和 ​​column​​ 三个参数,当收到请求时,通过从 ​​launchEditor​​ 唤醒 vscode 并打开对应的代码位置。

值得注意的是 webpack 每次编译都会重新生成一个 compliation 对象,都会运行一次 plugin,所以我们需要通过一个 ​​started​​ 标识记录一下当前是否有服务已经启动,防止服务启动多次。

​​launchEditor​​ 是直接引用的 react 封装好的 launchEditor.js[8] 文件(将里面 ​​REACT_EDITOR​​ 改为 ​​VUE_EDITOR​​,方便后面配合 ​​.env.local​​ 使用),它本质上是通过 node 提供的 ​​child_process​​ 模块,识别系统中运行中的编辑器集成并自动打开,通过接收​​file​​、​​line​​ 和 ​​column​​ 三个参数,可以打开具体的文件位置及将光标定位到相应的行和列。

此部分代码如下:

// 启动本地接口,访问时唤起vscode

import http from http;

import portFinder from portfinder;

import launchEditor from ./launch-editor;

let started = false;

export = function StartServer(callback: Function) {

  if (started) {

    return;

  }

  started = true;

  const server = http.createServer((req, res) => {

    // 收到请求唤醒vscode

    const params = new URLSearchParams(req.url.slice(1));

    const file = params.get(file);

    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:

        Content-Type,XFILENAME,XFILECATEGORY,XFILESIZE,X-URL-PATH,x-access-token,

    });

    res.end(ok);

    launchEditor(file, line, column);

  });

  // 寻找可用接口

  portFinder.getPort({  port: 4000 }, (err: Error, port: number) => {

    if (err) {

      throw err;

    }

    server.listen(port, () => {

      callback(port);

    });

  });

};

控制功能的开关

我们需要能够控制点击元素跳转 vscode 这个功能的开启和关闭,控制开关的实现方式有很多,例如按键组合触发、悬浮窗……此处采用悬浮窗的控制方式。

在页面中添加一个固定定位的悬浮窗,我在插件实现的悬浮窗是可以拖拽移动的,拖拽和样式部分的代码不是重点,因为不在这里详细展开了,有兴趣的同学可以看源码了解。

悬浮窗的 dom 部分如下:(此部分代码后面都会通过 vnode-plugin 自动注入到 html 中,无需手动添加):

V

我们用一个 ​​is_tracking​​ 变量作为功能是否打开的标识,当点击悬浮窗时,切换 ​​is_tracking​​ 的值,从而控制功能的开关(后面会提到):

// 功能是否开启

let is_tracking = false;

const suspension_control = document.getElementById(

  _vc-control-suspension

);

suspension_control.addEventListener(click, function (e) {

  if (!has_control_be_moved) {

    clickControl(e);

  } else {

    has_control_be_moved = false;

  }

});

// 功能开关

function clickControl(e) {

  let dom = e.target as HTMLElement;

  if (dom.id === _vc-control-suspension) {

    if (is_tracking) {

      is_tracking = false;

      dom.style.backgroundColor = gray;

    } else {

      is_tracking = true;

      dom.style.backgroundColor = lightgreen;

    }

  }

}

移动鼠标时显示 dom 信息

我们在全局添加一个 ​​fixed​​ 定位的遮罩层,然后添加一个 ​​mousemove​​ 监听事件。

鼠标移动时,如果 ​​is_tracking​​ 为 ​​true​​,表示功能打开,通过 ​​e.path​​ ,我们可以找到鼠标悬浮的 dom 冒泡数组。取第一个注入了 ​​_vc-path​​ 属性的 dom,然后通过 ​​setCover​​ 方法在 dom 上展示遮罩层。

​​setCover​​ 方法主要是将遮罩层定位到目标 dom 上,并设置遮罩层的大小和目标 dom 一样大,以及展示目标 dom 的标签、绝对路径等信息(类似 Chrome 调试时查看 dom 的效果)。

此部分代码如下:

// 鼠标移动时

window.addEventListener(mousemove, function (e) {

  if (is_tracking) {

    const nodePath = (e as any).path;

    let targetNode;

    if (nodePath[0].id === _vc-control-suspension) {

      resetCover();

    }

    // 寻找第一个有_vc-path属性的元素

    for (let i = 0; i < nodePath.length; i++) {

      const node = nodePath[i];

      if (node.hasAttribute && node.hasAttribute(__FILE__)) {

        targetNode = node;

        break;

      }

    }

    if (targetNode) {

      setCover(targetNode);

    }

  }

});

// 鼠标移到有对应信息组件时,显示遮罩层

function setCover(targetNode) {

  const coverDom = document.querySelector(#__COVER__) as HTMLElement;

  const targetLocation = targetNode.getBoundingClientRect();

  const browserHeight = document.documentElement.clientHeight; // 浏览器高度

  const browserWidth = document.documentElement.clientWidth; // 浏览器宽度

  coverDom.style.top = `${ targetLocation.top}px`;

  coverDom.style.left = `${ targetLocation.left}px`;

  coverDom.style.width = `${ targetLocation.width}px`;

  coverDom.style.height = `${ targetLocation.height}px`;

  const bottom = browserHeight - targetLocation.top - targetLocation.height; // 距浏览器视口底部距离

  const right = browserWidth - targetLocation.left - targetLocation.width; // 距浏览器右边距离

  const file = targetNode.getAttribute(_vs-path);

  const node = targetNode.getAttribute(_vc-node);

  const coverInfoDom = document.querySelector(#__COVERINFO__) as HTMLElement;

  const classInfoVertical =

    targetLocation.top > bottom

      ? targetLocation.top < 100

        ? _vc-top-inner-info

        : _vc-top-info

      : bottom < 100

      ? _vc-bottom-inner-info

      : _vc-bottom-info;

  const classInfoHorizon =

    targetLocation.left >= right ? _vc-left-info : _vc-right-info;

  const classList = targetNode.classList;

  let classListSpans = ;

  classList.forEach((item) => {

    classListSpans += ` .${ item}`;

  });

  coverInfoDom.className = `_vc-cover-info ${ classInfoHorizon} ${ classInfoVertical}`;

  coverInfoDom.innerHTML = `

${ node}${ classListSpans} ${ file}`;

}

点击遮罩层发送请求

在 window 上添加点击事件设置为捕获阶段(如果是冒泡阶段,会率先发生元素绑定的点击事件,影响我们的点击)。如果 ​​is_tracking​​ 为 true,则根据 ​​e.path​​ 找到第一个注入了源码信息的目标元素,调用 ​​trackCode​​ 方法发送请求唤醒 vscode。同时要通过 ​​e.stopPropagation()​​ 和 ​​e.preventDefault()​​ 阻止冒泡事件和元素默认的点击事件的发生。

​​trackCode​​ 中主要是拿到目标 dom 上注入的源码信息,然后解析为参数,去请求我们前面启动的 node server 服务,node server 会通过 ​​launchEditor​​ 去打开 vscode。

此部分代码如下:

// 按下对应功能键点击页面时,在捕获阶段

window.addEventListener(

  click,

  function (e) {

    if (is_tracking) {

      const nodePath = (e as any).path;

      let targetNode;

      // 寻找第一个有_vc-path属性的元素

      for (let i = 0; i < nodePath.length; i++) {

        const node = nodePath[i];

        if (node.hasAttribute && node.hasAttribute(__FILE__)) {

          targetNode = node;

          break;

        }

      }

      if (targetNode) {

        // 阻止冒泡

        e.stopPropagation();

        // 阻止默认事件

        e.preventDefault();

        // 唤醒 vscode

        trackCode(targetNode);

      }

    }

  },

  true

);

// 请求本地服务端,打开vscode

function trackCode(targetNode) {

  const file = targetNode.getAttribute(__FILE__);

  const line = targetNode.getAttribute(__LINE__);

  const column = targetNode.getAttribute(__COLUMN__);

  const url = `http://localhost:__PORT__/?file=${ file}&line=${ line}&column=${ column}`;

  const xhr = new XMLHttpRequest();

  xhr.open(GET, url, true);

  xhr.send();

}

在 html 中注入代码

最后我们要将上面的代码作为注入到 html 中,​​html-webpack-plugin​​ 提供了一个 ​​htmlWebpackPluginAfterHtmlProcessing​​ hook,我们可以在这个 hook 中在 body 最底下注入我们的代码:

import startServer from ./server;

import injectCode from ./get-inject-code;

class TrackCodePlugin {

  apply(complier) {

    complier.hooks.compilation.tap(TrackCodePlugin, (compilation) => {

      startServer((port) => {

        const code = injectCode(port);

        compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tap(

          HtmlWebpackPlugin,

          (data) => {

            // html-webpack-plugin编译后的内容,注入代码

            data.html = data.html.replace(, `${ code}\n`);

          }

        );

      });

    });

  }

}

export = TrackCodePlugin;

接入流程

可以根据以下流程在自己的 vue3 项目中接入体验一下:

安装​​vnode-loader​​和​​vnode-plugin​​:yarn add vnode-loader vnode-plugin -D

修改​​vue.config.js​​,添加如下代码(一定要只用于开发环境):// ...other code

module.exports = {

  // ...other code

  chainWebpack: (config) => {

    // ...other code

    if (process.env.NODE_ENV === development) {

      const VNodePlugin = require(vnode-plugin);

      config.module

        .rule(vue)

        .test(/.vue$/)

        .use(vnode-loader)

        .loader(vnode-loader)

        .end();

      config.plugin(vnode-plugin).use(new VNodePlugin());

    }

  }

};

在项目根目录添加一个名为​​.env.local​​ 的文件,内容如下:# editor

VUE_EDITOR=code

在vscode执行​​Command + Shift + P​​ ,输入​​shell Command: Install code command in PATH​​并点击该命令:

显示如下弹窗表示成功:

性能

可以会有人担心插件会拖慢 webpack 打包编译的速度,经多次大项目对比测试,在使用该 loader 和plugin 的前后,webpack build和rebuild的速度几乎无差别,所以可以大胆接入。

总结

现在大家对 webpack 的 loader 和 plugin 开发应该有了一定的了解,借助自定义的 loader 和 plugin 确实能做许多超乎想象的事情(尤其是 plugin,很多时候只缺一个脑洞),大家可以发挥想象空间去编写一个自己的 loader 和 plugin,为项目开发提供助力。

参考

概念 | webpack 中文文档[9]

https://juejin.cn/post/6901466406823575560

参考资料

[1]loader runner: ​https://github.com/webpack/loader-runner​

[2]事件钩子: ​https://webpack.docschina.org/api/compiler-hooks/​

[3]compiler: ​https://webpack.docschina.org/api/node/#compiler-instance​

[4]compilation: ​https://webpack.docschina.org/api/compilation-hooks/​

[5]launchEditor: ​https://github.com/facebook/create-react-app/blob/main/packages/react-dev-utils/launchEditor.js​

[6]loader runner: ​https://github.com/webpack/loader-runner​

[7]@vue/compiler-sfc: ​https://github.com/vuejs/vue-next/tree/master/packages/compiler-sfc#readme​

[8]launchEditor.js: ​https://github.com/facebook/create-react-app/blob/main/packages/react-dev-utils/launchEditor.js​

[9]概念 | webpack 中文文档: ​​https://webpack.docschina.org/concepts/​​

分享到:

滇ICP备2023006006号-16