基础概念
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}`);
}