面试官问你的webpack核心原理

324 阅读8分钟

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第8篇文章,点击查看活动详情

当你面试的时候,面试官如果问到如何在没有webpack的情况下,让代码打包?哦莫?你是不是还不清楚webpack打包的核心原理?那就看看这篇文章吧!

我们从头到尾来仔细的揣测一下webpack在打包的过程中到底是做了啥事;现在就假装你是webpack,当你碰见一堆文件的时候,你的脑瓜子里面肯定在想我要怎么将这些文件打包;万事开头难,咦?我第一步要干啥呢?

1.读取到入口文件中的内容

那么我们现在就要知道,webpack打包要干的就是将所有的文件里面的内容,汇聚到一个文件里面;所以关键来啦:webpack将所有文件里面的内容汇聚到同一个根文件里面,要迈出来的第一步是不是要先读取到文件里面的内容?所以你现在要做的第一件事情就是:读取到入口文件中的内容

2.递归的去读取模块所依赖(引入)的内容,生成一个抽象语法树

当你读取到了入口文件里面的内容,接下来你是不是要从头到尾的将这个文件里面的代码都读一遍;好了现在从第一行开始读取代码,你发现第一行是注释写着:// 这是main.js文件 , 好了读完了接着下一行:咦?你突然发现第二行里面写着:import add from './add.js' ,诶这好像也是一个文件,并且是在当前main.js外面引入的,所以你可能起疑惑了,自己在问自己:我要不要读取呢?答案当然是要的,并且你要先将这个外部引入的文件先读取完成,这才算读取完当前main.js里面的第二行代码。如果在这个引入的文件里面也有引入其他文件,同理就要将所有这样嵌套的文件全部读取完毕。当然你读取到的文件里面可能含有html,css等非js语法,使用你就要将这些语法转换成js的格式,生成一个抽象语法树。

总结一下:webpack在入口文件读取代码的时候,可能入口文件里面会引入其他的一些文件;引入的文件里面又嵌套的引入其他文件,那么webpack就要先将这些文件一个一个的读取完毕,直到读取完main.js的最后一行代码。当然读取的代码里面可能有html、css等格式,所以webpack会利用一些loader将这些格式转换成js格式,生成一个抽象语法树。

3.根据抽象语法树生成,生成浏览器能够运行的代码

当然生成的抽象语法树浏览器还不能识别,此时你就要根据这个抽象语法树生成浏览器可以运行的代码。到此你的使命就基本上完成了。乍一看,webpack的核心原理好像也不难,下面我们用实际代码来验证一下。

首先,我们在index.js里面引入了add与minus两个文件;所以现在index.js就是入口文件。

import add from './add.js'
import {minus} from './minus.js'

const sum=add(1,2)
const divsion=minus(3,2)

console.log(sum);
console.log(divsion);

现在要干的第一件事情就是要读取到这个入口文件。所以在node里面我们使用fs模块来读取文件。

 const getModuleInfo=(file)=>{   // 定义读取文件的方法
    const body=fs.readFileSync(file,'utf-8')   // fs.readFileSyncy异步读取文件
    console.log(body);
}

读取到了入口文件,接下来就要去解析这个入口文件来查找文件里面有没有引入其他文件。那么如何去解析分析这个入口文件呢?当然就要用到一个插件---bable;使用它的解析模块:@babel/paeser

 const getModuleInfo=(file)=>{   // 定义读取文件的方法
    const body=fs.readFileSync(file,'utf-8')   // fs.readFileSyncy异步读取文件
    // console.log(body);
     const ast=parser.parse(body,{  // 解析文件里面的内容,生成一个抽象语法树
        sourceType:'module'
    })
}

生成了抽象语法树接下来就是去抽象语法树里面查找入口文件里面的外部引入文件。@babel/traverse 这个插件可以遍历抽象语法树来获取入口文件以外的文件

 //遍历语法抽象树
    const deps={}
    traverse(ast,{
        ImportDeclaration({node}){  // node是生成的抽象语法树里的一个个对象
            const dirname= path.dirname(file) // 文件名
            const abspath='./'+path.join(dirname,node.source.value)  // 获取每个外部文件的路径,以便之后读取这个文件
            deps[node.source.value]=abspath  // 将这些文件路径存到一个数组
        }
    })

到这里我们已经读取到了入口文件,并且也读取到了引入的外部文件,生成了一个抽象语法树;接下来就是根据抽象语法树生成,生成浏览器能够运行的代码。所以我们要将生成的抽象语法树转换成ES5格式。借助插件:@babel/preset-env @babel/core 将AST转化成ES5。


    const {code}=babel.transformFromAst(ast,null,{  // 将AST准换成ES5格式
        presets:['@babel/preset-env']
    })

到这里我们就将代码转成了浏览器可以识别的代码。还记得我们外部的文件还没有进行处理吗?这些文件同样也要像入口文件一样的处理。

const parseModules=(file)=>{
    const entry=getModuleInfo(file)  // 调用读取文件的函数,这个函数会返回{file,deps,code}
    const temp=[entry]  // 将入口文件里面的信息存到一个数组里面,待会将入口文件里面引入的外部文件获取的信息再一个一个存到这个数组里面
    const depsGraph={}  // 存储每个文件的地址
    for(let i=0;i<temp.length;i++){
        const deps = temp[i].deps
        if(deps){
            for(const key in deps){
                if(deps.hasOwnProperty(key)){  // 防止遍历到原型上面的方法
                    temp.push(getModuleInfo(deps[key]))
                }
            }
        }
    }
    // console.log(temp);
    temp.forEach(moduleInfo=>{
        depsGraph[moduleInfo.file]={
            deps:moduleInfo.deps,
            code:moduleInfo.code
        }
    })
    return depsGraph
    // console.log(depsGraph);

}

经过上面的操作,现在我们就获取到了所有文件里面的全部代码,接着就是将这些文件代码整理在一起。

// 将多份代码整理在一起,此时代码里面会有require,但是浏览器不能识别,所以我们在这里面自己声明一个require函数,这样浏览器就可以识别到
const bundle=(file)=>{
    const depsGraph=JSON.stringify(parseModules(file))

    return `(function(graph){
        function require(file) {
          function absRequire(relPath) {
            return require(graph[file].deps[relPath])
          }
          var exports = {};
          (function(require, exports, code) {
            eval(code)
          })(absRequire, exports, graph[file].code)
          return exports
        }
        require('${file}')
      })(${depsGraph})`
}

最后一步,我们已经将这些代码整合到一起了,我们只要将这些代码整合到一个文件里就可以了。

fs.mkdirSync('./dist')  // 生成一个dist文件夹
fs.writeFileSync('./dist/main.js',content)  // 往dist里面加一个mian.js并且将整合的内容放进去

完整代码如下:

const fs=require('fs')      // 获取入口文件到代码
const path=require('path')
const parser=require('@babel/parser')
const traverse=require('@babel/traverse').default
const babel=require('@babel/core')

const getModuleInfo=(file)=>{   // 定义读取文件的方法
    const body=fs.readFileSync(file,'utf-8')   // fs.readFileSyncy异步读取文件
    // console.log(body);

    const ast=parser.parse(body,{  // 解析文件里面的内容,生成一个语法抽象树
        sourceType:'module'
    })

    //遍历语法抽象树
    const deps={}
    traverse(ast,{
        ImportDeclaration({node}){
            const dirname= path.dirname(file) // 文件名
            const abspath='./'+path.join(dirname,node.source.value) // 获取每个外部文件的路径,以便之后读取这个文件
            deps[node.source.value]=abspath  // 将这些文件路径存到一个数组
        }
    })

    const {code}=babel.transformFromAst(ast,null,{  // 将AST准换成ES5格式
        presets:['@babel/preset-env']
    })

    const moduleInfo={file,deps,code}  // file:就是入口文件地址,deps:入口文件里面所有的外部文件,code:当前文件转换成ES5之后生成的代码
    return moduleInfo
    // console.log(code);
}

const parseModules=(file)=>{
    const entry=getModuleInfo(file)  // 调用读取文件的函数,这个函数会返回{file,deps,code}
    const temp=[entry]  // 将入口文件里面的信息存到一个数组里面,待会将入口文件里面引入的外部文件获取的信息再一个一个存到这个数组里面
    const depsGraph={}  // 存储每个文件的地址
    for(let i=0;i<temp.length;i++){
        const deps = temp[i].deps
        if(deps){
            for(const key in deps){
                if(deps.hasOwnProperty(key)){  // 防止遍历到原型上面的方法
                    temp.push(getModuleInfo(deps[key]))
                }
            }
        }
    }
    // console.log(temp);
    temp.forEach(moduleInfo=>{
        depsGraph[moduleInfo.file]={
            deps:moduleInfo.deps,
            code:moduleInfo.code
        }
    })
    return depsGraph
    // console.log(depsGraph);

}
// 将多份代码整理在一起,此时代码里面会有require,但是浏览器不能识别,所以我们在这里面自己声明一个require函数,这样浏览器就可以识别到
const bundle=(file)=>{
    const depsGraph=JSON.stringify(parseModules(file))

    return `(function(graph){
        function require(file) {
          function absRequire(relPath) {
            return require(graph[file].deps[relPath])
          }
          var exports = {};
          (function(require, exports, code) {
            eval(code)
          })(absRequire, exports, graph[file].code)
          return exports
        }
        require('${file}')
      })(${depsGraph})`
}

const content=bundle('./src/index.js')
console.log(content);
fs.mkdirSync('./dist')
fs.writeFileSync('./dist/main.js',content)

到现在,我们就可以用自己写的webpack将文件打包让浏览器识别。

// add.js
export default(a,b)=>{
    return a+b
}

// minus.js
export const minus=(a,b)=>{
    return a-b
}

// index.js
import add from './add.js'
import {minus} from './minus.js'


const sum=add(1,2)
const divsion=minus(3,2)


console.log(sum);
console.log(divsion);

浏览器结果如下:

QQ图片20220928110418.png

总结:打包的核心原理

打包的核心步骤大致分为以下三步:

  1. 读取到入口文件中的内容

  2. 递归的去读取模块所依赖/引入的内容,生成一个抽象语法树

  3. 根据抽象语法树生成,生成浏览器能够运行的代码

当然webpack的源码肯定不会如此简单,本文只是将大致原理实现了一下。