webpack 要解決的兩個問題
現有代碼(接上文)
很遺憾,這三個文件不能運行
因爲瀏覽器不支持直接運行帶有 import 和 export 關鍵字的代碼
怎麼樣才能運行 import / export
- 不同瀏覽器功能不同
- 現代瀏覽器可以通過 <script type=midule> 來支持 import export
- IE 8~15不支持 import export,所以不可能運行
- 兼容策略
- 激進的兼容策略:把代碼全放在 <script type=module> 裏
- 缺點:不被 IE 8~15 支持;而且會導致文件請求過多(每個 import 的文件瀏覽器都會發出一個請求)
- 平穩的兼容策略:把關鍵字轉譯爲普通代碼,並把所有文件打包成一個文件
- 缺點:需要複雜的代碼來完成這件事情,接下來我們將完成這件事
編譯 import 和 export 關鍵字
解決第一個問題,怎麼把 import / export 轉換成函數
@babel/core 已經幫我們做了
// bundler_1.ts
import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import { readFileSync } from 'fs'
import { resolve, relative, dirname } from 'path';
import * as babel from '@babel/core'
// 設置根目錄
const projectRoot = resolve(__dirname, 'project_1')
// 類型聲明
type DepRelation = { [key: string]: { deps: string[], code: string } }
// 初始化一個空的 depRelation,用於收集依賴
const depRelation: DepRelation = {}
// 將入口文件的絕對路徑傳入函數,如 D:\demo\fixture_1\index.js
collectCodeAndDeps(resolve(projectRoot, 'index.js'))
console.log(depRelation)
console.log('done')
function collectCodeAndDeps(filepath: string) {
const key = getProjectPath(filepath) // 文件的項目路徑,如 index.js
if (Object.keys(depRelation).includes(key)) {
// 注意,重複依賴不一定是循環依賴
return
}
// 獲取文件內容,將內容放至 depRelation
const code = readFileSync(filepath).toString()
const { code: es5Code } = babel.transform(code, {
presets: ['@babel/preset-env']
})
// 初始化 depRelation[key]
depRelation[key] = { deps: [], code: es5Code }
// 將代碼轉爲 AST
const ast = parse(code, { sourceType: 'module' })
// 分析文件依賴,將內容放至 depRelation
traverse(ast, {
enter: path => {
if (path.node.type === 'ImportDeclaration') {
// path.node.source.value 往往是一個相對路徑,如 ./a.js,需要先把它轉爲一個絕對路徑
const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
// 然後轉爲項目路徑
const depProjectPath = getProjectPath(depAbsolutePath)
// 把依賴寫進 depRelation
depRelation[key].deps.push(depProjectPath)
collectCodeAndDeps(depAbsolutePath)
}
}
})
}
// 獲取文件相對於根目錄的相對路徑
function getProjectPath(path: string) {
return relative(projectRoot, path).replace(/\\/g, '/')
}
運行 node -r ts-node/register bundler_1.ts
a.js 的變化
1、import 關鍵字不見了
2、變成了 require
3、export 關鍵字不見了
4、變成了 exports['default']
具體分析轉譯後的 a.js 代碼
"use strict"
Object.defineProperty(exports, "__esModule", {value: true});
exports["default"] = void 0;
var _b = _interopRequireDefault(require("./b.js")); // 細節 1
function _interopRequireDefault(obj) { // 細節 1
return obj && obj.__esModule ? obj : { "default": obj }; // 細節 1
}
var a = {
value: 'a',
getB: function getB() {
return _b["default"].value + ' from a.js'; // 細節 1
}
}
var _default = a; // 細節 2
exports["default"] = _default; // 細節 2
第一行
Object.defineProperty(exports, "__esModule", {value: true});
其實等價於exports['__esModule'] = true
- 給當前模塊添加 __esModule: true 屬性,方便跟 CommonJS 模塊區分開
- 那爲什麼不直接用 exports.__esModule = true 非要裝隔壁?
- 其實可以用選項來切換的,兩種區別不大,上面的寫法功能更強,exports.__esModule 兼容性更好
第二行
exports['default'] = void 0;
- void 0 等價於 undefined,來JSer 的常用過時技巧
- 這句話是爲了強制清空 exports['default'] 的值
- 爲什麼要清空?目前暫時不理解,可能是有些特殊情況我現在沒有想到
第三行
import b from './b.js'
變成了var _b = _interopRequireDefault(require("./b.js"))
,b.value
變成了_b['default'].value
- _interopRequireDefault 這個函數在做什麼,其實就是一句話
obj && obj.__esModule ? obj : { "default": obj }
,看你是不是 es 模塊,如果是就直接導出(因爲 es 模塊有默認導出),如果不是就給你加一個默認導出(CommonJS 模塊沒有默認導出,加上方便兼容)- 其他 _interop 開頭的函數大多爲了兼容舊代碼
細節 2
export default a
變成了var _default = a; exports["default"] = _default;
,簡化一下就是exports["default"] = a
- 並不看不出來這樣寫的作用
- 如果不是默認導出,那麼代碼會是什麼樣子呢?
export const x = 'x';
會變成var x = 'x'; exports.x = x
以上我們可以知道 @babel/core
會把 import 關鍵字變成 require 函數,export 關鍵字會變成 exports 對象
本質:ESModule 語法變成了 CommonJS 規則
但我們還沒有發現 require 函數是怎麼寫的,目前先假設 require 已經寫好了
把多個文件打包成一個
打包成一個什麼樣的文件?
肯定包含了所有模塊,然後能執行所有模塊
var depRelation = [
{key: 'index.js', deps: ['a.js', 'b.js'], code: function... },
{key: 'a.js', deps: ['b.js'], code: function... },
{key: 'b.js', deps: ['a.js'], code: function... }
]
execute(depRelation[0].key) // 執行入口文件
function execute ......
爲什麼把 depRelation 從對象改爲數組?
因爲我們需要知道入口文件,數組的第一項就是入口,而對象沒有第一項的概念
現在有三個問題還沒解決
1、depRelation 是對象,需要編程一個數組
2、code 是字符串,需要變成一個函數
3、execute 函數待完善
問題 1
import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import { readFileSync } from 'fs'
import { resolve, relative, dirname } from 'path';
import * as babel from '@babel/core'
// 設置根目錄
const projectRoot = resolve(__dirname, 'project_1')
// 類型聲明
type DepRelation = { key: string, deps: string[], code: string }[] // 變動!!!
// 初始化一個空的 depRelation,用於收集依賴
const depRelation: DepRelation = [] // 變動!!!
// 將入口文件的絕對路徑傳入函數,如 D:\demo\fixture_1\index.js
collectCodeAndDeps(resolve(projectRoot, 'index.js'))
console.log(depRelation)
console.log('done')
function collectCodeAndDeps(filepath: string) {
const key = getProjectPath(filepath) // 文件的項目路徑,如 index.js
if (depRelation.find(item => item.key === key)) { // 變動!!!
// 注意,重複依賴不一定是循環依賴
return
}
// 獲取文件內容,將內容放至 depRelation
const code = readFileSync(filepath).toString()
const { code: es5Code } = babel.transform(code, {
presets: ['@babel/preset-env']
})
// 初始化 depRelation[key]
const item = { key, deps: [], code: es5Code } // 變動!!!
depRelation.push(item) // 變動!!!
// 將代碼轉爲 AST
const ast = parse(code, { sourceType: 'module' })
// 分析文件依賴,將內容放至 depRelation
traverse(ast, {
enter: path => {
if (path.node.type === 'ImportDeclaration') {
// path.node.source.value 往往是一個相對路徑,如 ./a.js,需要先把它轉爲一個絕對路徑
const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
// 然後轉爲項目路徑
const depProjectPath = getProjectPath(depAbsolutePath)
// 把依賴寫進 depRelation
item.deps.push(depProjectPath) // 變動!!!
collectCodeAndDeps(depAbsolutePath)
}
}
})
}
// 獲取文件相對於根目錄的相對路徑
function getProjectPath(path: string) {
return relative(projectRoot, path).replace(/\\/g, '/')
}
問題 2
把 code 由字符串改爲函數
- 步驟
1、在 code 字符串外面包一個function(require, module, exports){...}
(/reqire,module,export 這三個參數是 CommonJS 2 規範規定的/)
2、把 code 寫到文件裏,引號不會出現在文件中
3、不要用 eval,我們不需要執行這個函數,只需要寫進文件當中就好了 - 舉例
code = `
var b = require('./b.js)
exports.default = 'a'
`
code2 = `
function(require, module, exports) {
${code}
}
`
然後把
code: ${code2}
寫入最終文件中
最終文件裏的 code 就是函數了
更加詳細的栗子🌰:比如writeFileSync('hello.txt', '你好')
,那麼文件中將出現你好
兩個字,但是如果我們這麼寫writeFileSync('hello.txt', '"你好"')
,那麼文件中將出現"你好"
。
完善 execute 函數(主體思路)
const modules = {} // modules 用於緩存所有模塊�function execute(key) {
if (modules[key]) { return modules[key] }
var item = depRelation.find(i => i.key === key)
var require = (path) => {
return execute(pathToKey(path))
}
modules[key] = { __esModule: true } // modules['a.js']
var module = { exports: modules[key] }
item.code(require, module, module.exports)
return modules.exports
}
以上,我們就解決了上面的三個問題,下面就是我們最終的文件的主要內容(目前是手寫的,之後將用程序生成)
我們直接用 node 運行這個文件
和之前的未轉譯的代碼(
import a from './a.js'; import b from './b.js'; console.log(a.getB()); console.log(b.getA());
)一模一樣(這就對了),區別就是1、語法不同2、之前需要引入其他文件,現在 dist 不需要引入其他文件,因爲我們把所有內容寫進了一個文件,這就是 bundle,
但,怎麼得到最終文件?
答案很簡單:拼湊出字符串,然後寫進文件
var dist = ""
dist += content
writeFileSync('dist.js', dist)
// 請確保你的 Node 版本大於等於 14
// 請先運行 yarn 或 npm i 來安裝依賴
// 然後使用 node -r ts-node/register 文件路徑 來運行,
// 如果需要調試,可以加一個選項 --inspect-brk,再打開 Chrome 開發者工具,點擊 Node 圖標即可調試
import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import { writeFileSync, readFileSync } from 'fs'
import { resolve, relative, dirname } from 'path';
import * as babel from '@babel/core'
// 設置根目錄
const projectRoot = resolve(__dirname, 'project_1')
// 類型聲明
type DepRelation = { key: string, deps: string[], code: string }[]
// 初始化一個空的 depRelation,用於收集依賴
const depRelation: DepRelation = [] // 數組!
// 將入口文件的絕對路徑傳入函數,如 D:\demo\fixture_1\index.js
collectCodeAndDeps(resolve(projectRoot, 'index.js'))
writeFileSync('dist_2.js', generateCode())
console.log('done')
function generateCode() {
let code = ''
code += 'var depRelation = [' + depRelation.map(item => {
const { key, deps, code } = item
return `{
key: ${JSON.stringify(key)},
deps: ${JSON.stringify(deps)},
code: function(require, module, exports){
${code}
}
}`
}).join(',') + '];\n'
code += 'var modules = {};\n'
code += `execute(depRelation[0].key)\n`
code += `
function execute(key) {
if (modules[key]) { return modules[key] }
var item = depRelation.find(i => i.key === key)
if (!item) { throw new Error(\`\${item} is not found\`) }
var pathToKey = (path) => {
var dirname = key.substring(0, key.lastIndexOf('/') + 1)
var projectPath = (dirname + path).replace(\/\\.\\\/\/g, '').replace(\/\\\/\\\/\/, '/')
return projectPath
}
var require = (path) => {
return execute(pathToKey(path))
}
modules[key] = { __esModule: true }
var module = { exports: modules[key] }
item.code(require, module, module.exports)
return modules[key]
}
`
return code
}
function collectCodeAndDeps(filepath: string) {
const key = getProjectPath(filepath) // 文件的項目路徑,如 index.js
if (depRelation.find(item => item.key === key)) {
// 注意,重複依賴不一定是循環依賴
return
}
// 獲取文件內容,將內容放至 depRelation
const code = readFileSync(filepath).toString()
const { code: es5Code } = babel.transform(code, {
presets: ['@babel/preset-env']
})
// 初始化 depRelation[key]
const item = { key, deps: [], code: es5Code }
depRelation.push(item)
// 將代碼轉爲 AST
const ast = parse(code, { sourceType: 'module' })
// 分析文件依賴,將內容放至 depRelation
traverse(ast, {
enter: path => {
if (path.node.type === 'ImportDeclaration') {
// path.node.source.value 往往是一個相對路徑,如 ./a.js,需要先把它轉爲一個絕對路徑
const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
// 然後轉爲項目路徑
const depProjectPath = getProjectPath(depAbsolutePath)
// 把依賴寫進 depRelation
item.deps.push(depProjectPath)
collectCodeAndDeps(depAbsolutePath)
}
}
})
}
// 獲取文件相對於根目錄的相對路徑
function getProjectPath(path: string) {
return relative(projectRoot, path).replace(/\\/g, '/')
}
至此我們實現了最簡易的打包器,這就是webpack 就核心的功能,但是目前還有很多問題,webpack 是強大的打包工具,我們有很多的重複,而且 webpack 只能諸多類型的文件(通過loader),我們只支持 js 文件,還有 webpack 支持配置文件(如:入口文件,變量....),目前只是可以理解 webpack 的核心原理。