Webpack 核心原理 webpack 要解決的兩個問題 編譯 import 和 export 關鍵字 把多個文件打包成一個

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 的核心原理。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章