不使用Webpack,把两个文件打包成一个文件,你应该怎么做?

1,048 阅读13分钟

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

开头

记得小编刚入坑 Webpack 还是在 Webpack3 版本的时候,那会各种配置真是玩得出神入化,直至今日其版本已经更新到 Webpack5

足足经历了三个大版本的时光,它们每个版本各有特色与优点,后者版本也会结合吸收前者版本的优秀特性,再进而进化。总得来,Webpack 在往好的方向发展,也在与时俱进。

小编上手 Webpack5 有些时日了,新版本使用过程中,虽然也有踩坑,但是总体下来体验还是不错的,功能很强大;但是!!!我要说的是但是,尽管学了怎么久的 Webpack,这玩意儿给我的感受还是 "真TM难学!!!" ︶︿︶

解析打包过程

好了,扯得有点远了,我们回来看看本章主题,本章的主要目的是探索如何把两个文件打包成一个文件,这过程其实就是探索 Webpack 的核心打包原理

整个打包过程步骤:

  1. 获取入口文件地址,读取入口文件内容。
  2. 分析入口文件源码中的依赖,递归获取所有依赖。
    • 安装 @babel/parser 包,把源码转成AST树。
    • 安装 @babel/traverse 包,用于遍历AST树,收集依赖关系。
    • 安装 @babel/core 与 @babel/preset-env 包,把AST树转回ES6源码,再转成ES5形式的源码。
    • 根据依赖关系,递归获取到所有依赖。
  3. 生成依赖关系图,本质就是一个对象,key 为文件路径, value 为对象,存放文件源码、文件的其他依赖路径集合等等。
  4. 构造 require 方法与 exports 变量,输出可运行的最终代码。

(依赖?何为依赖?你可以认为每个引入的文件就是一个依赖)

上述是打包过程会经历的四个步骤,我们会按照这个步骤进行。当然,你现在看不懂可以跳过,没关系,等你读完本章并实际操作过一次,可以再回头过来看看。(。◕‿◕。)

准备工作

我们新建一个文件夹,创建以下这些文件:

image.png

初始化 package.json 文件。

npm init -y
// package.json
{
  "name": "merge-files",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "directories": {
    "lib": "lib"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Compiler 对象是我们最关键的对象,在 Webpack 源码 webpack/lib/Compiler.js 你也能找到该对象。

// lib/Compiler.js
class Compiler {
  run() {
    
  }
}

// 传递入口文件地址, 执行 Compiler.js 文件,即表示开始打包
new Compiler().run('./src/index.js');

需要打包的文件我们都放在 src 目录下。

// index.js
import {answer} from './answer.js';
console.log('今天天气如何?');
console.log(answer);
// answer.js
export const answer = '阳光明媚,好日子!';

创建完这四个文件后,我们的准备工作就算做完了,但在开始写正式的代码前,我们再来明确一下我们的目标。


我们的目的是想执行 index.js 文件,注意它里面还引用了 answer.js 文件,执行一个 JS 文件可以有这两种方式:

  1. 直接在 html 中引入。

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
    </head>
    <body>
        <script src="src/index.js"></script>
    </body>
    </html>
    

    但我们会发现浏览器控制台中没有打印出结果,会有如下报错:

    image.png

    这很正常,浏览器现在还不能直接不认识 ES6import 语法,自然就报错了。

  2. Node 环境中执行。

    image.png

    通过 node src/index.js 命令,我们也能让一个 JS 文件执行,但是依旧是报错了,根据报错提示,我们可以配置一下 package.json 文件,增加 type 配置项。

    {
      "name": "merge-files",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "directories": {
        "lib": "lib"
      },
      "type": "module", 
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "keywords": [],
      "author": "",
      "license": "ISC"
    }
    

    再次执行,我们可以看到结果虽然被打印出来了,但是提示我们该配置还是实验性功能。

    image.png

通过上面两种方式的结论,我们知道我们现在还不能去直接执行 index.js 文件,需要进行编译打包出来一下,使它变成可执行。最终我们确立一下目标,就是要把 index.jsanswer.js 两个文件搞一起,而且还有解决 ES6 语法问题,让它不管是浏览器直接引用还是放在 Node 环境中执行,都是正常没有问题的,这就是我们这章要实现的效果。

正式表演

做完上面各种前戏的准备工作后,我们下面就要开始正式上场表演了,我们就根据上面罗列的步骤一步一步来就行了。

1.获取入口文件

先来瞧瞧这下面的代码:

const fs = require("fs");

class Compiler {
  run(entryPath) {
    this.getDepAnalyse(entryPath);
  }
  getDepAnalyse(modulePath) {
    // 读取入口文件内容
    const source = this.getSource(modulePath);
    console.log(source);
  }
  getSource(path) {
    return fs.readFileSync(path, 'utf-8');
  }
}

new Compiler().run('./src/index.js');

我们增加了两个方法, getDepAnalyse() 方法主要用于对每个依赖的分析操作,getSource() 方法是一个专门用于读取文件内容的工具方法。我们把读取后结果打印出来,如我们预料,成功读取到 index.js 文件源码。(-^〇^-)

image.png

2.分析入口文件

下面我们继续按步骤来。

  • 安装 @babel/parser 包,把源码转成AST树。
npm install @babel/parser -D

先安装 @babel/parser 包,它是专门用于把源码转成 AST 树的工具包,@babel/parser 具体用法可以查看官方文档,用法挺简单的。

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

class Compiler {
  ...
  getDepAnalyse(modulePath) {
    const source = this.getSource(modulePath);
    // 把源码转成AST树
    const AST = parser.parse(source, {
      sourceType: 'module' // 表示解析的是ES6的模块模式, 默认为 script 模式
    });
    console.log(AST)
  }
  ...
}

new Compiler().run('./src/index.js');

把源码转换后,执行代码我们能看到打印出了一颗 AST 树。

image.png

对于 AST 树,它全称是抽象语法树(abstract syntax tree 或者缩写为 AST)。这里就不细说这是个啥玩意了,还不了解的小伙伴可以直接在掘金上搜一下 AST 应该有挺多文章能助你了解它。

我们也可以上一下这个网站 AST Explorer ,然后把 index.js 文件的内容复制往上一丢,你再自己随便点点玩玩,细细品一下,是不是非常棒? (●ω●) 这相当于在线可视化 AST 树了。

image.png



  • 安装 @balbel/traverse 包,用于遍历AST树,收集依赖关系。
npm install @babel/traverse -D

得到 AST 树后,我们继续安装 @babel/traverse 包,这个包是用于来遍历这棵 AST 树。虽然 AST 本质是一个巨大的对象,但是它属性众多,镶嵌的层级也比较深,如果单纯按我们平时自己写方法去操作,能搞死人,所以就有了这个包来提高我们的效率。

我们说回主题,我们借助这个包用于遍历 AST 树,进行依赖关系的收集。那何为依赖关系?其实就是对一个文件引入哪些其他文件的路径进行收集,就是我们要知道每个文件中引入了哪些其他文件。@babel/traverse 包能帮助我们快速获取到一个文件中引入的其他文件的路径。

那我们怎么来获取这些路径呢?在正式写代码前,我们先来看看这个在 AST Explorer 上写的例子:

image.png

如图,相同类型的 JS 语句生成的 AST 树中的类型是一样的,知道这点后,你看下面这段代码,就应该很容易看懂了。

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

class Compiler {
  ...
  getDepAnalyse(modulePath) {
    const source = this.getSource(modulePath);
    const AST = parser.parse(source, {
      sourceType: 'module'
    });
    // 收集依赖关系
    let dependencies = {};
    traverse(AST, {
      // 只处理import的模块, require的模块无法处理
      ImportDeclaration({node}) {
        const requireValue = node.source.value;
        const depsPath =  './' + path.join('src', requireValue); // 拼接文件相对于 src 下的绝对路径
        dependencies[requireValue] = depsPath;
      }
    });
    console.log(dependencies)
  } 
  ...
}

new Compiler().run('./src/index.js');

node.source.value 即为我们需要的值。

image.png

打印的结果:

image.png

需要特别注意:每个文件会有一个依赖关系对象,它本质是一个普通对象,记录着当前这个文件的所有依赖关系。像上面打印的 index 文件,它依赖了 answer.js 文件,所以我们以 answer.js 文件的原源码填写的相对路径作为 key ,以我们自己拼接的 src 的绝对路径作为 value。 假设我们在新建一个 test.js 文件,在 index.js 中引入,则:

image.png



  • 安装 @babel/core 与 @babel/preset-env 包,把AST树转回ES6源码,再转成ES5形式的源码。
npm install @babel/core @babel/preset-env -D

老样子,我们继续先装包,@babel/core 包能把 AST树 转回源码,再配合 @babel/preset-env 包能把源码转成 ES5 形式的源码。

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

class Compiler {
  ...
  getDepAnalyse(modulePath) {
    const source = this.getSource(modulePath);
    const AST = parser.parse(source, {
      sourceType: 'module'
    });
    let dependencies = {};
    traverse(AST, {
      ImportDeclaration({node}) {
        const requireValue = node.source.value;
        const depsPath =  './' + path.join('src', requireValue);
        dependencies[requireValue] = depsPath;
      }
    });
    // 把AST树的ES6代码转回成ES5源码
    const {code} = transformFromAst(AST, null, {
        presets: ['@babel/preset-env'] // 不加这个参数, 则返回ES6源码
    });
    console.log(code)
  } 
  ...
}

new Compiler().run('./src/index.js');

打印后的结果:

image.png



  • 根据依赖关系,递归获取到所有依赖。 到这里,我们就把 index.js 文件的工作全做完了,它其实就做了一件事件:"收集依赖关系"。工作的过程大概可以概括为: 读源码 -> 转成AST -> 遍历AST -> 依赖收集 -> 变回源码。 (⊙o⊙)

那么这只是处理了入口文件 index.js 文件,接下来我们也需要让 answer.js 文件也经历过这一完整步骤,进行它的依赖关系收集,因为它也可能引入了其他文件,我们需要收集到每个文件的依赖关系,最后才能生成一个完整的依赖关系图。

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

class Compiler {
  run(entryPath) {
    // 获取入口文件进行依赖收集后的结果
    const entryModule = this.getDepAnalyse(entryPath);
    const modules = [entryModule];
    // 递归获取所有依赖的模块, 是所有, 包括其他文件依赖的其他模块
    for(let i = 0;i<modules.length;i++) {
      let {dependencies} = modules[i];
      if(dependencies) {
        for(let modulePath in dependencies) {
          // 进行递归
          modules.push(this.getDepAnalyse(dependencies[modulePath]));
        }
      }
    }
    console.log(modules)
  }
  getDepAnalyse(modulePath) {
    const source = this.getSource(modulePath);
    const AST = parser.parse(source, {
      sourceType: 'module'
    });
    let dependencies = {};
    traverse(AST, {
      ImportDeclaration({node}) {
        const requireValue = node.source.value;
        const depsPath =  './' + path.join('src', requireValue);
        dependencies[requireValue] = depsPath;
      }
    });
    const {code} = transformFromAst(AST, null, {
      presets: ['@babel/preset-env']
    });

    // 返回 文件路径 文件的依赖关系 文件转成ES5后的源码
    return {modulePath, dependencies, code};
  } 
  ...
}

new Compiler().run('./src/index.js');

最后我们打印获取到这样子的一个结果:

image.png


上面的递归如果你看不太懂,可以看看这个简化版的例子:

var arr = [  {     deps: {      a: 1,       b: 2,    }   }];
for(var i = 0;i<arr.length;i++) {
  console.log(1); // 会执行三次
  let {deps} = arr[i];
  if(deps) {
     for(var key in deps) { // key 为 a b
        arr.push(deps[key]);
     }
  }
}
console.log(arr); //  [{…}, 1, 2]

需要注意如果把 for 改造成 forEach 形式则不会有这效果哦,只会执行一次。

3.生成完整依赖关系图

上面得到数组形式的依赖图,还不是我们最终的目标,因为它不利于后续的操作。我们期待的是一个对象的形式,key 为文件路径(我们用的是我们自己拼接 src 的绝对路径作为 key), value 为对象,存放该文件的相关信息。

那么为什么会是这样子的期待呢?我们来假设当依赖关系图是数组的形式,当我们在源码中某个文件中导入一个文件 import x from xxx,那么我们能获得这个文件的路径,当我们用它去依赖关系图中寻找,那就需要去循环查找吧,这就需要提供额外的循环查找方法了。但是如果是对象的形式,那就简单了,直接就能取得。

其实 Webpack 本身也是用对象这种形式的,因为依赖关系图最终还要结合一些字符串代码合成最终的可执行文件输出,所以这种形式是最方便的,看完下面第四步骤,可能更好理解一点。

最终完整依赖关系图:

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

class Compiler {
  run(entryPath) {
    const entryModule = this.getDepAnalyse(entryPath);
    const modules = [entryModule];
    for(let i = 0;i<modules.length;i++) {
      let {dependencies} = modules[i];
      if(dependencies) {
        for(let modulePath in dependencies) {
          modules.push(this.getDepAnalyse(dependencies[modulePath]));
        }
      }
    }
    // 生成需要的最终完整依赖关系图
    let depsGraph = {};
    modules.forEach(item => {
      depsGraph[item.modulePath] = {
        dependencies: item.dependencies,
        code: item.code
      };
    });
    console.log(depsGraph)
  }
  ...
}

new Compiler().run('./src/index.js');

打印结果:

image.png

4.构造require方法与exports变量,输出可运行的最终代码。

在生成最终文件之前,我们先来理理前面的步骤,现在我们是获取到了一个完整的依赖关系图了,里面含有每个文件的源码,而且是 ES5 形式的源码,能直接被浏览器识别的。理论上,我只要遍历一下关系图,借用 fs.writeFileSync() 把它们都输出在一个文件中,这个文件不就可以被执行了吗?不就得了吗?(●’◡’●)

是嘛?想想就知道没那么简单啦,小老弟。

我们且来看看这两个文件的现在源码形式长什么样式:

// index.js
"use strict";

var _answer = require("./answer.js");

console.log('今天天气如何?');
console.log(_answer.answer);
// answer.js
"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.answer = void 0;
var answer = '阳光明媚,好日子!';
exports.answer = answer;

呃......啊...!!! require() 方法与 exports 变量并没有定义,咦,莫名其妙,这样子代码还是跑不起来的,害。

既然没有,就只能靠自己了。好吧,其实也是 Webpack 也是自己定义这两个东西的,但它实现的 require 与我们稍微不同,它是 __webpack_require__

好了,我们先来看看最后的代码应该如何写:

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

class Compiler {
  run(entryPath) {
    ...
    // 输出最终可执行文件
    this.emitFiles(depsGraph, entryPath)
  }
  ...
  // 输出最终文件
  emitFiles(depsGraph, entryPath) {
    // 构造 require 方法与 exports 变量
    let res = `
      (function(depsGraph, entryPath) {
        function requireModule(moduleId) {
          var exports = {};
          function require(path) {
            return requireModule(depsGraph[moduleId].dependencies[path]);
          }
          (function(require, exports, code) {
            eval(code);
          })(require, exports, depsGraph[moduleId].code);
          return exports
        }
        requireModule(entryPath);
      })(${JSON.stringify(depsGraph)}, '${entryPath}')
    `;

    // 输出的文件
    let outputPath = './dist/index.js';
    fs.stat(outputPath, (err, stat) => {
        if(stat && stat.isFile()) {
          fs.unlinkSync(outputPath);
        }else {
          fs.mkdirSync('./dist');
        }
        fs.writeFileSync(outputPath, res, 'utf-8')
    });
  }
}

new Compiler().run('./src/index.js');

上面我们添加了一个 emitFiles() 新方法,在 run() 中调用,用于输出最终的文件。但是......那一坨啥玩意啊?看不懂!!!没关系,下面我们一步一步来解析。


  • 第一步 - 基本结构

我们现在想做的目的是把源码写出到文件,并在我们引用这个文件时,文件内容会被执行。

...
class Compiler {
  ...
  emitFiles(depsGraph, entryPath) {
    let res = `
      (function(depsGraph) {
        console.log(depsGraph);
      })(${JSON.stringify(depsGraph)}, '${entryPath}')
    `;
    eval(res);
  }
}

new Compiler().run('./src/index.js');

上面代码解释,因为我们最终会用 fs.writeFileSync('输出路径', '写出内容', '编码格式') 把内容写出到文件,但它需要内容为字符串形式,所以我们开始就定义了一个字符串变量;因为引用就会被执行,为了保证不污染全局,我们定义了一个自执行函数,让引入的文件形成一个闭包并自执行;并且我们传入了依赖关系图与入口文件路径。我们借用 eval() 函数来把这个字符串变量执行下。

依赖关系图depsGraph

image.png

  • 第二步 - 定义 exports 对象
...
class Compiler {
  ...
  emitFiles(depsGraph, entryPath) {
    let res = `
      (function(depsGraph) {
          // 这是一个导入其他文件的方法, 本质是 require 方法, 默认会返回 exports 对象
          function requireModule(moduleId) {
              var export = {};
              
              return export;
          }
          requireModule(entryPath);
      })(${JSON.stringify(depsGraph)}, '${entryPath}')
    `;
    eval(res);
  }
}

new Compiler().run('./src/index.js');
  • 第三步 - 执行入口文件、定义 require 方法

我们先取出 index.js 文件的源码看看。

...
class Compiler {
  ...
  emitFiles(depsGraph, entryPath) {
    let res = `
      (function(depsGraph) {
          function requireModule(moduleId) {
              var export = {};
              
              console.log(depsGraph[moduleId].code); // 打印 index.js 源码
              
              return export;
          }
          requireModule(entryPath);
      })(${JSON.stringify(depsGraph)}, '${entryPath}')
    `;
    eval(res);
  }
}

new Compiler().run('./src/index.js');

image.png

index.js 文件内容可以取到。接下我们就是要执行它,但是从上面源码中,我们可以看到,require 方法还没被定义,我们来尝试自己定义并执行看看看看:

...
class Compiler {
  ...
  emitFiles(depsGraph, entryPath) {
    let res = `
      (function(depsGraph) {
          function requireModule(moduleId) {
              var export = {};
              // 定义 require
              function require(path) {
              
              }
              // 执行 index.js 源码
              (function(code) {
                  eval(code); 
              })(depsGraph[moduleId].code);
              return export;
          }
          requireModule(entryPath);
      })(${JSON.stringify(depsGraph)}, '${entryPath}')
    `;
    eval(res);
  }
}

new Compiler().run('./src/index.js');

image.png

看上图结果,require 方法我们定义好了,但是 require 方法我们还没有具体实现,所以还有报错信息,那么它需要做些什么呢?

  • 第四步 - 完善 require 方法
...
class Compiler {
  ...
  emitFiles(depsGraph, entryPath) {
    let res = `
      (function(depsGraph) {
          function requireModule(moduleId) {
              var export = {};
              function require(path) {
                // 通过moduleId导入对应的模块
                return requireModule(depsGraph[moduleId].dependencies[path]);
              }
              (function(require, exports, code) {
                  eval(code); 
              })(require, exports, depsGraph[moduleId].code); // 把require与exports传入闭包中
              return export;
          }
          requireModule(entryPath);
      })(${JSON.stringify(depsGraph)}, '${entryPath}')
    `;
    eval(res);
  }
}

new Compiler().run('./src/index.js');

require 方法这里的调用是比较绕一点,也是精华所在,要仔细瞧一下。它是一个递归的过程,而递归的条件是每个文件中的导入语句。

还有就是 require(path) 接收的 path 参数是相对路径,这也就是我们为什么在上面第二步骤的时候选择相对路径作为 key 的原因。

结束表演

最后,当我们执行 node lib/Compiler.js 进行打包,我们目录下就会生成一个 dist/index.js 新文件,我们可以直接执行这个文件,或者在浏览器中引入。

image.png

image.png

至此,就大功告成。

当然,做完这个核心步骤,你可以再去扩展其他一些辅助功能来完善你自己的打包器,如打包命令、文件别名、文件省略后缀等等。(-^〇^-)

下面我们放一下 Compiler.js 的完整源码。

完整源码

// Compiler.js
const fs = require("fs");
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const path = require("path");
const { transformFromAst } = require('@babel/core');

class Compiler {
  run(entryPath) {
    const entryModule = this.getDepAnalyse(entryPath);
    const modules = [entryModule];
    for(let i = 0;i<modules.length;i++) {
      let {dependencies} = modules[i];
      if(dependencies) {
        for(let modulePath in dependencies) {
          modules.push(this.getDepAnalyse(dependencies[modulePath]));
        }
      }
    }
    let depsGraph = {};
    modules.forEach(item => {
      depsGraph[item.modulePath] = {
        dependencies: item.dependencies,
        code: item.code
      };
    });
    this.emitFiles(depsGraph, entryPath)
  }
  getDepAnalyse(modulePath) {
    const source = this.getSource(modulePath);
    const AST = parser.parse(source, {
      sourceType: 'module'
    });
    let dependencies = {};
    traverse(AST, {
      ImportDeclaration({node}) {
        const requireValue = node.source.value;
        const depsPath =  './' + path.join('src', requireValue);
        dependencies[requireValue] = depsPath;
      }
    });
    const {code} = transformFromAst(AST, null, {
      presets: ['@babel/preset-env']
    });
    return {modulePath, dependencies, code};
  } 
  getSource(path) {
    return fs.readFileSync(path, 'utf-8');
  }
  // 输入最终文件
  emitFiles(depsGraph, entryPath) {
    let res = `
      (function(depsGraph, entryPath) {
        function requireModule(moduleId) {
          var exports = {};
          function require(path) {
            return requireModule(depsGraph[moduleId].dependencies[path]);
          }
          (function(require, exports, code) {
            eval(code);
          })(require, exports, depsGraph[moduleId].code);
          return exports
        }
        requireModule(entryPath);
      })(${JSON.stringify(depsGraph)}, '${entryPath}')
    `;
    let outputPath = './dist/index.js';
    fs.stat(outputPath, (err, stat) => {
        if(stat && stat.isFile()) {
          fs.unlinkSync(outputPath);
        }else {
          fs.mkdirSync('./dist');
        }
        fs.writeFileSync(outputPath, res, 'utf-8')
    });
  }
}

new Compiler().run('./src/index.js');






至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。