Webpack 核心原理

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342