手写简易版bundler.js

1,259 阅读4分钟

webpack的定位是一个bundler,最基本的要解决的是将多个JS模块打包成可以在浏览器上运行的代码。接下来我们将实现一个简易的miniWebpack也就是一个bundler:由入口文件对代码进行打包,打包成可以在浏览器运行的代码。

被打包项目介绍

整个演示项目的目录结构如下所示,其中src下的文件是bundler.js需要打包的代码。

├── bundler.js
├── package-lock.json
└── src
    ├── index.js
    ├── msg.js
    └── word.js

src下各文件内容如下: word.js

const word = 'miniWebpack';
export default word;

msg.js

import word from './word.js'
const msg = `hello ${word}`;
export default msg;

index.js

import msg from './msg.js'
console.log(msg)
export default index;

实现bundler.js

要实现bundler,我们需要实现3部分功能: moduleAnalyser:模块分析。分析模块,得到模块的依赖、代码等信息。 makeDependenciesGraph:生成依赖图谱。遍历打包项目,得到所有需要的模块的分析结果 。 generateCode:生成可执行代码。提供require()函数和exports对象,生成可以在浏览器执行的代码。

模块分析

使用fs模块读取module的内容;使用@babel/parser将文件内容转换成抽象语法树AST;使用@babel/traverse遍历了AST ,对每个ImportDeclaration节点(保存的相对于module的路径信息)做映射,把依赖关系拼装在 dependencies对象里;使用@babel/core结合@babel/preset-env预设,将AST转换成了浏览器可以执行的代码。

const moduleAnalyser = (fileName) => {
  // 1.fs模块根据路径读取到了module的内容
  const content = fs.readFileSync(fileName, 'utf-8');
  // 2.使用@babel/parser将文件内容转换成抽象语法树AST
  const ast = parser.parse(content, {
    sourceType: 'module'
  })
  // 3.使用@babel/traverse遍历了AST ,对每个ImportDeclaration节点做映射,把依赖关系拼装在 dependencies对象里
  let dependencies = {};
  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirName = path.dirname(fileName);
      const newFile = './' + path.join(dirName, node.source.value);
      // key是相对于当前模块的路径,value为相对于bundler.js的路径。
      dependencies[node.source.value] = newFile;
    }
  })
  // 4.使用@babel/core结合@babel/preset-env预设,将AST转换成了浏览器可以执行的代码
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"]
  })
  return {
    fileName,
    dependencies,
    code
  }
}

模块分析流程图如下:

调用console.log(moduleAnalyser('./src/index.js')),可以在控制台打印出以下内容:

{ fileName: './src/index.js',
  dependencies: { './msg.js': './src/msg.js' },
  code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nexports["default"] = void 0;\n\nvar _msg = _interopRequireDefault(require("./msg.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(_msg["default"]);\nvar _default = index;\nexports["default"] = _default;' }

执行moduleAnalyser(module),可以返回module的fileName、dependencies、code信息。注意在code里,import语法已经变成一个require函数了,export语法,也变成了在给一个exports变量赋值。

依赖图谱生成

调用moduleAnalyser('./src/index.js')拿到入口文件的dependencies映射,接下来再把入口文件的依赖路径再一次做模块分析,再把依赖模板的依赖路径再一次做模块分析...... 其实就是广度优先遍历,可以很轻松得到这次打包所有需要的模块的分析结果。

//生成依赖图谱
const makeDependenciesGraph = (entry) => {
  //entryModule:入口文件的dependencies映射
  const entryModule = moduleAnalyser(entry);
  //graphArray:图谱动态数组,初始只有一个元素entryModule
  const graphArray = [entryModule];
  for (let i = 0; i < graphArray.length; i++) {
    const item = graphArray[i];
    //dependencies:当前模块的dependencies映射
    const { dependencies } = item;
    //如果当前模块有依赖文件,则遍历dependencies,调用moduleAnalyser,对依赖文件进行模板分析
    if (dependencies) {
      for (let j in dependencies) {
        graphArray.push(moduleAnalyser(dependencies[j]))
      }
    }
  }
  //graph:遍历graphArray生成更利于打包使用的graph。其中key为fileName,value为dependencies和code
  const graph = {};
  graphArray.forEach(item => {
    graph[item.fileName] = {
      dependencies: item.dependencies,
      code: item.code
    }

  })
  return graph;
}

生成依赖图谱流程图如下: 调用console.log(makeDependenciesGraph('./src/index.js')),可以在控制台打印出以下内容:

{ './src/index.js': 
   { dependencies: { './msg.js': './src/msg.js' },
     code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nexports["default"] = void 0;\n\nvar _msg = _interopRequireDefault(require("./msg.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(_msg["default"]);\nvar _default = index;\nexports["default"] = _default;' },
  './src/msg.js': 
   { dependencies: { './word.js': './src/word.js' },
     code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nexports["default"] = void 0;\n\nvar _word = _interopRequireDefault(require("./word.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nvar msg = "hello ".concat(_word["default"]);\nvar _default = msg;\nexports["default"] = _default;' },
  './src/word.js': 
   { dependencies: {},
     code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nexports["default"] = void 0;\nvar word = \'miniWebpack\';\nvar _default = word;\nexports["default"] = _default;' } }

生成代码

我们需要开始生成最终可运行的代码了。在上文”模块分析“部分,我们知道利用@babel/core结合@babel/preset-env生成的浏览器可执行代码里,import语法已经变成一个require函数了,export语法,也变成了在给一个exports变量赋值。所以我们的”生成代码“部分,需要提供一个require函数了和exports对象。

//generateCode 根据依赖图谱生成浏览器可执行代码
const generateCode = (entry) => {
   //根据entry,调用makeDependenciesGraph生成依赖图谱graph
  const graph = JSON.stringify(makeDependenciesGraph(entry))
  //根据依赖图谱生成浏览器可执行代码
  return `
  可运行代码...
  `
}

为了不污染全局作用域,我们使用立即执行函数来包装我们的代码,将依赖图谱graph作为参数传入:

(function(graph){
  // todo
})(${graph})

在graph中找到入口文件的code,并运行它:

  return `
  (function(graph){
    function require(module){
      eval(graph[module].code)
    }
    require('${entry}')
  })(${graph})

在入口文件的code,我们同样需要调用require去获取依赖模块模块导出的对象exports,所以require函数必须有导出对象,还要支持内部调用require函数。但是注意!!此require并非现在声明的require函数,定义code内部使用的require函数 -> localRequire。因为我们观察之前编译出的代码,可以知道在code中,require函数传的参数是相对于当前module的相对路径,但是我们打包生成可运行代码时,需要的是相对于bundler.js的相对路径。这时候,我们之前给每个module存的dependencies映射再次派上了用场,localRequire() 传入依赖相对于module的相对路径,根据graph对象,返回依赖相对于bundler.js的相对路径。

(function(graph){
  function require(module){
    function localRequire(relativePath){
      return require(graph[module].dependencies[relativePath])
    }
    var exports={};
    eval(graph[module].code)
    return exports;
  }
  require('${entry}')
})(${graph})

为了防止模块内部变量污染其它模块,我们在eval外面包一层立即执行函数,将localRequire、exports和code作为参数传入。

  (function(graph){
    function require(module){
      function localRequire(relativePath){
        return require(graph[module].dependencies[relativePath])
      }
      var exports={};
      (function(require,exports,code){
        eval(code)
      })(localRequire,exports,graph[module].code)
      return exports;
    }
    require('${entry}')
  })(${graph})

由此一个bundler就写完了,最终生成的代码,也是可以直接在浏览器中运行的。 调用console.log(generateCode('./src/index.js')),可以在控制台打印出以下内容:

  (function(graph){
    function require(module){
      function localRequire(relativePath){
        return require(graph[module].dependencies[relativePath])
      }
      var exports={};
      (function(require,exports,code){
        eval(code)
      })(localRequire,exports,graph[module].code)
      return exports;
    }
    require('./src/index.js')
  })({"./src/index.js":{"dependencies":{"./msg.js":"./src/msg.js"},"code":"\"use strict\";\n\nvar _msg = _interopRequireDefault(require(\"./msg.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log(_msg[\"default\"]); // export default index;"},"./src/msg.js":{"dependencies":{"./word.js":"./src/word.js"},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = void 0;\n\nvar _word = _interopRequireDefault(require(\"./word.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nvar msg = \"hello \".concat(_word[\"default\"]);\nvar _default = msg;\nexports[\"default\"] = _default;"},"./src/word.js":{"dependencies":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = void 0;\nvar word = 'miniWebpack';\nvar _default = word;\nexports[\"default\"] = _default;"}})  

将这段代码赋值到浏览器控制台,可以看到代码执行情况: 完整的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')

//moduleAnalyser:分析一个模块的文件依赖
const moduleAnalyser = (fileName) => {
  // 1.fs模块根据路径读取到了入口文件的内容
  const content = fs.readFileSync(fileName, 'utf-8');
  // 2.使用@babel/parser将文件内容转换成抽象语法树AST
  const ast = parser.parse(content, {
    sourceType: 'module'
  })
  // 3.使用@babel/traverse遍历了AST ,对每个ImportDeclaration节点(保存的相对于入口文件的路径)做映射,把依赖关系拼装在 dependencies对象里
  let dependencies = {};
  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirName = path.dirname(fileName);
      //newFile 相对于bundler.js的相对路径,打包的时候用这个。
      const newFile = './' + path.join(dirName, node.source.value);
      // key是相对于当前模块的路径,value为相对于bundler.js的路径。
      dependencies[node.source.value] = newFile;
    }
  })
  // 4.使用@babel/core结合@babel/preset-env预设,将AST转换成了浏览器可以执行的代码
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"]
  })
  return {
    fileName,
    dependencies,
    code
  }
}
//生成依赖图谱
const makeDependenciesGraph = (entry) => {
  //entryModule:入口文件的dependencies映射
  const entryModule = moduleAnalyser(entry);
  //graphArray:图谱动态数组,初始只有一个元素entryModule
  const graphArray = [entryModule];
  for (let i = 0; i < graphArray.length; i++) {
    const item = graphArray[i];
    //dependencies:当前模块的dependencies映射
    const { dependencies } = item;
    //如果当前模块有依赖文件,则遍历dependencies,调用moduleAnalyser,对依赖文件进行模板分析
    if (dependencies) {
      for (let j in dependencies) {
        graphArray.push(moduleAnalyser(dependencies[j]))
      }
    }
  }
  //graph:遍历graphArray生成更利于打包使用的graph。其中key为fileName,value为dependencies和code
  const graph = {};
  graphArray.forEach(item => {
    graph[item.fileName] = {
      dependencies: item.dependencies,
      code: item.code
    }

  })
  return graph;
}
//generateCode 根据依赖图谱生成浏览器可执行代码
const generateCode = (entry) => {
  const graph = JSON.stringify(makeDependenciesGraph(entry))
  //大的闭包,防止打包生成的代码污染全局环境
  //浏览器可执行的代码里有require方法,有exports对象,bundler.js打包后的代码需要提供一个require方法和exports对象。
  //小的闭包,防止模块内部变量污染其它模块
  //localRequire 传入依赖相对于module的相对路径,根据graph对象,返回依赖相对于bundler.js的相对路径
  return `
  (function(graph){
    function require(module){
      function localRequire(relativePath){
        return require(graph[module].dependencies[relativePath])
      }
      var exports={};
      (function(require,exports,code){
        eval(code)
      })(localRequire,exports,graph[module].code)
      return exports;
    }
    require('${entry}')
  })(${graph})
  `
}

const code = generateCode('./src/index.js');
console.log(code)

欢迎关注公众号阅读原文~