魯迅說: 當我們會用一樣東西的時候,就要適當地去了解一下這個東西是怎麼運轉的。
一. 什麼是Webpack
二. 寫一個簡單的Webpack
1. 看一下Webpack的流程圖
當然我不可能實現全部功能, 因為能力有限, 我只挑幾個重要的實現
2. 準備工作
建立兩個專案, 一個為專案juejin-webpack
, 一個為我們自己寫的打包工具, 名字為xydpack
1)juejin-webpack
專案主入口檔案內容和打包配置內容為 :
// webpack.config.js
const path = require('path')
const root = path.join(__dirname, './')
const config = {
mode : 'development',
entry : path.join(root, 'src/app.js'),
output : {
path : path.join(root, 'dist'),
filename : 'bundle.js'
}
}
module.exports = config
複製程式碼
// app.js
/*
// moduleA.js
let name = 'xuyede'
module.exports = name
*/
const name = require('./js/moduleA.js')
const oH1 = document.createElement('h1')
oH1.innerHTML = 'Hello ' + name
document.body.appendChild(oH1)
複製程式碼
2)為了方便除錯,我們需要把自己的xydpack
包link
到本地, 然後引入到juejin-webpack
中, 具體操作如下
// 1. 在xydpack專案的 package.json檔案中加上 bin屬性, 並配置對應的命令和執行檔案
{
"name": "xydpack",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"bin": {
"xydpack" : "./bin/xydpack.js"
}
}
// 2. 在xydpack專案中新增相應路徑的xydpack.js檔案, 並在頂部加上該檔案的執行方式
#! /usr/bin/env node
console.log('this is xydpack')
// 3. 在 xydpack專案的命令列上輸入 npm link
// 4. 在 juejin-webpack專案的命令列上輸入 npm link xydpack
// 5. 在 juejin-webpack專案的命令列上輸入 npx xydpack後, 會輸出 this is xydpack 就成功了
複製程式碼
3. 編寫 xydpack.js
從第一步的流程圖中我們可以看出, webpack
打包檔案的第一步是獲取打包配置檔案的內容, 然後去例項化一個Compiler
類, 再通過run
去開啟編譯, 所以我可以把xydpack.js
修改為
#! /usr/bin/env node
const path = require('path')
const Compiler = require('../lib/compiler.js')
const config = require(path.resolve('webpack.config.js'))
const compiler = new Compiler(config)
compiler.run()
複製程式碼
然後去編寫compiler.js
的內容
ps : 編寫xydpack
可以通過在juejin-webpack
專案中使用npx xydpack
去除錯
4. 編寫 compiler.js
1. Compiler
根據上面的呼叫我們可以知道, Compiler
為一個類, 並且有run
方法去開啟編譯
class Compiler {
constructor (config) {
this.config = config
}
run () {}
}
module.exports = Compiler
複製程式碼
2. buildModule
在流程圖中有一個buildModule
的方法去實現構建模組的依賴和獲取主入口的路徑, 所以我們也加上這個方法
const path = require('path')
class Compiler {
constructor (config) {
this.config = config
this.modules = {}
this.entryPath = ''
this.root = process.cwd()
}
buildModule (modulePath, isEntry) {
// modulePath : 模組路徑 (絕對路徑)
// isEntry : 是否是主入口
}
run () {
const { entry } = this.config
this.buildModule(path.resolve(this.root, entry), true)
}
}
module.exports = Compiler
複製程式碼
在buildModule
方法中, 我們需要從主入口出發, 分別獲取模組的路徑以及對應的程式碼塊, 並把程式碼塊中的require
方法改為__webpack_require__
方法
const path = require('path')
const fs = require('fs')
class Compiler {
constructor (config) { //... }
getSource (modulePath) {
const content = fs.readFileSync(modulePath, 'utf-8')
return content
}
buildModule (modulePath, isEntry) {
// 模組的原始碼
let source = this.getSource(modulePath)
// 模組的路徑
let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/')
if (isEntry) this.entryPath = moduleName
}
run () {
const { entry } = this.config
this.buildModule(path.resolve(this.root, entry), true)
}
}
module.exports = Compiler
複製程式碼
3. parse
得到模組的原始碼後, 需要去解析,替換原始碼和獲取模組的依賴項, 所以新增一個parse
方法去操作, 而解析程式碼需要以下兩個步驟 :
- 使用AST抽象語法樹去解析原始碼
- 需要幾個包輔助
@babel/parser -> 把原始碼生成AST
@babel/traverse -> 遍歷AST的結點
@babel/types -> 替換AST的內容
@babel/generator -> 根據AST生成新的原始碼
複製程式碼
注意 : @babel/traverse
和@babel/generator
是ES6
的包, 需要使用default
匯出
const path = require('path')
const fs = require('fs')
const parser = require('@babel/parser')
const t = require('@babel/types')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
class Compiler {
constructor (config) { //... }
getSource (modulePath) { //... }
parse (source, dirname) {
// 生成AST
let ast = parser.parse(source)
// 遍歷AST結點
traverse(ast, {
})
// 生成新的程式碼
let sourceCode = generator(ast).code
}
buildModule (modulePath, isEntry) {
let source = this.getSource(modulePath)
let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/')
if (isEntry) this.entryPath = moduleName
this.parse(source, path.dirname(moduleName))
}
run () {
const { entry } = this.config
this.buildModule(path.resolve(this.root, entry), true)
}
}
module.exports = Compiler
複製程式碼
那麼得到的ast
是什麼呢, 大家可以去 AST Explorer 檢視程式碼解析成ast
後是什麼樣子。
當有函式呼叫的語句類似require()/ document.createElement()/ document.body.appendChild()
, 會有一個CallExpression
的屬性儲存這些資訊, 所以接下來要乾的事為 :
- 程式碼中需要改的函式呼叫是
require
, 所以要做一層判斷 - 引用的模組路徑加上主模組
path
的目錄名
const path = require('path')
const fs = require('fs')
const parser = require('@babel/parser')
const t = require('@babel/types')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
class Compiler {
constructor (config) { //... }
getSource (modulePath) { //... }
parse (source, dirname) {
// 生成AST
let ast = parser.parse(source)
// 模組依賴項列表
let dependencies = []
// 遍歷AST結點
traverse(ast, {
CallExpression (p) {
const node = p.node
if (node.callee.name === 'require') {
// 函式名替換
node.callee.name = '__webpack_require__'
// 路徑替換
let modulePath = node.arguments[0].value
if (!path.extname(modulePath)) {
// require('./js/moduleA')
throw new Error(`沒有找到檔案 : ${modulePath} , 檢查是否加上正確的檔案字尾`)
}
modulePath = './' + path.join(dirname, modulePath).replace(/\\/g, '/')
node.arguments = [t.stringLiteral(modulePath)]
// 儲存模組依賴項
dependencies.push(modulePath)
}
}
})
// 生成新的程式碼
let sourceCode = generator(ast).code
return {
sourceCode, dependencies
}
}
buildModule (modulePath, isEntry) {
let source = this.getSource(modulePath)
let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/')
if (isEntry) this.entryPath = moduleName
let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName))
}
run () {
const { entry } = this.config
this.buildModule(path.resolve(this.root, entry), true)
}
}
module.exports = Compiler
複製程式碼
遞迴獲取所有的模組依賴, 並儲存所有的路徑與依賴的模組
const path = require('path')
const fs = require('fs')
const parser = require('@babel/parser')
const t = require('@babel/types')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
class Compiler {
constructor (config) { //... }
getSource (modulePath) { //... }
parse (source, dirname) { //... }
buildModule (modulePath, isEntry) {
let source = this.getSource(modulePath)
let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/')
if (isEntry) this.entryPath = moduleName
let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName))
this.modules[moduleName] = JSON.stringify(sourceCode)
dependencies.forEach(d => this.buildModule(path.join(this.root, d)), false)
}
run () {
const { entry } = this.config
this.buildModule(path.resolve(this.root, entry), true)
}
}
module.exports = Compiler
複製程式碼
4. emit
在獲取了所有的模組依賴關係和主入口後, 接下來要把資料插入模板並寫入配置項中的output.path
因為需要一個模板, 所以借用一下webpack
的模板, 使用EJS
去生成模板, 不瞭解EJS
的點這裡, 模板的內容為 :
// lib/template.ejs
(function (modules) {
var installedModules = {};
function __webpack_require__(moduleId) {
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
module.l = true;
return module.exports;
}
return __webpack_require__(__webpack_require__.s = "<%-entryPath%>");
})
({
<%for (const key in modules) {%>
"<%-key%>":
(function (module, exports, __webpack_require__) {
eval(<%-modules[key]%>);
}),
<%}%>
});
複製程式碼
下面我們編寫emit
函式
const path = require('path')
const fs = require('fs')
const parser = require('@babel/parser')
const t = require('@babel/types')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const ejs = require('ejs')
class Compiler {
constructor (config) { //... }
getSource (modulePath) { //... }
parse (source, dirname) { //... }
buildModule (modulePath, isEntry) { //... }
emit () {
const { modules, entryPath } = this
const outputPath = path.resolve(this.root, this.config.output.path)
const filePath = path.resolve(outputPath, this.config.output.filename)
if (!fs.readdirSync(outputPath)) {
fs.mkdirSync(outputPath)
}
ejs.renderFile(path.join(__dirname, 'template.ejs'), { modules, entryPath })
.then(code => {
fs.writeFileSync(filePath, code)
})
}
run () {
const { entry } = this.config
this.buildModule(path.resolve(this.root, entry), true)
this.emit()
}
}
module.exports = Compiler
複製程式碼
如果寫到這, 在juejin-webpack
專案裡輸入npx xydpack
就會生成一個dist
目錄, 裡面有一個bundle.js
檔案, 可執行在瀏覽器中, 演示
三. 加上 loader
經過二之後, 只是單純地轉了一下程式碼, 好像沒啥意義~
所以我們要加上loader
, 對loader
不熟悉的點 這裡 , 因為是手寫嘛, 所以我們loader
也自己寫一下
注意 : 因為這個東西相當簡易, 所以只能玩一下樣式的loader
, 其他的玩不了, 所以只演示寫一下樣式的loader
1. 樣式的loader
我個人習慣使用stylus
去編寫樣式, 所以樣式就寫stylus-loader
和style-loader
首先, 在配置項上加上loader
, 然後在app.js
中引入init.styl
// webpack.config.js
const path = require('path')
const root = path.join(__dirname, './')
const config = {
mode : 'development',
entry : path.join(root, 'src/app.js'),
output : {
path : path.join(root, 'dist'),
filename : 'bundle.js'
},
module : {
rules : [
{
test : /\.styl(us)?$/,
use : [
path.join(root, 'loaders', 'style-loader.js'),
path.join(root, 'loaders', 'stylus-loader.js')
]
}
]
}
}
module.exports = config
-----------------------------------------------------------------------------------------
// app.js
const name = require('./js/moduleA.js')
require('./style/init.styl')
const oH1 = document.createElement('h1')
oH1.innerHTML = 'Hello ' + name
document.body.appendChild(oH1)
複製程式碼
在根目錄建立一個loaders
目錄去編寫我們的loader
// stylus-loader
const stylus = require('stylus')
function loader (source) {
let css = ''
stylus.render(source, (err, data) => {
if (!err) {
css = data
} else {
throw new Error(error)
}
})
return css
}
module.exports = loader
-----------------------------------------------------------------------------------------
// style-loader
function loader (source) {
let script = `
let style = document.createElement('style')
style.innerHTML = ${JSON.stringify(source)}
document.body.appendChild(style)
`
return script
}
module.exports = loader
複製程式碼
loader
是在讀取檔案的時候進行操作的, 因此修改compiler.js
, 在getSource
函式加上對應的操作
const path = require('path')
const fs = require('fs')
const parser = require('@babel/parser')
const t = require('@babel/types')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const ejs = require('ejs')
class Compiler {
constructor (config) { //... }
getSource (modulePath) {
try {
let rules = this.config.module.rules
let content = fs.readFileSync(modulePath, 'utf-8')
for (let i = 0; i < rules.length; i ++) {
let { test, use } = rules[i]
let len = use.length - 1
if (test.test(modulePath)) {
// 遞迴處理所有loader
function loopLoader () {
let loader = require(use[len--])
content = loader(content)
if (len >= 0) {
loopLoader()
}
}
loopLoader()
}
}
return content
} catch (error) {
throw new Error(`獲取資料錯誤 : ${modulePath}`)
}
}
parse (source, dirname) { //... }
buildModule (modulePath, isEntry) { //... }
emit () { //... }
run () { //... }
}
module.exports = Compiler
複製程式碼
然後執行npx xydpack
打包, 會新增一段這樣的程式碼
"./src/style/init.styl":
(function (module, exports, __webpack_require__) {
eval("let style = document.createElement('style');\nstyle.innerHTML = \"* {\\n padding: 0;\\n margin: 0;\\n}\\nbody {\\n color: #f40;\\n}\\n\";\ndocument.head.appendChild(style);");
}),
複製程式碼
然後執行就可以了, 演示
*2. 指令碼的loader
指令碼的loader
, 第一個想到的就是babel-loader
, 我們自己寫一個babel-loader
, 但是需要使用webpack
去打包, 修改配置檔案為
// webpack.config.js
resolveLoader : {
modules : ['node_modules', path.join(root, 'loaders')]
},
module : {
rules : [
{
test : /\.js$/,
use : {
loader : 'babel-loader.js',
options : {
presets : [
'@babel/preset-env'
]
}
}
}
]
}
複製程式碼
使用babel
需要三個包: @babel/core | @babel/preset-env | loader-utils
安裝後, 然後編寫babel-loader
const babel = require('@babel/core')
const loaderUtils = require('loader-utils')
function loader (source) {
let options = loaderUtils.getOptions(this)
let cb = this.async();
babel.transform(source, {
...options,
sourceMap : true,
filename : this.resourcePath.split('/').pop(),
}, (err, result) => {
// 錯誤, 返回的值, sourceMap的內容
cb(err, result.code, result.map)
})
}
module.exports = loader
複製程式碼
然後使用webpack
打包就行了
四. 總結
到這裡, 我們就可以大概猜一下webpack
的運作流程是這樣的 :
- 獲取配置引數
- 例項化Compiler, 通過run方法開啟編譯
- 根據入口檔案, 建立依賴項, 並遞迴獲取所有模組的依賴模組
- 通過loader去解析匹配到的模組
- 獲取模板, 把解析好的資料套進不同的模板
- 輸出檔案到指定路徑
注意 : 我這個只是自己鬧著玩的, 要學webpack
, 點 這裡
ps : 馬上畢業然後失業了, 有沒有哪家公司缺頁面仔的請聯絡我, 切圖也行的, 我很耐造
郵箱 : will3virgo@163.com