webpack 手写 loader 与 plugin,手写简易版本的 webpack

前言>本文示例

手写 loader

什么是 loader?

loader 用于对模块的源代码进行转换。loader 可以使你在 import 或 “load(加载)” 模块时预处理文件。因此,loader 类似于其他构建工具中“任务(task)”,并提供了处理前端构建步骤的得力方式。loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript 或将内联图像转换为 data URL。loader 甚至允许你直接在 JavaScript 模块中 import CSS 文件!

对于 webpack 来说,一切资源皆是模块,但由于 webpack 默认只支持 es5 的 js 以及 json,像是 es6+, react,css 等都要由 loader 来转化处理。

loader 代码结构

loader 只是一个导出为函数的 js 模块。

module.exports = function(source, map) {
	return source;
}

其中 source 表示匹配上的文件资源字符串,map 表示 SourceMap。

注意: 不要写成箭头函数,因为 loader 内部的属性和方法,需要通过 this 进行调用,比如默认开启 loader 缓存,配制 this.cacheable(false) 来关掉缓存

同步 loader

需求: 替换 js 里的某个字符串

实现:
新建个 replaceLoader.js:

module.exports = function (source) {
  return `${source.replace('hello', 'world')} `;
};

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$/, use: './loaders/replaceLoader.js' }],
  },
};

传递参数

上面的 replaceLoader 是固定将某个字符串(hello)替换掉,实际场景中更多的是通过参数传入,即

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);
};

resolveLoader

上述设置 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 工作机制

在手写 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 定义成类。

Tapable

上面代码上可以看到创建了个 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 钩子的绑定与执行

同步与异步 hook 的绑定与执行是不一样的:

Async*(异步)Sync* (同步)
绑定:tapAsync/tapPromise/tap绑定:tap
执行:callAsync/promise执行:call
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 与 Compliation

Compiler:编译管理器,webpack 启动后会创建 compiler 对象,该对象一直存活直到结束退出。
Compliation: 单次编辑过程的管理器,比如 watch = true 时,运行过程中只有一个 compiler 但每次文件变更触发重新编译时,都会创建一个新的 compilation 对象

手写 plugin

有了上面的讲解,现在来手写 plugin

什么是 plugin

插件是 webpack 的支柱功能。webpack 自身也是构建于你在 webpack 配置中用到的相同的插件系统之上!
插件目的在于解决 loader 无法实现的其他事。

插件类似于 React, Vue 里的生命周期,就是个某个时间点会触发,比如 emit 钩子:输出 asset 到 output 目录之前执行;done 钩子:在编译完成时执行。

plugin 代码结构

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;

简易版本 webpack

该版本要实现的功能,不包括对于 options 参数的处理,比如 WebpackOptionsApply 将所有的配置 options 参数转换成 webpack 内部插件。也不包括对于非 js 的处理,只是单纯个将 es6 js 文件转成支持浏览器运行的代码。其中涉及 js 转成 ast 对象,获取依赖图谱,输出文件。

简易版本 webpack

项目初始化

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,大体要有如下几个功能:

  1. 读取配制文件
  2. 从入口文件开始,递归去读取模块所依赖的文件内容,生成依赖图
  3. 根据依赖图,生成浏览器能够运行的最终代码
  4. 生成 bundle 文件

读取配制文件

首先像 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 ,打印出
ast 内容
可以看到数组里有两个 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
可以看到是转成了 es5 语法,但里头有个 require 函数,该函数浏览器可不自带,需要定义个。

获取依赖图

上面已经实现了单个文件依赖的获取,现在从入口模块开始,对每个模块以及模块的依赖模块进行分析,最终返回一个包含所有模块信息的对象,存储在 this.modules 里。
lib 目录下新建 compiler.js

  1. constructor 构造函数里接收 options 参数(包含入口与出口配制信息) 以及 this.modules 用于存储模块内容
  2. run 函数,作为入口函数,从入口文件开始获取所有的依赖信息存储在 this.modules 里
  3. buildModule 调用 parser 里封装的函数,返回个对象,包含文件名称,依赖数组,以及相应的可执行代码
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
modules

生成代码

根据上面的模块分析数据,生成最终浏览器运行的代码。
看下上一节得到的依赖图,可以看到,最终的 transformCode 里包含 exports 以及 require 这样的语法,而这两个不是浏览器自带的,需要我们在代码里实现。
在 compiler.js 新增 emitFile 函数,用于生成最终代码,并写入 output 里

  1. 首先将所有的模块信息,转成以模块名称为 key ,并定义个函数(该函数接收 require 与 exports 参数,模块代码作为函数体) 作为值
  2. 然后定义个 IIFE (立即执行函数),参数为上一步的处理结果 modules 对象,函数里头定义个 require 函数,接收文件名,函数逻辑为根据文件名获取文件代码,定义个 exports 对象,并将 require 与 exports 作为参数传入函数执行,最后返回 exports
  3. 最后将最终生成的代码写入 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} },`;
    });
    console.info(modules, 'modules');
    const bundle = `
            (function(modules) {
                function require(fileName) {
                    const fn = modules[fileName];
        
                    const exports = {};
        
                    fn(require, exports );
                    console.info(32323)
                    return exports;
                }

                require('${this.entry}');
            })({${modules}})
        `;

    fs.writeFileSync(outputPath, bundle, 'utf-8');
  }

在 compiler.js run 函数里调用 emitFiles

compiler 完整代码

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} },`;
    });
    console.info(modules, 'modules');
    const bundle = `
            (function(modules) {
                function require(fileName) {
                    const fn = modules[fileName];
        
                    const exports = {};
        
                    fn(require, exports);
                    console.info(32323)
                    return exports;
                }

                require('${this.entry}');
            })({${modules}})
        `;

    fs.writeFileSync(outputPath, bundle, 'utf-8');
  }
};

执行 node index.js 会生成最终代码并写入 dist 目录下的 main.js
记得 dist 目录要手动创建
main.js
在 index.html 里引入 main.js 可以正常显示出结果
结果

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值