webpack loader 与plugin 开发实战 —— 点击 vue 页面元素跳转到对应的 vscode 代码
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/
整体思路
要做到点击元素能够跳转 vscode,首先需要某种手段打开 vscode,借助一个 plugin 实现如下功能:
打开 vscode:借助 react 封装的 launchEditor[5] 方法,可以识别各种编辑器并唤醒,原理是通过 node 的 child_process api 去启动 vscode 点击元素时通知跳转:在本地启动一个 node server 服务,点击元素时发送一个请求,然后 node server 去触发跳转
要能够跳转到 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": ["/**" ],
"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:当前节点的 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 的本地服务接收 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 中,无需手动添加):
"_vc-control-suspension" draggable="true">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 += ` "_vc-node-class-name">.${item}`;
});
coverInfoDom.className = `_vc-cover-info ${classInfoHorizon} ${classInfoVertical}`;
coverInfoDom.innerHTML = `"_vc-node-name">${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('