基础概念
webpack是一款强大的模块打包工具,它可以引入配置文件完成前端高度定制化的构建工作.
webpack默认只能理解JavaScript和JSON文件,但实际工作中各种需求层出不穷,文件类型也多种多样.比如.vue、.ts、图片、.css等,这就需要loader增强webpack处理文件的能力.
在webpack的配置文件中,我们经常会看到如下设置.
module.exports = {
...
module: {
rules: [
{
test: /\.less$/i, // 匹配.less结尾的文件
use: [
"html-loader",
"css-loader",
'less-loader'
],
}
],
}
...
};
js代码里如果使用import导入一个样式文件style.less(代码如下),webpack碰到.less后缀的文件不知所措.因为它默认只能处理以.js和.json结尾的文件.
//js文件
import "./style.less";
有了loader的赋能,webpack便有能力处理.less文件.
比如上面的配置代码,项目中一旦碰到导入以.less为后缀的样式文件,webpack会先将文件内容发送给less-loader处理,less-loader将所有less语法的样式转变成普通的css样式.
普通的css样式继续发送给css-loader处理,css-loader最主要的功能是解析css语法中的@import和图片路径,处理完后导入的css合并在了一起.
合并后的css文件再继续传递,发送给html-loader处理,它最终将样式内容插入到了html头部的style标签下,页面也因此添加了样式.
从上面的案例我们看出,每个loader的职责都是单一的,自己只负责自己的那一小块.但不管什么格式的文件,只要将特定功能的loader组合起来,它就能增强webpack的能力,使各种稀奇古怪的文件都能被正确识别并处理.
另外值得关注,loader在上面配置use数组中的执行顺序是从后往前.
了解了loader的基本用途之后,我们不禁思考,loader为什么功能这么强大,它是如何实现的呢?
我们接下来手写一个自定义loader,以此来加深理解loader的价值与用途.
自定义loader
随着es6的不断普及,应用async、await处理异步代码的情况越来越多(代码如下).async、await的出现使js处理异步操作变得简单.同时代码出现异常后,也可以通过try、catch进行捕捉.
async function start(){
console.log("Hello world");
await loadData();
console.log("end world");
}
假设现在项目团队要为每个项目部署监控系统,一旦生产环境下js出现异常,要将报错信息及时上传到后台日志服务器.
项目需要对所有的async函数进行try、catch捕捉,期待的输出结果如下:
async function start(){
try{
console.log("Hello world");
await loadData();
console.log("end world");
}catch(error){
console.log(error);
logger(error); //处理错误信息
}
}
如果项目规模庞大,人工手动添加try、catch不仅效率低下还容易出错,这时候工程化的价值便体现出来了.我们可以自定义一个loader自动给项目中所有的async函数添加异常捕捉.
loader基础API
首先我们先学习一下loader的基础api.在项目文件夹下创建一个文件error-loader.js,编写下面的测试代码(代码如下).
loader本质上是一个函数,参数content是一段字符串,存储着文件的内容,最后将loader函数导出就可以提供给webpack使用了.
webpack的配置文件在设置rules时(代码如下),只需要将use里的loader指向上面导出的loader函数的文件路径,这样webpack就能顺利引用loader了.另外我们还可以添加options属性给loader函数传参.
//error-loader.js
//loader函数
module.exports = function (content){
console.log(this.query); // { name: 'hello' }
return content;
}
//webpack.config.js
//webpack配置
module.exports = {
module:{
rules:[
{
test:/\.js$/,
use:[
{
loader:path.resolve(__dirname,"./error-loader.js"),
options:{
name:"hello"
}
}
]
}
]
}
}
项目一旦启动打包,webpack检测到.js文件,它就会把文件的代码字符串传递给error-loader.js导出的loader函数执行.
我们上面编写的loader函数并没有对代码字符串content做任何操作,直接返回了结果.那么我们自定义loader的目的就是为了对content源代码做各种数据操作,再将操作完的结果返回.
比如我们可以使用正则表达式将content中所有的console.log语句全部去掉,那么最后我们生成的打包文件里就不会包含console.log.
另外我们在开发一些功能复杂的loader时,可以接收配置文件传入的参数.例如上面webpack.config.js中给error-loader传入了一个对象{name:"hello"},那么在自定义的loader函数中可以通过this.query获取到参数.
loader函数除了直接使用return将content返回之外,还可以使用this.callback(代码如下)达到相同的效果.
this.callback能传递以下四个参数.第三个参数和第四个参数可以不填.this.callback传递的参数会发送给下一个loader函数接受,每一个loader函数形成了流水线上的一道道工序,最终将代码处理成期待的结果.
- 第一个参数为错误信息,没有出错可以填
null - 第二个参数为
content,也是要进行数据操作的目标 - 第三个参数为
sourceMap,选填项.它将打包后的代码与源码链接起来,方便开发者调试,一般通过babel生成. - 第四个参数为
meta额外信息,选填项.
module.exports = function (content){
this.callback(null,content);
}
以上介绍的内容都是使用同步的方式编写,万一loader函数里面需要做一些异步的操作就要采用如下方式.
this.async()调用后返回一个callback函数,等到异步操作完,就可以继续使用callback将content返回.
//上一个loader可能会传递sourceMap和meta过来,没穿就为空
module.exports = function (content,sourceMap,meta){
const callback = this.async();
setTimeout(()=>{ // 模拟异步操作
callback(null,content);
},1000)
}
异常捕捉的loader编写
上面介绍完一些基本api之后,接下来开发一款捕捉async函数执行异常的loader.
loader函数的第一个参数content,我们可以利用正则表达式修改content.但如果实现的功能比较复杂,正则表达式会变得异常复杂难以开发.
主流的方法是将代码字符串转化对象,我们对对象进行数据操作,再将操作完的对象转化为字符串返回.
这就可以借助babel相关的工具帮助我们实现这一目的,代码如下.(如果对babel不熟悉的同学可以忽略这一小节,以后有机会单独对babel展开分析)
@babel/parser模块首先将源代码content转化成ast树,再通过@babel/traverse遍历ast树,寻找async函数的节点.
async函数的节点被寻找到后,通过@babel/types模块给async函数添加try,catch表达式包裹,再替换原来的旧节点.
最后使用@babel/generator模块将操作后的ast树转化成目标代码返回.
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const generate = require('@babel/generator').default;
const t = require("@babel/types");
const ErrorLoader = function (content,sourceMap,meta){
const ast = parser.parse(content); // 将代码转换成为ast树
traverse(ast,{
//遍历函数表达式
FunctionDeclaration(path){
//判断当前节点是不是async函数
const isAsyncFun = t.isFunctionDeclaration(path.node,{async:true});
if(!isAsyncFun){ // 不是async函数就停止操作
return ;
}
const bodyNode = path.get("body");
// 是不是大括号表达式
if(t.isBlockStatement(bodyNode)){
const FunNode = bodyNode.node.body;
if(FunNode.length == 0) { // 空函数
return;
}
if(FunNode.length !== 1 || t.isTryStatement(FunNode[0])){ // 函数内没有被try ... catch 包裹
// 异常捕捉的代码
const code = `
console.log(error);
`;
//使用try、catch包裹,生成目标节点
const resultAst = t.tryStatement(
bodyNode.node,
t.catchClause(t.identifier("error"),
t.blockStatement(parser.parse(code).program.body)
)
)
//将转化后的节点替换原来的节点
bodyNode.replaceWithMultiple([resultAst]);
}
}
}
})
//将目标ast转化成代码
this.callback(null,generate(ast).code,sourceMap,meta);
}
module.exports = ErrorLoader;
loader源码解析
了解了自定义loader的实现方式,接下来我们解读一些平时工作中非常常见的loader源码,摸清楚它们的底层实现原理.
less-loader
less-loader简化后的源码如下,它的执行流程很简单.通过require("less")去加载less插件,然后调用less插件去编译source源代码输出结果.
这样所有的less语法都会编译成css,编译完成后调用callback返回处理结果.
async function lessLoader(source) {
const options = this.getOptions(schema);
const callback = this.async();
const implementation = require("less");
const lessOptions = getLessOptions(this, options, implementation);
let result;
try {
result = await implementation.render(source, lessOptions);
} catch (error) {
callback(new LessError(error));
return;
}
const { css, imports } = result;
let map = typeof result.map === "string" ? JSON.parse(result.map) : result.map;
callback(null, css, map);
}
export default lessLoader;
file-loader
file-loader通常被用来处理图片,字体以及其他格式的文件,执行流程可以梳理如下:
file-loader首先通过interpolateName函数根据配置中的name属性和content内容生成文件名- 有了文件名路径
url,再根据用户配置options,生成目标outputPath和publicPath - 最后执行
this.emitFile函数,调起webpack的钩子函数,向outputPath路径创建文件内容
//file-loader源代码简化
import path from 'path';
import { getOptions, interpolateName } from 'loader-utils';
export default function loader(content) {
const options = getOptions(this); // 获取配置项
const context = options.context || this.rootContext;
const name = options.name || '[contenthash].[ext]';
//据 name 配置和 content 内容 生成一个hash文件名
const url = interpolateName(this, name, {
context,
content,
regExp: options.regExp,
});
let outputPath = url;
//如果用户配置了 outputPath
if (options.outputPath) {
if (typeof options.outputPath === 'function') {
outputPath = options.outputPath(url, this.resourcePath, context);
} else {
outputPath = path.posix.join(options.outputPath, url); // 将outputPath和url拼接起来
}
}
// publicPath 等于 webpack配置的根路径拼接上outputPath
let publicPath = `__webpack_public_path__ + ${JSON.stringify(outputPath)}`;
//用户没有有配置publicPath
if (options.publicPath) {
if (typeof options.publicPath === 'function') {
publicPath = options.publicPath(url, this.resourcePath, context);
} else {
//将用户配置的publicPath拼接上文件名
publicPath = `${
options.publicPath.endsWith('/')
? options.publicPath
: `${options.publicPath}/`
}${url}`;
}
publicPath = JSON.stringify(publicPath);
}
if (typeof options.emitFile === 'undefined' || options.emitFile) {
this.emitFile(outputPath, content, null); // 调用webpack的钩子函数创建文件
}
return `${esModule ? 'export default' : 'module.exports ='} ${publicPath};`;
}
export const raw = true;
vue-loader
前端同学做日常vue项目开发时,通常会使用单文件组件(代码如下).
单文件组件由三部分组成:template、script、style.通过这三个标签,html、js以及css可以写在一个文件里,通常该文件都会命名为.vue格式.
webpack是无法解析.vue文件,正是在vue-loader的作用下,webpack才将单文件组件解析成了浏览器能够执行的代码.
<template>
<div class="main">hello world</div>
</template>
<script>
export default {}
</script>
<style>
.main{
color:red;
}
</style>
vue-loader经过简化后的源码如下,我们可以从源码出梳理出它的运行机制.
module.exports = function (source) {
const loaderContext = this
const {
request,
sourceMap,
} = loaderContext
//将.vue文件解析后生成的结果,包含template、style、script
const descriptor = parse({
source,
compiler,
filename,
sourceRoot,
needMap: sourceMap
})
// 如果发现文件中包含不同type,比如 foo.vue?type=template&id=xxxxx
// type = template | script | style
// selectBlock会给不同的type寻找相应的loader加载
if (incomingQuery.type) {
return selectBlock(
descriptor,
loaderContext,
incomingQuery,
!!options.appendExtension
)
}
// 处理template
let templateImport = `var render, staticRenderFns`
let templateRequest
if (descriptor.template) {
//...
templateImport = `import { render, staticRenderFns } from ${request}`
}
// 处理script
let scriptImport = `var script = {}`
if (descriptor.script) {
//...
scriptImport = (
`import script from ${request}\n` +
`export * from ${request}` // support named exports
)
}
// 处理styles
let stylesCode = ``
if (descriptor.styles.length) {
stylesCode = genStylesCode(/*...*/)
}
/*
import { render,staticRenderFns } from "./foo.vue?vue&type=template&id=32euy323lang=pug&";
import script from "./foo.vue?vue&type=script&lang=js&";
import style0 from "./foo.vue?vue&type=style&index=0&module=true&lang=css&"
*/
let code = `
${templateImport}
${scriptImport}
${stylesCode}
var component = normalizer(
script,
render,
staticRenderFns,
...
)
export default component.exports;
`;
code = code.trim() + `\n`
return code;
}
vue-loader其实会执行两轮,第一轮执行完先生成一个code字符串(代码如下).
这段代码最关键的三个变量:templateImport、scriptImport和stylesCode最后编译的数据结构对应着注释的那部分.
从注释代码我们可以看出,foo.vue又被import了三次,并且后面还携带了一个关键参数type,它被用来用来指定是template、script还是style.
/*
import { render,staticRenderFns } from "./foo.vue?vue&type=template&id=32euy323lang=pug&";
import script from "./foo.vue?vue&type=script&lang=js&";
import style0 from "./foo.vue?vue&type=style&index=0&module=true&lang=css&"
*/
let code = `
${templateImport}
${scriptImport}
${stylesCode}
var component = normalizer( // 生成vue组件
script,
render,
staticRenderFns,
...
)
export default component.exports;
`
return code;
这三次import会触发vue-loader的第二轮执行,此时代码执行到selectBlock(代码如下)时直接返回结果.
selectBlock内部会根据文件名后面的参数type加载相应的loader处理,最终template、script和style都会被对应的loader处理并返回结果.
上面三块代码处理完毕后,就可以调用Vue的api生成组件,并编译成浏览器端能够执行的代码.
if (incomingQuery.type) {
return selectBlock(
descriptor,
loaderContext,
incomingQuery,
!!options.appendExtension
)
}
css-loader
css-loader的功能非常强大,它可以导入所有使用的@import语法的css,并且还能处理css引入的图片url,另外还能实现css的模块化.
以下为简化后的css-loader源码.我们通过阅读源码可知,css-loader实现这些功能主要调用了postcss插件.
css-loader首先定义了一个plugins数组,plugins装载了处理css-modules、@import、url以及icss插件,再以参数的形式提供给postcss调用,从而让css-loader也具备了相应的能力.
export default async function loader(content, map, meta) {
const plugins = [];
const callback = this.async();
let options;
const replacements = [];
const exports = [];
//处理css-modules
if (shouldUseModulesPlugins(options)) {
plugins.push(...getModulesPlugins(options, this));
}
//处理@import
if (shouldUseImportPlugin(options)) {
plugins.push(
importParser({...})
);
}
//处理url()语句
if (shouldUseURLPlugin(options)) {
plugins.push(
urlParser({...})
);
}
//处理icss相关逻辑
if (needToUseIcssPlugin) {
plugins.push(
icssParser({...})
);
}
const { resourcePath } = this;
let result;
try {
result = await postcss(plugins).process(content, {...}); // 调用postcss插件处理content
} catch (error) {
callback(error);
return;
}
const importCode = getImportCode(imports, options); //导入的依赖
let moduleCode;
try {
moduleCode = getModuleCode(result, api, replacements, options, this); //导出的内容
} catch (error) {
callback(error);
return;
}
const exportCode = getExportCode( // 其他导出的信息
exports,
replacements,
needToUseIcssPlugin,
options
);
callback(null, `${importCode}${moduleCode}${exportCode}`);
}