六十行代码阐述webpack-core的思想

863 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

虽然是五月的尾巴,但是竟然也算6月更文呢!就很棒哟~😄

前言

大家好,我是小阵 🔥,一路奔波不停的码字业务员
身为一个前端小菜鸟,总是有一个飞高飞远的梦想,因此,每点小成长,我都想要让它变得更有意义,为了自己,也为了更多值得的人
如果喜欢我的文章,可以关注 ➕ 点赞,与我一同成长吧~😋
加我微信:zzz886885,邀你进群,一起学习交流,摸鱼学习两不误🌟

开开心心学技术大法~~

开心

来了来了,他真的来了~

正文

先看下关键的bundle.js文件代码,才67行哟!

截屏2022-05-29 上午12.29.57.png

好的开搞!

webpack打包思想

每一个前端应该都接触过webpack,想必大家对他所能做到的事情都已经很清楚了,总结来说,大概就是以下这些

  • 资源依赖分析
  • 统一模块导入和输出
  • 将多文件整个到单文件
  • module内部命名不会污染全局变量
  • 多种不同资源文件统一整合管理

接下来我们要手动实现一下前面几个功能,最后一个功能可以结合webpack-loader来理解,如果大家对webpackloader也很感兴趣,不妨自己也研究一下,刚好我最近刚出了个小白版的loader教程

资源依赖分析

首先我们知道,webpack会定义一个入口文件(当然也可以是多个入口文件,不过道理是一样的),通过入口文件,不停跟踪引入到入口文件的资源,然后再跟踪二级资源文件,一步一步跟踪下去,我们首先就是要用代码实现这一步。

根目录定义一个lib/bundle.js文件来写我们的node代码

首先,跟踪的关键在于我们要先知道文件的内容

// 引入node的fs文件模块来操作文件
const fs = require('fs');

// 拿到入口文件路径,我们重点演示思想,所以这里手动写上
// 如果想模拟真实情况,可以在外部定义一个webpack.config.js抛出一个对象,拿去对象中定义的entry,当然,这不重要
// 因为我的bundle.js在lib下面,入口文件又在src内部,所以这样写路径,大家可以按照自己的习惯写入口路径
// 注意我这里用了path.resolve,如果大家入口文件在根目录下的话,直接用相对路径就可以
const entryPath = path.resolve(__dirname, '../src/index.js')

// 通过同步方法拿到文件内容字符串
const source = fs.readFileSync(entryPath,{
    // 定义下获取文件内容的编码,如果不写utf-8,默认为Buffer
    encoding: 'utf-8'
})

其次要知道文件中的导入的依赖路径。

要想拿到依赖路径,可以通过正则,也可以通过ast语法树

babel的ast在线演示

// 通过正则
// 因为上面已经拿到了文件字符串,所以可以通过正则来拿到依赖路径
// ... 这里就不写正则了,比较麻烦。。嘿嘿

// 通过ast语法树
// 相较于上面的方法,更推荐ast语法树
const path = require('path')
const { parse } = require('@babel/parser')
const traverse = require('@babel/traverse')

// 这里的source就是上面拿到的文件内容字符串
// 通过@babel/parser的parse方法,可以通过js的program字符串拿到对应ast语法树
const ast = parse(source, {
        sourceType: "module"
    })
// 因为根据实际情况,每个文件不止有一个依赖,所以建立数组,统一管理,将所有的依赖路径都放到这个数组中记录
const depsArr = []
// traverse可以帮我们遍历ast语法树,当遇到指定节点时会触发指定回调
traverse.default(ast, {
    // 因为我们要拿到import的内容,所以这里定义ImportDeclaration的回调函数
    ImportDeclaration: function ({ node }) {
      // node节点的source.value就是我们要的路径信息 
      depsArr.push(path.resolve(path.dirname(filepath))
    }
})

parse传送门 traverse传送门 最后通过依赖路径进一步继续分析后续依赖模块的信息,并形成依赖关系图

为了结构清晰,将上述步骤整合到一个方法getMoudleInfo

const getMoudleInfo = (filepath) => {
  let source = fs.readFileSync(filepath, {
    encoding: 'utf-8'
  });
  const ast = parse(source, {
    sourceType: "module"
  })
  const depsArr = []
  traverse.default(ast, {
    ImportDeclaration: function ({ node }) {
      node.source.value = path.resolve(path.dirname(filepath), node.source.value)
      depsArr.push(node.source.value)
      // depsArr.push(path.resolve(path.dirname(filepath),node.source.value))
    }
  })
  const { code } = transformFromAst(ast, null, {
    presets: ['env']
  })
  return {
    source: code, filepath, depsArr,
  }
}

getMoudleInfo可以拿到单个模块的信息,其中包括模块内容source,文件路径filepath,模块依赖的资源路径depsArr

之后我们通过depsArr来做递归遍历调用,就可以完整的生成文件的依赖关系图graph

const getGraphInfo = (filepath) => {
  const moduleInfo = getMoudleInfo(filepath);
  const graph = [moduleInfo]
  for (const module of graph) {
    module.depsArr.forEach(depPath => {
      const child = getMoudleInfo(depPath)
      // 通过一边遍历依赖,一边将child添加入graph数组来实现递归遍历
      graph.push(child)
    });
  }
  return graph
}

最终生成的graph是类似这样的结构

image.png

其中mapping不是这一步加上的,会在后面加上,大家继续看下去就知道啦

统一模块导入和输出

统一模块的导入和导出,我们需要用到@babel/coretransformFromAst方法,将所有的import语法都统一改成require的方式

const { transformFromAst } = require('@babel/core')

// 返回的code就是将import转成require之后的代码,依然是utf-8的字符串
const { code } = transformFromAst(ast, null, {
    presets: ['env']
})

transformFromAst传送门

可以看到我们除了用到了@babel/core之外,还用到了presets: ['env'],这个配置需要我们下载一个babel的预设env,所以还要装一个babel-preset-env的包

将多文件整合到单文件

假设我们不做任何处理,单纯的把入口文件index.js和被引入的printUtils.js文件贴到一个文件target.js中。

  // 模拟入口文件的调用
  require('./index.js');
  
  function index(){
      const { printName } = require('./printUtils');
      console.log('pringName', printName())
      console.log('index.js')
  }
   function printUtils(){
       console.log('printUtils.js')
       export function printName() {
          console.log('内部的printName')
          return 'zzz'
        }
  }

这时我们还要明确一点,哪怕我们代码中的引入导出都统一成requirecjs的规范,但是依然不是浏览器可以直接识别的代码。

所以,我们还需要自己实现一下require方法,让我们的浏览器可以认识requiremodule.exports

这里我先直接贴下代码,当然,这个require还是比较简单地,为了完善webapck-core的功能,还会有之后的idMap引入。因为不妨碍演示大体功能,所以我先贴一下简单的require代码,下面会有完整的。

// 我们需要一个map来通过路径映射到对应的方法,当然,这个map是需要动态生成的,我们先用静态的来说一下大概思路
// 当然,因为相对路径是可能重复的,用绝对路径更可靠
// 同样的,相对于用绝对路径当key值的,是用id这种唯一key就更加让人放心,这个下面会说到
  const map = {
      './test.js':test,
      './test2.js':test2
  }
  function require(path) {
    const fn = map[path]
    const module = {
      exports: {}
    }
    const loaclRequire = path=>{
      return require(idMap[path])
    }
    fn(require, module)
    return module.exports
  };

这时,我们创建一个index.html文件,将target.js文件引入之后发现浏览器其实已经可以正常访问了

image.png

既然功能没问题了,那我们再回顾下我们的目标。

  1. 多文件整合到单文件,这个我们已经完成了,
  2. map动态生成问题,因为这个map是用来定位具体依赖哪个模块的,因此这个map可以在生成文件依赖图的时候顺便生成下
  3. mapkey有重名风险问题,我们上面说了,最好使用唯一标识的id,这个在webpack中是hash,我们自己实现的暂时不用搞成hash,只需要在生成依赖图的时候维护一个累加的number就行了
  4. 为避免全局变量的污染,最好做一个函数自执行
  5. map是变动的,最好作为自执行的入参传入

依据上面的分析,我们把文件改造成以下内容

; (function (map) {
// 因为最好用id做key,所以这里入参为id
  function require(id) {
  // 考虑到一些文件没有依赖内容,所以也不存在依赖路径与id,这里做下兼容
    if( typeof id === 'undefined')return;
    // 依据传入的map,拿到对应fn和idMap
    const [fn,idMap] = map[id]
    const module = {
      exports: {}
    }
    // 文件内require引入的依然是path,所以要做下map转换
    const loaclRequire = path=>{
      return require(idMap[path])
    }
    fn(loaclRequire, module,module.exports)
    return module.exports
  };

  // 因为入口文件一定是依赖图的第一个模块,所以通过require(0)来调用入口文件,从而启动之后的依赖打包
  require(0)
})(
    // map作为入参
  {
  0: [function index(require, module) {
    const { printName } = require('./printUtils.js')
    console.log('pringName', printName())
    console.log('index.js')

  },
  // 为了使map的key值唯一,我们维护一个路径与id的对应关系map
  {
    './printUtils.js':1
  }],
  1: [function printUtils(require, module) {
    module.exports = {
      printName: function () {
        console.log('内部的printName')
        return 'zzz'
      }
    }
  },{}],
})

相应的,加上map的映射之后,前面的getGraphInfo的方法变为

// 设置全局变量id(当然,也可以作为自执行的局部id,看你怎样定义)
let id = 0;
// getGraphInfo,添加了module.mapping字段,用来维护依赖路径与id的关系
const getGraphInfo = (filepath) => {
  const moduleInfo = getMoudleInfo(filepath);
  const graph = [moduleInfo]
  for (const module of graph) {
    module.mapping = {}
    module.depsArr.forEach(depPath => {
      id++;
      const child = getMoudleInfo(depPath)
      module.mapping = {
        ...module.mapping,
        [depPath]: id
      }
      graph.push(child)
    });
  }
  return graph
}

现在基本完成了大部分工作,只剩下最后的动态生成target.js文件和输出文件到dist

上面我们写死了target.js的内容,那通过观察就可以很轻松知道target.js文件中哪些是动态变化的。

没错,基本只有自执行的入参那一大坨是动态生成的,这块内容也正是我们在第一步第二步中完成的文件依赖图。我们只需要依照之前生成的graph遍历填充这个文件就可以了

那怎样填充呢?

老办法,一是通过字符串拼接,二是通过模板字符串。我们这里用ejs来做模板拼接一下这个target.ejs

首先我们要先定义一个build函数,然后将前面生成的graph作为data传入ejs模板中来渲染生成我们需要的文件

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

const entryPath = path.resolve(__dirname, '../src/index.js')

function build(graph) {
  // 读取模板的utf-8字符串
  const template = fs.readFileSync(path.resolve(__dirname, './target.ejs'), {
    encoding: 'utf-8'
  })
  // 通过ejs生成目标code
  const targetCode = ejs.render(template, { data: graph })
  // 将目标code输出到dist目录的built.js文件中
  fs.writeFileSync(path.resolve(__dirname, '../dist/built.js'), targetCode)
}
const moduleInfo = getGraphInfo(entryPath)

build(moduleInfo);

之后我们将之前写好的target.js文件直接改成target.ejs,将内容的关键内容改下就ok啦,这个比较简单,就不细说了

; (function (map) {
  function require(id) {
    if( typeof id === 'undefined')return;
    const [fn,idMap] = map[id]
    const module = {
      exports: {}
    }
    const loaclRequire = path=>{
      return require(idMap[path])
    }
    fn(loaclRequire, module,module.exports)
    return module.exports
  };

  require(0)
})({
  <% data.forEach(function(info,index){ %> 
    <%-  index %>: [function (require, module,exports) {
      <%- info.source %> 
  
    },
      <%- JSON.stringify(info.mapping) %> 
    ],
  <% }) %> 
  
})

享受成果

上面的步骤都完成了,那就检查自己的劳动成果吧!

$ node lib/bundle.js

可以看到dist目录下生成了built.js文件

image.png

我们在index.html中引入一下看下效果

image.png

image.png

确实没问题,我们再试着多引入一些依赖文件

//index.js
import { printName } from './printUtils.js'
import './js/index1.js';
console.log('pringName', printName())
console.log('index.js')



// js/index1.js
console.log('js/index1.js',);


// printUtils.js
import test from './test1.js'
import test2 from './test2.js'
export function printName() {
  console.log('内部的printName')
  return 'zzz'
}


// test1.js
console.log('我是test1');


// test2.js
import test3 from "./test3.js";
console.log('我是test2',test3);


// test3.js
console.log('test3',);
export default 'test3'

image.png

看到这里你基本已经明白了webpack的一系列操作是怎样搞得啦,看明白了不如自己动手做一遍哟!还有还有,如果看了本文有学到一些东西或者觉得作者不容易的可以帮忙点赞转发哟!

爱你们!!

比心

结语

往期好文推荐「我不推荐下,大家可能就错过了史上最牛逼vscode插件集合啦!!!(嘎嘎~)😄」