Webpack 核心原理(打包)

66 阅读5分钟

Webpack 核心原理(打包)

打包:bundle 打包器:bundler

首先我们有下面的代码例子 (下面的代码都是在project_1文件夹内)

// a.js
import b from './b.js'
const a = {
  value: 'a',
  getB: () => b.value + ' from a.js'
}
export default a

// b.js
import a from './a.js'
const b = {
  value: 'b',
  getA: () => a.value + ' from b.js'
}
export default b

// index.js
import a from './a.js'
import b from './b.js'
console.log(a.getB())
console.log(b.getA())

index.html中引入index.js

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script type="module" src="index.js"></script> 
</body>
</html>

然后使用http-server project_1去启动它们,会看到

1.jpg 虽然能成功输出,但是伴随着请求过多的问题 2.jpg 如何解决请求过多的问题呢?

如果把所有文件都打包成一个文件,是不是就能解决了呢

打包

在介绍打包之前,需要先了解@babel/core是怎么把import/export转换成函数的

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 文件路径 来运行, 如果需要调试,可以加一个选项 --inspect-brk,再打开 Chrome 开发者工具,点击 Node 图标即可调试

然后可以看到

{
  'index.js': {
    deps: [ 'a.js', 'b.js' ],
    code: '"use strict";\n' +
      '\n' +
      'var _a = _interopRequireDefault(require("./a.js"));\n' +
      '\n' +
      'var _b = _interopRequireDefault(require("./b.js"));\n' +
      '\n' +
      'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
      '\n' +
      'console.log(_a["default"].getB());\n' +
      'console.log(_b["default"].getA());'
  },
  'a.js': {
    deps: [ 'b.js' ],
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports["default"] = void 0;\n' +
      '\n' +
      'var _b = _interopRequireDefault(require("./b.js"));\n' +
      '\n' +
      'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
      '\n' +
      'var a = {\n' +
      "  value: 'a',\n" +
      '  getB: function getB() {\n' +
      `    return _b["default"].value + ' from a.js';\n` +
      '  }\n' +
      '};\n' +
      'var _default = a;\n' +
      'exports["default"] = _default;'
  },
  'b.js': {
    deps: [ 'a.js' ],
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports["default"] = void 0;\n' +
      '\n' +
      'var _a = _interopRequireDefault(require("./a.js"));\n' +
      '\n' +
      'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
      '\n' +
      'var b = {\n' +
      "  value: 'b',\n" +
      '  getA: function getA() {\n' +
      `    return _a["default"].value + ' from b.js';\n` +
      '  }\n' +
      '};\n' +
      'var _default = b;\n' +
      'exports["default"] = _default;'
  }
}
done

我再简化一下就会得到

"use strict";
Object.defineProperty(exports, "__esModule", {value: true});
exports["default"] = void 0;
var _b = _interopRequireDefault(require("./b.js"));
function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { "default": obj };
}
var a = {
  value: 'a',  
  getB: function getB() {
    return _b["default"].value + ' from a.js';
  }
};
var _default = a;
exports["default"] = _default;

逐行解释

Object.defineProperty(exports, "__esModule", {value: true});
给当前模块添加_esModule:true属性,主要是和CommonJS模块区分

exports["default"] = void 0;
1. void 0 等价于undefined
2. 这句代码主要是为了情况exports['default']的值
3. 暂时没有找到为什么需要清空

var _b = _interopRequireDefault(require("./b.js"));
这句其实就是import b from './b.js'

下面解释一下 _interopRequireDefault
1. _下划线前缀主要是为了避免与其它变量重名
2. 该函数的主要意图是给模块添加了'default'
3. 为什么需要加default,CommonJS模块并没有默认导出,加上方便兼容
4. 内部实现: return obj && obj.__esModule ? obj : { "default": obj };
5. _interop开头的函数大多数都是为了兼容旧代码

export default a 变成了
var _default = a;
exports["default"] = _default;

稍稍简化就变成 exports["default"] = a
如果你还有
const x = 'X'; export {x} 则会变成
var x = 'X'; export.x = x

暂时没有看出中间变量_default的作用

总结

import关键字会变成require函数,export关键字会变成exports对象

本质就是ESModule语法变成了CommonJS规则

手动实现一个打包器

接下来我们手动实现一个打包器(bundler)

首先你的目录结构是

3.jpg

4.jpg

index.js作为打包入口文件,把所有需要打包的js文件引入到index.js文件即可,dist.js是打包后的文件

// bundeler.js
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')
// 类型声明
type DepRelation = { key: string, deps: string[], code: string }[]
// 初始化一个空的 depRelation,用于收集依赖
const depRelation: DepRelation = [] // 数组!

// 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js
collectCodeAndDeps(resolve(projectRoot, 'index.js'))

// 得到打包的文件 dist.js
writeFileSync('./project/dist.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(i => i.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, '/')

可执行 node -r ts-node/register bundler 进行打包

最终会得到一个打包后的文件dist.js, 可以使用node dist运行,也可以在html里面引入运行

其原理就是拼凑字符串,然后writeFileSync写入文件

虽然实现了把文件都打包到一个文件里面,但是还存在如下问题

  • 生成的代码中有多个重复的 _interopXXX 函数
  • 只能引入和运行 JS 文件
  • 只能理解 import,无法理解 require
  • 不支持插件
  • 不支持配置入口文件和 dist 文件名