構建專欄系列目錄入口javascript
陳恆,微醫前端技術部平臺支撐組,最帥的架構師~前端
在前端社區裏,webpack
能夠說是一個經久不衰的話題。其強大、靈活的功能曾極大地促進了前端工程化進程的發展,伴隨了無數前端項目的起與落。其紛繁複雜的配置也曾讓無數前端人望而卻步,笑稱須要一個新工種"webpack 配置工程師"。做爲一個歷史悠久,最多見、最經典的打包工具,webpack
極具討論價值。理解 webpack
,掌握 webpack
,不管是在面試環節,仍是在平常項目搭建、開發、優化環節,都能帶來很多的收益。那麼本文將從核心理念出發,帶各位讀者撥開 webpack
的外衣,看透其本質。 java
其實這個問題在 webpack
官網的第一段就給出了明確的定義:node
At its core, webpack is a static module bundler for modern JavaScript applications. When webpack processes your application, it internally builds a dependency graph which maps every module your project needs and generates one or more bundles.webpack
其意爲:git
webpack 的核心是用於現代 JavaScript 應用程序的靜態模塊打包器。 當 webpack 處理您的應用程序時,它會在內部構建一個依賴關係圖,該圖映射您項目所需的每一個模塊並生成一個或多個包。github
要素察覺:靜態模塊打包器、依賴關係圖、生成一個或多個包。雖然現在的前端項目中,webpack
扮演着重要的角色,囊括了諸多功能,但從其本質上來說,其仍然是一個「模塊打包器」,將開發者的 JavaScript
模塊打包成一個或多個 JavaScript
文件。web
那麼,爲何須要一個模塊打包器呢?webpack
倉庫早年的 README
也給出了答案:面試
As developer you want to reuse existing code. As with node.js and web all file are already in the same language, but it is extra work to use your code with the node.js module system and the browser. The goal of
webpack
is to bundle CommonJs modules into javascript files which can be loaded by<script>
-tags.npm
能夠看到,node.js
生態中積累了大量的 JavaScript
寫的代碼,卻由於 node.js
端遵循的 CommonJS
模塊化規範與瀏覽器端格格不入,致使代碼沒法獲得複用,這是一個巨大的損失。因而 webpack
要作的就是將這些模塊打包成能夠在瀏覽器端使用 <script>
標籤加載並運行的JavaScript
文件。
或許這並非惟一解釋 webpack
存在的緣由,但足以給咱們很大的啓發——把 CommonJS
規範的代碼轉換成可在瀏覽器運行的 JavaScript
代碼
既然瀏覽器端沒有 CommonJS
規範,那就實現一個好了。從 webpack
打包出的產物,咱們能看出思路。
新建三個文件觀察其打包產物:
src/index.js
const printA = require('./a')
printA()
複製代碼
src/a.js
const printB = require('./b')
module.exports = function printA() {
console.log('module a!')
printB()
}
複製代碼
src/b.js
module.exports = function printB() {
console.log('module b!')
}
複製代碼
執行 npx webpack --mode development
打包產出 dist/main.js
文件
上圖中,使用了 webpack
打包 3 個簡單的 js 文件 index.js
/a.js
/b.js
, 其中 index.js
中依賴了 a.js
, 而 a.js
中又依賴了 b.js
, 造成一個完整依賴關係。
那麼,webpack
又是如何知道文件之間的依賴關係的呢,如何收集被依賴的文件保證不遺漏呢?咱們依然能從官方文檔找到答案:
When webpack processes your application, it starts from a list of modules defined on the command line or in its configuration file. Starting from these entry points, webpack recursively builds a dependency graph that includes every module your application needs, then bundles all of those modules into a small number of bundles - often, just one - to be loaded by the browser.
也就是說,webpack
會從配置的入口開始,遞歸的構建一個應用程序所須要的模塊的依賴樹。咱們知道,CommonJS
規範裏,依賴某一個文件時,只須要使用 require
關鍵字將其引入便可,那麼只要咱們遇到require
關鍵字,就去解析這個依賴,而這個依賴中可能又使用了 require
關鍵字繼續引用另外一個依賴,因而,就能夠遞歸的根據 require
關鍵字找到全部的被依賴的文件,從而完成依賴樹的構建了。
能夠看到上圖最終輸出裏,三個文件被以鍵值對的形式保存到 __webpack_modules__
對象上, 對象的 key
爲模塊路徑名,value
爲一個被包裝過的模塊函數。函數擁有 module
, module.exports
, __webpack_require__
三個參數。這使得每一個模塊都擁有使用 module.exports
導出本模塊和使用 __webpack_require__
引入其餘模塊的能力,同時保證了每一個模塊都處於一個隔離的函數做用域範圍。
爲何 webpack
要修改require
關鍵字和require
的路徑?咱們知道require
是node
環境自帶的環境變量,能夠直接使用,而在其餘環境則沒有這樣一個變量,因而須要webpack
提供這樣的能力。只要提供了類似的能力,變量名叫 require
仍是 __webpack_require__
其實無所謂。至於重寫路徑,固然是由於在node
端系統會根據文件的路徑加載,而在 webpack
打包的文件中,使用原路徑行不通,因而須要將路徑重寫爲 __webpack_modules__
的鍵,從而找到相應模塊。
而下面的 __webpack_require__
函數與 __webpack_module_cache__
對象則完成了模塊加載的職責。使用 __webpack_require__
函數加載完成的模塊被緩存到 __webpack_module_cache__
對象上,以便下次若是有其餘模塊依賴此模塊時,不須要從新運行模塊的包裝函數,減小執行效率的消耗。同時,若是多個文件之間存在循環依賴,好比 a.js
依賴了 b.js
文件, b.js
又依賴了 a.js
,那麼在 b.js
使用 __webpack_require__
加載 a.js
時,會直接走進 if(cachedModule !== undefined)
分支而後 return
已緩存過的 a.js
的引用,不會進一步執行 a.js
文件加載,從而避免了循環依賴無限遞歸的出現。
不能說這個由 webpack
實現的模塊加載器與 CommonJS
規範一毛同樣,只能說八九不離十吧。這樣一來,打包後的 JavaScript
文件能夠被 <script>
標籤加載且運行在瀏覽器端了。
瞭解了 webpack
處理後的 JavaScript
長成什麼樣子,咱們梳理一下思路,依葫蘆畫瓢手動實現一個簡易的打包器,幫助理解。
要作的事情有這麼些:
話很少說,建立一個項目,並安裝所需依賴
npm init -y
npm i @babel/core @babel/parser @babel/traverse webpack webpack-cli -D
複製代碼
其中:
@babel/parser
用於解析源代碼,產出 AST@babel/traverse
用於遍歷 AST,找到 require
語句並修改爲 _require_
,將引入路徑改造爲相對根的路徑@babel/core
用於將修改後的 AST 轉換成新的代碼輸出建立一個入口文件 myPack.js 並引入依賴
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
複製代碼
緊接着,咱們須要對某一個模塊進行解析,併產出其模塊信息,包括:模塊路徑、模塊依賴、模塊轉換後代碼
// 保存根路徑,全部模塊根據根路徑產出相對路徑
let root = process.cwd()
function readModuleInfo(filePath) {
// 準備好相對路徑做爲 module 的 key
filePath =
'./' + path.relative(root, path.resolve(filePath)).replace(/\\+/g, '/')
// 讀取源碼
const content = fs.readFileSync(filePath, 'utf-8')
// 轉換出 AST
const ast = parser.parse(content)
// 遍歷模塊 AST,將依賴收集到 deps 數組中
const deps = []
traverse(ast, {
CallExpression: ({ node }) => {
// 若是是 require 語句,則收集依賴
if (node.callee.name === 'require') {
// 改造 require 關鍵字
node.callee.name = '_require_'
let moduleName = node.arguments[0].value
moduleName += path.extname(moduleName) ? '' : '.js'
moduleName = path.join(path.dirname(filePath), moduleName)
moduleName = './' + path.relative(root, moduleName).replace(/\\+/g, '/')
deps.push(moduleName)
// 改造依賴的路徑
node.arguments[0].value = moduleName
}
},
})
// 編譯回代碼
const { code } = babel.transformFromAstSync(ast)
return {
filePath,
deps,
code,
}
}
複製代碼
接下來,咱們從入口出發遞歸地找到全部被依賴的模塊,並構建成依賴樹
function buildDependencyGraph(entry) {
// 獲取入口模塊信息
const entryInfo = readModuleInfo(entry)
// 項目依賴樹
const graphArr = []
graphArr.push(entryInfo)
// 從入口模塊觸發,遞歸地找每一個模塊的依賴,並將每一個模塊信息保存到 graphArr
for (const module of graphArr) {
module.deps.forEach((depPath) => {
const moduleInfo = readModuleInfo(path.resolve(depPath))
graphArr.push(moduleInfo)
})
}
return graphArr
}
複製代碼
通過上面一步,咱們已經獲得依賴樹可以描述整個應用的依賴狀況,最後咱們只須要按照目標格式進行打包輸出便可
function pack(graph, entry) {
const moduleArr = graph.map((module) => {
return (
`"${module.filePath}": function(module, exports, _require_) { eval(\`` +
module.code +
`\`) }`
)
})
const output = `;(() => { var modules = { ${moduleArr.join(',\n')} } var modules_cache = {} var _require_ = function(moduleId) { if (modules_cache[moduleId]) return modules_cache[moduleId].exports var module = modules_cache[moduleId] = { exports: {} } modules[moduleId](module, module.exports, _require_) return module.exports } _require_('${entry}') })()`
return output
}
複製代碼
直接使用字符串模板拼接成類 CommonJS
規範的模板,自動加載入口模塊,並使用 IIFE 將代碼包裝,保證代碼模塊不會影響到全局做用域。
最後,編寫一個入口函數 main
用以啓動打包過程
function main(entry = './src/index.js', output = './dist.js') {
fs.writeFileSync(output, pack(buildDependencyGraph(entry), entry))
}
main()
複製代碼
執行並驗證結果
node myPack.js
複製代碼
至此,咱們使用了總共不到 90 行代碼(包含註釋),完成了一個極簡的模塊打包工具。雖然沒有涉及任何 Webpack
源碼, 但咱們從打包器的設計原理入手,走過了打包工具的核心步驟,簡易卻不失完整。
本文從 webpack
的設計理念和最終實現出發,梳理了其做爲一個打包工具的核心能力,並使用一個簡易版本實現幫助更直觀的理解其本質。總的來講,webpack
做爲打包工具無非是從應用入口出發,遞歸的找到全部依賴模塊,並將他們解析輸出成一個具有類 CommonJS 模塊化規範的模塊加載能力的 JavaScript 文件。
因其優秀的設計,在實際生產環節中,webapck
還能擴展出諸多強大的功能。然而其本質還是模塊打包器。不管是什麼樣的新特性或新能力,只要咱們把握住打包工具的核心思想,任何問題終將迎刃而解。