阅读 241

不用AST抽象语法树,实现一个简易的webpack打包器

项目结构

├── dist
    ├── bundle,js   //打包生成的文件
└── src
    ├── index.js       
    ├── action.js        
    ├── family-name.js        
    └── name,js
└── webpack.js     // 打包器
复制代码

需求分析

// index.js
let action = require('./action.js').action;
let name = require('./name.js').name;
let message = `${name} is ${action}`;
console.log( message );

// action.js
let action = 'making webpack';
exports.action = action;

// name.js
let familyName = require('./family-name.js').name;
exports.name = `${familyName} GongJS`;

// family-name.js
exports.name = 'Alibaba';
复制代码

在上面的代码中,分别定义了index.jsaction.jsname.jsfamily-name.js四个文件,在node环境下运行node index.js命令时,会打印出:

Alibaba GongJS making webpack

那如果我们想在浏览器环境下,也能达到相同的效果,该怎么做?显然,如果我们直接把index.js文件里的代码直接复制到浏览器的控制台里执行,控制台会报错,因为浏览器并没有帮我们实现node环境里的CommonJS规范,换句话说浏览器识别不了require这个函数,没法去加载其他模块,所以通常这个时候我们需要借助一下第三方的打包工具如webpack,来对我们的代码进行打包处理生成浏览器可以识别的代码。下面让我们来动手写一个简易的webpack,使其也能达到相同的效果。

逆推思路

既然在浏览器里无法像在node里面导入其他模块,那么我们就需要把用到的模块都提前打包放在同一个文件bundle.js里供浏览器加载识别,现在需要考虑的是:当打包完后,bundle.js里的代码要如何组织,才能保证代码能够执行并且执行(调用模块)的顺序也是正确的?下面几点是我们需要考虑的:

  • 模块的引用是有顺序的,入口文件index.js是第一个执行的
  • 模块里面的代码要能够执行,我们需要用函数来包裹它

综合这几点,bundle.js文件里的代码可能是这样的:

modules = {
 // index.js
  0: function() {
    let action = require('./action.js').action;
    let name = require('./name').name;
    let message = `${name} is ${action}`;
    console.log( message ); 
  },
  
  // action.js
  1: function() {
    let action = 'making webpack';
    exports.action = action;
  },
  
  // name.js
  2: function() {
    let familyName = require('./family-name.js').name;
    exports.name = `${familyName} GongJS`;  
  },
  
  // family-name.js
  3: function() {
    exports.name = 'Alibaba';
  } 
}
复制代码

在上面的代码中,我们把所有的模块都放到modules对象里,通过对象的key值:0,1,2,3来区分模块的调用顺序,比如modules[0]就表示调用之前的index.js模块,在index.js里又会按顺序先调用action.js modules[1],name.js modules[2];因为浏览器里并没有帮我们定义requireexports这两个变量,所以显然这两个是需要我们自己定义传给模块调用的,并且我们需要一个执行函数exec,用来调用modules[0](index.js),现在的代码就变成了下面这种形式:

modules = {
 // index.js
  0: function(require,exports) {
    let action = require('./action.js').action;
    let name = require('./name').name;
    let message = `${name} is ${action}`;
    console.log( message ); 
  },
  
  // action.js
  1: function(require,exports) {
    let action = 'making webpack';
    exports.action = action;
  },
  
  // name.js
  2: function(require,exports) {
    let familyName = require('./family-name.js').name;
    exports.name = `${familyName} GongJS`;  
  },
  
  // family-name.js
  3: function(require, exports) {
    exports.name = 'Alibaba';
  } 
  
//执行模块,返回结果
function exec(id) {
  let fn = modules[id];
  let  exports =  {};
  fn(require, exports);
  function require(path) {
    //todo...
    //根据模块路径,返回模块执行的结果
  }
}
exec(0) // 首先调用modules[0],即index.js

复制代码

下面,就是要考虑怎么实现require这个函数。这个函数实现的功能是:当我们给它传入不同路径参数时,它能够去执行相应的模块,并把结果返回。所以,我们需要把路径和模块映射起来,这样index.js里的代码执行到require('./action.js')时候,它知道去执行modules[1](action.js)里的函数,我们再对代码做一下改造:

modules = {
  0: [function(require, exports, module) {
    let action = require('./action.js').action;
    let name = require('./name.js').name;
    let message = `${name} is ${action}`;
    console.log( message ); 
  },
    {                        // mapping对象,存的每个模块的依赖
      './action.js': 1,
      './name.js': 2
    }
  ],

  1: [function(require, exports, module) {
    let action = 'making webpack';
    exports.action = action;
  }, 
    {       

    }
  ],

  2: [function(require, exports, module) {
    let familyName = require('./family-name.js').name;
    exports.name = `${familyName} GongJS`;  
  },
    {
      './family-name.js': 3
    }
  ],

  3: [function(require, exports, module) {
    exports.name = 'Alibaba';
  },
    {

    }
  ] 
}

//执行模块,返回结果
function exec(id) {
  let [fn, mapping] = modules[id]; // 拿到模块的执行函数和依赖对象
  let exports =  {};
  fn && fn(require, exports);

  function require(path) {
    //根据模块路径,返回模块执行的结果
    return exec(mapping[path]);
  }

  return exports;
}

exec(0)  // 从index.js开始执行
复制代码

我们把每个模块里对其他模块的依赖都单独抽离出来放到一个对象(mapping)里,key就是模块的路径,而value就是该模块在整个modules里的索引,这样再调用require('./action.js')时候,它知道要去调用modules[1]对应的函数,并且所有的模块也是顺序执行的,到这里我们的bundle.js就算改造完了,把他扔到浏览器里,也能够打印出:

Alibaba GongJS making webpack

那我们后面要做的就是实现一个简易的打包器,把index.jsaction.jsname.jsfamily-name.js这四个文件打包成bundle.js里面的代码。

开始实现

解析依赖

const fs = require('fs')
let fileContent = fs.readFileSync('./src/index.js', 'utf-8');
function getDependencies(str) {
  let reg = /require\(['"](.+?)['"]\)/g;
  let result = null;
  let dependencies = [];
  while(result = reg.exec(str)) {
    dependencies.push(result[1]);
  }
  return dependencies;
}
console.log(getDependencies(fileContent))
复制代码

这里我们通过正则匹配的方式去匹配类似require(./action.js)的字段,把模块的相关依赖给抽离出来。我们看看该函数的执行效果:

var str = "let action = require('./action.js').action;let name = require('./name.js').name;let message = `${name} is ${action}`;console.log(message);"
var reg = /require\(['"](.+?)['"]\)/g;
reg.exec(file)

// 第一次返回
 ["require('./action.js')", "./action.js", index: 13, input: "let action = require('./action.js').action;let nam…ge = `${name} is ${action}`;console.log(message);", groups: undefined]
reg.exec(file)

// 第二次返回
 ["require('./name.js')", "./name.js", index: 54, input: "let action = require('./action.js').action;let nam…ge = `${name} is ${action}`;console.log(message);", groups: undefined]
reg.exec(file)

// 第三次返回
null
复制代码

这样我们就知道了index.js这个模块依赖了action.js和name.js这两个模块,后面需要通过这两个依赖去构建mapping

构建模版

参照之前bundle.js代码,我们需要知道模块名(文件名)、模块索引(0,1,2,3)、模块内容(代码块)、模块依赖(mapping),继续改造代码:

const fs = require('fs');
const path = require('path');

let ID = 0;

function getDependencies(str) {
  let reg = /require\(['"](.+?)['"]\)/g;
  let result = null;
  let dependencies = [];
  while(result = reg.exec(str)) {
    dependencies.push(result[1]);
  }
  return dependencies;
}

function createAsset(filename) {
  let fileContent = fs.readFileSync(filename, 'utf-8');
  const id = ID++;
  return {
    id: id,
    filename: filename,
    dependencies: getDependencies(fileContent),
    code: `function(require, exports, module) { 
        ${fileContent}
    }`
  }
}
复制代码

我们把index.js里的内容读取出来,通过之前的getDependencies获取该模块的依赖,并且把读出来内容作为该模块的代码块。后面,我们还需要把其他几个文件(name.js\action.js\family-name.js)里的模版都按照这个方式构建出来。

在这里我们还没有拿到mapping的相关信息,模版的构造还没有完全结束。

const fs = require('fs');
const path = require('path');

let ID = 0;
 
....

function createGraph(filename) {
  let asset = createAsset(filename);
  let queue = [asset];
  
  for(let asset of queue) {
    const dirname = path.dirname(asset.filename);
    asset.mapping = {};
    asset.dependencies.forEach(relativePath => {
      const absolutePath = path.join(dirname, relativePath); // 获取文件的全路径
      const child = createAsset(absolutePath);
      asset.mapping[relativePath] = child.id;  // 获取依赖,拿到mapping的相关信息
      queue.push(child);
    });
  }

  return queue;
}
复制代码

这里我们通过for of的形式来遍历,这样当queue新增了元素,下次遍历就会从新增的元素开始遍历。

拼接字符串

当我们把所有模块的内容都读出来并且配置成相应的模版格式,这时候我们需要把这些内容都拼接起来,并且写入到bundle.js文件里。

const fs = require('fs');
const path = require('path');

let ID = 0;

...

function createBundle(graph) {
  let modules = '';  // 定义一个空字段,用来拼接字符串
  graph.forEach(mod => {
    modules += `${mod.id}: [
      ${mod.code},
      ${JSON.stringify(mod.mapping)}
    ],`;
  });
  
  // 拼接模版字符串,这里用了一个立即执行函数来包裹之前的exec函数,并把拼接好的参数当成参数传递进去
  const result = `(function(modules){
    function exec(id) {
      let [fn, mapping] = modules[id];
      console.log(fn, mapping)
      let module = { exports: {} };
    
      fn && fn(require, module.exports);
    
      function require(path) {
        //根据模块路径,返回模块执行的结果
        return exec(mapping[path]);
      }
    
      return module.exports;
    }
    
    exec(0)
  })(
    {${modules}}
  )`
  
  // 生产bundle.js文件
  fs.writeFileSync('../dist/bundle.js', result);
}

 // 传入入口文件名,开始打包
  let graph = createGraph('./src/index.js');
  createBundle(graph)
复制代码

最后,当我们在执行node webpack.js命令时,代码就会开始打包并生成bundle.js文件,内容 如下:

(function (modules) {
  function exec(id) {
    let [fn, mapping] = modules[id];
    console.log(fn, mapping)
    let module = {
      exports: {}
    };


    fn && fn(require, module.exports);

    function require(path) {
      //根据模块路径,返回模块执行的结果
      return exec(mapping[path]);
    }

    return module.exports;
  }

  exec(0)
})({
  0: [
    function (require, exports, module) {
      let action = require('./action.js').action;
      let name = require('./name.js').name;

      let message = `${name} is ${action}`;
      console.log(message);
    },
    {
      "./action.js": 1,
      "./name.js": 2
    }
  ],
  1: [
    function (require, exports, module) {
      let action = 'making webpack';

      exports.action = action;
    },
    {}
  ],
  2: [
    function (require, exports, module) {
      let familyName = require('./family-name.js').name;

      exports.name = `${familyName} GongJS`;
    },
    {
      "./family-name.js": 3
    }
  ],
  3: [
    function (require, exports, module) {
      exports.name = 'Alibaba';
    },
    {}
  ],
})
复制代码

把这段代码放到浏览器控制台也能正确的打印出:

Alibaba GongJS making webpack

github源码地址