实现一个简单的webpack

608 阅读5分钟

前言

我们都知道webpack是当前最流行的一款工程化工具,他具有编译代码,压缩代码,处理模块化等等功能,其原理就是将代码转换成运行环境能够识别的代码,那么我们如何实现一个简单的webpack呢?

思路

我们都知道webpack其主要是通过一个entry来进行生成依赖图,通过依赖图生成我们所需要的代码,然后通过output输出到指定文件夹。所以我们大致实现思路是:

  1. 根据entry生成依赖图
  2. 根据依赖图生成运行环境(当前是浏览器)能够识别的代码
  3. 根据output将代码生成到指定文件夹下的指定文件中

实现

实例

我们当前需要将以下文件进行打包处理,生成一个js文件。

// test.js
import b from './test2'
console.log('这是第一个例子' + b);

// test2.js
import a from './test1'
const b = '你好,'+ a;
export default b;

// test1.js
const a = '二锅头';
export default a;
 

生成依赖图

按照上面的例子,我们需要知道具体的某个文件所对应的引用的文件,那么我们就需要有一个办法可以获取到其引用的文件。

获取代码引用的文件名

我们可以通过@babel/traverse中的ImportDeclaration方法获取我们所需要的数据,traverse是一个遍历更新@babel/parser生成的AST的工具,我们可以通过其提供的方法来获取所需要的节点数据,详情可以看@babel/traverse,具体代码实现如下:

const traverse = require('@babel/traverse').default
getDeps(ast) {
    let deps = [];
    traverse(ast,{
        ImportDeclaration({node}) {
            deps.push(node.source.value);
        }
    })
    return deps;
}

这样我们就获取了单个文件的依赖了。我们上面说了traverse是遍历@babel/parser生成的AST工具,所以我们要获取依赖,首先就必须先将文件转换成AST格式。

将文件转换成AST格式

我们只需要通过使用@babel/parser中的parse方法就能将文件转换成AST格式的文件了。详细用法可以看@babel/parser因此我们需要将文件转换成二进制的文件,然后传入其方法中。为了能够将文件转换成二进制,我们需要通过fs.readFileSync将文件读取转换成二进制文件。具体代码实现如下:

const parser = require('@babel/parser');
const fs = require('fs');
parse2Ast(fileName) {
    return parser.parse(fs.readFileSync(fileName,'utf-8'),{
        sourceType:'module'
    })
}

至此我们所需要的基础的方法写好了。那么我们就需要去生成所有的依赖图了

获取所有的依赖

我们可以在上面获取具体某一文件所引用的文件,那么我们就可以通过定义一个数组,数组每一项都保存当前文件名以及所引用的文件,具体格式为:

interface Deps {
    fileName:string;
    dependence:Array<string>;
}

那么我们就得到了以下的代码:

getDepsMap(fileInfo) {
    let queue = [fileInfo];
    for(const item of queue) {
        item.dependence.forEach(deps=>{
            queue.push(this.parse(deps));
        })
    }
    return queue;
}
parse(fileName,isEntry) {
    const filePath  = fileName.indexOf('.js') >=0?fileName:`${fileName}.js`;
    const dirName = isEntry?'':path.dirname(this.options.entry);
    const absolutePath = path.join(dirName,filePath);
    const ast = this.parse2Ast(absolutePath);
    return {
        fileName,
        dependence:this.getDeps(ast)
    }
}

这样我们就得到了一个完整的依赖图,是一个数组格式的数据。层层依赖。

生成浏览器可识别的js文件

上面我们已经获取到了整个依赖,最终我们需要将上述的所有的依赖文件全部合并成一个文件,那么我们就需要做到可以将文件中的代码拿出来合并,因此我们就需要将AST格式的文件转换成浏览器可识别的js源码文件

将AST格式文件转换成源码

此时我们需要借助@babel/code,其具有一个方法那就是transformFromAst.该方法是将AST格式文件转换成一个源码。具体方法如下:

const {transofrmFromAst} = require('@babel/core');
parseAst2Code(ast) {
    const {code} = transformFromAst(ast,null,{
        presets:['@babel/preset-env']
    })
    return code;
}

因此我们可以在上述的生成依赖图的parse方法中增加一个数据,那就是最终的源码

parse(fileName,isEntry) {
    const filePath  = fileName.indexOf('.js') >=0?fileName:`${fileName}.js`;
    const dirName = isEntry?'':path.dirname(this.options.entry);
    const absolutePath = path.join(dirName,filePath);
    const ast = this.parse2Ast(absolutePath);
    return {
        fileName,
        dependence:this.getDeps(ast),
        code: this.parseAst2Code(ast)
    }
}
合并成一个文件

我们上面已经得到了一个依赖图,包含所有的依赖数据以及源码,那么我们只需要循环依赖,然后将源码合并即可,具体方法如下:

build(depsMap) {
    let modules = '';
    depsMap.forEach(queueItem=>{
        modules += `'${queueItem.fileName}': function(require,module,exports){${queueItem.code}}`
    })
    return `(function(modules)) {
        function require(filenName) {
            const fn = modules[fileName];
            const module = {export:{}};
            fn(require,module,module.exports);
            return module.exports;
        }
        require(`${this.options.entry}`)
    }({${modules}})`
}

至此我们整个的编译过程完成,然后我们就需要根据output生成文件夹以及生成文件

生成文件

根据上面的方法我们可以得到一个js源码但是如何生成一个文件呢,这就需要借助fs.writeFileSync来生成一个文件,同时我们需要生成一个文件夹,需要借助fs.mkdirSync方法来生成。具体方法如下:

doCompileAndReplace(content) {
    const { path, filename } = this.options.output;
    let truePath = path ? path : 'dist';
    let trueFileName = filename ? filename : 'bundle.js';
    fs.mkdirSync(truePath);
    fs.writeFileSync(`${truePath}/${trueFileName}`, content);
}

当然这样处理还有问题,那就是当第二次编译的时候就会提示文件夹已存在,因此我们需要做一个判断处理,那就是如果文件夹存在,删除文件夹以及下属所有文件,因此上述方法可以替换为:

doCompileAndReplace(content) {
    const { path, filename } = this.options.output;
    let truePath = path ? path : 'dist';
    let trueFileName = filename ? filename : 'bundle.js';
    if (fs.existsSync(truePath)) {
        for (const dir of fs.readdirSync(truePath)) {
            this.deleteFiles(dir, truePath);
        }
        fs.writeFileSync(`${truePath}/${trueFileName}`, content);
    } else {
        fs.mkdirSync(truePath);
        fs.writeFileSync(`${truePath}/${trueFileName}`, content);
    }
}
deleteFiles(dir, truePath) {
    const targetDir = path.resolve(truePath, dir);
    if (path.extname(targetDir) === "") {
        fs.readdirSync(targetDir).forEach((file) => {
            deleteFiles(path.join(targetDir, file), truePath);
        });
    } else {
        fs.unlinkSync(targetDir);
    }
}

至此,整个编译过程完成完成。详细代码可异步到个人仓库中看(还未上传,后续补),也可以通过以下命令安装npm包

npm install -g lulisimplewebpack

然后在所需要的地方执行simpleWebpack

附上完整代码,后续删除,放git上

/**
 * 第一步:从options中的entry来build文件
 * 第二步:在build文件中通过递归获取文件所有的依赖,生成依赖图
 * 第三步:在生成依赖图的过程中需要将依赖的文件读取出来后生成源码
 * 第四步:将文件读取出来生成源码需要将文件先转换成AST格式,然后再转换成源码
 * 第五步:根据options.output配置生成浏览器可识别的js文件,然后输出到指定的位置
 *
 * @class SimpleWebpack
 */
const parser = require('@babel/parser');
const fs = require('fs');
const path = require('path');
const { transformFromAst } = require('@babel/core');
const traverse = require('@babel/traverse').default;
class SimpleWebpack {
    constructor(options) {
        this.options = options;
    }
    /**
     * 根据文件的绝对路径,生成AST文件
     * @param {string} fileName
     * @return {*} AST格式数据
     * @memberof SimpleWebpack
     */
    parse2Ast(fileName) {
        const content = fs.readFileSync(fileName, 'utf-8');
        return parser.parse(content, {
            sourceType: 'module'
        })
    }
    /**
     * 根据ast格式数据,生成源码
     * @param {*} ast
     * @return {*} 
     * @memberof SimpleWebpack
     */
    parseAst2Code(ast) {
        const { code } = transformFromAst(ast, null, {
            presets: ['@babel/preset-env']
        });
        return code;
    }
    /**
     * 获取文件中的依赖,使用traverse中的ImportDeclaration来处理
     * @param {*} ast
     * @return {*} 
     * @memberof SimpleWebpack
     */
    getDeps(ast) {
        const deps = [];
        traverse(ast, {
            ImportDeclaration({ node }) {
                deps.push(node.source.value);
            }
        })
        return deps;
    }
    getDepsMap(fileInfo) {
        let queue = [fileInfo];
        for (const item of queue) {
            item.dependence.forEach(deps => {
                queue.push(this.parse(deps));
            });
        }
        return queue;
    }
    /**
     * 根据文件名来生成文件的依赖关系映射以及文件的code信息
     * @param {*} fileName
     * @param {*} isEntry
     * @return {*} 
     * @memberof SimpleWebpack
     */
    parse(fileName, isEntry) {
        const filePath = fileName.indexOf('.js') >= 0 ? fileName : `${fileName}.js`;
        const dirName = isEntry ? '' : path.dirname(this.options.entry);
        const absolutePath = path.join(dirName, filePath);
        const ast = this.parse2Ast(absolutePath);
        return {
            fileName,
            dependence: this.getDeps(ast),
            code: this.parseAst2Code(ast)
        }
    }
    /**
     * 根据
     *
     * @param {*} depsMap
     * @return {*} 
     * @memberof SimpleWebpack
     */
    bundle(depsMap) {
        let modules = '';
        depsMap.forEach(queueItem => {
            modules += `'${queueItem.fileName}':  function (require, module, exports) { ${queueItem.code} },`
        })
        return `(function(modules) {
        function require(fileName) {
          const fn = modules[fileName];
          const module = { exports : {} };
          fn(require, module, module.exports);
          return module.exports;
        }
        require('${this.options.entry}');
      })({${modules}})`
    }
    buildFile() {
        // 获取入口文件的依赖
        let entryFile = this.parse(this.options.entry, true);
        // 获取依赖图
        let depsMap = this.getDepsMap(entryFile);
        // 生成浏览器可识别的js文件
        let content = this.bundle(depsMap);
        // 生成文件夹以及写入文件
        this.doCompileAndReplace(content);
    }
    /**
     * 根据output配置生成dist包,且生成相应的文件
     * @param {*} content
     * @memberof SimpleWebpack
     */
    doCompileAndReplace(content) {
        const { path, filename } = this.options.output;
        let truePath = path ? path : 'dist';
        let trueFileName = filename ? filename : 'bundle.js';
        if (fs.existsSync(truePath)) {
            for (const dir of fs.readdirSync(truePath)) {
                this.deleteFiles(dir, truePath);
            }
            fs.writeFileSync(`${truePath}/${trueFileName}`, content);
        } else {
            fs.mkdirSync(truePath);
            fs.writeFileSync(`${truePath}/${trueFileName}`, content);
        }
    }
    /**
     * 删除文件夹中的文件
     *
     * @param {*} dir
     * @param {*} truePath
     * @memberof SimpleWebpack
     */
    deleteFiles(dir, truePath) {
        const targetDir = path.resolve(truePath, dir);
        if (path.extname(targetDir) === "") {
            fs.readdirSync(targetDir).forEach((file) => {
                deleteFiles(path.join(targetDir, file), truePath);
            });
        } else {
            fs.unlinkSync(targetDir);
        }
    }
}
module.exports = SimpleWebpack;