深入理解webpack打包机制(二)

可以使用npx my-pick命令后,我们就可以在my-pick中编写自己的webpack:

webpack.config.js中导出的是一个对象,这个对象是webpack的配置参数。说白了,导出对象的入口 出口 module plugins...什么的 全是webpack参数中的其中一个。那webpack就可以理解为一个很大的函数,把参数传递进去 返回结果。

1 在bin同级目录 创建lib目录,用来存放打包的核心的配置文件 -> mkdir lib -> cd /lib -> touch Compiler.js

2 在my-pick.js中引入webpack.config.js的配置

#! /usr/bin/env node

let path = require('path');

let config = require(path.resolve('webpack.config.js'));

let Compiler = require('../lib/Compiler');

let compiler = new Compiler(config);

compiler.run();

在my-pick.js中, 首先通过path模块引入了webpack.config.js,其次 又引入了lib/Compiler.js,
不难发现,Compiler是一个类,并且通过new,new出了Compiler的实例对象compiler。最后执行compiler的run()方法。
3 接下来就要在lib/Compiler.js中新建Compiler类

let path = require('path');
let fs = require('fs');

class Compiler{
    constructor(config){
        this.config = config;
        this.entry = config.entry;
        this.entryId = '';
        this.modules = {};
        this.rootPath = process.cwd();
    }
    run(){
        this.buildModule(path.reaolve(this.rootPath,this.entry),true);
        this.emit();
    }
    buildModule(modulePath, isEntry){
        
    }
    emit(){

    }
}

module.exports = Compiler;

constructor中传入了刚才获取到的webpack.config.js的配置对象config,拿到配置中的入口entry,entryId用来存放主入口,modules对象用来存放依赖key和源码value,rootPath是当前工作路径,类似于dirname。 原型上添加了run方法,run方法内部又执行了buildModule()和emit()方法。buildModule方法的作用是通过传入的路径,获取到文件源码,解析模块之间的依赖(不是它的主要作用)。emit方法作用是把最后解析好的源码和依赖关系发射出去。
下面开始写buildModule()方法:

let path = require('path');
let fs = require('fs');

class Compiler{
    constructor(config){
        this.config = config;
        this.entry = config.entry;
        this.entryId = '';
        this.modules = {};
        this.rootPath = process.cwd();
    }
    run(){
        this.buildModule(path.resolve(this.rootPath,this.entry),true);
        this.emit();
    }
    buildModule(modulePath, isEntry){
        let source = this.getSource(modulePath);
        let moduleName = './'+path.relative(this.rootPath,modulePath);
        if(isEntry){ this.entryId = moduleName };
        let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName));
    }
    getSource(sourcePath){
        return fs.readFileSync(sourcePath,'utf8');
    }
    emit(){

    }
}


module.exports = Compiler;

buildModule()传入了两个参数。第一个参数是文件的路径,在本次代码中是主入口路径,但是之后不一定是主入口的路径。第二个参数是判断该路径是否为主入口的表示,true表示是主入口,false表示不是主入口。很显然,本次调用的buildModule()函数是主入口,所以第二个参数传递的是true。
buildModule函数第一行表示根据路径获取到源码,根据单独写的this.getSource()方法。第二行是把传入的绝对路径转换为相对路径。第三行是判断是否是主入口,是的话就把该路径保存到主入口entryId中。第四行中又多了一个parse()方法,parse()方法返回了源码sourceCode和依赖dependencies。它是核心方法,这个函数传入了源码和路径,注意这个路径是父级路径,path.dirname()。比如 原路径是‘./src/index.js’,父路径就是'./src'。为什么这样写,待会在parse()中会体现出来。

下面开始写parse方法。
写parse方法之前需要安装3个第三方库。它们分别是:babaylon, @babel/tarverse, @babel/types, @babel/generator。

  • npm i babylon @babel/traverse @babel/types @babel/generator -D

3个第三方库的作用分别是: babylon 用来生成抽象语法树AST,基本的抽象语法树作用不懂请自行百度。生成AST后才能找出每个模块之间的依赖关系。@babel/tarverse 用来遍历抽象语法树的每个节点。 @babel/types 用来替换AST抽象语法树中的某些需要替换的节点。@babel/generator,生成AST替换后的结果。

let path = require('path');
let fs = require('fs');
let babylon = require('babylon');
let traverse = require('@babel/traverse').default;
let t = require('@babel/types');
let generator = require('@babel/generator').default;

class Compiler{
    constructor(config){
        this.config = config;
        this.entry = config.entry;
        this.entryId = '';
        this.modules = {};
        this.rootPath = process.cwd();
    }
    run(){
        this.buildModule(path.resolve(this.rootPath,this.entry),true);
        this.emit();
    }
    buildModule(modulePath, isEntry){
        let source = this.getSource(modulePath);
        let moduleName = './'+path.relative(this.rootPath,modulePath);
        if(isEntry){ this.entryId = moduleName };
        let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName));
        this.modules[moduleName] = sourceCode;
    }
    parse(source, parentPath){
        let dependencies = [];
        let ast = babylon.parse(source);
        traverse(ast, {
            CallExpression(p){
                if(p.node.callee.name === 'require'){
                    p.node.callee.name = '__webpack_require__';
                    let moduleName = p.node.arguments[0].value;
                    moduleName = moduleName + (path.extname(moduleName)?'':'.js');
                    moduleName = './'+path.join(parentPath,moduleName);
                    dependencies.push(moduleName);
                    p.node.arguments = [t.stringLiteral(moduleName)];
                }
            }
        });
        let sourceCode = generator(ast).code;
        return { sourceCode, dependencies };
    }
    getSource(sourcePath){
        return fs.readFileSync(sourcePath,'utf8');
    }
    emit(){
        
    }
}


module.exports = Compiler;

首先,parse()方法是解析模块的依赖关系的。它接收了两个参数,分别是源码source和父级路径。方法内部定义了一个空数组dependencies,用来存放解析到的依赖。接着通过第三方库Babylon生成该源码的抽象语法树ast,又通过traverse解析该语法树,解析语法树的流程为: 首先找到节点node的callee,callee有个name属性。找到name为require即找到该文件的依赖,并'require'其替换为'__webpack_require__',至于为什么替换为这个名字,原因是webpack打包后自己实现的require方式名就是'__webpack_require__'。替换后方便我们之后进行模版操作。
紧接着,又拿到了依赖的文件名a.js,不过这个依赖的文件名不一定都是带有后缀.js的。所以,判断它有没有后缀名,没有的话手动添加.js。
接下来就用到了刚才传递的第二个参数:父级路径'./src'。拿到依赖文件名'a.js'和父级路径'./src',将其拼接成依赖的相对路径'./src/a.js'。并把该依赖存到dependencies依赖数组中。
并且通过babel-types将ast中的把AST中的stringLiteral替换为相对路径。
最后一步,通过替换好的抽象语法树和babel-generator,生成结果。并返回souceCode和dependencies。

在buildModule方法中打印this.module即可看到结果:

buildModule(modulePath, isEntry){
        let source = this.getSource(modulePath);
        let moduleName = './'+path.relative(this.rootPath,modulePath);
        if(isEntry){ this.entryId = moduleName };
        let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName));
        this.modules[moduleName] = sourceCode;
        console.log(this.modules);
    }
    打印结果:
    { './src/index.js':
   'console.log(\'index.js\');\n\n__webpack_require__("./src/index.js/a.js");' }

可以看到,this.module对象中存放了key(依赖的路径)和value(ast语法树解析后的源码)。表示已经成功解析。但是value中还是有__webpack_require__,因为刚才只解析一次,我们需要递归解析所有的依赖,并放到this.module中。
递归解析依赖很简单,只需要循环解析dependencies数组的元素即可,因为dependencies数组中存放的便是所有依赖。
注意,第二个参数要传fasle。因为主入口路径只有一个。执行第一次该this.buildModule()方法就已经把主入口确定并且保存到this.entryId中。

buildModule(modulePath, isEntry){
        let source = this.getSource(modulePath);
        let moduleName = './'+path.relative(this.rootPath,modulePath);
        if(isEntry){ this.entryId = moduleName };
        let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName));
        this.modules[moduleName] = sourceCode;
        dependencies.forEach((depend)=>{
            this.buildModule(path.join(this.rootPath,depend),false);
        });
        console.log(this.modules);
    }

执行命令 npx my-pick 可以看到:

{ './src/index.js':
   'console.log(\'index.js\');\n\n__webpack_require__("./src/a.js");',
  './src/a.js':
   'let b = __webpack_require__("./src/b.js");\n\nconsole.log(\'a.js\');\nconsole.log(b);',
  './src/b.js': 'module.exports = \'b.js\';' }
{ './src/index.js':
   'console.log(\'index.js\');\n\n__webpack_require__("./src/a.js");',
  './src/a.js':
   'let b = __webpack_require__("./src/b.js");\n\nconsole.log(\'a.js\');\nconsole.log(b);',
  './src/b.js': 'module.exports = \'b.js\';' }
{ './src/index.js':
   'console.log(\'index.js\');\n\n__webpack_require__("./src/a.js");',
  './src/a.js':
   'let b = __webpack_require__("./src/b.js");\n\nconsole.log(\'a.js\');\nconsole.log(b);',
  './src/b.js': 'module.exports = \'b.js\';' }

所有的依赖关系,index.js, a.js, .js 都被完整的保存到this.modules对象中。至于为什么打印了3次,那是因为dependencies数组中存放了3个元素,通过forEach()循环了3次。
(注意一点,执行npx my-pick命令时 需要到webpack目录中去执行,不要在my-pick目录执行,那样会报错)

未完待续...

相关推荐