loader 用于对模块的源代码进行转换。loader 可使你在 import 或 "load(加载)" 模块时预处理文件。所以,loader 相似于其余构建工具中“任务(task)”,并提供了处理前端构建步骤的得力方式。loader 能够将文件从不一样的语言(如 TypeScript)转换为 JavaScript 或将内联图像转换为 data URL。loader 甚至容许你直接在 JavaScript 模块中 import CSS 文件!javascript
对于 webpack 来讲,一切资源皆是模块,但因为 webpack 默认只支持 es5 的 js 以及 json,像是 es6+, react,css 等都要由 loader 来转化处理。css
loader 就只是一个导出为函数的 js 模块。html
module.exports = function(source, map) {
return source;
}
复制代码
其中 source 表示匹配上的文件资源字符串,map 表示 SourceMap。前端
注意: 不要写成箭头函数,由于 loader 内部的属性和方法,须要经过 this 进行调用,好比默认开启 loader 缓存,配制 this.cacheable(false) 来关掉缓存java
需求: 替换 js 里的某个字符串node
实现:react
新建个 replaceLoader.js:webpack
module.exports = function (source) {
return `${source.replace('hello', 'world')} `;
};
复制代码
webpack.config.js:git
const path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'main.js',
},
module: {
rules: [{ test: /\.js$/, use: './loaders/replaceLoader.js' }],
},
};
复制代码
上面的 replaceLoader 是固定将某个字符串(hello)替换掉,实际场景中更多的是经过参数传入es6
webpack.config.js:
const path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'main.js',
},
module: {
rules: [
{
test: /\.js$/,
// 经过 options 参数传参
use: [
{
loader: './loaders/replaceLoader.js',
options: {
name: 'hello',
},
},
],
// 经过字符串来传参
// use: './loaders/replaceLoader.js?name=hello'
},
],
},
};
复制代码
以上两种传参方式,若是使用 query 属性来获取参数,就会出现字符串传参获取到的是字符串, options 传参获取到的是对象格式,很差处理。这里推荐使用 loader-utils 库来处理。
这里使用库里的 getOptions 函数来接收参数
const { getOptions } = require('loader-utils');
module.exports = function (source) {
const params = getOptions(this);
return `${source.replace(params.name, 'world')} `;
};
复制代码
第一种: loader 内直接经过 throw 抛出
const { getOptions } = require('loader-utils');
module.exports = function (source) {
const params = getOptions(this);
throw new Error('出错了');
};
复制代码
第二种: 经过 this.callback 传递错误
this.callback({
//当没法转换原内容时,给 Webpack 返回一个 Error
error: Error | Null,
//转换后的内容
content: String | Buffer,
//转换后的内容得出原内容的Source Map(可选)
sourceMap?: SourceMap,
//原内容生成 AST语法树(可选)
abstractSyntaxTree?: AST
})
复制代码
第一个参数表示错误信息,当传递 null 时,做用跟前面的直接 return 个字符串做用相似,更建议采用这种方式返回内容
const { getOptions } = require('loader-utils');
module.exports = function (source) {
const params = getOptions(this);
this.callback(new Error("出错了"), `${source.replace(params.name, 'world')} `);
};
复制代码
当遇到要处理异步需求时,好比获取文件,此时经过 this.async() 告知 webpack 当前 loader 是异步运行。
const fs = require('fs');
const path = require('path');
module.exports = function (source) {
const callback = this.async();
fs.readFileSync(
path.resolve(__dirname, '../src/async.txt'),
'utf-8',
(error, content) => {
if (error) {
callback(error, '');
}
callback(null, content);
}
);
};
复制代码
其中 callback 是跟上面 this.callback 同样的用法。
经过 this.emitFile 进行文件写入。
const { interpolateName } = require('loader-utils');
const path = require('path');
module.exports = function (source) {
const url = interpolateName(this, '[name].[ext]', { source });
this.emitFile(url, source);
this.callback(null, source);
};
复制代码
上述设置 loader 时将整个文件路径都配置了,这样写多了,是有些麻烦的,能够经过 resolveLoader 定义 loader 的查找文件路径。
const path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'main.js',
},
resolveLoader: { modules: ['./loaders/', 'node_modules'] },
module: {
rules: [
{
test: /\.js$/,
// 经过 options 参数传参
use: [
{
loader: 'asyncLoader.js',
},
{
loader: 'emitLoader.js',
},
{
loader: 'replaceLoader.js',
options: {
name: 'hello',
},
},
],
// 经过字符串来传参
// use: './loaders/replaceLoader.js?name=hello'
},
],
},
};
复制代码
在手写 plugin 以前,先讲下 webpack 里 plugin 的工做机制,方便后续的讲解。
在 webpack.js 有以下代码:
compiler = new Compiler(options.context);
compiler.options = options;
if (options.plugins && Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
复制代码
能够看到会遍历 options.plugins 并依次调用 apply 方法,固然若是 plugin 是个函数的话,会调用 call,官网推荐将 plugin 定义成类。
上面代码中能够看到建立了个 Compiler 实例,将传递给各个 plugin。那么 Compiler 究竟是作什么的?
进入 Compiler.js 与 Compilation.js ,能够看到这两个类都继承自 Tapable
class Compiler extends Tapable {}
class Compilation extends Tapable {}
复制代码
Tapable 是一个相似于 Node.js 的 EventEmitter 的库,主要是控制钩子函数的发布与订阅,控制着 webpack 的插件系统。 Tapable 库暴露了不少 Hook(钩子)类,其中既有同步 Hook,好比 SyncHook;也有异步 Hook,好比 AsyncSeriesHook。
new 一个 hook 获取咱们须要的钩子,该方法接收数组参数 options,非必传。 好比:
const hook1 = new SyncHook(["arg1", "arg2", "arg3"]);
复制代码
同步与异步 hook 的绑定与执行是不同的:
Async*(异步) | Sync* (同步) |
---|---|
绑定:tapAsync/tapPromise/tap | 绑定:tap |
执行:callAsync/promise | 执行:call |
const { SyncHook } = require('tapable');
const hook1 = new SyncHook(["arg1", "arg2", "arg3"]);
//绑定事件到webapck事件流
hook1.tap('hook1', (arg1, arg2, arg3) => console.log(arg1, arg2, arg3)) //1,2,3
//执行绑定的事件
hook1.call(1,2,3)
复制代码
模拟个 Compiler.js
const { SyncHook, AsyncSeriesHook } = require('tapable');
module.exports = class Compiler {
constructor() {
this.hooks = {
add: new SyncHook(), // 无参同步
reduce: new SyncHook(['arg']), // 有参同步
fetchNum: new AsyncSeriesHook(['arg1', 'arg2']), // 异步 hook
};
}
// 入口执行函数
run() {
this.add();
this.reduce(20);
this.fetchNum('async', 'hook');
}
add() {
this.hooks.add.call();
}
reduce(num) {
this.hooks.reduce.call(num);
}
fetchNum() {
this.hooks.fetchNum.promise(...arguments).then(
() => {},
(error) => console.info(error)
);
}
};
复制代码
自定义个 plugin,绑定上面定义的几个 hook
class MyPlugin {
apply(compiler) {
compiler.hooks.add.tap('add', () => console.info('add'));
compiler.hooks.reduce.tap('reduce', (num) => console.info(num));
compiler.hooks.fetchNum.tapPromise('fetch tapAsync', (num1, num2) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`tapPromise to ${num1} ${num2}`);
resolve();
}, 1000);
});
});
}
}
module.exports = MyPlugin;
复制代码
模拟执行
const MyPlugin = require('./my-plugin');
const Compiler = require('./compiler');
const myPlugin = new MyPlugin();
const options = {
plugins: [myPlugin],
};
const compiler = new Compiler();
for (const plugin of options.plugins) {
if (typeof plugin === 'function') {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
compiler.run();
复制代码
具体代码见 MyPlugins
Compiler:编译管理器,webpack 启动后会建立 compiler 对象,该对象一直存活直到结束退出。
Compliation: 单次编辑过程的管理器,好比 watch = true 时,运行过程当中只有一个 compiler 但每次文件变动触发从新编译时,都会建立一个新的 compilation 对象
有了上面的讲解,如今来手写 plugin
插件是 webpack 的支柱功能。webpack 自身也是构建于你在 webpack 配置中用到的相同的插件系统之上! 插件目的在于解决 loader 没法实现的其余事。
插件相似于 React, Vue 里的生命周期,在某个时间点会触发,好比 emit 钩子:输出 asset 到 output 目录以前执行;done 钩子:在编译完成时执行。
plugin 就是个类,该类里有个 apply 方法,方法会接收 compiler 参数 插件定义:
class DemoPlugin {
// 插件名称
apply(compiler) {
// 定义个 apply 方法
// 同步的 hook ,采用 tap,第二个函数参数只有 compilation 参数
compiler.hooks.compile.tap('demo plugin', (compilation) => {
//插件的 hooks
console.info(compilation); // 插件处理逻辑
});
}
}
module.exports = DemoPlugin;
复制代码
插件使用:
plugins: [ new DemoPlugin() ]
复制代码
在类的 constructor 里接收便可
接收参数:
class DemoPlugin {
constructor(options) {
this.options = options;
}
// 插件名称
apply(compiler) {
// 定义个 apply 方法
// 同步的 hook ,采用 tap,第二个函数参数只有 compilation 参数
compiler.hooks.compile.tap('demo plugin', (compilation) => {
//插件的 hooks
console.info(this.options); // 插件处理逻辑
});
}
}
module.exports = DemoPlugin;
复制代码
传递参数:
plugins: [new DemoPlugin({ name: 'zhangsan' })],
复制代码
webpack 在 emit 阶段,会将 compliation.assets 文件写入磁盘。因此可使用 compilation.assets 对象设置要写入的文件。
class CopyRightWebpackPlugin {
apply(compiler) {
// 异步的 hook ,采用 tap,第二个函数参数有 compilation 跟 cb 参数,必定要调用 cb()
compiler.hooks.emit.tapAsync(
'CopyrightWebpackPlugin',
(compilation, cb) => {
compilation.assets['copyright.txt'] = {
source() {
return 'copyright by webInRoad';
},
size() {
return 11;
},
};
cb();
}
);
}
}
module.exports = CopyRightWebpackPlugin;
复制代码
该版本要实现的功能,不包括对于 options 参数的处理,好比 WebpackOptionsApply 将全部的配置 options 参数转换成 webpack 内部插件。也不包括对于非 js 的处理,只实现将 es6 js 文件转成支持浏览器端运行的代码。其中涉及 js 转成 AST,获取依赖图谱,输出文件。
npm init -y
复制代码
初始化 package.json,以及建立 src 目录,该目录底下新建 index.js 与 welcome.js。其中 index.js 引用 welcome.js。 目录结构以下: 文件代码以下:
// index.js
import { welcome } from './welcome.js';
document.write(welcome('lisi'));
// welcome.js
export function welcome(name) {
return 'hello' + name;
}
复制代码
根目录下新建个 index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="./src/index.js"></script>
</head>
<body>
</body>
</html>
复制代码
使用浏览器访问该 index.html ,显然是会报错的,由于浏览器目前还没法直接支持 import 语法
Uncaught SyntaxError: Cannot use import statement outside a module
复制代码
本质上,webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。当 webpack 处理应用程序时,它会在内部构建一个 依赖图(dependency graph),此依赖图对应映射到项目所需的每一个模块,并生成一个或多个 bundle。
根据官网给出的对于 webpack 定义,咱们要实现的简单版本 webpack,大致要有以下几个功能:
首先建立个相似 webpack.config.js 的 simplepack.config.js 配制文件
'use strict';
const path = require('path');
module.exports = {
entry: path.join(__dirname, './src/index.js'),
output: {
path: path.join(__dirname, './dist'),
filename: 'main.js'
}
};
复制代码
而后在根目录下建立 lib 文件夹,用于实现简单版本 webpack。
lib 下建立个 index.js 引用 compiler.js 以及 simplepack.config.js 文件
const Compiler = require('./compiler');
const options = require('../simplepack.config');
new Compiler(options).run();
复制代码
compiler.js 文件先建立放在那。
在 lib 目录下新建 parser.js (用于解析文件),还有 test.js (用于测试 parser.js 的功能)
在 parser.js 里定义个函数 getAST ,采用 node fs 包加载文件内容
const fs = require('fs');
module.exports = {
getAST: (path) => {
const content = fs.readFileSync(path, 'utf-8');
return content;
},
};
复制代码
在 test.js 里
const { getAST } = require('./parser');
const path = require('path');
const content = getAST(path.join(__dirname, '../src/index.js'));
console.info(content);
复制代码
node test.js 获得入口文件的字符串内容:
能够经过正则表达式等方式获取 import 以及 export 的内容以及相应的路径文件名,但当文件里 import 的文件过多时,这种处理方式会很麻烦。这里咱们借助 babylon 来完成文件的解析,生成 AST 抽象语法树。
parser.js:
const babylon = require('babylon');
// 根据代码生成 AST (抽象语法树)
getAST: (path) => {
const content = fs.readFileSync(path, 'utf-8');
return babylon.parse(content, {
sourceType: 'module', // 表示项目里采用的是 ES Module
});
},
复制代码
test.js
const { getAST } = require('./parser');
const path = require('path');
const ast = getAST(path.join(__dirname, '../src/index.js'));
console.info(ast.program.body);
复制代码
文件内容是在 ast.program.body 里的。
执行 node test.js ,打印出 能够看到数组里有两个 Node 节点,每一个 Node 节点都有个 type 属性,像第一个 Node 节点的 type 值为 ImportDeclaration,即对应 index.js 里的第一行 import 语句,第二行是表达式,因此 type 值为 ExpressionStatement。
咱们能够经过遍历 Node 节点,获取 type 值为 ImportDeclaration 的,该 Node 的 source.value 属性是引用模块的相对路径。但这种方式有些复杂,咱们借用 babel-traverse 来获取依赖:
在 parser.js 里新增 getDependencies 函数,用于根据 AST 获取依赖内容
const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
module.exports = {
// 根据代码生成 AST (抽象语法树)
getAST: (path) => {
const content = fs.readFileSync(path, 'utf-8');
return babylon.parse(content, {
sourceType: 'module',
});
},
// 分析依赖
getDependencies: (ast) => {
const dependencies = [];
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value);
},
});
return dependencies;
},
};
复制代码
test.js
const { getAST, getDependencies } = require('./parser');
const path = require('path');
const ast = getAST(path.join(__dirname, '../src/index.js'));
const dependencies = getDependencies(ast);
console.info(dependencies);
复制代码
执行 node test.js
获取到了相对于入口文件的依赖文件路径
获取依赖以后,咱们须要对 ast 作语法转换,把 es6 的语法转化为 es5 的语法,使用 babel 核心模块 @babel/core 以及 @babel/preset-env完成
在 parser.js 里新增 transform 方法,用于根据 ast ,生成对应的 es5 代码。
const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
const { transformFromAst } = require('babel-core');
module.exports = {
// 根据代码生成 AST (抽象语法树)
getAST: (path) => {
const content = fs.readFileSync(path, 'utf-8');
return babylon.parse(content, {
sourceType: 'module',
});
},
// 分析依赖
getDependencies: (ast) => {
const dependencies = [];
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value);
},
});
return dependencies;
},
// 将 ast 转换成 es5 代码
transform: (ast) => {
const { code } = transformFromAst(ast, null, {
presets: ['env'],
});
return code;
},
};
复制代码
test.js
const { getAST, getDependencies, transform } = require('./parser');
const path = require('path');
const ast = getAST(path.join(__dirname, '../src/index.js'));
const dependencies = getDependencies(ast);
const source = transform(ast);
console.info(source);
复制代码
能够看到是转成了 es5 语法,但里头有个 require 函数,该函数浏览器可不自带,须要定义个。
上面已经实现了单个文件依赖的获取,如今从入口模块开始,对每一个模块以及模块的依赖模块进行分析,最终返回一个包含全部模块信息的对象,存储在 this.modules 里。 lib 目录下新建 compiler.js
const path = require('path');
const { getAST, getDependencies, transform } = require('./parser');
module.exports = class Compiler {
constructor(options) {
const { entry, output } = options;
this.entry = entry;
this.output = output;
this.modules = [];
}
// 首先获取入口的信息,对象里包含文件名称,编译成 es5 的代码,
// 还有依赖模块数组;
// 而后遍历模块的依赖,往 this.modules 里添加模块信息,
// 这样就能够继续获取依赖模块所依赖的模块,至关于递归获取模块信息
run() {
const entryModule = this.buildModule(this.entry, true);
this.modules.push(entryModule);
this.modules.map((_module) => {
_module.dependencies.map((dependency) => {
this.modules.push(this.buildModule(dependency));
});
});
}
// 模块构建
// 要区分是入口文件仍是其余的,由于其余的路径是相对路径,
// 须要转成绝对路径,或者说相对于项目文件夹
buildModule(filename, isEntry) {
let ast;
if (isEntry) {
ast = getAST(filename);
} else {
let absolutePath = path.join(path.dirname(this.entry), filename);
ast = getAST(absolutePath);
}
return {
filename,
dependencies: getDependencies(ast),
transformCode: transform(ast),
};
}
};
复制代码
执行 node index.js
根据上面的模块分析数据,生成最终浏览器运行的代码。 看下上一节获得的依赖图,能够看到,最终的 transformCode 里包含 exports 以及 require 这样的语法,而这两个不是浏览器自带的,须要咱们在代码里实现。 在 compiler.js 新增 emitFile 函数,用于生成最终代码,并写入 output 里
// 输出文件
emitFiles() {
const outputPath = path.join(this.output.path, this.output.filename);
let modules = '';
this.modules.map((_module) => {
modules += `'${_module.filename}': function (require, exports) { ${_module.transformCode} },`;
});
const bundle = ` (function(modules) { function require(fileName) { const fn = modules[fileName]; const exports = {}; fn(require, exports ); return exports; } require('${this.entry}'); })({${modules}}) `;
fs.writeFileSync(outputPath, bundle, 'utf-8');
}
复制代码
在 compiler.js run 函数里调用 emitFiles
const fs = require('fs');
const path = require('path');
const { getAST, getDependencies, transform } = require('./parser');
module.exports = class Compiler {
constructor(options) {
const { entry, output } = options;
this.entry = entry;
this.output = output;
this.modules = [];
}
run() {
const entryModule = this.buildModule(this.entry, true);
this.modules.push(entryModule);
this.modules.map((_module) => {
_module.dependencies.map((dependency) => {
this.modules.push(this.buildModule(dependency));
});
});
this.emitFiles();
}
// 模块构建
buildModule(filename, isEntry) {
let ast;
if (isEntry) {
ast = getAST(filename);
} else {
let absolutePath = path.join(path.dirname(this.entry), filename);
ast = getAST(absolutePath);
}
return {
filename,
dependencies: getDependencies(ast),
transformCode: transform(ast),
};
}
// 输出文件
emitFiles() {
const outputPath = path.join(this.output.path, this.output.filename);
let modules = '';
this.modules.map((_module) => {
modules += `'${_module.filename}': function (require, exports) { ${_module.transformCode} },`;
});
const bundle = ` (function(modules) { function require(fileName) { const fn = modules[fileName]; const exports = {}; fn(require, exports); return exports; } require('${this.entry}'); })({${modules}}) `;
fs.writeFileSync(outputPath, bundle, 'utf-8');
}
};
复制代码
执行 node index.js 会生成最终代码并写入 dist 目录下的 main.js 记得 dist 目录要手动建立 在 index.html 里引入 main.js 能够正常显示出结果