初探webpack之编写loader
加载器是的核心之一,其用于将不同类型的文件转换为可识别的模块,即用于把模块原内容按照需求转换成新内容,用以加载非模块,通过配合扩展插件,在构建流程中的特定时机注入扩展逻辑来改变构建结果,从而完成一次完整的构建。
描述
是一个现代应用程序的静态模块打包器,当处理应用程序时,它会递归地构建一个依赖关系图,其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个。
使用作为前端构建工具通常可以做到以下几个方面的事情:
代码转换: 编译成、编译成等。
文件优化: 压缩、、代码,压缩合并图片等。
代码分割: 提取多个页面的公共代码、提取首屏不需要执行部分的代码让其异步加载。
模块合并: 在采用模块化的项目里会有很多个模块和文件,需要构建功能把模块分类合并成一个文件。
自动刷新: 监听本地源代码的变化,自动重新构建、刷新浏览器页面,通常叫做模块热替换。
代码校验: 在代码被提交到仓库前需要校验代码是否符合规范,以及单元测试是否通过。
自动发布: 更新完代码后,自动构建出线上发布代码并传输给发布系统。
对于来说,一切皆模块,而仅能处理出以及文件,因此如果要使用其他类型的文件,都需要转换成可识别的模块,即或模块。也就是说无论什么后缀的文件例如、、文件等等,都需要当作来使用,但是直接当作来使用肯定是不行的,因为这些文件并不符合的语法结构,所以就需需要来处理,帮助我们将一个非文件转换为文件,例如、、等等。
在这里编写一个简单的,设想一个简单的场景,在这里我们关注,从实例出发,在平时我们构建项目时都是通过编写文件来作为模块的,这种单文件组件的方式虽然比较清晰,但是如果一个组件比较复杂的话,就会导致整个文件相当大。当然中给我们提供了在文件中引用、的方式,但是这样用起来毕竟还是稍显麻烦,所以我们可以通过编写一个,在编写代码时将三部分即、、进行分离,之后在中将其合并,再我们编写的完成处理之后再交与去处理之后的事情。当然,关注点分离不等于文件类型分离,将一个单文件分成多个文件也只是对于代码编写过程中可读性的倾向问题,在这里我们重点关注的是编写一个简单的而不在于对于文件是否应该分离的探讨。文中涉及到的所有代码都在。
实现
搭建环境
初探webpack之从零搭建Vue开发环境 中搭建的简单开发环境,环境的相关的代码都在中的分支中,我们直接将其并安装。
git clone https://github.com/WindrunnerMax/webpack-simple-environment.git
git checkout webpack--vue-cli
yarn install --registry https://registry.npm.taobao.org/
之后便可以通过运行来查看效果,在这里我们先打印一下此时的目录结构。
webpack--vue-cli
├── dist
│ ├── static
│ │ └── vue-large.b022422b.png
│ ├── index.html
│ ├── index.js
│ └── index.js.LICENSE.txt
├── public
│ └── index.html
├── src
│ ├── common
│ │ └── styles.scss
│ ├── components
│ │ ├── tab-a.vue
│ │ └── tab-b.vue
│ ├── router
│ │ └── index.ts
│ ├── static
│ │ ├── vue-large.png
│ │ └── vue.jpg
│ ├── store
│ │ └── index.ts
│ ├── views
│ │ └── framework.vue
│ ├── App.vue
│ ├── index.ts
│ ├── main.ts
│ ├── sfc.d.ts
│ └── sum.ts
├── LICENSE
├── README.md
├── babel.config.js
├── package.json
├── tsconfig.json
├── webpack.config.js
└── yarn.lock
编写loader
在编写之前,我们先关注一下上边目录结构中的文件,因为此时我们需要将其拆分,但是如何将其拆分是需要考虑一下的,为了尽量不影响正常的使用,在这里采用了如下的方案。
将部分留在了文件中,因为一些插件例如是会检查中的一些语法,例如将其抽离出作为文件,对于等语法是会有提醒的,而且如果不存在文件的话,对于在中使用也需要修改,所以本着最小影响的原则我们将部分留在了文件中,保存了这个声明的文件。
对于部分,我们将其抽出,如果是使用编写的,那么就将其命名为,同样编写的就命名为。
对于部分,我们将其抽出,与部分采用同样的方案,使用、、也分别命名为、、,而对于我们通过注释的方式来实现。
通过以上的修改,我们将文件目录再次打印出来,重点关注于文件的分离。
webpack--loader
├── dist
│ ├── static
│ │ └── vue-large.b022422b.png
│ ├── index.html
│ ├── index.js
│ └── index.js.LICENSE.txt
├── public
│ └── index.html
├── src
│ ├── common
│ │ └── styles.scss
│ ├── components
│ │ ├── tab-a
│ │ │ ├── tab-a.vue
│ │ │ └── tab-a.vue.ts
│ │ └── tab-b
│ │ ├── tab-b.vue
│ │ └── tab-b.vue.ts
│ ├── router
│ │ └── index.ts
│ ├── static
│ │ ├── vue-large.png
│ │ └── vue.jpg
│ ├── store
│ │ └── index.ts
│ ├── views
│ │ └── framework
│ │ ├── framework.vue
│ │ ├── framework.vue.scss
│ │ └── framework.vue.ts
│ ├── App.vue
│ ├── index.ts
│ ├── main.ts
│ ├── sfc.d.ts
│ └── sum.ts
├── LICENSE
├── README.md
├── babel.config.js
├── package.json
├── tsconfig.json
├── vue-multiple-files-loader.js
├── webpack.config.js
└── yarn.lock
现在我们开始正式编写这个了,首先需要简单说明一下的输入与输出以及常用的模块。
简单来说是一个从到的函数,输入的是字符串的代码,输出也是字符串的代码。
通常来说对于各种文件的处理已经都有很好的轮子了,我们自己来编写的通常是用来做代码处理的,也就是说在中拿到之后,我们将其转换为树,然后在这个上进行一些修改,之后再将其转换为字符串代码之后进行返回。
从字符串到语法分析树是为了得到计算机容易识别的数据结构,在中自带了一些工具,是代码转的工具,是遍历工具,是转换到字符串代码的工具。
既然是字符串到字符串的,那么在代码转换为处理之后需要转为字符串,然后再传递到下一个,下一个可能又要进行相同的转换,这样还是比较耗费时间的,所以可以通过进行速率打点,以及来存储。
是在中常用的辅助类,常用的有绝对路径转请求的相对路径,来获取配置时传递的参数。
由于我们在这里这个需求是用不到相关的处理的,所以还是比较简单的一个实例,首先我们需要写一个文件,然后配置在中,在根目录我们建立一个,然后在的部分找到,将这部分修改为如下配置。
// ...
{
test: /\.vue$/,
use: [
"vue-loader",
{
loader: "./vue-multiple-files-loader",
options: {
// 匹配的文件拓展名
style: ["scss", "css"],
script: ["ts"],
},
},
],
}
// ...
首先可以看到在之后我们编写了一个对象,这个对象的参数是一个字符串,这个字符串是将来要被传递到当中的,也就是说在中他会自动帮我们把这个模块即。是有优先级的,在这里我们的目标是首先经由这个将代码处理之后再交与进行处理,所以我们要将写在后边,这样就会首先使用代码了。我们通过这个对象传递参数,这个参数可以在中拿到。
关于的优先级,首先定义配置的时候,除了与选项,还有一个选项,其可接受的参数分别是前置、普通、内联、后置,其优先级也是,那么相同优先级的就是从右到左、从下到上,从上到下很好理解,至于从右到左,只是选择了方式,而不是的方式而已,在技术上实现从左往右也不会有难度,就是函数式编程中的两种组合方式而已。此外,我们在的时候还可以跳过某些,跳过、跳过和、跳过和,比如,关于的跳过,官方的建议是,除非从另一个处理生成的,一般不建议主动使用。
现在我们已经处理好这个文件的创建以及的引用了,那么我们可以通过他来编写代码了,通常来说,一般是比较耗时的应用,所以我们通过异步来处理这个,通过告诉这个将会异步地回调,当我们处理完成之后,使用其返回值将处理后的字符串代码作为参数执行即可。
module.exports = async function (source {
const done = this.async(;
// do something
done(null, source;
}
对于文件的操作,我们使用来处理,以便我们能够更好地使用。
const fs = require("fs";
const { promisify } = require("util";
const readDir = promisify(fs.readdir;
const readFile = promisify(fs.readFile;
下面我们回到上边的需求上来,思路很简单,首先我们在这个中仅会收到以结尾的文件,这是在中配置的,所以我们在这里仅关注文件,那么在这个文件下,我们需要获取这个文件所在的目录,然后将其遍历,通过中配置的来构建正则表达式去匹配同级目录下的与的相关文件,对于匹配成功的文件我们将其读取然后按照文件的规则拼接到中,然后将其返回之后将代码交与处理即可。
那么我们首先处理一下当前目录,以及当前处理的文件名,还有正则表达式的构建,在这里我们传递了、和,那么对于这个文件来说,将会构建和这两个正则表达式。
const filePath = this.context;
const fileName = this.resourcePath.replace(filePath + "/", "";
const options = loaderUtils.getOptions(this || {};
const styleRegExp = new RegExp(options.style.map(it => `${fileName}\\.${it}$`.join("|";
const scriptRegExp = new RegExp(options.script.map(it => `${fileName}\\.${it}$`.join("|";
之后我们通过遍历目录的方式,来匹配符合要求的和的文件路径。
let stylePath = null;
let scriptPath = null;
const files = await readDir(filePath;
files.forEach(file => {
if (styleRegExp.test(file stylePath = path.join(filePath, file;
if (scriptRegExp.test(file scriptPath = path.join(filePath, file;
};
之后对于部分,存在匹配节点且原文件不存在标签,则异步读取文件之后将代码进行拼接,如果拓展名不为的话,例如是编写的那么就会将其作为去处理,之后将其拼接到这个字符串中。
if (scriptPath && !/<script[\s\S]*?>/.test(source {
const extName = scriptPath.split(".".pop(;
if (extName {
const content = await readFile(scriptPath, "utf8";
const scriptTagContent = [
"<script ",
extName === "js" ? "" : `lang="${extName}" `,
">\n",
content,
"</script>",
].join("";
source = source + "\n" + scriptTagContent;
}
}
之后对于部分,存在匹配节点且原文件不存在标签,则异步读取文件之后将代码进行拼接,如果拓展名不为的话,例如是编写的那么就会将其作为去处理,如果代码中存在单行的字样的话,就会将这个部分作处理,之后将其拼接到这个字符串中。
if (stylePath && !/<style[\s\S]*?>/.test(source {
const extName = stylePath.split(".".pop(;
if (extName {
const content = await readFile(stylePath, "utf8";
const scoped = /\/\/[\s]scoped[\n]/.test(content ? true : false;
const styleTagContent = [
"<style ",
extName === "css" ? "" : `lang="${extName}" `,
scoped ? "scoped " : " ",
">\n",
content,
"</style>",
].join("";
source = source + "\n" + styleTagContent;
}
}
在之后使用触发回调完成的流程,相关代码如下所示,完整代码在中的分支当中。
const fs = require("fs";
const path = require("path";
const { promisify } = require("util";
const loaderUtils = require("loader-utils";
const readDir = promisify(fs.readdir;
const readFile = promisify(fs.readFile;
module.exports = async function (source {
const done = this.async(;
const filePath = this.context;
const fileName = this.resourcePath.replace(filePath + "/", "";
const options = loaderUtils.getOptions(this || {};
const styleRegExp = new RegExp(options.style.map(it => `${fileName}\\.${it}$`.join("|";
const scriptRegExp = new RegExp(options.script.map(it => `${fileName}\\.${it}$`.join("|";
let stylePath = null;
let scriptPath = null;
const files = await readDir(filePath;
files.forEach(file => {
if (styleRegExp.test(file stylePath = path.join(filePath, file;
if (scriptRegExp.test(file scriptPath = path.join(filePath, file;
};
// 存在匹配节点且原`.vue`文件不存在`script`标签
if (scriptPath && !/<script[\s\S]*?>/.test(source {
const extName = scriptPath.split(".".pop(;
if (extName {
const content = await readFile(scriptPath, "utf8";
const scriptTagContent = [
"<script ",
extName === "js" ? "" : `lang="${extName}" `,
">\n",
content,
"</script>",
].join("";
source = source + "\n" + scriptTagContent;
}
}
// 存在匹配节点且原`.vue`文件不存在`style`标签
if (stylePath && !/<style[\s\S]*?>/.test(source {
const extName = stylePath.split(".".pop(;
if (extName {
const content = await readFile(stylePath, "utf8";
const scoped = /\/\/[\s]scoped[\n]/.test(content ? true : false;
const styleTagContent = [
"<style ",
extName === "css" ? "" : `lang="${extName}" `,
scoped ? "scoped " : " ",
">\n",
content,
"</style>",
].join("";
source = source + "\n" + styleTagContent;
}
}
// console.log(stylePath, scriptPath, source;
done(null, source;
};