以前好友但願能介紹一下 webapck 相關的內容,因此最近花費了兩個多月的準備,終於完成了 webapck 系列,它包括一下幾部分:html
全部的內容以後會陸續放出,若是你有任何想要了解的內容或者有任何疑問,關注公衆號【前端瓶子君】回覆【123】添加好友,我會解答你的疑問。前端
做爲一個前端開發人員,咱們花費大量的時間去處理 webpack、gulp 等打包工具,將高級 JavaScript 項目打包成更復雜、更難以解讀的文件包,運行在瀏覽器中,那麼理解 JavaScript 打包機制就很必要,它幫助你更好的調試項目、更快的定位問題產生的問題,而且幫助你更好的理解、使用 webpack 等打包工具。
在這章你將會深刻理解 JavaScript 打包器是什麼,它的打包機制是什麼?解決了什麼問題?若是你理解了這些,接下來的 webpack 優化就會很簡單。node
一個模塊能夠有不少定義,但我認爲:模塊是一組與特定功能相關的代碼。它封裝了實現細節,公開了一個公共API,並與其餘模塊結合以構建更大的應用程序。webpack
所謂模塊化,就是爲了實現更高級別的抽象,它將一類或多種實現封裝到一個模塊中,咱們沒必要考慮模塊內是怎樣的依賴關係,僅僅調用它暴露出來的 API 便可。git
例如在一個項目中:github
<html> <script src="/src/man.js"></script> <script src="/src/person.js"></script> </html>
其中 person.js
中依賴 man.js
,在引用時若是你把它們的引用順序顛倒就會報錯。在大型項目中,這種依賴關係就顯得尤爲重要,並且極難維護,除此以外,它還有如下問題:web
因此,模塊就尤爲重要。shell
因爲先後端 JavaScript 分別擱置在 HTTP 的兩端,它們扮演的角色不一樣,側重點也不同。 瀏覽器端的 JavaScript 須要經歷從一個服務器端分發到多個客戶端執行,而服務器端 JS 則是相同的代碼須要屢次執行。前者的瓶頸在於寬帶,後者的瓶頸則在於 CPU 等內存資源。前者須要經過網絡加載代碼,後者則須要從磁盤中加載, 二者的加載速度也不是在一個數量級上的。 因此先後端的模塊定義不是一致的,其中服務器端的模塊定義爲:npm
但 CommonJS 是以同步方式導入,由於用於服務端,文件都在本地,同步導入即便卡住主線程影響也不大,但在瀏覽器端,若是在 UI 加載的過程當中須要花費不少時間來等待腳本加載完成,這會形成用戶體驗的很大問題。 鑑於網絡的緣由, CommonJS 爲後端 JavaScript 制定的規範並不徹底適合與前端的應用場景,下面來介紹 JavaScript 前端的規範。json
require/exports
來執行的,這也是咱們現今最經常使用的模塊定義;所謂打包器,就是前端開發人員用來將 JavaScript 模塊打包到一個能夠在瀏覽器中運行的優化的 JavaScript 文件的工具,例如 webapck、rollup、gulp 等。
舉個例子,你在一個 html 文件中引入多個 JavaScript 文件:
<html> <script src="/src/entry.js"></script> <script src="/src/message.js"></script> <script src="/src/hello.js"></script> <script src="/src/name.js"></script> </html>
這四個引入文件之間存在以下依賴關係:
<img width="356" alt="687474703a2f2f7265736f757263652e6d757969792e636e2f696d6167652f32303139313232363232313535332e706e67" src="https://user-images.githubusercontent.com/19721451/71776036-36a3f900-2fc5-11ea-8c50-22db086a86a0.png">
在 HTML 引入時,咱們須要注意這 4 個文件的引入順序(若是順序出錯,項目就會報錯),若是將其擴展到具備實際功能的可用的 web 項目中,那麼可能須要引入幾十個文件,依賴關係更是複雜。
因此,咱們須要將每一個依賴項模塊化,讓打包器幫助咱們管理這些依賴項,讓每一個依賴項可以在正確的時間、正確的地點被正確的引用。
另外,當瀏覽器打開該網頁時,每一個 js 文件都須要一個單獨的 http 請求,即 4 個往返請求,才能正確的啓動你的項目。
咱們知道瀏覽器加載模塊很慢,即便是 HTTP/2 支持有效的加載許多小文件,但其性能都不如加載一個更加有效(即便不作任何優化)。
所以,最好將全部 4 個文件合併爲1個:
<html> <script src="/dist/bundle.js"></script> </html>
這樣只須要一次 http 請求便可。
因此,模塊化與捆綁是打包器須要實現的兩個最主要功能。
如何打包到一個文件喃?它一般有一個入口文件,從入口文件開始,獲取全部的依賴項,並打包到一個文件 bundle.js
中。例如上例,咱們能夠以 /src/entry.js
做爲入口文件,進行合併其他的 3 個 JavaScript 文件。
固然合併不能是簡單的將 4 個文件全部內容放入一個 bundle.js
中。咱們先思考一下,它具體該怎麼實現喃?
首先咱們惟一肯定的是入口文件的地址,經過入口文件的地址能夠
因爲依賴模塊的引入是經過相對路徑(import './message.js'
),因此,咱們須要保存入口文件的路徑,結合依賴模塊的相對地址,就能夠肯定依賴模塊絕對地址,讀取它的內容。
如何在依賴關係中去表示一個模塊,以方便在依賴圖中引用
因此咱們能夠模塊表示爲:
import
依賴模塊爲相對路徑,結合當前絕對路徑,獲取依賴模塊路徑;其中 filename(絕對路徑) 能夠做爲每一個模塊的惟一標識符,經過 key: value 形式,直接獲取文件的內容一依賴模塊:
// 模塊 'src/entry': { code: '', // 文件解析後內容 dependencies: ["./message.js"], // 依賴項 }
咱們已經肯定了模塊的表示,那怎麼才能將這全部的模塊關聯起來,生成一個依賴關係圖,經過這個依賴關係能夠直接獲取全部模塊的依賴模塊、依賴模塊的代碼、依賴模塊的來源、依賴模塊的依賴模塊。
如今對於每個模塊,能夠惟一表示的就是 filename
,而咱們在由入口文件遞歸解析時,咱們能夠獲取到每一個文件的依賴數組 dependencies
,也就是每一個依賴項的相對路徑,因此咱們須要定義一個:
// 關聯關係 let mapping = {}
用來在運行代碼時,由 import
相對路徑映射到 import
絕對路徑。
因此咱們模塊能夠定義爲[filename: {}]:
// 模塊 'src/entry': { code: '', // 文件解析後內容 dependencies: ["./message.js"], // 依賴項 mapping:{ "./message.js": "src/message.js" } }
則依賴關係圖爲:
// graph 依賴關係圖 let graph = { // entry 模塊 "src/entry.js": { code: '', dependencies: ["./src/message.js"], mapping:{ "./message.js": "src/message.js" } }, // message 模塊 "src/message.js": { code: '', dependencies: [], mapping:{}, } }
當項目運行時,經過入口文件成功獲取入口文件代碼內容,運行其代碼,當遇到 import
依賴模塊時,經過 mapping
映射其爲絕對路徑,就能夠成功讀取模塊內容。
而且每一個模塊的絕對路徑 filename 是惟一的,當咱們將模塊接入到依賴圖 graph
時,僅僅須要判斷 graph[filename]
是否存在,若是存在就不須要二次加入,剔除掉了模塊的重複打包。
現今,可當即執行的代碼形式,最流行的就是 IIFE(當即執行函數),它同時可以解決全局變量污染的問題。
所謂 IIFE,就是在聲明市被直接調用的匿名函數,因爲 JavaScript 變量的做用域僅限於函數內部,因此你沒必要考慮它會污染全局變量。
(function(man){ function log(name) { console.log(`hello ${name}`); } log(man.name) })({name: 'bottle'}); // hello bottle
fs.writeFile
寫入 dist/bundle.js
便可。
至此,打包流程與實現方案已肯定,接下來就實踐一遍吧!
新建一個 minipack 文件夾,並 npm init
,建立如下文件:
- src - - entry.js // 入口 js - - message.js // 依賴項 - - hello.js // 依賴項 - - name.js // 依賴項 - index.js // 打包 js - minipack.config.js // minipack 打包配置文件 - package.json - .gitignore
其中 entry.js
:
import message from './message.js' import {name} from './name.js' message() console.log('----name-----: ', name)
message.js
:
import {hello} from './hello.js' import {name} from './name.js' export default function message() { console.log(`${hello} ${name}!`) }
hello.js
:
export const hello = 'hello'
name.js
:
export const name = 'bottle'
minipack.config.js
:
const path = require('path') module.exports = { entry: 'src/entry.js', output: { filename: "bundle.js", path: path.resolve(__dirname, './dist'), } }
並安裝文件
npm install @babel/core @babel/parser @babel/preset-env @babel/traverse --save-dev
至此,整個項目建立完成。接下來就是打包了:
/dist/bundle.js
在 ./index.js 文件中,咱們建立一個打包器,首先解析入口文件,咱們使用 @babel/parser
解析器進行解析:
步驟一:讀取入口文件內容
// 獲取配置文件 const config = require('./minipack.config'); // 入口 const entry = config.entry; const content = fs.readFileSync(entry, 'utf-8');
步驟二:使用 @babel/parser
(JavaScript解析器)解析代碼,生成 ast(抽象語法樹)
const babelParser = require('@babel/parser') const ast = babelParser.parse(content, { sourceType: "module" })
其中,sourceType
指示代碼應解析的模式。能夠是"script"
, "module"
或 "unambiguous"
之一,其中 "unambiguous"
是讓 @babel/parser
去猜想,若是使用 ES6 import
或 export
的話就是 "module"
,不然爲 "script"
。這裏使用 ES6 import
或 export
,因此就是 "module"
。
因爲 ast 樹較複雜,因此這裏咱們能夠經過 https://astexplorer.net/ 查看:
咱們已經獲取了入口文件全部的 ast,接下來咱們要作什麼喃?
dependencies
咱們已經知道了入口文件的 ast,能夠經過 @babel/core
的 transformFromAst
方法,來解析入口文件內容:
const {transformFromAst} = require('@babel/core'); const {code} = transformFromAst(ast, null, { presets: ['@babel/preset-env'], })
就須要經過 ast 獲取全部的依賴模塊,也就是咱們須要獲取 ast 中全部的 node.source.value
,也就是 import
模塊的相對路徑,經過這個相對路徑能夠尋找到依賴模塊。
步驟一:定義一個依賴數組,用來存放 ast 中解析出的全部依賴
const dependencies = []
步驟二:使用 @babel/traverse
,它和 babel 解析器配合使用,能夠用來遍歷及更新每個子節點
traverse
函數是一個遍歷 AST
的方法,由 babel-traverse
提供,他的遍歷模式是經典的 visitor
模式 ,visitor
模式就是定義一系列的 visitor
,當碰到 AST
的 type === visitor
名字時,就會進入這個 visitor
的函數。類型爲 ImportDeclaration
的 AST 節點,其實就是咱們的 import xxx from xxxx
,最後將地址 push
到 dependencies
中.
const traverse = require('@babel/traverse').default traverse(ast, { // 遍歷全部的 import 模塊,並將相對路徑放入 dependencies ImportDeclaration: ({node}) => { dependencies.push(node.source.value) } })
{ dependencies, code, }
完整代碼:
/** * 解析文件內容及其依賴, * 指望返回: * dependencies: 文件依賴模塊 * code: 文件解析內容 * @param {string} filename 文件路徑 */ function createAsset(filename) { // 讀取文件內容 const content = fs.readFileSync(filename, 'utf-8') // 使用 @babel/parser(JavaScript解析器)解析代碼,生成 ast(抽象語法樹) const ast = babelParser.parse(content, { sourceType: "module" }) // 從 ast 中獲取全部依賴模塊(import),並放入 dependencies 中 const dependencies = [] traverse(ast, { // 遍歷全部的 import 模塊,並將相對路徑放入 dependencies ImportDeclaration: ({ node }) => { dependencies.push(node.source.value) } }) // 獲取文件內容 const { code } = transformFromAst(ast, null, { presets: ['@babel/preset-env'], }) // 返回結果 return { dependencies, code, } }
步驟一:獲取入口文件:
const mainAssert = createAsset(entry)
步驟二:建立依賴關係圖:
因爲每一個模塊都是 key: value 形式,因此定義依賴圖爲:
// entry: 入口文件絕對地址 const graph = { [entry]: mainAssert }
步驟三:遞歸搜索全部的依賴模塊,加入到依賴關係圖中:
定義一個遞歸搜索函數:
/** * 遞歸遍歷,獲取全部的依賴 * @param {*} assert 入口文件 */ function recursionDep(filename, assert) { // 跟蹤全部依賴文件(模塊惟一標識符) assert.mapping = {} // 因爲全部依賴模塊的 import 路徑爲相對路徑,因此獲取當前絕對路徑 const dirname = path.dirname(filename) assert.dependencies.forEach(relativePath => { // 獲取絕對路徑,以便於 createAsset 讀取文件 const absolutePath = path.join(dirname, relativePath) // 與當前 assert 關聯 assert.mapping[relativePath] = absolutePath // 依賴文件沒有加入到依賴圖中,才讓其加入,避免模塊重複打包 if (!queue[absolutePath]) { // 獲取依賴模塊內容 const child = createAsset(absolutePath) // 將依賴放入 queue,以便於繼續調用 recursionDep 解析依賴資源的依賴, // 直到全部依賴解析完成,這就構成了一個從入口文件開始的依賴圖 queue[absolutePath] = child if(child.dependencies.length > 0) { // 繼續遞歸 recursionDep(absolutePath, child) } } }) }
從入口文件開始遞歸:
// 遍歷 queue,獲取每個 asset 及其因此依賴模塊並將其加入到隊列中,直至全部依賴模塊遍歷完成 for (let filename in queue) { let assert = queue[filename] recursionDep(filename, assert) }
步驟一:建立一個了當即執行函數,用於在瀏覽器上直接運行
const result = ` (function() { })() `
步驟二:將依賴關係圖做爲參數傳遞給當即執行函數
定義傳遞參數 modules:
let modules = ''
遍歷 graph
,將每一個 mod
以 key: value,
的方式加入到 modules
,
注意:因爲依賴關係圖要傳入以上當即執行函數中,而後寫入到 dist/bundle.js
運行,因此,code
須要放在 function(require, module, exports){${mod.code}}
中,避免污染全局變量或其它模塊同,時,代碼在轉換成 code 後,使用的是 commonJS 系統,而瀏覽器不支持 commonJS(瀏覽器沒有 module 、exports、require、global),因此這裏咱們須要實現它們,並注入到包裝器函數內。
for (let filename in graph) { let mod = graph[filename] modules += `'${filename}': [ function(require, module, exports) { ${mod.code} }, ${JSON.stringify(mod.mapping)}, ],` }
步驟三:將參數傳入當即執行函數,並當即執行入口文件:
首先實現一個 require 函數,require('${entry}')
執行入口文件,entry
爲入口文件絕對路徑,也爲模塊惟一標識符
const result = ` (function(modules) { require('${entry}') })({${modules}}) `
注意:modules
是一組 key: value,
,因此咱們將它放入 {}
中
步驟四:重寫瀏覽器 require
方法,當代碼運行 require('./message.js')
轉換成 require(src/message.js)
const result = ` (function(modules) { function require(moduleId) { const [fn, mapping] = modules[moduleId] function localRequire(name) { return require(mapping[name]) } const module = {exports: {}} fn(localRequire, module, module.exports) return module.exports } require('${entry}') })({${modules}}) `
注意:
moduleId
爲傳入的 filename
,爲模塊的惟一標識符const [fn, mapping] = modules[id]
來得到咱們的函數包裝(function(require, module, exports) {${mod.code}}
)和 mappings
對象require
都是 require
相對路徑,而不是絕對路徑,因此重寫 fn
的 require
方法,將 require
相對路徑轉換成 require
絕對路徑,即 localRequire
函數module.exports
傳入到 fn
中,將依賴模塊內容須要輸出給其它模塊使用時,當 require
某一依賴模塊時,就能夠直接經過 module.exports
將結果返回// 打包 const result = bundle(graph) // 寫入 ./dist/bundle.js fs.writeFile(`${output.path}/${output.filename}`, result, (err) => { if (err) throw err; console.log('文件已被保存'); })
原本想簡單的寫寫,結果修修改改又那麼多🤦♀️🤦♀️🤦♀️,但總要吃透纔好。
源碼地址:https://github.com/sisterAn/m...
參考了minipack,解決了它會出現模塊被重複打包的問題,同時借鑑了webpack以filename爲惟一標識符進行模塊定義。