理解前端打包工具原理,不在話下

_潤物無聲_發表於2019-03-30

概述

眼下wepack似乎已經成了前端開發中不可缺少的工具之一,而他的一切皆模組的思想隨著webpack版本不斷的迭代(webpack 4)使其打包速度更快,效率更高的為我們的前端工程化服務

相信大家使用webpack已經很熟練了,他通過一個配置物件,其中包括對入口,出口,外掛的配置等,然後內部根據這個配置物件去對整個專案工程進行打包,從一個js檔案切入(此為單入口,當然也可以設定多入口檔案打包),將該檔案中所有的依賴的檔案通過特定的loader和外掛都會按照我們的需求為我們打包出來,這樣在面對當前的ES6、scss、less、postcss就可以暢快的儘管使用,打包工具會幫助我們讓他們正確的執行在瀏覽器上。可謂是省時省力還省心啊。

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

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

專案地址

Pack:點選github

原理

當我們更加深入的去了解javascript這門語言時,去知道javascript更底層的一些實現,對我們理解好的開源專案是由很多幫助的,當然對我們自身技術提高會有更大的幫助。 javascript是一門弱型別的解釋型語言,也就是說在我們執行前不需要編譯器來編譯出一個版本供我們執行,對於javascript來說也有編譯的過程,只不過大部分情況下編譯發生在程式碼執行前的幾微秒,編譯完成後會盡快的執行。也就是根據程式碼的執行去動態的編譯。而在編譯過程中通過語法和詞法的分析得出一顆語法樹,我們可以將它稱為AST抽象語法樹(Abstract Syntax Tree)也稱為AST語法樹,指的是原始碼語法所對應的樹狀結構。也就是說,一種程式語言的原始碼,通過構建語法樹的形式將原始碼中的語句對映到樹中的每一個節點上。】。而這個AST卻恰恰使我們分析打包工具的重點核心。

我們都熟悉babel,他讓前端程式設計師很爽的地方在於他可以讓我們暢快的去書寫ES6、ES7、ES8.....等等,而他會幫我們統統都轉成瀏覽器能夠執行的ES5版本,它的核心就是通過一個babylon的js詞法解析引擎來分析我們寫的ES6以上的版本語法來得到AST(抽象語法樹),再通過對這個語法樹的深度遍歷來對這棵樹的結構和資料進行修改。最終轉通過整理和修改後的AST生成ES5的語法。這也就是我們使用babel的主要核心。一下是語法樹的示例

需要轉換的檔案(index.js)

    // 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)
複製程式碼

生成的AST

    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可以參考 www.jianshu.com/p/019d449a9…)。通過遍歷對他的屬性或者值進行修改根據相應的演算法規則重新組成程式碼。當分析我們正常的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,最終變換成我們想要的以及瀏覽器能相容的程式碼,收集每一個檔案的依賴,最終形成一個依賴鏈,然後通過這個依賴關係最後輸出打包後的檔案。

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

相關文章