一个简单打包器打包实现

355 阅读4分钟

背景

简单实现下模块打包器打包流程,大概了解webpack等工具打包核心流程

任务

首先,我们应当了解模块打包器做了什么?

简单来说,模块打包器打包过程就是读取需要打包的文件内容,构造一种依赖关系并将它们根据一定规则合并生成一个文件

根据上述过程,我们可以得出一些任务:

  • 文件读取:文件内容读取
  • 获取依赖:根据文件内容分析文件的依赖
  • 构造关系:根据上述的文件依赖构造一种关系
  • 生成文件:生成文件,将上述关系进行一定规则写入到生成的文件

下面我们将一个简单目录结构的项目进行打包实现一下。

项目

目录

- mini-webpack
    --src
       -index.js
       -foo.js
    --build.js
    --bundle.ejs
    --package.json

src文件

index.js

import { foo } from './foo.js';
console.log('this is main process');
foo();

foo.js

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

流程图

实现

文件读取

这里我们借助node中的fs模块path模快即可

// build.js
import { readFileSync, writeFileSync } from 'fs';
import path from 'path';

function getFileContent(filePath) {
    // 打包目录 src
  return readFileSync(path.resolve('./src', filePath), { encoding'utf-8'})
}

获取依赖

在上述文件读取后,我们可获得文件的内容,但如何通过内容获取到文件的依赖呢?

这里我们通过ast来处理,借助 @babel/parser@babel-traverse 可实现这个功能,实现如下:

// build.js
import { readFileSync, writeFileSync } from 'fs';
import path from 'path';
import parser from '@babel/parser';
import traverse from '@babel/traverse';

function getFileContent(filePath) {
    // 打包目录 src
  return readFileSync(path.resolve('./src', filePath), { encoding'utf-8'})
}
// 获取文件内容及依赖
function createAssets(entry) {
  let assets = {};    // 这个对象记录当前文件的一些信息,以供后面使用
  // 1.获取内容
  let data = getFileContent(entry);
  assets.filePath = entry;
  
  assets.deps = [];
  // 2. 获取依赖
  // 2.1 转成ast
  let ast = parser.parse(data, {
    sourceType"module",
  });
  assets.source = data;    // 存储文件信息
  // 2.2 ast生成树遍历
  traverse.default(ast, {
      // ImportDeclaration 可获取到 import 进来的文件路径
    ImportDeclaration({node}) {
      const { source } = node;
      assets.deps.push(source.value);    // 搜集依赖
    }
  });
  return assets;
}

备注:

构造关系

通过上述依赖关系,我们可以构建一种关系,即文件之间的关系。这里我们用数组来存储文件之间的关系。

// build.js
....
let entry = './index.js';
// 将文件依赖构造成图,通过递归方式
function creatGraph() {
  let entryAssets = createAssets(entry);
  // 用 quene 搜集依赖
  let quene = [entryAssets];
  for(const assets of quene) {
    //遍历 deps
    assets.deps.forEach((filePath) => {
      let asset = createAssets(filePath);
      quene.push(asset);
    });
  }
  return quene;
}

文件生成

由于ESM模块浏览器运行会报错,我们需要考虑怎么样才能在浏览器下执行。

这里我们可以用一个IIFE(立即执行函数),并且模仿CommonJS中的导入导出来实现。参考如下:

// 例子
(function (map) {
  function require(id) {
    let asset = map[id];
    let [fn, mapping] = asset;
    function localRequire(path) {
      return require(mapping[path]);
    }
    let module = {
      exports: {}
    };
    fn(localRequire, modulemodule.exports);
    return module.exports;
  }
  require('1');
})({
    // 这里1,2为文件模块id,如果为路径的话存在不同文件下的同名文件会有问题
  1: [function (requiremoduleexports) {
    let { foo } = require("./foo.js");
    console.log('this is main process');
    foo();
    // 加多一个map,用于映射路径与模块之间的关系
  }, { "./foo.js"2 }],

  2: [function (requiremoduleexports) {
    function foo() {
      console.log('this is foo content');
    }
    module.exports = {
      foo
    }
  }, {}],
})

由于 @babel/core支持将esm语法转换为commonJS语法 故上述想法可以实现。

这里我们将上面例子作为模板,模块内容作为数据,借用ejs.js来生成文件内容,模板代码如下:

//bundle.ejs
(function(map) {
  function require(id) {
    let asset = map[id];
    let [fn, mapping] = asset;

    function localRequire(path) {
      return require(mapping[path]);
    }

    let module = {
      exports: {}
    };

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

  require('1');
})({
  <% data.forEach(function(asset){ %>
    <%-asset.id%>: [function(requiremoduleexports) {
      <%-asset.source%>
    }, <%-JSON.stringify(asset.mapping)%>],
  <%})%>
})

build.js整体代码如下:

import { readFileSync, writeFileSync } from 'fs';
import * as parser from '@babel/parser';
import path from 'path';
import traverse from '@babel/traverse';
import ejs from 'ejs';
import { transformFromAst } from '@babel/core';

let entry = './index.js';
let id = 1;

function getFileContent(filePath) {
  return readFileSync(path.resolve('./src', filePath), { encoding: 'utf-8'})
}

// 获取文件内容及依赖
function createAssets(entry) {
  let assets = {};
  // 1.获取内容
  let data = getFileContent(entry);
  assets.filePath = entry;
  
  // 2. 获取依赖
  // 2.1 转成ast
  assets.deps = [];
  let ast = parser.parse(data, {
    sourceType: "module",
  });
  
  let { code } = transformFromAst(ast, null, {
    presets: ['env']
  });

  assets.source = code;
  assets.id = id;
  assets.mapping = {};

  // 2.2 ast生成树遍历
  traverse.default(ast, {
    ImportDeclaration({node}) {
      const { source } = node;
      assets.deps.push(source.value);
      assets.mapping[source.value] = ++id;
    }
  });
  
  return assets;
}

// 文件依赖转为图
function creatGraph() {
  let entryAssets = createAssets(entry);
  // 用 quene 搜集依赖
  let quene = [entryAssets];
  for(const assets of quene) {
    //遍历 deps
    assets.deps.forEach((filePath) => {
      let asset = createAssets(filePath);
      quene.push(asset);
    });
  }
  
  return quene;
}

let quene = creatGraph();

/**
 * 根据图关系生成浏览器可执行文件
 * 1. 读取模板
 * 2. 模板数据进行替换
 * 2.1 由于文件是用的esm模式,需要改为common.js,此时需要用到 babel-preset, babel-preset-env
 * 3. 生成文件 
 */
// 获取模板
let template = readFileSync('./bundle.ejs', { encoding: 'utf-8'});
// 模板插入数据
let code = ejs.render(template, {
  data: quene
});
// 生成文件
writeFileSync('./dist/bundle.js', code, { encoding: 'utf-8'});

备注:

  1. esm模块转为commonJS规范:借用@babel/core可实现
  2. ejs官网:ejs.bootcss.com/#docs

总结

通过打包器实现,可以了解到基本打包器流程,即读取文件内容->获得依赖 ->进行关系构造 -> 生成文件。

了解到通过babel相关包处理一些事情如文件内容转成ast,通过ast获取导入相关信息,ast将esm转为commonJS规范,以及模板ejs的使用。