『手写webpack』实现打包核心流程

这次来实现webpack,webpack的基本功能是从main.js出发,收集依赖,把所有js文件合并到bundle.js中。假设有main.js和foo.js两个文件:

main.js:

js 复制代码
// 使用esm模块化,需要在package.json设置"type": "module",

// 需要带.js,否则解析出的相对路径不带.js,找不到文件
import { foo } from './foo.js';

foo();

console.log('main');

foo.js:

js 复制代码
export function foo() {
  console.log('foo');
}

目录结构:

markdown 复制代码
- dist
  - bundle.js
- example
  - foo.js
  - main.js
index.js
bundle.ejs

依赖收集

因为bundle.js中包含了所有用到的js文件的代码,所以必须通过import语句,收集到该文件依赖了哪些其他的文件,构建出Graph。

首先实现函数createAsset,每个文件都创建一个asset对象。

先调用fs.readFileSync读取main.js文件,把文件内容传入parser.parse(使用@babel/parser),构建出AST,然后遍历AST(使用@babel/traverse),当遇到类型为"ImportDeclaration"的结点时,其node.source.value属性保存了相对路径,把它添加到deps数组中。最后返回构建好的asset。

js 复制代码
function createAsset(filePath) {
  const source = fs.readFileSync(filePath, {
    encoding: 'utf-8'
  });

  const ast = parser.parse(source, {
    // 因为用到了import,必须设置此选项,否则报错
    sourceType: 'module'
  });

  const deps = [];
  // 遍历ast的结点
  traverse.default(ast, {
    // 处理到该类型结点时,执行此回调
    ImportDeclaration({ node }) {
      deps.push(node.source.value);
    }
  });

  return {
    filePath,
    source,
    deps,
  };
}

运行,打印出main.js的deps为['./foo.js']。

遍历图

收集每个asset的deps后,图实际上已经建立了。我们使用BFS遍历这个图,把所有的asset收集到数组中,用于bundle.js中代码的生成。

js 复制代码
function createGrapth() {
  const mainAsset = createAsset('./example/main.js');
  const queue = [mainAsset];

  for (const asset of queue) {
    asset.deps.forEach((relativePath) => {
      // path.resolve得到的是绝对路径
      // 此处 './example/' + relativePath 会更简单
      const child = createAsset(path.resolve('./example', relativePath));
      queue.push(child);
    });
  }
  // queue中收集了所有的asset
  return queue;
}

const graph = createGrapth();

现在graph中就包含了"main.js"和"foo.js"对应的两个asset。

合并代码

先让我们手动把两个文件的代码合并起来,能够正常运行。

直接把代码放在一起肯定是不行的,可能产生命名冲突。

js 复制代码
// 直接合并
import { foo } from './foo.js';

foo();

console.log('main');

export function foo() {
  console.log('foo');
}

可以在原先的文件内容外面裹一层函数,靠函数作用域规避命名冲突。但是import和export语句只能在最外层,因此要转换成cjs的写法。

再实现require:

js 复制代码
// 这是我们自己实现的bundle.js
function require(filePath) {
  const map = {
    './main.js': mainjs,
    './foo.js': foojs
  }
  const fn = map[filePath];
  const module = {
    exports: {}
  }
  fn(require, module, module.exports);
  return module.exports;
}

require('./main.js');

function mainjs(require, module, exports) {
  const { foo } = require('./foo.js');
  foo();
  console.log('main');
}

function foojs(require, module, exports) {
  function foo() {
    console.log('foo');
  }
  module.exports = {
    foo
  };
}

直接在index.html中引入该bundle.js,可以正常运行,控制台依次打印foo和main。

运行流程:require中传入'./main.js',拿到mainjs并执行,遇到require,传入了'./foo.js',拿到foojs并执行,设置module.exports,require将exports返回,赋值给mainjs中的foo,然后执行foo函数,输出'foo',最后输出'main'。

稍微修改写法,现在上边是固定的,只有最下方提供的modules参数需要动态修改。

js 复制代码
(function (modules) {
  function require(filePath) {
    const fn = modules[filePath];
    const module = {
      exports: {}
    };

    fn(require, module, module.exports);
    return module.exports;
  }

  require('./main.js');
})({
  './main.js': function (require, module, exports) {
    const { foo } = require('./foo.js');
    foo();
    console.log('main');
  },
  './foo.js': function (require, module, exports) {
    function foo() {
      console.log('foo');
    }
    module.exports = {
      foo
    };
  }
});

实现build函数,graph稍加转换生成data,把模板和data传给ejs(需要安装ejs),生成代码,并保存到dist/bundle.js中。

js 复制代码
function build(graph) {
  const data = graph.map(({ id, code, mapping }) => {
    return {
      filePath,
      code,
      mapping
    };
  });

  const template = fs.readFileSync('./bundle.ejs', { encoding: 'utf-8' });
  // 根据template生成代码,data必须包裹在对象中
  const code = ejs.render(template, { data });
  fs.writeFileSync('./dist/bundle.js', code);
}

build(graph);

此时有一个注意点,我们在自己实现的bundle.js中,原先函数中使用的import/export都被替换成了cjs的语法,但是在代码中,如何让每个asset的code自动作这种转换呢?还是使用babel。需要安装babel-core和babel-preset-env。

js 复制代码
function createAsset(filePath) {
  // ...
  
  // import -> require
  const { code } = transformFromAst(ast, null, { presets: ['env'] });

  return {
    filePath,
    code, // 现在code就符合cjs规范了
    deps,
  };
}

构建ejs模板:

ejs 复制代码
(function (modules) {
  function require(filePath) {
    const fn = modules[filePath];
    const module = {
      exports: {}
    };

    fn(require, module, module.exports);
    return module.exports;
  }

  require('./main.js');
})({
  <% data.forEach(info => { %>
    <%- 'info['filePath']' %>: function (require, module, exports) {
    <%- info['code'] %>
  },
  <%});%>
});

在终端输入"node index.js"运行,发现确实生成了dist/bundle.js,但是此时并不能在浏览器中运行,因为filePath使用了本机的绝对路径,在live-server上找不到。解决方案有二:1. 前面解析路径时,不使用path.resolve。2. 添加一层映射,给每个asset添加id,modules对象的key用id表示。

使用方法二,需要给asset添加id和mapping属性,id设置全局变量,不断自增。

js 复制代码
let id = 0;

function createAsset(filePath) {
  // ...
  
  return {
    filePath,
    code,
    deps,
    mapping: {},
    id: id++
  };
}

还需要给mapping添加映射关系,最后mapping的结构类似于是:{'./foo.js': 1 },key是asset的相对路径,值是其id。

js 复制代码
function createGrapth() {
  const mainAsset = createAsset('./example/main.js');
  const queue = [mainAsset];

  for (const asset of queue) {
    asset.deps.forEach((relativePath) => {
      const child = createAsset(path.resolve('./example', relativePath));
      // 添加映射关系
      asset.mapping[relativePath] = child.id;
      queue.push(child);
    });
  }
  return queue;
}

然后require需要传入id,modules中的每个成员变为数组。还要提供接收filePath的localRequire函数,作一层中间转换。

js 复制代码
(function (modules) {
  function require(id) {
    const [fn, mapping] = modules[id];
    const module = {
      exports: {}
    };

    function localRequire(filePath) {
      const id = mapping[filePath];
      return require(id);
    }

    fn(localRequire, module, module.exports);
    return module.exports;
  }

  require(0);
})({
  1: [
    function (require, module, exports) {
      const { foo } = require('./foo.js');
      foo();
      console.log('main');
    },
    { './foo.js': 2 }
  ],
  2: [
    function (require, module, exports) {
      function foo() {
        console.log('foo');
      }
      module.exports = {
        foo
      };
    },
    {}
  ]
});

新的ejs模板:

js 复制代码
(function (modules) {
  function require(id) {
    const [fn, mapping] = modules[id];
    const module = {
      exports: {}
    };

    function localRequire(filePath) {
      const id = mapping[filePath];
      return require(id);
    }

    fn(localRequire, module, module.exports);
    return module.exports;
  }

  require(0);
})({
  <% data.forEach(info => { %>
    <%- info['id'] %>: [function (require, module, exports) {
    <%- info['code'] %>
  }, <%- JSON.stringify(info['mapping']) %>],
  <%});%>
});

生成的bundle.js:

js 复制代码
(function (modules) {
  function require(id) {
    const [fn, mapping] = modules[id];
    const module = {
      exports: {}
    };

    function localRequire(filePath) {
      const id = mapping[filePath];
      return require(id);
    }

    fn(localRequire, module, module.exports);
    return module.exports;
  }

  require(0);
})({
  
    0: [function (require, module, exports) {
    "use strict";

var _foo = require("./foo.js");

(0, _foo.foo)(); // 需要带.js,否则解析出的相对路径不带.js,找不到文件

console.log('main');
  }, {"./foo.js":1}],
  
    1: [function (require, module, exports) {
    "use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.foo = foo;

var _bar = require("./bar.js");

function foo() {
  console.log('foo');
}

(0, _bar.bar)();
  }, {"./bar.js":2}],
  
    2: [function (require, module, exports) {
    "use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.bar = bar;

function bar() {
  console.log('bar');
}
  }, {}],
  
});

现在就能在浏览器正常运行了。

即使修改文件内容,或者在foo.js中添加对bar的依赖(如上所示),都能动态的生成新的bundle.js,把所有文件打包到一起。

总结

打包的核心流程:

  1. 从main.js出发。
  2. 读取文件内容,解析代码得到ast,遍历ast,遇到ImportDeclaration结点就添加依赖。需要使用babel转换成符合cjs的代码。
  3. 构建图,图中每个结点都是asset,拥有filePath、code(该文件内的代码)、deps等属性。
  4. 图结合模板,生成bundle.js。
相关推荐
Domain-zhuo4 小时前
如何提高webpack的构建速度?
前端·webpack·前端框架·node.js·ecmascript
m0_7482345219 小时前
前端Vue3字体优化三部曲(webFont、font-spider、spa-font-spider-webpack-plugin)
前端·webpack·node.js
Web阿成19 小时前
3.学习webpack配置 尝试打包ts文件
前端·学习·webpack·typescript
小木_.20 小时前
【python 逆向分析某有道翻译】分析有道翻译公开的密文内容,webpack类型,全程扣代码,最后实现接口调用翻译,仅供学习参考
javascript·python·学习·webpack·分享·逆向分析
Web阿成20 小时前
5.学习webpack配置 babel基本配置
前端·学习·webpack
理想不理想v3 天前
webpack最基础的配置
前端·webpack·node.js
臣妾没空3 天前
全栈里程碑二:前端基础建设
webpack
Domain-zhuo3 天前
如何利用webpack来优化前端性能?
前端·webpack·前端框架·node.js·ecmascript
初学者7.3 天前
Webpack学习笔记(2)
笔记·学习·webpack
理想不理想v3 天前
webpack如何自定义插件?示例
前端·webpack·node.js