『手写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。
相关推荐
小纯洁w6 小时前
Webpack 的 require.context 和 Vite 的 import.meta.glob 的详细介绍和使用
前端·webpack·node.js
海盗强13 小时前
Webpack打包优化
前端·webpack·node.js
祈澈菇凉19 小时前
如何优化 Webpack 的构建速度?
前端·webpack·node.js
懒羊羊我小弟1 天前
常用 Webpack Plugin 汇总
前端·webpack·npm·node.js·yarn
祈澈菇凉2 天前
Webpack的持久化缓存机制具体是如何实现的?
前端·webpack·gulp
懒羊羊我小弟3 天前
Webpack 基础入门
前端·webpack·rust·node.js·es6
刽子手发艺3 天前
Selenium+OpenCV处理滑块验证问题
opencv·selenium·webpack
懒羊羊我小弟3 天前
常用Webpack Loader汇总介绍
前端·webpack·node.js
真的很上进5 天前
【1.8w字深入解析】从依赖地狱到依赖天堂:pnpm 如何革新前端包管理?
java·前端·vue.js·python·webpack·node.js·reactjs
SuperherRo6 天前
信息收集-Web应用&JS架构&URL提取&数据匹配&Fuzz接口&WebPack分析&自动化
javascript·webpack·自动化·fuzz