經過實現一個簡易打包工具,分析打包的原理

概述

眼下wepack彷佛已經成了前端開發中不可缺乏的工具之一,而他的一切皆模塊的思想隨着webpack版本不斷的迭代(webpack 4)使其打包速度更快,效率更高的爲咱們的前端工程化服務
javascript

相信你們使用webpack已經很熟練了,他經過一個配置對象,其中包括對入口,出口,插件的配置等,而後內部根據這個配置對象去對整個項目工程進行打包,從一個js文件切入(此爲單入口,固然也能夠設置多入口文件打包),將該文件中全部的依賴的文件經過特定的loader和插件都會按照咱們的需求爲咱們打包出來,這樣在面對當前的ES六、scss、less、postcss就能夠暢快的儘管使用,打包工具會幫助咱們讓他們正確的運行在瀏覽器上。可謂是省時省力還省心啊。css

那當下的打包工具的核心原理是什麼呢?今天就來經過模擬實現一個小小的打包工具來爲探究一下他的核心原理嘍。文中有些知識是點到,沒有深挖,若是有興趣的能夠自行查閱資料。前端

功力尚淺,只是入門級的瞭解打包工具的核心原理,簡單的功能java

項目地址

Pack:https://github.com/liuchengying/Packnode

原理

當咱們更加深刻的去了解javascript這門語言時,去知道javascript更底層的一些實現,對咱們理解好的開源項目是由不少幫助的,固然對咱們自身技術提升會有更大的幫助。
javascript是一門弱類型的解釋型語言,也就是說在咱們執行前不須要編譯器來編譯出一個版本供咱們執行,對於javascript來講也有編譯的過程,只不過大部分狀況下編譯發生在代碼執行前的幾微秒,編譯完成後會盡快的執行。也就是根據代碼的執行去動態的編譯。而在編譯過程當中經過語法和詞法的分析得出一顆語法樹,咱們能夠將它稱爲AST抽象語法樹(Abstract Syntax Tree)也稱爲AST語法樹,指的是源代碼語法所對應的樹狀結構。也就是說,一種編程語言的源代碼,經過構建語法樹的形式將源代碼中的語句映射到樹中的每個節點上。】。而這個AST卻偏偏使咱們分析打包工具的重點核心。react

咱們都熟悉babel,他讓前端程序員很爽的地方在於他可讓咱們暢快的去書寫ES六、ES七、ES8.....等等,而他會幫咱們通通都轉成瀏覽器可以執行的ES5版本,它的核心就是經過一個babylon的js詞法解析引擎來分析咱們寫的ES6以上的版本語法來獲得AST(抽象語法樹),再經過對這個語法樹的深度遍從來對這棵樹的結構和數據進行修改。最終轉經過整理和修改後的AST生成ES5的語法。這也就是咱們使用babel的主要核心。一下是語法樹的示例webpack

須要轉換的文件(index.js)git

// es6  index.js
    import add from './add.js'
    let sum = add(1, 2);
    export default sum
    // ndoe build.js
    const fs = require('fs')
    const babylon = require('babylon')

    // 讀取文件內容
    const content = fs.readFileSync(filePath, 'utf-8')
    // 生成 AST 經過babylon
    const ast = babylon.parse(content, {
        sourceType: 'module'
    })
    console.log(ast)

執行文件(在node環境下build.js)程序員

// node build.js
    // 引入fs 和 babylon引擎
    const fs = require('fs')
    const babylon = require('babylon')

    // 讀取文件內容
    const content = fs.readFileSync(filePath, 'utf-8')
    // 生成 AST 經過babylon
    const ast = babylon.parse(content, {
        sourceType: 'module'
    })
    console.log(ast)

生成的ASTes6

ast = {
        ...
        ...
        comments:[],
        tokens:[Token {
                    type: [KeywordTokenType],
                    value: 'import',
                    start: 0,
                    end: 6,
                    loc: [SourceLocation] },
                Token {
                    type: [TokenType],
                    value: 'add',
                    start: 7,
                    end: 10,
                    loc: [SourceLocation] },
                Token {
                    type: [TokenType],
                    value: 'from',
                    start: 11,
                    end: 15,
                    loc: [SourceLocation] },
                Token {
                    type: [TokenType],
                    value: './add.js',
                    start: 16,
                    end: 26,
                    loc: [SourceLocation] },
                Token {
                    type: [KeywordTokenType],
                    value: 'let',
                    start: 27,
                    end: 30,
                    loc: [SourceLocation] },
                Token {
                    type: [TokenType],
                    value: 'sum',
                    start: 31,
                    end: 34,
                    loc: [SourceLocation] },
                ...
                ...
                Token {
                    type: [KeywordTokenType],
                    value: 'export',
                    start: 48,
                    end: 54,
                    loc: [SourceLocation] },
                Token {
                    type: [KeywordTokenType],
                    value: 'default',
                    start: 55,
                    end: 62,
                    loc: [SourceLocation] },
                Token {
                    type: [TokenType],
                    value: 'sum',
                    start: 63,
                    end: 66,
                    loc: [SourceLocation] },
            ]
   }

上面的示例就是分析出來的AST語法樹。babylon在分析源代碼的時候,會逐個字母的像掃描機同樣讀取,而後分析得出語法樹。(關於語法樹和babylon能夠參考 https://www.jianshu.com/p/019d449a9282)。經過遍歷對他的屬性或者值進行修改根據相應的算法規則從新組成代碼。當分析咱們正常的js文件時,每每獲得的AST會很大甚至幾萬、幾十萬行,因此須要很優秀的算法才能保證速度和效率。下面本項目中用到的是babel-traverse來解析AST。對算法的感興趣的能夠去了解一下。以上部分講述的知識點並無深刻,緣由如題目,只是要探索出打包工具的原理,具體知識點感興趣的本身去了解下吧。原理部分大概介紹到這裏吧,下面開始施實戰。

項目目錄

├── README.md
    ├── package.json
    ├── src
    │   ├── lib
    │   │   ├── bundle.js // 生成打包後的文件
    │   │   ├── getdep.js // 從AST中得到文件依賴關係
    │   │   └── readcode.js //讀取文件代碼,生成AST,處理AST,而且轉換ES6代碼
    │   └── pack.js // 向外暴露工具入口方法
    └── yarn.lock

思惟導圖

經過思惟導圖能夠更清楚羅列出來思路

具體實現

流程梳理中發現,重點是找到每一個文件中的依賴關係,咱們用deps來收集依賴。從而經過依賴關係來模塊化的把依賴關係中一層一層的打包。下面一步步的來實現

主要經過 代碼 + 解釋 的梳理過程

讀取文件代碼

首先,咱們須要一個入口文件的路徑,經過node的fs模塊來讀取指定文件中的代碼,而後經過以上提到的babylon來分析代碼獲得AST語法樹,而後經過babel-traverse庫來從AST中得到代碼中含有import的模塊(路徑)信息,也就是依賴關係。咱們把當前模塊的全部依賴文件的相對路徑都push到一個deps的數組中。以便後面去遍歷查找依賴。

const fs = require('fs')
    // 分析引擎
    const babylon = require('babylon')
    // traverse 對語法樹遍歷等操做
    const traverse = require('babel-traverse').default
    // babel提供的語法轉換
    const { transformFromAst } = require('babel-core')
    // 讀取文件代碼函數
    const readCode = function (filePath) {
        if(!filePath) {
            throw new Error('No entry file path')
            return
        }
        // 當前模塊的依賴收集
        const deps = []
        const content = fs.readFileSync(filePath, 'utf-8')
        const ast = babylon.parse(content, { sourceType: 'module' })
        // 分析AST,從中獲得import的模塊信息(路徑)
        // 其中ImportDeclaration方法爲當遍歷到import時的一個回調
        traverse(ast, {
            ImportDeclaration: ({ node }) => {
                // 將依賴push到deps中
                // 若是有多個依賴,因此用數組
                deps.push(node.source.value)
            }
        })
        // es6 轉化爲 es5
        const {code} = transformFromAst(ast, null, {presets: ['env']})
        // 返回一個對象
        // 有路徑,依賴,轉化後的es5代碼
        // 以及一個模塊的id(自定義)
        return {
            filePath,
            deps,
            code,
            id: deps.length > 0 ? deps.length - 1 : 0
        }
}

module.exports = readCode

相信上述代碼是能夠理解的,代碼中的註釋寫的很詳細,這裏就不在多囉嗦了。須要注意的是,babel-traverse這個庫關於api以及詳細的介紹不多,能夠經過其餘途徑去了解這個庫的用法。
另外須要在強調一下的是最後函數的返回值,是一個對象,該對象中包含的是當前這個文件(模塊)中的一些重要信息,deps中存放的就是當前模塊分析獲得的全部依賴文件路徑。最後咱們須要去遞歸遍歷每一個模塊的全部依賴,以及代碼。後面的依賴收集的時候會用到。

依賴收集

經過上面的讀取文件方法咱們獲得返回了一個關於單個文件(模塊)的一些重要信息。filePath(文件路徑),deps(該模塊的全部依賴),code(轉化後的代碼),id(該對象模塊的id)
咱們經過定義deps爲一個數組,來存放全部依賴關係中每個文件(模塊)的以上重要信息對象
接下來咱們經過這個單文件入口的依賴關係去搜集該模塊的依賴模塊的依賴,以及該模塊的依賴模塊的依賴模塊的依賴......咱們經過遞歸和循環的方式去執行readCode方法,每執行一次將readCode返回的對象push到deps數組中,最終獲得了全部的在依賴關係鏈中的每個模塊的重要信息以及依賴。

const readCode = require('./readcode.js')
    const fs = require('fs')
    const path = require('path')
    const getDeps = function (entry) {
        // 經過讀取文件分析返回的主入口文件模塊的重要信息  對象
        const entryFileObject = readCode(entry)
        // deps 爲每個依賴關係或者每個模塊的重要信息對象 合成的數組
        // deps 就是咱們提到的最終的核心數據,經過他來構建整個打包文件
        const deps = [entryFileObject ? entryFileObject : null]
        // 對deps進行遍歷 
        // 拿到filePath信息,判斷是css文件仍是js文件
        for (let obj of deps) {
            const dirname = path.dirname(obj.filePath)
            obj.deps.forEach(rPath => {
                const aPath = path.join(dirname, rPath)
                if (/\.css/.test(aPath)) {
                    // 若是是css文件,則不進行遞歸readCode分析代碼,
                    // 直接將代碼改寫成經過js操做寫入到style標籤中
                    const content = fs.readFileSync(aPath, 'utf-8')
                    const code = `
                    var style = document.createElement('style')
                    style.innerText = ${JSON.stringify(content).replace(/\\r\\n/g, '')}
                    document.head.appendChild(style)
                    `
                    deps.push({
                        filePath: aPath,
                        reletivePaht: rPath,
                        deps,
                        code,
                        id: deps.length > 0 ? deps.length : 0
                    })
                } else {
                    // 若是是js文件  則繼續調用readCode分析該代碼
                    let obj = readCode(aPath)
                    obj.reletivePaht = rPath
                    obj.id = deps.length > 0 ? deps.length : 0
                    deps.push(obj)
                }
            })
        }
        // 返回deps
        return deps
    }

module.exports = getDeps

可能在上述代碼中有疑問也許是在對deps遍歷收集所有依賴的時候,又循環又重複調用的可能有一點繞,還有一點可能就是對於deps這個數組最後究竟要幹什麼用,不要緊,繼續往下看,後面就會懂了。

輸出文件

到如今,咱們已經能夠拿到了全部文件以及對應的依賴以及文件中的轉換後的代碼以及id,是的,就是咱們上一節中返回的deps(就靠它了),可能在上一節還會有人產生疑問,接下來,咱們就直接上代碼,慢慢道來慢慢解開你的疑惑。

const fs = require('fs')
    // 壓縮代碼的庫   
    const uglify = require('uglify-js')
    // 四個參數
    // 1. 全部依賴的數組   上一節中返回值
    // 2. 主入口文件路徑
    // 3. 出口文件路徑
    // 4. 是否壓縮輸出文件的代碼
    // 以上三個參數,除了第一個deps以外,其餘三個都須要在該項目主入口方法中傳入參數,配置對象
    const bundle = function (deps, entry, outPath, isCompress) {
        let modules = ''
        let moduleId
        deps.forEach(dep => {
            var id = dep.id
            // 重點來了
            // 此處,經過deps的模塊「id」做爲屬性,而其屬性值爲一個函數
            // 函數體爲 當前遍歷到的模塊的「code」,也就是轉換後的代碼
            // 產生一個長字符
            // 0:function(......){......},
            // 1: function(......){......}
            // ...
            modules = modules + `${id}: function (module, exports, require) {${dep.code}},`
        });
        // 自執行函數,傳入的剛纔拼接的對象,以及deps
        // 其中require使咱們自定義的,模擬commonjs中的模塊化
        let result = `
            (function (modules, mType) {
                function require (id) {
                    var module = { exports: {}}
                    var module_id = require_moduleId(mType, id)
                    modules[module_id](module, module.exports, require)
                    return module.exports
                }
                require('${entry}')
            })({${modules}},${JSON.stringify(deps)});
            function require_moduleId (typelist, id) {
                var module_id
                typelist.forEach(function (item) {
                    if(id === item.filePath || id === item.reletivePaht){
                        module_id = item.id
                    }
                })
                return module_id
            }
        `
        // 判斷是否壓縮
        if(isCompress) {
            result = uglify.minify(result,{ mangle: { toplevel: true } }).code
        }
        // 寫入文件 輸出
        fs.writeFileSync(outPath + '/bundle.js', result)
        console.log('打包完成【success】(./bundle.js)')
    }

    module.exports = bundle

這裏仍是要在詳細的敘述一下。由於咱們要輸出文件,顧出現了大量的字符串。
解釋1:modules字符串
modules字符串最後經過遍歷deps獲得的字符串爲

modules = `
        0:function (module, module.exports, require){相應模塊的代碼},
        1: function (module, module.exports, require){相應模塊的代碼},
        2: function (module, module.exports, require){相應模塊的代碼},
        3: function (module, module.exports, require){相應模塊的代碼},
        ...
        ...
    `

若是咱們在字符串的兩端分別加上」{「和」}「,若是當成代碼執行的話那不就是一個對象了嗎?對啊,這樣0,1,2,3...就變成了屬性,而屬性的值就是一個函數,這樣就能夠經過屬性直接調用函數了。而這個函數的內容就是咱們須要打包的每一個模塊的代碼通過babel轉換以後的代碼啊。
解釋2:result字符串

// 自執行函數 將上面的modules字符串加上{}後傳入(對象)
    (function (modules, mType) {
        // 自定義require函數,模擬commonjs中的模塊化
        function require (id) {
            // 定義module對象,以及他的exports屬性
            var module = { exports: {}}
            // 轉化路徑和id,已調用相關函數
            var module_id = require_moduleId(mType, id)
            // 調用傳進來modules對象的屬性的函數
            modules[module_id](module, module.exports, require)
            return module.exports
        }
        require('${entry}')
    })({${modules}},${JSON.stringify(deps)});

    // 路徑和id對應轉換,目的是爲了調用相應路徑下對應的id屬性的函數
    function require_moduleId (typelist, id) {
        var module_id
        typelist.forEach(function (item) {
            if(id === item.filePath || id === item.reletivePaht){
                module_id = item.id
            }
        })
        return module_id
    }

至於爲何咱們要經過require_modulesId函數來轉換路徑和id的關係呢,這要先從babel吧ES6轉成ES5提及,下面列出一個ES6轉ES5的例子
ES6代碼

import a from './a.js'
    let b = a + a
    export default b

ES5代碼

'use strict';

    Object.defineProperty(exports, "__esModule", {
        value: true
    });

    var _a = require('./a.js');

    var _a2 = _interopRequireDefault(_a);
    function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
    var b = _a2.default + _a2.default;
    
    exports.default = b;

1.以上代碼爲轉化前和轉換後,有興趣的能夠去babel官網試試,能夠發現轉換後的這一行代碼**var _a = require('./a.js');**,他爲咱們轉換出來的require的參數是文件的路徑,而咱們須要調用的相對應的模塊的函數其屬性值都是以id(0,1,2,3...)命名的,因此須要轉換
2.還有一點可能有疑問的就是爲何會用function (module, module.exports, require){...}這樣的commonjs模塊化的形式呢,緣由是babel爲咱們轉後後的代碼模塊化採用的就是commonjs的規範。

最後

最後一步就是咱們去封裝一下,向外暴露一個入口函數就能夠了。這一步效仿一下webpack的api,一個pack方法傳入一個config配置對象。這樣就能夠在package.json中寫scripts腳原本npm/yarn來執行了。

const getDeps = require('./lib/getdep')
    const bundle = require('./lib/bundle')

    const pack = function (config) {
    if(!config.entryPath || !config.outPath) {
        throw new Error('pack工具:請配置入口和出口路徑')
        return
    }
    let entryPath = config.entryPath
    let outPath = config.outPath
    let isCompress = config.isCompression || false

    let deps = getDeps(entryPath)
    bundle(deps, entryPath, outPath, isCompress)

}

module.exports = pack

傳入的config只有是三個屬性,entryPath,outPath,isCompression。


總結

一個簡單的實現,只爲了探究一下原理,並無完善的功能和穩定性。但願對看到的人能有幫助

打包工具,首先經過咱們代碼文件進行詞法和語法的分析,生成AST,再經過處理AST,最終變換成咱們想要的以及瀏覽器能兼容的代碼,收集每個文件的依賴,最終造成一個依賴鏈,而後經過這個依賴關係最後輸出打包後的文件。

初來乍到,穩重有解釋不當或錯的地方,還請多理解,有問題能夠在評論區交流。還有別忘了你的👍...

相關文章
相關標籤/搜索