likes
comments
collection
share

mini-webpack实现多个js模块打包到同一个文件下

作者站长头像
站长
· 阅读数 12

面试官问:你知道webpack做了什么嘛?

有一次面试,我真的遇到了面试官问到这个问题。我回答了他,webpack把静态资源按照配置文件进行打包,同时我们可以使用loader来处理特定的文件,用plugin来加强webpack的行为等等。

结果最后面试官告诉他,他只想听到我说

webpack就是把多个js模块打包到同一个文件下

显然他对我的答案不是很满意。最近我学了babel,我花了好几天的时间来做他说的答案。尝试将多个js模块打包到同一个文件下。

实现思路

  • 构建依赖图
  • 根据依赖图,将涉及到的文件和入口文件中的es module语法转为commonjs语法,并且生成对应的源代码
  • 实现require方法
  • 根据依赖图生成模块关系对象
  • 最后将各个模块的文件内容和require文件内容,以及模块关系对象放入同一个文件下(bundle.js)

前置知识

  • babel的ast是什么,以及babel的api
  • node文件基本知识
  • 模板字符串函数

最终结果

编译前:

mini-webpack实现多个js模块打包到同一个文件下

编译后:

mini-webpack实现多个js模块打包到同一个文件下

构建依赖图

const parser = require('@babel/parser')
const path = require('path')
const fs = require('fs')
const {
    default: traverse
} = require('@babel/traverse')

function createAsset(filePath) {
    const dep = [];
    
    const relativePath = path.join(__dirname, filePath)
    // 1.读取路径对应的源代码
    const sourceCode = fs.readFileSync(relativePath, {
        encoding: 'utf-8'
    });
    // 2.传入源代码
    const ast = parser.parse(sourceCode, {
        sourceType: 'module'
    });

    // 3.查询该ast中的import声明语句,并且将对应的路径放入dep中
    // 比如import foo from './foo/foo.js' 最后push('./foo/foo.js')
    traverse(ast, {
        ImportDeclaration(path) {
            const modulePath = path.node.source.value;
            dep.push(modulePath);
        }
    });

    return {
        dep
    };
}

根据依赖图,将涉及到的文件和入口文件中的es module语法转为commonjs语法

以替换import为require为例

其中ast,是当前文件产生的ast,traverse方法是用来遍历ast的。 以ImportDeclaration为例,当遍历到ast中的import语句时,就会执行ImportDeclaration方法,并且把当前path传入,当前path指的就是匹配到import语句的ast。 当执行generateRequireAst方法时,传入的path就是importPath,该方法有替换import节点为require节点的功能。

    traverse(ast, {
        ImportDeclaration(path) {
            const modulePath = path.node.source.value;
            dep.push(modulePath);
            // 根据源代码中的import语句ast,生成require语句ast。
            const requireAst = generateRequireAst(path)
            // 替换当前path,用requireAst替换当前importAstPath。
            path.replaceWith(requireAst)
        },
        ExportNamedDeclaration(path) {
            // 修改源代码中的export语句,转换为module.exports语句。
            // 处理导出的非默认情况

            // 获取导出语句中的名称,比如函数名,变量名等 以及获取替换的目标ast节点
            // 比如导出语句中 export const a = 'tom' 他的替换目标节点是const a = 'tom'
            const {
                variableNames,
                node
            } = getNamesAndNode(path)

            // 根据字符串生成module.exports.str = str对应的ast节点数组
            const asts = variableNames.map(item => generatModuleExport(item))
            // 在当前节点下方插入module.exports.xx = xx节点
            path.insertAfter(asts)
            const hasExportSpecifier = path.get('specifiers').length
            // 如果是export {a,b} 这种导出对象的声明语句,那么在前一步已经插入了
            // module.exports.a = a ; module.exports.b = b; 此时应该直接删除该ast节点
            if (hasExportSpecifier) {
                path.remove()
            } else {
            // 其他情况 比如export const a = 'tom'.在上一步插入module.exports节点之后.
            // 在这一步直接将export const a = 'tom' 替换成 const a = 'tom',此时就需要node
                path.replaceWith(node)
            }

        },
        ExportDefaultDeclaration(path) {
            // 修改源代码中的export语句,转换为module.exports语句
            // 处理es默认导出模块
            const isDefaultExport = path.isExportDefaultDeclaration()
            if (isDefaultExport) {
                updateDefaultExportAstToModule(path)
            }
        }
    });

执行完成之后,import的ast替换为了require方法对应的ast,export的语句也进行了相应的更新。 但是我们需要使用一个方法将这个文件的内容包裹起来,否则会污染全局作用域。 见以下代码: 假如有'./main.js'文件,内容如下。

import foo from './foo/foo.js';

const setup = () => {
    foo();
    console.log('set up')
}

setup()

经过上面对模块的处理之后,代码会变成这样。

  const {
    foo
  } = require('./foo/foo.js');
  const setup = () => {
    foo();
    console.log('set up');
  };
  setup();

此时你需要使用一个方法,将这些内容包含起来,同时对模块做一些处理,否则会污染全局作用域。

function srcmainjs(require, module, exports) {
  const {
    foo
  } = require('./foo/foo.js');
  const setup = () => {
    foo();
    console.log('set up');
  };
  setup();
}

比如我们这里将根据路径生成一个方法名,并产生一些参数。那么接下来就要按照上面的代码,创建一个函数体,并且注入一些参数

// 再次遍历当前ast,此时ast已经完成了修改。
    traverse(ast, {
    // 直接在文件program节点下
        Program(path) {
        // 通过get获取program节点下的body属性对应的内容。这里获取到的就是文件里的所有内容对应的ast
            const programBodyPath = path.get('body')
            // 通过一个方法,创建一个函数ast,并且将所有ast放进函数ast的body中,参数同时也放进去了
            const functionAst = generatCommonFunction(functionName, programBodyPath)
            // 替换program节点下的节点内容。
            path.node.body = [functionAst]
            console.log(path)
        }
    })

以下是对这一步的图解

body下面是三个节点 mini-webpack实现多个js模块打包到同一个文件下

body下面是一个函数,这就是这一步做的事情,将上面的三个节点都包裹起来,然后替换program下的body中对应的节点内容 mini-webpack实现多个js模块打包到同一个文件下

生成源代码

在上一步中,我们处理完所有代码,将能得到一个ast。这个ast已经将es module涉及的ast转换成了require涉及的ast。所以我们只需要使用babel的一个插件叫@babel/generator来根据该ast生成对应源代码就能完成这一步了。

    const {
        default: generator
    } = require('@babel/generator')
    // 省略以上代码... 这里已经是最新的ast
    const {
        code
    } = generator(ast)

require实现

(function(moduleRelation){
    function require(path){
        const map = moduleRelation
    
        const fn = map[path]
        const module = {
            exports:{}
        }
        fn(require, module, module.exports)
        return module.exports;
    }
    require('./main.js')
})(moduleRelation)

我们期望最后会生成一个模块映射关系对象moduleRelation,然后加载bundle.js的时候,直接执行这个自执行函数,同时执行入口文件./main.js。

根据依赖图生成模块关系对象

const mapTemplate = function (str, maps) {
    // maps = {'./main.js':'srcmainjs'}
    const mapArr = Object.entries(maps)
    const mapStrArr = mapArr.map(item => {
        // ['./main.js','srcmainjs']
        return `'${item[0]}':${item[1]}`
    })
    const mapStr = mapStrArr.join(',')
    return `${str[0]} { ${mapStr} };`
}

    // 这里是代码中的函数handleRoot,生成的对象为res,假如生成的是如下res
    const res = [
    {relativePath:'./main.js', functionName: 'srcmainjs'},
    {relativePath:'./foo/foo.js',functionName:'srcfoofoojs'}
    ]:
    const maps = {}
    res.forEach(item => {
        maps[item.relativePath] = item.functionName
    })
    const map = mapTemplate `const moduleRelation = ${maps}`
    
    // 最后生成的就是
    // const map = `const moduleRelation =  { './main.js':srcmainjs,'./foo/foo.js':srcfoofoojs };`

将各个模块的文件内容和require文件内容,以及模块关系对象放入同一个文件下(bundle.js)

function generatorFile(res, requireCode) {
    // res是上一步的结果。里面包含了文件路径字符串、生成的函数名称、源代码
    const to = path.resolve('./src')
    res.forEach(item => {
        const relativePath = `${path.relative(to,item.relativePath)}`
        const res = replaceAll(relativePath, '\\', '/')
        item.relativePath = `./${res}`
    })
    // 构建模块关系对象
    const maps = {}
    res.forEach(item => {
        maps[item.relativePath] = item.functionName
    })
    const map = mapTemplate `const moduleRelation = ${maps}`
    // 将每个文件的源代码拼接起来,而每个文件里面的内容都是一个函数,
    // 所以这里都是函数声明对应的源代码字符串的拼接
    const souceCodes = res.map(item => item.code).join('\n');
    // 最后将源代码和模块关系对象,以及require方法对应的字符串全部拼接起来,成为最后要写入文件的内容
    const writeTarget = souceCodes + '\n' + map + '\n' + requireCode
    fs.writeFileSync(path.join(__dirname, 'bundle.js'), writeTarget)
}

处理项目文件的源代码如下

const {
  createAsset,
  handleRoot,
  generatorFile
} = require('./utils');
const fs = require('fs');
const path = require('path');

// 返回一个对象,该对象包含修改后的代码字符串,依赖的文件名数组,本身文件的路径字符串,函数名
const root = createAsset('./main.js');

// 加载我们自己实现的require方法字符串
const requireJsPath = path.join(__dirname, './require.js')
const requireCode = fs.readFileSync(requireJsPath, {
  encoding: 'utf-8'
});
// 将root对象传入,由方法内部遍历依赖并依次调用createAsset,并产生一个数组
const res = handleRoot(root)

// 最后将项目的依赖对应的所有文件进行打包,放入bundle.js下,然后将require方法字符串也放入,同时产生一个模块对应关系对象
generatorFile(res,requireCode)

utils的源代码如下

const parser = require('@babel/parser')
const {
    default: traverse
} = require('@babel/traverse')
const template = require('@babel/template')
const GAstNode = require('@babel/types')
const {
    default: generator
} = require('@babel/generator')
const fs = require('fs');
const path = require('path');

function replaceAll(str, flag, replaceFlag) {
    if (str.includes(flag)) {
        const target = str.replace(flag, replaceFlag)
        return replaceAll(target, flag, replaceFlag)
    } else {
        return str
    }
}

function getFunctionId(filePath) {
    const relativePath = path.join(__dirname, filePath)

    const sliceIndex = relativePath.search(/src[\\/].*/)

    const target = relativePath.slice(sliceIndex)


    const noLine = replaceAll(target, '\\', '')
    const result = replaceAll(noLine, '.', '')
    return result;
}

function generateRequireAst(aPath) {
    const specifiers = aPath.get('specifiers')
    const defaultSpecifier = []
    const nameSpecifier = []
    const fileSource = aPath.get('source').node.value
    specifiers.forEach(item => {
        const isDefaultSpecifier = item.isImportDefaultSpecifier()
        if (isDefaultSpecifier) {
            defaultSpecifier.push(item.node.local.name)
        } else {
            nameSpecifier.push(item.node.local.name)
        }
    })
    defaultSpecifier.push(...nameSpecifier)
    const statmentPropertyName = defaultSpecifier.join()
    const templateAst = template.statement(`const { ${statmentPropertyName} } = require('${fileSource}')`)()
    return templateAst
}

function generatModuleExport(propName) {
    const statementAst = template.statement(`module.exports.${propName} = ${propName}`)()

    return statementAst;
}

function updateDefaultExportAstToModule(path) {
    const variableNames = []
    const isDefaultExport = path.isExportDefaultDeclaration()
    if (isDefaultExport) {
        // 默认导出非对象的情况
        if (path.node.declaration.type === 'Identifier') {
            const name = path.node.declaration.name
            const asts = generatModuleExport(name)
            path.replaceWith(asts)
            return
        }
        // 默认导出为对象的情况
        if (path.node.declaration.type === 'ObjectExpression') {
            const names = path.node.declaration.properties.map(item => item.key.name)
            variableNames.push(...names)
            const asts = variableNames.map(item => generatModuleExport(item))
            path.replaceWithMultiple(asts)
            return
        }
    }
}

/**
 * @description 获取当前语句对应的节点路径中的ast节点以及变量名,函数名... 
 * @param {Object} path 当前语句对应的ast路径
 * @return {Object} node 当前path的替换目标ast, variableNames 当前语句的变量名或者函数名等名称信息
 */
function getNamesAndNode(path) {
    const variableNames = []
    let node = null

    path.traverse({
        FunctionDeclaration(child) {
            const {
                name
            } = child.node.id

            node = child.node

            variableNames.push(name)
        },
        VariableDeclarator(child) {
            const {
                name
            } = child.node.id

            node = child.parentPath.node

            variableNames.push(name)
        },
        ExportSpecifier(child) { // 非default但是是一个对象的情况
            const {
                name
            } = child.node.exported

            variableNames.push(name);
        },
    })

    return {
        variableNames,
        node
    }
}

function generatCommonFunction(fnName, programBodyPath) {
    const functionId = GAstNode.identifier(fnName)
    const requireId = GAstNode.identifier('require')
    const moduleId = GAstNode.identifier('module')
    const exportId = GAstNode.identifier('exports')
    const blockStatement = GAstNode.blockStatement(programBodyPath.map(item => item.node))
    const functionAst = GAstNode.functionExpression(functionId, [requireId, moduleId, exportId], blockStatement)
    return functionAst
}

function createAsset(filePath) {
    const dep = [];

    const functionName = getFunctionId(filePath)
    const relativePath = path.join(__dirname, filePath)
    const sourceCode = fs.readFileSync(relativePath, {
        encoding: 'utf-8'
    });

    const ast = parser.parse(sourceCode, {
        sourceType: 'module'
    });

    traverse(ast, {
        ImportDeclaration(path) {
            const modulePath = path.node.source.value;
            dep.push(modulePath);
            // 修改源代码中的import语句,转换为require语句。
            const requireAst = generateRequireAst(path)
            path.replaceWith(requireAst)
        },
        ExportNamedDeclaration(path) {
            // 修改源代码中的export语句,转换为module.exports语句。
            // 处理导出的非默认情况

            // 获取导出语句中的名称,比如函数名,变量名等 以及获取替换的目标ast节点,比如导出语句中 export const a = 'tom' 他的替换目标节点是const a = 'tom'
            const {
                variableNames,
                node
            } = getNamesAndNode(path)

            // 根据字符串生成module.exports.str = str对应的ast节点数组
            const asts = variableNames.map(item => generatModuleExport(item))
            // 在当前节点下方插入module.exports.xx = xx节点
            path.insertAfter(asts)
            const hasExportSpecifier = path.get('specifiers').length
            // 如果是export {a,b} 这种导出对象的声明语句,那么在前一步已经插入了 module.exports.a = a ; module.exports.b = b; 此时应该直接删除该ast节点
            if (hasExportSpecifier) {
                path.remove()
            } else {
                // 其他情况 比如export const a = 'tom'.在上一步插入module.exports节点之后.在这一步直接将export const a = 'tom' 替换成 const a = 'tom',此时就需要node
                path.replaceWith(node)
            }

        },
        ExportDefaultDeclaration(path) {
            // 修改源代码中的export语句,转换为module.exports语句。
            // 处理es默认导出模块
            const isDefaultExport = path.isExportDefaultDeclaration()
            if (isDefaultExport) {
                updateDefaultExportAstToModule(path)
            }
        }
    });

    traverse(ast, {
        Program(path) {
            const programBodyPath = path.get('body')
            const functionAst = generatCommonFunction(functionName, programBodyPath)
            path.node.body = [functionAst]
            console.log(path)
        }
    })

    const {
        code
    } = generator(ast)

    return {
        code,
        dep,
        relativePath,
        functionName
    };
}

function handleRoot(root) {
    const queue = [root];

    for (const asset of queue) {
        asset.dep.forEach(item => {
            const child = createAsset(item)
            queue.push(child)
        });
    }

    return queue;
}

function generatorFile(res, requireCode) {
    const to = path.resolve('./src')
    res.forEach(item => {
        const relativePath = `${path.relative(to,item.relativePath)}`
        const res = replaceAll(relativePath, '\\', '/')
        item.relativePath = `./${res}`
    })
    // 构建模块关系对象
    const maps = {}
    res.forEach(item => {
        maps[item.relativePath] = item.functionName
    })
    const map = mapTemplate `const moduleRelation = ${maps}`
    const souceCodes = res.map(item => item.code).join('\n');
    const writeTarget = souceCodes + '\n' + map + '\n' + requireCode
    fs.writeFileSync(path.join(__dirname, 'bundle.js'), writeTarget)
}

const mapTemplate = function (str, maps) {
    // maps = {'./main.js':'srcmainjs'}
    const mapArr = Object.entries(maps)
    const mapStrArr = mapArr.map(item => {
        // ['./main.js','srcmainjs']
        return `'${item[0]}':${item[1]}`
    })
    const mapStr = mapStrArr.join(',')
    return `${str[0]} { ${mapStr} };`
}

module.exports = {
    createAsset,
    handleRoot,
    generatorFile
}