查看原文
其他

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

前端大全 2022-09-24

The following article is from ELab团队 Author Elab.zhoulixiang


Meta

摘要

本文以一个点击 vue 页面元素跳转到对应 vscode 代码的 loader 和 plugin 开发实战,讲述 webpack loader 和 plugin 开发的简单入门。

观众收益

通过本文,你可以对 webpack 的 loader 和 plugin 有一个更清晰的认知,以及如何开发一个 loader 和 plugin,同时也穿插了一些 vue、css、node 方面的一些相关知识,扩充你的知识面。

效果

先上效果:

源码仓库

  • https://github.com/zh-lx/vnode-loader
  • https://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/

整体思路

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

实现过程

实现 vnode -loader

调试

借助 loader-runner 调试

我们在开发 loader 的过程中,往往需要打断点或者打印部分信息来进行调试,但是如果每次都启动 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": ["<node_internals>/**"],

      "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<any>, 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-loaderstyle-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:当前节点的 ast
  • source:当前节点对应的源码字符串
  • 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 的本地服务接收 fileline 和 column 三个参数,当收到请求时,通过从 launchEditor 唤醒 vscode 并打开对应的代码位置。

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

launchEditor 是直接引用的 react 封装好的 launchEditor.js[8] 文件(将里面 REACT_EDITOR 改为 VUE_EDITOR,方便后面配合 .env.local 使用),它本质上是通过 node 提供的 child_process 模块,识别系统中运行中的编辑器集成并自动打开,通过接收fileline 和 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 中,无需手动添加):

<div id="_vc-control-suspension" draggable="true">V</div>

我们用一个 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 += ` <span class="_vc-node-class-name">.${item}</span>`;

  });

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

  coverInfoDom.innerHTML = `<div><span class="_vc-node-name">${node}</span>${classListSpans}<div/><div>${file}</div>`;

}

点击遮罩层发送请求

在 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('</body>', `${code}\n</body>`);

          }

        );

      });

    });

  }

}



export = TrackCodePlugin;

接入流程

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

  1. 安装vnode-loadervnode-plugin:
yarn add vnode-loader vnode-plugin -D
  1. 修改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());

    }

  }

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

VUE_EDITOR=code
  1. 在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/


- EOF -

推荐阅读  点击标题可跳转

1、WebStorm 和 VsCode 的结合体来了!

2、史上最全,用 60+ VSCode 插件,打造最强编辑器

3、Webpack5 核心打包原理全流程解析,看这一篇就够了


觉得本文对你有帮助?请分享给更多人

推荐关注「前端大全」,提升前端技能

点赞和在看就是最大的支持❤️

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

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