從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年的最後一天,送朋友們一句話:「永遠年輕,永遠熱淚盈眶。」


來源於個人我的公衆號「碼農小劉」。

不爲流量,只爲能結交更多喜歡互聯網、熱愛前端的朋友。

相關文章
相關標籤/搜索