美文网首页
手写webpack

手写webpack

作者: miao8862 | 来源:发表于2021-05-08 23:58 被阅读0次

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

不使用webpack会有什么问题?

为了说明问题,我们先创建几个文件add.jsindex.jsindex.html

文件目录

使用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,3))
<!-- src/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>测试模块化开发的代码</title>
  <script src="./index.js"></script>
</head>
<body>
</body>
</html>
image.png

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

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

一、实现最初的打包bundle.js

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

第一步,加载子模块

往往模块是别的库(比如nodejs),用的commonjs来写的,那么我们就要处理加载模块的问题:
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
image.png
  • 使用eval
console.log(eval(`1+5`)) //6

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

  <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>
image.png

2. 导出的变量提示不存在
解决:创建一个exports对象,这是为了符合commonjs的规范的导出写法

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

这时,再看浏览器已经不报错了,继续


image.png

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))

再打开浏览器,还是显示结果6,没毛病,继续!

第二步,实现加载模块

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

<!-- src/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,3))
  </script>
image.png
  1. 提示require未定义
    解决:自己模拟实现一个require方法,在刚刚的立即执行函数外,封装一个require方法,并将exports.default(也就是add方法,这里写成exports.default也是为了符合cjs规范)返回
    // 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))
image.png

第三步,文件读取

require('./add.js')这时的文件是写死的,还不能按照参数形式处理

    // 5. 这时的文件是写死的,还不能按照参数形式处理
    var add  = require('./add.js');
    console.log(add(1,3))

解决:文件我们可以用对象映射方式,再套一个自执行函数,以它的参数形式传入

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

<head>
  <!-- <script src="./index.js"></script> -->
</head>
<body>
  <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,3))
      `,
      "./add.js": `
        exports.default = function(a, b) {return a + b;}
      `
    })
  </script>
</body>
</html>

再看下结果:


image.png

没毛病,噔噔噔噔,有没有觉得,这一串东东老熟悉了?

  // 打包后的结果,bundle.js
 (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,3))
      `,
      "./add.js": `
        exports.default = function(a, b) {return a + b;}
      `
    })

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

二、分析模块间的依赖关系,获取依赖图

刚刚的例子呢,是为了让大家快速了解webpack的原理,我们是人工分析依赖关系,来写的一个小demo,但是实际情况要比我们刚刚说的复杂多了,比如依赖之间是往往一个模块依赖多个模块,模块之间还有嵌套问题,比如下面这样的图形结构;使用的还不是ES5的语法,而是ES6语法,还需要我们转义。

{
   "./src/index.js": {
     "deps": { "./add.js": "./src/add.js" },
     "code": "....."
   },
   "./src/add.js": {
     "deps": {},
     "code": "......"
   }
}

我们还要处理的问题,大概可以总结为:

  1. 收集依赖
  2. ES6ES5
  3. 实现importexport

为了高大上些,我们使用ES6语法改下文件:

// src/add.js
// ES6语法
export default (a, b) => a + b; 

// src/index.js
// ES6语法
import add from './add.js'
console.log(add(1,3))

开始来实现我们的webpack.js

1、实现单个模块的分析方法 getModuleInfo

第一步,使用fs模块读取文件

// 引入fs模块,用来读写文件
const fs = require('fs')
/**
 * 模块分析
 * @param {*} file 
 */
function getModuleInfo(file) {
  // 1. 读取文件
  const body = fs.readFileSync(file, 'utf-8')

}

第二步,使用babel的parser模块,将文件字符串内容转换成AST树

  • 什么是AST树?

ast是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码中的一种结构,这所以说是抽象的,是因为抽象语法树并不会表示出真实语法出现的每一个细节,比如说,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现。抽象语法树并不依赖于源语言的语法,也就是说语法分析阶段所采用的上下文无文文法,因为在写文法时,经常会对文法进行等价的转换(消除左递归,回溯,二义性等),这样会给文法分析引入一些多余的成分,对后续阶段造成不利影响,甚至会使合个阶段变得混乱。因些,很多编译器经常要独立地构造语法分析树,为前端,后端建立一个清晰的接口

  • 体验AST树
    网站:https://astexplorer.net/
    下面图,是将console.log(111)转换成AST的结果

    image.png
  • 安装babel
    一般将字符串转换为抽象语法树,我们都是通过工具来完成的,这点上babel就已经实现的比较完美了,在使用前,我们需要先安装相关依赖:
    npm i @babel/parser @babel/traverse @babel/core @babel/preset-env

  • 使用babel转换AST

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

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

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

第三步,使用babel的traverse模块,分析AST树,收集依赖

const traverse = require('@babel/traverse').default
function getModuleInfo(file) {
  // 1. 读取文件
  const body = fs.readFileSync(file, 'utf-8')

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

  // 3. 收集依赖
  const deps = {}
  traverse(ast, {
    ImportDeclaration({node}) {
      // 获取当前目录名
      const dirname = path.dirname(file)
      // 设置绝对路径
      const abspath = './' + 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')

这时候,就可以看到index.js的依赖文件为add.js

第四步,使用babel的transformFromAst模块,将ES6转为ES5

const babel = require("@babel/core");

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

结果输出如下:


image.png

第五步,输出模块信息

  // 5. 输出模块信息
  const moduleInfo = {
    file,
    deps,
    code
  }
  return moduleInfo

完整的单个模块分析代码:

// 引入fs模块,用来读写文件
const fs = require('fs')
// 引入path模块,处理路径问题
const path = require('path')

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

/**
 * 模块分析
 * @param {*} file 
 */
function getModuleInfo(file) {
  // 1. 读取文件
  const body = fs.readFileSync(file, 'utf-8')

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

  // 3. 收集依赖
  const deps = {}
  traverse(ast, {
    ImportDeclaration({node}) {
      // 获取当前目录名
      const dirname = path.dirname(file)
      // 设置绝对路径
      const abspath = './' + path.join(dirname, node.source.value)
      deps[node.source.value] = abspath
    }
  })

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

  // 5. 输出模块信息
  const moduleInfo = {
    file,
    deps,
    code
  }
  return moduleInfo
}


const info = getModuleInfo('./src/index.js')
console.log({info})
// { info:
//   { file: './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\n// src/index.js\n// ES5语法\n// var add  = require(\'./add.js\')\n// console.log(add(1,3))\n// ES6语法\nconsole.log((0, _add["default"])(1, 3));' } }

2、分析模块间的依赖关系

// 解析模块间的关系
function parseModules(file) {
  // 从入口开始
  const entry = getModuleInfo(file)
  const temp = [entry]
  // 依赖关系图
  const depsGraph = {}
  // 递归获取依赖关系
  getDeps(temp, entry)

  // 组装依赖
  temp.forEach((moduleInfo) => {
    depsGraph[moduleInfo.file] = {
      deps: moduleInfo.deps,
      code: moduleInfo.code,
    };
  });
  return depsGraph

}

// 递归获取依赖关系
function getDeps(temp, {deps}) {
  Object.keys(deps).forEach(key => {
    const child = getModuleInfo(deps[key])
    temp.push(child)
    getDeps(temp, child)
  })
}

const graph = parseModules('./src/index.js')
console.log('graph:', graph)

可以看到,输出结果,就是我们想要的依赖图结构了:


image.png

三、最终组合打包

有了依赖树,前面第一个demo我们写了bundle.js,那么我们将它们组装起来,就是我们想要最终打包结果了

// 9. 打包
function bundle(file) {
  // 获取依赖图
  const depsGraph = JSON.stringify(parseModules(file))
  // 跟第一个demo中的打包文件,拼接起来
  return `
  (function (graph) {
    function require(file) {
      function absRequire(relPath) {
        return require(graph[file].deps[relPath])
      }
      var exports = {};
      (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/bundle.js 文件

// 判断有没dist目录,没有就创建
!fs.existsSync("./dist") && fs.mkdirSync("./dist");
// 将打包后的文件写入./dist/bundle.js中
fs.writeFileSync("./dist/bundle.js", content);

结果展示:

// dist/bundle.js

  (function (graph) {
    function require(file) {
      function absRequire(relPath) {
        return require(graph[file].deps[relPath])
      }
      var exports = {};
      (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\n// src/index.js\n// ES5语法\n// var add  = require('./add.js')\n// console.log(add(1,3))\n// ES6语法\nconsole.log((0, _add[\"default\"])(1, 3));"},"./src\\add.js":{"deps":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = void 0;\n\n// src/add.js\n// 使用es5导出模块:src/add.js\n// exports.default = function(a, b) {return a + b;}\n// ES6语法\nvar _default = function _default(a, b) {\n  return a + b;\n};\n\nexports[\"default\"] = _default;"}})

五、测试,这个打包结果

<!-- index2.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <title>测试手写的webpack.js</title>
  <script src="../dist/bundle.js"></script>
</head>
<body>
</body>
</html>
image.png

至此,一个基本的手写webpack就完成了,总结下webpack的处理流程:

  1. 读取⼊⼝⽂件;
  2. 基于 AST(抽象语法树) 分析⼊⼝⽂件,并产出依赖列表;
  3. 使⽤ Babel 将相关模块编译到 ES5;
  4. webpack有⼀个智能解析器(各种babel),⼏乎可以处理任何第三⽅库。⽆论它们的模块形式是
    CommonJS、AMD还是普通的JS⽂件;甚⾄在加载依赖的时候,允许使⽤动态表require("、/templates/"+name+"、jade")。以下这些⼯具底层依赖了不同的解析器⽣成AST,⽐如eslint使⽤了espree、babel使⽤了acorn
  5. 对每个依赖模块产出⼀个唯⼀的 ID,⽅便后续读取模块相关内容;
  6. 将每个依赖以及经过 Babel 编译过后的内容,存储在⼀个对象中进⾏维护;
  7. 遍历上⼀步中的对象,构建出⼀个依赖图(Dependency Graph);
  8. 将各模块内容 bundle 产出

附上完整代码:

// ./webpack.js
// 引入fs模块,用来读写文件
const fs = require('fs')
// 引入path模块,处理路径问题
const path = require('path')

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

/**
 * 模块分析
 * @param {*} file 
 */
function getModuleInfo(file) {
  // 1. 读取文件
  const body = fs.readFileSync(file, 'utf-8')

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

  // 3. 收集依赖
  const deps = {}
  traverse(ast, {
    ImportDeclaration({node}) {
      // 获取当前目录名
      const dirname = path.dirname(file)
      // 设置绝对路径
      const abspath = './' + 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
  }
  return moduleInfo
}

// const info = getModuleInfo('./src/index.js')
// console.log({info})

// 6. 解析模块间的关系
function parseModules(file) {
  // 从入口开始
  const entry = getModuleInfo(file)
  const temp = [entry]
  // 依赖关系图
  const depsGraph = {}
  // 7. 递归获取依赖关系
  getDeps(temp, entry)

  // 8. 组装依赖
  temp.forEach((moduleInfo) => {
    depsGraph[moduleInfo.file] = {
      deps: moduleInfo.deps,
      code: moduleInfo.code,
    };
  });
  return depsGraph

}


// 递归获取依赖关系
function getDeps(temp, {deps}) {
  Object.keys(deps).forEach(key => {
    const child = getModuleInfo(deps[key])
    temp.push(child)
    getDeps(temp, child)
  })
}

// console.log('graph:', graph)


// 9. 打包
function bundle(file) {
  // 获取依赖图
  const depsGraph = JSON.stringify(parseModules(file))
  // 跟第一个demo中的打包文件,拼接起来
  return `
  (function (graph) {
    function require(file) {
      function absRequire(relPath) {
        return require(graph[file].deps[relPath])
      }
      var exports = {};
      (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);

参考:
https://juejin.cn/post/6854573217336541192

相关文章

网友评论

      本文标题:手写webpack

      本文链接:https://www.haomeiwen.com/subject/vklpdltx.html