从0到1,手写webpack的开发之路。

今天就是2019的最后一天,提早祝你们元旦快乐,css

 这几年一路走来略有心得,从了编程,也不能荒废了爱写做的手艺,因此平时有空会写点文章,关于本身的职场、人生经验之谈。前端

今天发表下本身对手写webpack的看法(若有不对,欢迎评论交流)
java



若不是生活所迫,谁会把本身弄的一身才华。[ 手动滑稽 ] node

正文

1、webpack是个啥?

webpack是一个工具,是一个致力于作前端构建的工具。简单的理解:webpack就是一个模块打包机器,它能够将前端的js代码(无论ES6/ES7)、引用的css资源、图片资源、字体资源等各类资源进行打包整合,最后按照预设规则输出到一个或多个js模块文件中,而且能够作到兼容浏览器运行。webpack



2、webpack怎么用?

下载、安装、建立配置文件(webpack.config.js)、输入配置项、搞定!web

//webpack.config.js

let path = require('path'); 

module.exports =  {
    mode: 'development',
    entry: './src/index.js',
    output: {
        filename:'main.js',
        path: path.resolve(__dirname, 'dist')
    }
}复制代码

这是一个最简单的配置,只包含了模式,入口文件以及出口文件,接下来咱们仅先讨论webpack对js文件的打包编程

3、测试demo

首先咱们建立一个空项目webpack-test,该项目下有三个js文件数组

//index.js  入口文件 

let result = require('./a.js');
console.log(result);
// a.js  引用不b.js文件 

let b = require('./b.js');

module.exports = 'a' + b;
// b.js 

module.exports = 'b';复制代码

好了,三个js文件建立好了,咱们指望webpack将这三个文件打包成一个文件,而且能正常打印'ab'。浏览器

命令行执行 npx webpack 能够看到生成main.js文件缓存

//  main.js  打包后的文件 

 (function(modules) { 
 	var installedModules = {};
 	function __webpack_require__(moduleId) {
 		if(installedModules[moduleId]) {
 			return installedModules[moduleId].exports;
 		}
 		var module = installedModules[moduleId] = {
 			i: moduleId,
 			l: false,
 			exports: {}
 		};

 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
 		module.l = true;
 		return module.exports;
 	}
 	return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })
 ({

 "./src/a.js":
 (function(module, exports, __webpack_require__) {
  eval("let b = __webpack_require__(/*! ./b.js */ \"./src/b.js\");\r\n\r\nmodule.exports = 'a' + b;\n\n//# sourceURL=webpack:///./src/a.js?");
 }),

 "./src/b.js":
 (function(module, exports) {
  eval("module.exports = 'b';\n\n//# sourceURL=webpack:///./src/b.js?");
 }),

 "./src/index.js":
 (function(module, exports, __webpack_require__) {
  eval("let result = __webpack_require__(/*! ./a.js */ \"./src/a.js\");\r\nconsole.log(result);\n\n//# sourceURL=webpack:///./src/index.js?");
 })
 });复制代码

彻底符合预期,三个 js文件打包成一个,并正常打印出'ab'。

4、分析打包文件

咱们刚才打包出的main.js文件,就是webpack最后生成的文件,那么咱们来分析下man.js,首先将里面内容清空

//  main.js  

 (function(modules) { 
 	var installedModules = {};
 	function __webpack_require__(moduleId) {
 		// ...
 	}
 	return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })();复制代码

整个文件只含一个当即执行函数(IIFE)咱们一般叫它webpackBootstrap ,函数内部最后执行__webpack_require__()函数,这个函数咱们暂且不去理会,咱们先来看传入参数(modules)是什么?

{

 "./src/a.js":
 (function(module, exports, __webpack_require__) {
  eval("let b = __webpack_require__(/*! ./b.js */ \"./src/b.js\");\r\n\r\nmodule.exports = 'a' + b;\n\n//# sourceURL=webpack:///./src/a.js?");
 }),

 "./src/b.js":
 (function(module, exports) {
  eval("module.exports = 'b';\n\n//# sourceURL=webpack:///./src/b.js?");
 }),

 "./src/index.js":
 (function(module, exports, __webpack_require__) {
  eval("let result = __webpack_require__(/*! ./a.js */ \"./src/a.js\");\r\nconsole.log(result);\n\n//# sourceURL=webpack:///./src/index.js?");
 })
 }复制代码

很明显,参数是一个对象,key对应各代码块相对路径,value则是代码块自己。 接下来咱们看函数内部都作了什么事?

// main.js   

 (function(modules) { 
  //  定义installedModules用来缓存_webpack_require_函数加载过的模块
  var installedModules = {};
  // 定义模块加载函数  __webpack_require__ 且该函数只接收一个参数moduleId
 	function __webpack_require__(moduleId) {
 		// ...
  }
  // 执行模块加载函数 传入参数为 './src/index.js' 即入口文件相对路径
 	return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })();复制代码

能够看到其实主要作了两件事:

一、定义一个模块加载函数 webpack_require。
二、使用加载函数加载入口模块 “./src/index.js”。
接下来咱们分析__webpack_require__函数内部逻辑

// webpack 模块加载函数 __webpack_require__

 	function __webpack_require__(moduleId) {
    // 重复加载则利用缓存
 		if(installedModules[moduleId]) {
 			return installedModules[moduleId].exports;
    }
    // 第一次被加载的模块 初始化模块对象 并缓存到installedModules对象里
 		var module = installedModules[moduleId] = {
 			i: moduleId,  // module 对象i 属性值为传入参数moduleId 即 模块相对路径值
 			l: false,     // l 属性值为false 标识未加载
 			exports: {}   // 模块导出对象
 		}
    //  
    /**
     * module.exports 模块导出对象引用 其实就是改变了模块包裹函数内部的 this 指向
     * module 当前模块对象引用
     * module.exports 模块导出对象引用
     * __webpack_require__ 用于在模块中加载其余模块
     */ 
     modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    //  模块加载标识为已加载 
     module.l = true;
    //  返回当前模块的导出对象引用
 		return module.exports;
 	}复制代码

首先,加载函数使用了闭包变量 installedModules,用来将已加载过的模块保存在内存中。 接着是初始化模块对象,并把它挂载到缓存里。而后是模块的执行过程,加载入口文件时 modules[moduleId] 其实就是 ./src/index.js 对应的模块函数。执行模块函数前传入了跟模块相关的几个实参,让模块能够导出内容,以及加载其余模块的导出。最后标识该模块加载完成,返回模块的导出内容。

根据 webpack_require 的缓存和导出逻辑,咱们得知在整个 IIFE 运行过程当中,加载已缓存的模块时,都会直接返回installedModules[moduleId].exports,换句话说,相同的模块只有在第一次引用的时候才会执行模块自己。

模块都经过modules[moduleId].call(module.exports, module, module.exports, webpack_require);这个函数加载进来 下面咱们就进入到 modules[moduleId]代码块内部。 首先加载的确定是入口文件'./src/index.js'

// "./src/index.js":
 (function(module, exports, __webpack_require__) {
  let result = __webpack_require__("./src/a.js");
  console.log(result);
 })复制代码

能够看到当加载index.js的时候 先经过__webpack_require__函数先去加载a.js

// "./src/a.js":
 (function(module, exports, __webpack_require__) {
  let b = __webpack_require__("./src/b.js");
  module.exports = 'a' + b;
 })复制代码

当去加载a.js时候其实又先去加载b.js

(function(module, exports) {
  module.exports = 'b';
 })复制代码

经过代码咱们很直观能够看出模块加载流程,只需肯定入口文件,就能够将全部模块按顺序加载进来,而且经过moduleId参数可确保同一模块只需加载一次



5、肯定咱们本身的实现方案

经过以上分析,咱们须要作如下工做:

一、拿到入口文件路径(简单 配置信息里就有) 二、拿到各模块相对路径以及源码(须要本身实现) 三、实现模块加载函数 (参照_webpack_require__)

第一步配置咱们本身的打包命令

"scripts": {
    "test-webpack": "node test-webpack.js"
  }复制代码

第二步建立执行文件

// test-webpack.js

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

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

// 编译类   
let compiler = new Compiler(config);
// 运行代码
compiler.run();复制代码

这个文件里干了两件事,第一拿到打包配置信息(webpack.config.js),第二执行编译函数(Compiler()),咱们的核心代码其实也就在Compiler.js这个文件里

// Compiler.js

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

class Compiler{
    constructor(config){
        // 接收传入config 将config挂载到实例config上  
        this.config = config;
        // 入口文件路径
        this.entryPath = config.entry ;
        // 工做路径
        this.root = process.cwd();
        // 解析全部的模块依赖
        this.modules = {};
    }
    // run 方法开始编译
    run(){
        // 第一步须要建立模块依赖关系
        // buildModule 函数接收两个参数,一、文件绝对路径,二、是不是主模块
        this.buildModule(path.resolve(this.root,this.entryPath),true);

        // 将打包后的文件发射出去
        this.emitFile();    
    }
    // 建立模块依赖关系
    buildModule(modulePath,isEntry){
        
    }
    emitFile(){

    }
}

module.exports = Compiler;复制代码

以前咱们说过咱们须要作的就是生成实参, buildModule函数须要帮咱们拿到key值 value值

// 建立模块依赖关系
    buildModule(modulePath,isEntry){
        // 拿到模块id 相对路径   即key值 根据绝对路径和工做路径就可获取相对路径
        let moduleId = './' + path.relative(this.root,modulePath);
        // 是不是主入口
        if( isEntry ){
            this.entryPath =  moduleId;
        }
        // 根据路径拿到模块源码
        let sourceCode = fs.readFileSync(modulePath,'utf8');
        // 解析源码 改造源码 拿到value值
        let { resultCode,dependencies } = this.parse(sourceCode,path.dirname(moduleId)); 

        // 把相对路径和模块中的内容对应起来
        this.modules[moduleId] = resultCode;
        // 递归执行 加载每个依赖模块
        dependencies.forEach(dep=>{
            this.buildModule(path.resolve(this.root,dep),false);
        })
    }复制代码

key 值是肯定的,value值须要咱们解析改造,咱们先来看源码是什么

let result = require('./a.js');
console.log(result);复制代码

再来看目标代码是什么

{"./src/index.js":
(function(module, exports, __webpack_require__) {
    eval("let result = __webpack_require__(/*! ./a.js */ \"./src/a.js\");\r\nconsole.log(result);\n\n//# sourceURL=webpack:///./src/index.js?");})
 }复制代码

对比源码 和 目标代码
一、将require转为__webpack_require__
二、参数'./a.js' 转为 './src/a.js'

这块须要引入一个概念抽象语法树(AST),解析源码用的,这块不对它作过多介绍。
而后须要引入几个辅助包
一、babylon //其做用是把源码转换为ast
二、@babel/traverse //遍历节点
三、@babel/types //替换遍历节点
四、@babel/generator //生成替换的节点

// 解析源码
    parse(source,parentPath){
        // 源码解析成ast
        let ast = babylon.parse(source);
        // 存储每一个模块所需依赖的模块路径
        let dependencies = [];
        // 遍历解析后的源码
        traverse(ast,{
            CallExpression(p){  
                let node = p.node; // 对应每一个节点
                if( node.callee.name === 'require' ){
                    // 若是节点是require 将 require 改形成 __webpack_require__
                    node.callee.name = '__webpack_require__' ;
                    // 改造require里面的参数 './a.js' > './src/a.js'
                    let resultPath = node.arguments[0].value;  //拿到模块引用名字
                    resultPath = resultPath + (path.extname(resultPath)?'':'.js') // 判断是否写后缀名
                    resultPath = './' + path.join(parentPath,resultPath); //拼接上父路径 
                    dependencies.push(resultPath);
                    node.arguments = [t.stringLiteral(resultPath)]  //源码名字改掉
                }
            }
        });
        // 生成转换后的代码
        let resultCode =  generator(ast).code;
        // 输出转化后的源码和依赖
        return {resultCode,dependencies }
    }复制代码

咱们的核心代码就完成了,打印一下转化后的源码

{ './src\\index.js':
   '//index.js 入口文件 \nlet result = __webpack_require__("./src\\\\a.js");\n\nconsole.log(result);',
  './src\\a.js':
   '// a.js 引用不b.js文件 \nlet b = __webpack_require__("./src\\\\b.js");\n\nmodule.exports = \'a\' + b;',
  './src\\b.js': '// b.js \nmodule.exports = \'b\';' }复制代码

生成main.js

(function (modules) { 
    var installedModules = {};
    function __webpack_require__(moduleId) {
        if(installedModules[moduleId]) {
            return installedModules[moduleId].exports;
        }
        var module = installedModules[moduleId] = {
            i: moduleId,
            l: false,
            exports: {}
        };
        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
        module.l = true;
        return module.exports;
    }
    __webpack_require__.m = modules;
    __webpack_require__.c = installedModules;
    __webpack_require__.d = function(exports, name, getter) {
        if(!__webpack_require__.o(exports, name)) {
            Object.defineProperty(exports, name, { enumerable: true, get: getter });
        }
    };
    __webpack_require__.r = function(exports) {
        if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
            Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
        }
        Object.defineProperty(exports, '__esModule', { value: true });
    };

    __webpack_require__.t = function(value, mode) {
        if(mode & 1) value = __webpack_require__(value);
        if(mode & 8) return value;
        if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
        var ns = Object.create(null);
        __webpack_require__.r(ns);
        Object.defineProperty(ns, 'default', { enumerable: true, value: value });
        if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
        return ns;
    };
    __webpack_require__.n = function(module) {
        var getter = module && module.__esModule ?
            function getDefault() { return module['default']; } :
            function getModuleExports() { return module; };
        __webpack_require__.d(getter, 'a', getter);
        return getter;
    };
    __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
    __webpack_require__.p = "";
    return __webpack_require__(__webpack_require__.s = "<%-entryPath%>");
})({
    <%for(let in key modules){%>
        "<%-key%>":
        (function(module, exports, __webpack_require__) {
            eval(`<%-modules[key]%>`),
        })
    <%}%>
})复制代码

模板文件用ejs去作,将webpackBootstrap源码拿过来稍微改造下,将入口文件路径和参数传入,接下来咱们须要写emitFile函数

emitFile(){
        // 输出到那个目录下
        let main = path.join(this.config.output.path,this.config.output.filename);
        // 将代码都出来
        let templateStr = fs.readFileSync(path.join(__dirname,'template.ejs'),'utf8');
        // 用ejs渲染 获得目标代码块
        let code = ejs.render(templateStr,{entryPath:this.entryPath,modules:this.modules});
        // 将路径和代码块对应起来
        this.assets = {};
        this.assets[main] = code;
        // 将文件写入
        fs.writeFileSync(main,this.assets[main]);
        
    }复制代码

好了 咱们已经实现了webpack打包,接下来咱们看webpack中的loader与插件

loader

什么是loader? webpack只能处理javaScript的模块,若是须要处理其余类型的文件,就须要使用loader进行转化。说的更直白些就是说webpack打包的时候只能识别.js文件,那么它遇到其余类型的文件就不知道该怎么办了,这个时候须要一个函数将其余类型的文件包起来转换成js代码,而后webpack就能够执行打包,而这个函数就是loader。常见的loder有file-loader、url-loader、style-loader、css-loader、less-loader等。下面咱们手写下less-loader和css-loader。

//  less-loader.js

// 引入less模块
let less = require('less');

//  loader 就是个函数,拿到源码转换成目标代码返回 less-loader 干的事就是把Less代码转换成css
function loader( code ){
    let css = '';
    less.render(code,function(err,c){
        css = c.css;
    })
    css = css.replace(/\n/g, '\\n');
     return css;
}

module.exports = loader;复制代码

less-loader 干的事就是把Less代码转换成css

//  style-loader.js

function loader( code ){
    let style = `
        document.createElement('style');
        style.innerHTML = ${JSON.stringify(code)}
        document.head.appendChild(style);
    `
    return style;
}

module.exports = loader;复制代码

style-loader就更简单了,拿到css源码,而后建立style标签,将源码赋值给style标签,最后将style插入头部

好了,咱们知道loader其实就是函数,目的是为了加载非js文件,那么webpack如何执行这些函数呢?

// webpack.config.js

let path = require('path'); 

module.exports =  {
    mode: 'development',
    entry: './src/index.js',
    output: {
        filename:'main.js',
        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /\.less/,
                use: [
                    path.resolve(__dirname,'loader','style-loader'),
                    path.resolve(__dirname,'loader','less-loader'),
                ]
            }
        ]
    }
}复制代码

固然须要在webpack.config.js 文件中配置信息告诉webpack什么状况下用什么loader,上面这段配置信息表面,当加载.less文件时,先执行less-loader函数,再执行 style-loader函数。 而后咱们看在加载函数中拿到这些信息后怎么作?

getSource(modulePath){
        // 拿到loader配置信息,是个数组
        let rules = this.config.module.rules;
        let content = fs.readFileSync(modulePath,'utf8');
        // 遍历每一个规则来处理
        for ( let i = 0; i < rules.length; i++) {
            let rule = rules[i];
            let { test, use } = rule;
            // 从最后一个规则开始执行
            let len = use.length - 1 ;
            if( test.test(modulePath) ){  // 这个模块须要用loader转换
                // 获取对应的loader函数
                function normalLoader(){
                    let loader = require(use[len--]);
                    content = loader(content) 
                    //  递归调用
                    if( len >= 0 ){
                        normalLoader();
                    }
                }
                normalLoader();
            }
        }
        return content;
    }复制代码

当咱们加载模块的时候,须要拿到loader配置信息,而后匹配什么文件用什么对应的loader函数,最后将处理后的代码返回。

plugin

webpack插件是一个具备apply方法的js对象,apply方法会被webpack的compiler(编译器)对象调用,而且compiler对象可在整个编译生命周期内访问。

实现插件功能咱们的compiler里面必须定义生命周期钩子函数,借助tapable(能够实现发布订阅) 实现咱们的钩子函数。

let { SyncHook } = require('tapable');

class Compiler{
    constructor(config){
        // 接收传入config 将config挂载到实例config上  
        this.config = config;
        // 入口文件路径
        this.entryPath = config.entry ;
        // 工做路径
        this.root = process.cwd();
        // 解析全部的模块依赖
        this.modules = {};
        // 建立钩子函数
        this.hooks = {
            entryOption: new SyncHook(),
            compile: new SyncHook(),
            afterCompile: new SyncHook(),
            run: new SyncHook(),
            emit: new SyncHook(),
            done: new SyncHook(),
        }
        // 若是配置了plugins 参数 拿到每一个插件 并执行其apply方法。
        let plugins = this.config.plugins;
        if(Array.isArray(plugins)){
            plugins.forEach(plugin=>{
                plugin.apply(this);
            });
        }
    }
}复制代码

而后将钩子函数放入对应的生命周期内

// run 方法开始编译
    run(){
        // 执行开始编译钩子函数
        this.hooks.compile.call();
        // 第一步须要建立模块依赖关系
        // buildModule 函数接收两个参数,一、文件绝对路径,二、是不是主模块
        this.buildModule(path.resolve(this.root,this.entryPath),true);

        // 执行编译完钩子函数
        this.hooks.afterCompile.call();

        // 将打包后的文件发射出去
        this.emitFile();    

        // 执行发射完钩子函数
        this.hooks.emit.call();

        // 最终完成钩子函数
        this.hooks.emit.call();

    }复制代码

下面咱们就编写一个插件, webpack插件的组成:

一个JavaScript函数或者class(ES6语法)。 在它的原型上定义一个apply方法。 指定挂载的webpack事件钩子。 处理webpack内部实例的特定数据。 功能完成后调用webpack提供的回调。

class TestPlugin{
    apply(compiler){
        compiler.hooks.compile.tap('compile',function(){
            console.log('开始编译阶段,执行须要的插件')
        })
    }
}复制代码

咱们定义了TestPlugin这么一个插件, 它有一个apply方法,该方法内部监听开始编译钩子函数,最后咱们将插件配置上

plugins: [
        new TestPlugin(),
    ]复制代码

运行

$ yarn run test-webpack
yarn run v1.21.0
$ node test-webpack.js
开始编译阶段,执行须要的插件
Done in 2.82s.复制代码

能够看到咱们的插件已经实现了。

这样咱们一个简易版的webpack就已经实现了。

在2019年的最后一天,送朋友们一句话:“永远年轻,永远热泪盈眶。”


来源于个人我的公众号「码农小刘」。

不为流量,只为能结交更多喜欢互联网、热爱前端的朋友。

相关文章
相关标签/搜索