手写一个简单的 webpack 核心原理打包代码

本文主要讲解一些 webpack 打包核心原理的大概步骤,及手写一个简单的 webpack。

从本质上讲,webpack是现代 JavaScript 应用程序的静态模块打包器。当 webpack 处理你的应用程序时,它会在内部从一个或多个入口点构建一个依赖关系图,然后将您项目所需的每个模块组合成一个或多个bundles,这些 bundles 是用于提供内容的静态资源。

在开始手写之前,我们先来看下为什么要使用webpack呢?我们用个例子来演示热身:

一、不使用webpack会有什么问题?

我们首先建立一个空的项目,使用 npm init -y 快速初始化一个 package.json,然后在根目录下创建 src 目录,src 目录下创建 index.jsadd.js。根目录下创建 index.html,其中 index.html 引入 index.js,在 index.js 引入 add.js

使用es5写法导入导出模块,这里是使用commonjs规范

// src/add.js
exports.default = function(a, b) {return a + b;}

// src/index.js
var add  = require('./add.js')
console.log(add(1,5))
复制代码
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./src/index.js"></script>
  </body>
</html>
复制代码

当我们在浏览器打开 index.html 后,控制台会提示 Uncaught ReferenceError: require is not defined

我们直接使用模块化开发时,可以看到浏览器并不识别commonjs的模块化用法,会提示 require is not defined,这就不利于我们进行工程化开发了,所以 webpack 最核心解决的问题,就是读取这些文件,按照模块间依赖关系,重新组装成可以运行的脚本。

webpack是怎么解决这个问题的呢?

二、实现原始打包代码

先看下它有几个问题和它们各自的解决方案:

1. 加载子模块

往往模块是别的库(比如nodejs),用的commonjs来写的,那么我们就要处理加载模块的问题:

1.1 读取子模块 add.js

读取文件后的代码字符串是不能直接运行的

// 读取到的文件内容,它返回的是一个字符串,并不是一个可执行的语句,比如下面这样:
`exports.default = function(a, b) {return a + b;}`
复制代码

那么,如何使字符串能够变成可执行代码呢?

  • 使用 new Function
new Function(`1+5`)
// 等同于
function (){
  1+5
}
(new Function(`1+5`))() // 6
复制代码
  • 使用 eval
console.log(eval(`1+5`)) //6
复制代码

可以看出,使用 eval 非常简洁方便,所以这里我们使用 eval 来解决。解决第一步后,我们将其放在html的script脚本运行一下:

<!-- index.html -->

<script>
  // 读取到的文件内容
  `exports.default = function(a, b) {return a + b;}`
  // 第一种运行方式:使用new Function
  // (new Function(`exports.default = function(a, b) {return a + b;}`))()
  // 第二种运行方式:eval
  eval(`exports.default = function(a, b) {return a + b;}`)
</script>
复制代码

这样会提示一个新的错误 Uncaught ReferenceError: exports is not defined,我们继续往下看

1.2 导出的变量提示不存在

解决:创建一个 exports 对象,这是为了符合 commonjs 的规范的导出写法

// 创建一个exports对象,为了使其符合cjs规范
var exports = {}
eval(`exports.default = function(a, b) {return a + b;}`)
console.log(exports.default(1, 5))
复制代码

这时,刷新页面后再看浏览器已经不报错了,继续

1.3 变量全局污染

如果在导出的文件中,还要一些其它的变量,比如 var a = 1; 之类的,就会造成全局污染 解决:为了避免全局污染,我们使用自执行函数包裹起来,它会为其创建一个独立的作用域,这也是很多框架中会使用到的技巧

// 2. 创建一个exports对象,为了使其符合cjs规范
var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合
// 1. 使用eval将字符串转化为可执行脚本
// eval(`exports.default = function(a, b) {return a + b;}`)
// 3. 为了避免全局污染
(function(exports, code) {
  eval(code)
})(exports, `exports.default = function(a, b) {return a + b;}`)
console.log(exports.default(1, 5))
复制代码

2. 实现加载模块

这一步,是实现 index.js 中,调用子模块中方法,并执行的步骤,我们可以先将 index.js 内容拷贝到脚本,看会提示什么错误,再根据错误,一步步去解决

<!-- index.html -->

<script>
  var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合

  (function(exports, code) {
    eval(code)
  })(exports, `exports.default = function(a, b) {return a + b;}`)

  // index.js的内容
  var add  = require('./add.js')
  console.log(add(1,5))
</script>
复制代码

此时控制台会提示 Uncaught ReferenceError: require is not defined

解决方法是:自己模拟实现一个 require 方法,在刚刚的立即执行函数外,封装一个 require 方法,并将 exports.default(也就是 add 方法,这里写成exports.default 也是为了符合cjs规范)返回

<!-- index.html -->

<script>
  // 4. 实现require方法
  function require(file) {
    (function(exports, code) {
      eval(code)
    })(exports, `exports.default = function(a, b) {return a + b;}`)
    return exports.default;
  }
  
  var add  = require('./add.js');
  console.log(add(1,3))
</script>
复制代码

此时刷新浏览器,控制台已经打印出来了结果 6

3. 文件读取

这时的文件是写死的,require('./add.js') ,还不能按照参数形式处理,所以我们可以用对象映射方式创建一个文件列表,再套一个自执行函数,以它的参数形式传入

// 文件列表对象大概长这样
{
  "index.js": `
    var add  = require('./add.js')
    console.log(add(1,5))
  `,
  "add.js": `
    exports.default = function(a, b) {return a + b;}
  `
}
复制代码
<!-- index.html -->

<script>
  var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合
  
  (function(list) {
    function require(file) {
      (function(exports, code) {
        eval(code)
      })(exports, list[file])
      return exports.default;
    }
    require('./index.js')
  })({
    "./index.js": `
      var add  = require('./add.js')
      console.log(add(1,5))
    `,
    "./add.js": `
      exports.default = function(a, b) {return a + b;}
    `
  })
</script>
复制代码

此时刷新浏览器,控制台的打印还是 6。成功了!!!

上面这些代码就是我们平常用 webpack 打包后看到的那一堆看都不想看的结果了='=(也就是万恶的 bundle.js ),这就是一个 webpack 最小模块打包的雏形了

好了,经过这一套操作写来,你可能对 webpack 的原理有了基本的认识。

三、手写 webpack 打包工具

webpack 核心打包原理如下:

  • 1.获取主模块内容

  • 2.分析模块

    • 安装 @babel/parser 包(转AST
  • 3.对模块内容进行处理

    • 遍历 AST 收集依赖,安装 @babel/traverse
    • ES6转ES5,安装 @babel/core@babel/preset-env
  • 4.递归所有模块,获取模块依赖图对象

  • 5.生成最终代码

好了,现在我们开始根据上面核心打包原理的思路来实践一下,首先,我们先将所有的代码都改为es6

// src/add.js
export default function(a, b) {return a + b;}

// src/index.js
import add  from './add.js'
console.log(add(1,5))
复制代码

1. 获取主模块内容

我们在根目录创建一个 bundle.js 文件,既然要读取文件内容,我们需要用到node.js的核心模块 fs

// bundle.js

const fs = require('fs')

const getModuleInfo = file => {
    const body = fs.readFileSync(file, 'utf-8')
    console.log(body)
}
getModuleInfo('./src/index.js')
复制代码

我们首先来看读到的内容是什么,在根目录执行命令 node bundule.js,打印了以下信息:

import add  from './add.js'
console.log(add(1,5))
复制代码

我们可以看到,入口文件 index.js 的所有内容都以字符串形式输出了,我们接下来可以借助babel提供的功能,来完成入口文件的分析。

2. 分析模块

我们安装 @babel/parser ,演示时安装的版本号为 ^7.9.6

这个babel模块的作用,就是把我们js文件的代码内容,转换成js对象的形式,这种形式的js对象,称做抽象语法树(Abstract Syntax Tree, 以下简称AST)

// bundle.js

const fs = require('fs')
const parser = require('@babel/parser')

const getModuleInfo = file => {
    const body = fs.readFileSync(file, 'utf-8')
    const ast = parser.parse(body, {
        // 表示我们要解析的是es6模块
       sourceType: 'module' 
    })
    console.log(ast)
    console.log(ast.program.body)
}
getModuleInfo('./src/index.js')
复制代码

我们打印出了ast,注意文件内容是在ast.program.body中,如下图所示:

体验AST树: astexplorer.net/ 可以将 index.js 中的代码复制到此网站中,查看详细的 AST

入口文件内容被放到一个数组中,总共有两个 Node 节点,我们可以看到,每个节点有一个 type 属性,其中第一个 Node 的 type 属性是 ImportDeclaration ,这对应了我们入口文件的import语句,并且,其 source.value 属性是引入这个模块的相对路径,这样我们就得到了入口文件中对打包有用的重要信息了。

接下来要对得到的 AST 做处理,返回一份结构化的数据,方便后续使用。

3. 对模块内容进行处理

3.1 收集依赖

ast.program.body 部分数据的获取和处理,本质上就是对这个数组的遍历,在循环中做数据处理,这里同样引入一个babel的模块 @babel/traverse 来完成这项工作。

// bundle.js

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default

const getModuleInfo = file => {
  // 1. 读取文件
  const body = fs.readFileSync(file, 'utf-8')

  // 2. 转换AST语法树
  const ast = parser.parse(body, {
    sourceType: 'module' 
  })

  // 3. 收集依赖
  const deps = {}
  traverse(ast, {
    ImportDeclaration({ node }) {
      // 获取当前目录名
      const dirname = path.dirname(file);
      // 设置绝对路径
      // path.sep 自动识别window或linux、mac等系统并转换响应的 斜杠 "/"
      const absPath = '.' + path.sep + path.join(dirname, node.source.value)
      deps[node.source.value] = absPath
    }
  })
  console.log("deps:",deps) // deps: { './add.js': './src\\add.js' }
}
getModuleInfo('./src/index.js')
复制代码

创建一个对象 deps,用来收集模块自身引入的依赖,使用traverse遍历ast,我们只需要对ImportDeclaration的节点做处理,注意我们做的处理实际上就是把相对路径转化为绝对路径,这时候,就可以看到 index.js 的依赖文件为 add.js

3.2 ES6 转 ES5

安装 @babel/core @babel/preset-env,演示时安装的版本号均为 ^7.9.6

获取依赖之后,我们需要对 ast 做语法转换,把 ES6 的语法转化为 ES5 的语法,使用babel核心模块 @babel/core 以及 @babel/preset-env 完成

// bundle.js

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require("@babel/core");

const getModuleInfo = file => {
  // 1. 读取文件
  const body = fs.readFileSync(file, 'utf-8')

  // 2. 转换AST语法树
  const ast = parser.parse(body, {
    sourceType: 'module' 
  })

  // 3. 收集依赖
  const deps = {}
  traverse(ast, {
    ImportDeclaration({ node }) {
      // 获取当前目录名
      const dirname = path.dirname(file);
      // 设置绝对路径
      // path.sep 自动识别window或linux、mac等系统并转换响应的 斜杠 "/"
      const absPath = '.' + path.sep + path.join(dirname, node.source.value)
      deps[node.source.value] = absPath
    }
  })
  console.log("deps:",deps) // deps: { './add.js': './src\\add.js' }

  // 4. ES6转换ES5
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"]
  })
  console.log("code:", code)

  // 5. 输出模块信息
  const moduleInfo = { file, deps, code }
  console.log(moduleInfo)
  return moduleInfo
}
getModuleInfo('./src/index.js')
复制代码

如下图所示,我们最终把一个模块的代码,转化为一个对象形式的信息,这个对象包含文件的绝对路径,文件所依赖模块的信息,以及模块内部经过babel转化后的代码

4. 递归所有模块,获取依赖对象

这个过程,也就是获取依赖图(dependency graph)的过程,这个过程就是从入口模块开始,对每个模块以及模块的依赖模块都调用 getModuleInfo 方法就行分析,最终返回一个包含所有模块信息的对象

// bundle.js

const parseModules = file => {
  // 5. 定义依赖图
  const depsGraph = {}
  // 6. 首先获取入口模块的信息
  const entry = getModuleInfo(file)
  const temp = [entry]
  for (let i = 0; i < temp.length; i++) {
    const item = temp[i]
    const deps = item.deps
    if (deps) {
      // 7. 遍历模块的依赖,递归获取模块信息
      for (const key in deps) {
        if (Object.hasOwnProperty.call(deps, key)) {
          temp.push(getModuleInfo(deps[key]))
        }
      }
    }
  }
  // 8. 生成最终的依赖对象
  temp.forEach(moduleInfo => {
    depsGraph[moduleInfo.file] = {
      deps: moduleInfo.deps,
      code: moduleInfo.code
    }
  })
  console.log(depsGraph)
  return depsGraph
} 
parseModules('./src/index.js')
复制代码

获得的 depsGraph 依赖对象如下图:

有没有发现,这个依赖对象很像我们第二大节 二、实现原始打包代码 中第三小节中,我们自定义的 文件列表。所以接下来我们将 depsGraph 和我们上面编写的打包代码组合一下。

5. 生成最终代码

// bundle.js

// 9. 打包
const bundle = file => {
  // 获取依赖图
  const depsGraph = JSON.stringify(parseModules(file))
  return `
  (function (graph) {
    function require(file) {
      var exports = {};
      (function (exports,code) {
        eval(code)
      })(exports,graph[file].code)
      return exports
    }
    require('${file}')
  })(${depsGraph})`;
}

const content = bundle('./src/index.js')
console.log(content)
复制代码

上面的写法是有问题的,我们需要对file做绝对路径转化,否则 graph[file].code 是获取不到的,定义 adsRequire 方法做相对路径转化为绝对路径

// bundle.js

// 9. 打包
const bundle = file => {
  // 获取依赖图
  const depsGraph = JSON.stringify(parseModules(file))
  return `
  (function (graph) {
    function require(file) {
      var exports = {};
      function absRequire(relPath){
        return require(graph[file].deps[relPath])
      }
      (function (require,exports,code) {
        eval(code)
      })(absRequire,exports,graph[file].code)
      return exports
    }
    require('${file}')
  })(${depsGraph})`;
}

const content = bundle('./src/index.js')
console.log(content)
复制代码

生成的内容如图所示:

接下来,我们只需要把生成的内容写入一个JavaScript文件即可:

// bundle.js

const content = bundle('./src/index.js')
// 判断有没dist目录,没有就创建
!fs.existsSync("./dist") && fs.mkdirSync("./dist");

// 将打包后的文件写入./dist/bundle.js中
fs.writeFileSync("./dist/bundle.js", content);
复制代码

生成的结果如下:


  (function (graph) {
    function require(file) {
      var exports = {};
      function absRequire(relPath){
        return require(graph[file].deps[relPath])
      }
      (function (require,exports,code) {
        eval(code)
      })(absRequire,exports,graph[file].code)
      return exports
    }
    require('./src/index.js')
  })({"./src/index.js":{"deps":{"./add.js":".\\src\\add.js"},"code":"\"use strict\";\n\nvar _add = _interopRequireDefault(require(\"./add.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log((0, _add[\"default\"])(1, 5));"},".\\src\\add.js":{"deps":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = _default;\n\nfunction _default(a, b) {\n  return a + b;\n}"}})
复制代码

最后,我们在index.html引入这个./dist/bundle.js文件,我们可以看到控制台正确输出了我们想要的结果。

6. 完整 bundle.js 代码

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require("@babel/core");

const getModuleInfo = file => {
  // 1. 读取文件
  const body = fs.readFileSync(file, 'utf-8')

  // 2. 转换AST语法树
  const ast = parser.parse(body, {
    sourceType: 'module' 
  })

  // 3. 收集依赖
  const deps = {}
  traverse(ast, {
    ImportDeclaration({ node }) {
      // 获取当前目录名
      const dirname = path.dirname(file);
      // 设置绝对路径
      // path.sep 自动识别window或linux、mac等系统并转换响应的 斜杠 "/"
      const absPath = '.' + path.sep + path.join(dirname, node.source.value)
      deps[node.source.value] = absPath
    }
  })
  console.log("deps:",deps) // deps: { './add.js': './src\\add.js' }

  // 4. ES6转换ES5
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"]
  })
  console.log("code:", code)

  const moduleInfo = { file, deps, code }
  console.log(moduleInfo)
  return moduleInfo
}

const parseModules = file => {
  // 5. 定义依赖图
  const depsGraph = {}
  // 6. 首先获取入口的信息
  const entry = getModuleInfo(file)
  const temp = [entry]
  for (let i = 0; i < temp.length; i++) {
    const item = temp[i]
    const deps = item.deps
    if (deps) {
      // 7. 遍历模块的依赖,递归获取模块信息
      for (const key in deps) {
        if (Object.hasOwnProperty.call(deps, key)) {
          temp.push(getModuleInfo(deps[key]))
        }
      }
    }
  }
  // 8. 生成最终的依赖对象
  temp.forEach(moduleInfo => {
    depsGraph[moduleInfo.file] = {
      deps: moduleInfo.deps,
      code: moduleInfo.code
    }
  })
  console.log(depsGraph)
  return depsGraph
} 

// 9. 打包
const bundle = file => {
  // 获取依赖图
  const depsGraph = JSON.stringify(parseModules(file))
  return `
  (function (graph) {
    function require(file) {
      var exports = {};
      function absRequire(relPath){
        return require(graph[file].deps[relPath])
      }
      (function (require,exports,code) {
        eval(code)
      })(absRequire,exports,graph[file].code)
      return exports
    }
    require('${file}')
  })(${depsGraph})`;
}

const content = bundle('./src/index.js')
console.log(content)

// 判断有没dist目录,没有就创建
!fs.existsSync("./dist") && fs.mkdirSync("./dist");

// 将打包后的文件写入./dist/bundle.js中
fs.writeFileSync("./dist/bundle.js", content);
复制代码

参考链接:

猜你喜欢

转载自juejin.im/post/7063007090335285256