對比Webpack,使用Babel+Node實現一個100行的小型打包工具

前言

Webpack很強大,做爲前端開發人員咱們必須熟練掌握。但它的原理其實並不難理解,甚至很簡單。畢竟全部複雜的事物都是由簡單的事物組合造成的。不光是Webpack,像Vue,React這樣成熟的前端框架亦是如此。css

讀完本文,你會認識到:html

  1. Webpack打包本質仍是使用fs模塊讀寫文件,加以組合。
  2. Babel真的很強大,方便咱們分析源代碼,提取有用的信息。
  3. 若是你瞭解過loader,你就會知道讀取源代碼以後能夠如何操做,而不是僅僅進行簡單的字符串匹配。

另外,但願你能跟着本身實現一遍,代碼量真的不大前端

源碼,能夠clone下來寫一寫node

預備知識

先看一個例子,也許你還不知道,node其實還有這樣一個彩蛋:webpack

新建test.js輸入一行代碼:git

/* test.js */
console.log(arguments.callee.toString())
複製代碼

在命令行中輸入node test.js運行結果以下:es6

function (exports, require, module, __filename, __dirname) {
    console.log(arguments.callee.toString())
}
複製代碼

注意這是控制檯輸出的代碼,也就是console.log()的輸出結果github

因爲arguments.callee這個屬性指向函數的調用者,咱們使用toString()轉化後發現這竟然是一個函數,由此說明,node的代碼實際上是運行在一個函數中的。咱們寫的代碼最終會被這樣一個函數包裹,經常使用的require,module,exports,__dirname, __filename都是這個函數的參數,因此咱們才隨處可用。web

進入正題

代碼結構

  • message.js:定義了兩個變量,並導出
export const message = 'qin'
export const weather = 'sunny day'
複製代碼
  • say.js: 定義一個函數並導出
export default function (name) {
    console.log(`hello ${name}`)
}
複製代碼
  • main.css: 樣式文件
#app {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    animation: breath 2s ease infinite;
}
@keyframes breath {
    from, to {
        width: 100px;
        height: 100px;
        background-color: black;
    }
    50% {
        width: 200px;
        height: 200px;
        background-color: red;
    }
}
複製代碼
  • main.js:入口文件
import hello from './js/say.js'
import { message, weather } from './js/message.js'
import './css/main.css'

hello(message)

hello(`今天的天氣是:${weather}`)
複製代碼

打包思路

  1. 首先咱們要從入口文件main.js開始,遞歸解析依並讀取文件內容。可使用@babel/parser來實現。
  2. 獲取文件內容以後作相應的處理,例如css咱們須要使用一點js代碼構建style節點,並插入頁面中,也就是常說的CSS in JS這個概念。
  3. 將全部資源合併成一個文件,實現打包。打包後的代碼要運行在瀏覽器環境中,因此爲了不產生全局污染,咱們須要將打包後的代碼放進閉包中運行,併爲其傳遞所運行須要的參數,因此,打包後的代碼總體結構以下:
(function (參數) {
    /* 函數體 */
})(傳參)
複製代碼

面臨的問題

  1. 瀏覽器不認import語法,咱們須要使用babel轉換爲ES5
  2. 咱們的打包工具運行在node環境中,打包過程當中勢必使用CommonJs的模塊規範,即便用require和module.exports來組織模塊之間的引用關係。但問題是瀏覽器中沒有require,沒有module,沒有exports。

聰明的你應該想到了,開篇提到的例子就是爲了解決這個問題。npm

借鑑webpack

配置webpack進行打包,具體配置很是簡單這裏就不貼代碼了。若是你還不會配置的話,或許須要先學習webpack的基礎知識。

我刪剪了部分代碼,那不屬於咱們討論的範疇,最後生成的bundle.js內容以下:

(function(modules) {
	// webpack中用來模擬node環境下的require函數
 	function __webpack_require__(path) {
        // 構造一個模塊
        var module = { exports: {} };
		// 執行模塊對應的函數
        modules[path].call(module.exports, module, module.exports,__webpack_require__);
        
		// 返回模塊加載的的結果
        return module.exports;
 	}

 	__webpack_require__("./src/main.js");
 }) ({
    "./src/css/main.css": (function(module, exports, __webpack_require__) {
        eval("var api = __webpack_require__(/*! ../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js */ \"./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\");\n var content = __webpack_require__(/*! !../../node_modules/css-loader/dist/cjs.js!./main.css */ \"./node_modules/css-loader/dist/cjs.js!./src/css/main.css\");\n\n content = content.__esModule ? content.default : content;\n\n if (typeof content === 'string') {\n content = [[module.i, content, '']];\n }\n\nvar options = {};\n\noptions.insert = \"head\";\noptions.singleton = false;\n\nvar update = api(content, options);\n\nvar exported = content.locals ? content.locals : {};\n\n\n\nmodule.exports = exported;\n\n//# sourceURL=webpack:///./src/css/main.css?");
    }),

    "./src/js/message.js": (function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"message\", function() { return message; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"weather\", function() { return weather; });\nconst message = 'qin';\nconst weather = 'sunny day';\n\n//# sourceURL=webpack:///./src/js/message.js?");
    }),

    "./src/js/say.js": (function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        eval("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (function (name) {\n console.log(`hello ${name}`);\n});\n\n//# sourceURL=webpack:///./src/js/say.js?");
    }),

    "./src/main.js": (function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _js_say_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./js/say.js */ \"./src/js/say.js\");\n/* harmony import */ var _js_message_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./js/message.js */ \"./src/js/message.js\");\n/* harmony import */ var _css_main_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./css/main.css */ \"./src/css/main.css\");\n/* harmony import */ var _css_main_css__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_css_main_css__WEBPACK_IMPORTED_MODULE_2__);\n\n\n\nObject(_js_say_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(_js_message_js__WEBPACK_IMPORTED_MODULE_1__[\"message\"]);\nObject(_js_say_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(`今天的天氣是:${_js_message_js__WEBPACK_IMPORTED_MODULE_1__[\"weather\"]}`);\n\n//# sourceURL=webpack:///./src/main.js?");
    })

});
複製代碼

能夠看到,總體是一個閉包函數,傳遞的參數爲已加載的全部的模塊組成的對象。這裏能夠看到,模塊就是一個個的函數,即開篇提到的例子。閉包函數的主體是,經過模擬的require函數找到對應模塊並調用。至於eavl,不用多說了吧?傳入代碼內容字符串就會執行了。

開始實現

須要用到的工具以下

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser') // 生成抽象語法樹
const { transformFromAst } = require('@babel/core') // 轉換es6語法
const { default: traverse } = require('@babel/traverse') // 抽象語法樹分析

複製代碼

注意,traverse模塊是ES6 Module,因此使用CommonJs引入時須要加上default

快速安裝:

npm install @babel/parser @babel/core @babel/traverse @babel/parser -D
複製代碼

解析文件內容

好的,如今在根目錄下新建bundler.js用來打包,咱們的打包流程將寫在這裏。首先實現analyze函數:

/**
 * 經過路徑讀取文件並解析
 * @param {String} filePath 
 * @return {Object} 解析結果
 */
const analyze = function (filePath) {
    const content = fs.readFileSync(filePath, 'utf-8')
    const ast = parser.parse(content, { sourceType: 'module' })
    const dependencies = [] 
    // 轉換es6語法,並獲得轉換後的源代碼
    const { code } = transformFromAst(ast, null, {
        presets: ['@babel/env']
    })
    // 分析依賴
    traverse(ast, {
        // 分析依賴的鉤子
        ImportDeclaration ({ node }) {
            dependencies.push(node.source.value) // 得到全部依賴
        }
    })
    return {
        filePath,
        dependencies,
        code
    }
}
複製代碼

這裏解釋下traverse函數的做用。咱們使用@babel/parser生成抽象語法樹AST,就是一個描述代碼結構的JSON對象,這個對象中包含了語法信息。咱們能夠打印下

console.log(ast.program.body)
複製代碼

結果是一個數組,我截取了數組中的一個元素,以下:

Node {
    type: 'ImportDeclaration',
    start: 0,
    end: 31,
    loc: SourceLocation { start: [Position], end: [Position] },
    specifiers: [ [Node] ],
    source: Node {
      type: 'StringLiteral',
      start: 18,
      end: 31,
      loc: [SourceLocation],
      extra: [Object],
      value: './js/say.js'
    }
}
複製代碼

能夠看到type: 'ImportDeclaration'說明這是一個import引入語法,如此一來,咱們就能夠輕鬆的拿到對應的依賴,如上例是./js/say.js

traverse中的ImportDeclaration鉤子,參數中包含node屬性,這就是咱們須要找的依賴文件,咱們將它保存起來用於下面的分析。

遞歸解析依賴

經過對代碼的依賴分析,獲取全部資源,用於最後的打包

/**
 * 經過入口文件遞歸解析依賴,並返回全部的依賴
 * @param {String} entryFile 入口文件
 * @return 依賴的全部代碼
 */
const getAssets = function (entryFile) {
    const entry = analyze(entryFile)
    const dependencies = [entry] // 起初依賴只包含入口,隨着遍歷不斷加入
    for (const asset of dependencies) {
        // 獲取目錄名
        const dirname = path.dirname(asset.filePath)
        asset.dependencies.forEach(relPath => {
            // 將相對路徑轉換爲絕對路徑,相對路徑是基於dirname的
            const absolutePath = path.join(dirname, relPath)
            // 處理css文件
            if (/\.css$/.test(absolutePath)) {
                const content = fs.readFileSync(absolutePath, 'utf-8')
                // 使用js插入style節點
                const cssInsertCode = `
                    const stylesheet = document.createElement('style');
                    stylesheet.innerText = ${JSON.stringify(content)};
                    document.head.appendChild(stylesheet);
                `
                dependencies.push({
                    filePath: absolutePath,
                    relPath, // 記得保存相對路徑,由於require的時候須要用到
                    dependencies: [],
                    code: cssInsertCode
                })
            } else {
                const child = analyze(absolutePath)
                child.relPath = relPath // 同上
                dependencies.push(child) // 遞歸解析
            }            
        })
    }
    return dependencies
}
複製代碼

開始打包

打包的目的是將文件合併,因爲瀏覽器環境限制,咱們須要構造閉包,還要模擬node的環境變量。

/**
 * 打包流程主函數
 * @param {String} entry 入口文件
 * @return void
 */
const bundle = function (entry) {
    const dependencies = getAssets(entry)
    // 將依賴構建成對象
    const deps = dependencies.map(dep => {
        const filePath = dep.relPath || entry
        // 路徑和模塊造成映射
        return `'${filePath}':function (exports, require, module) { ${dep.code} }`
    })

    // 構造require函數,babel解析後的代碼是node環境下的,咱們須要構造相應的函數
    // 來模擬原生require,從咱們構建的deps對象中獲取相應模塊函數
    const result = `(function(deps){
        function require(path){
            // 構造一個模塊,表示當前模塊
            const module = { exports: {} }
            // 執行對應的模塊,並傳入參數
            deps[path](module.exports, require, module)
            // 返回模塊導出的內容,也就是require函數獲取到的內容
            return module.exports
        }
        require('${entry}') // 從入口文件開始執行
    })({${deps.join(',')}})`

    // 若是你想壓縮成一行能夠加上這個,可是相應的要安裝babel-preset-minify
    // const ast = parser.parse(result, { sourceType: 'script' })
    // const { code } = transformFromAst(ast, null, {
    //     presets: ['minify']
    // })
    
    // 寫入文件
    fs.writeFileSync('./public/vendors.js', result) // 若是你壓縮了,這裏填code
}

// 運行打包
bundle('./src/main.js')
複製代碼

須要注意的是,咱們要將代碼覺得本的形式拼接在一塊兒,不然代碼將會直接運行生成結果,這不是咱們想要的。牢記,咱們是在拼接代碼。

${deps.join(',')}獲得的內容是一個字符串,咱們用一個大括號括起來,在運行時就至關因而一個對象了,即{${deps.join(',')}}

也許你會想直接構造一個對象而後使用JSON.stringify不就行了嗎。實際上不行,由於咱們的這個對象的鍵值對中,key能夠是字符串,可是value不行,value是咱們模擬的一個node模塊,是一個函數,JSON.stringify會致使咱們最終獲取到的是函數的字符串,而不是函數。

驗收成果

打包後的vendors.js內容以下:

(function (deps) {
  function require(path) {
    const module = {
      exports: {}
    }
    deps[path](module.exports, require, module)
    return module.exports
  }
  require('./src/main.js')
})({
  './src/main.js': function (exports, require, module) {
    "use strict";

    var _say = _interopRequireDefault(require("./js/say.js"));

    var _message = require("./js/message.js");

    require("./css/main.css");

    function _interopRequireDefault(obj) {
      return obj && obj.__esModule ? obj : {
        "default": obj
      };
    }

    (0, _say["default"])(_message.message);
    (0, _say["default"])("\u4ECA\u5929\u7684\u5929\u6C14\u662F\uFF1A".concat(_message.weather));
  },
  './js/say.js': function (exports, require, module) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });
    exports["default"] = _default;

    function _default(name) {
      console.log("hello ".concat(name));
    }
  },
  './js/message.js': function (exports, require, module) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });
    exports.weather = exports.message = void 0;
    var message = 'qin';
    exports.message = message;
    var weather = 'sunny day';
    exports.weather = weather;
  },
  './css/main.css': function (exports, require, module) {
    const stylesheet = document.createElement('style');
    stylesheet.innerText = "#app {\r\n position: absolute;\r\n top: 50%;\r\n left: 50%;\r\n transform: translate(-50%, -50%);\r\n animation: breath 2s ease infinite;\r\n}\r\n@keyframes breath {\r\n from, to {\r\n width: 100px;\r\n height: 100px;\r\n background-color: black;\r\n }\r\n 50% {\r\n width: 200px;\r\n height: 200px;\r\n background-color: red;\r\n }\r\n}";
    document.head.appendChild(stylesheet);
  }
})
複製代碼

對比webpack的結果,是否是很類似?只不過咱們沒有使用eval函數,而是將代碼直接寫在函數體中。

新建html文件並引入vendors.js

<div id="app"></div>
<script src="./vendors.js"></script>
複製代碼

結果以下:

動畫效果

控制檯

生效了,沒問題。

這就是咱們自制的一個小型打包工具啦~喜歡點個贊哈😊

補充

在webpack打包代碼結果展現那裏,我刪除的代碼是關於webpack的一些更高級的功能的。例如webpack內置了緩存機制,一個模塊加載事後就會緩存起來,並賦予id值,而後標記爲已加載。之後再加載這個模塊的時候經過標記判斷,加載過的話就直接讀緩存。

咱們構建的module是這樣的:

const module = { exports: {} }
複製代碼

__webpack_require__中構建的module是這樣的:

// installedModules就是緩存
var module = installedModules[moduleId] = {
    i: moduleId, // 經過id來獲取
    l: false, // loaded:標識是否加載過
    exports: {}
};
複製代碼

咱們再去node中打印一下module的值:

console.log(module, module.exports === exports)
// 結果以下:
Module {
  id: '.',
  path: 'c:\\Users\\Administrator\\Desktop',
  exports: {},
  parent: null,
  filename: 'c:\\Users\\Administrator\\Desktop\\a.js',
  loaded: false,
  children: [],
  paths: [
    'c:\\Users\\Administrator\\Desktop\\node_modules',
    'c:\\Users\\Administrator\\node_modules',
    'c:\\Users\\node_modules',
    'c:\\node_modules'
  ]
} true
複製代碼

看這結構模,是這麼的類似~文件名,模塊id,路徑等。是否是有種盡在掌握的感受?😄

相比之下咱們構建的module就很簡陋了,不過仍是能說明問題的,至少證實,node的模塊化機制也沒有那麼難理解嘛。

咱們還能夠看到,module.exports和exports是同一個對象,指向同一塊內存,所以咱們既能夠經過exports.a = 1這種屬性的方式導出,也能夠經過module.exports = {a:1}這種字面量的方式導出。

可是使用exports時,不能直接賦值,如:exports = {a:1},這是沒法正常導出的,涉及js中引用類型的存儲問題,這裏再也不贅述。

參考

【掘金】實現小型打包工具

相關文章
相關標籤/搜索