Webpack入门到精通 二(核心原理)

588 阅读5分钟

webpack 要解决的两个问题

假设现在我们有以下代码

index.js

import a from './a.js'
import b from './b.js'

console.log(a.getA())

console.log(b.getB())

a.js

import b from './b.js'

const a = {
  value: 'a',
  getA: () => b.value + 'from a.js'
}

export default a

b.js

import a from './a.js'

const b = {
  value: 'b',
  getB: () => a.value + 'from b.js'
}

export default b

很遗憾,在浏览器中不能直接运行,带有importexport关键字的代码,那我们想要,并且必须要在浏览器中运行这些代码应该怎么办呢?

怎样在浏览器中运行import/export?

现代浏览器可以通过<script type="module"></script>来支持importexport ie就不再这里提了,明年就跟他说拜拜了。

兼容策略

  • 激进的策略:把代码全部放在<script type="module"></script>里面,缺点:会导致请求的文件数量过多。

  • 平稳兼容的策略:把关键字转换成普通代码,并把所有文件打包成一个文件,缺点:需要编写一些复杂的代码才能够做到这件事情。

编译import和export关键字(问题一)

在上篇文章的基础上进行改动,

image.png

再来看一下得到的依赖结果,主要的变化就是依赖里面的code从之前的es6代码变成了es5代码里面没有了importexport

image.png

分析转换后的ES5代码

我们在来仔细的观察一下a.js变成es5之后的代码

"use strict";
Object.defineProperty(exports, "__esModule", {value: true}); // 疑惑 1
exports["default"] = void 0;                                 // 疑惑 2
var _b = _interopRequireDefault(require("./b.js"));          // 细节 1
function _interopRequireDefault(obj) {                       // 细节 1
  return obj && obj.__esModule ? obj : { "default": obj };   // 细节 1
}
var a = {
  value: 'a',  
  getB: function getB() {
    return _b["default"].value + ' from a.js';               // 细节 1
  }
};
var _default = a;                                            // 细节 2
exports["default"] = _default;                               // 细节 2

答疑

//隐式
Object.defineProperty(exports, "__esModule", {value: true});

//显示
Object.defineProperty(exports, "__esModule", {
  enumerable: false,
  configurable: false,
  writable: false,
  value: true
});

//为什么不这样写呢?可能是为了添加一个不可枚举的属性,并且默认不能让其他人修改。只能自己使用,当做标记
exports.__esModule = true   

这个是给exports对象添加一个__esModule的一个属性值为true,那问什么不写成下面这一种呢?我本人的分析(注释)

exports["default"] = void 0;   //这段代码的意思?
  1. void 0 等价于 undefined,老 JSer 的常见过时技巧
  2. 这句话是为了强制清空 exports['default'] 的值
import a from './a.js' 变成了
var _a = _interopRequireDefault(require("./a.js"));
a.getA() 变成了 
__a["default"].getA()

_interopRequireDefault(module)函数

  1. _下划线前缀是为了避免与其他变量重名
  2. 该函数的意图是给模块添加 'default'
  3. 为什么要加 default:CommonJS 模块没有默认导出,加上方便兼容
  4. 内部实现:return m && m.__esModule ? m : { "default": m }
  5. 其他 _interop 开头的函数大多都是为了兼容旧代码
export default 变成了
 var a = {
  value: 'a',
  getA: function getA() {
    return _b["default"].value + 'from a.js';
  }
};
var _default = a;
exports["default"] = _default;


export {c} 变成了
var c = {
  value: 'c'
};
exports.c = c;

把多个文件打成一个包(问题二)

我们先来想一想这个文件具有哪些功能?肯定包含所有的模块,然后能够执行所有模块。我们先来写一段伪代码来描述我们期待的结果。

//构建依赖关系
var depRelation = [
  {
    key: 'index.js',
    deps: ['a.js', 'b.js'],
    code: function().....
  },
  {
    key: 'a.js',
    deps: [],
    code: function().....
  }
]
//从入口文件开始执行代码
execute(depRelation[0].key)

function exection(key) {
  var item = depRelation.find(i => key === i.key)
  item.code()  ?   //执行入口文件代码
}

下面我们开始对之前构建依赖关系的代码进行改造。我们目前主要有三个问题

  1. 之前构建的depRelation是一个对象,现在我要把它变成一个函数,为什么要变成数组呢?因为我们要把入口文件的依赖关系放在第一个位置。
  2. 之前的code还是一个字符串,需要把字符串变成函数。
  3. execute函数待完善。

把code字符串改成函数

  1. code字符串外面包一个function(require,module,exports){....}
  2. code写到文件里面,引号就不会出现在文件中。 就想下面这样
code = `
import a from './a.js'
import b from './b.js'
console.log(a.getA())
console.log(b.getB())
`
code2 = function(require,module,exports){
 ${code}
}
//最终把code2写入到文件中,就可以

打包器完成

import * as babel from '@babel/core';
//分析index.js里面代码依赖的文件
import { resolve, relative, dirname } from 'path'
import { readFileSync, writeFileSync } from 'fs'

import { parse } from '@babel/parser'
import traverse from '@babel/traverse';


//设置项目根目录


const projectRoot = resolve(__dirname, 'project-03')

type DepRelation = { key: string, deps: string[], code: string }[]


//初始化

const depRelation: DepRelation = []

function collectCodeAndDeps(filepath: string) {
  let key = getProjectPath(filepath)
  if (depRelation.find(item => key === item.key)) {
    // 注意,重复依赖不一定是循环依赖
    return
  }
  //先读取index文件的内容
  //把字符串代码转换成ats
  let code = readFileSync(resolve(filepath)).toString()

  //把读取到的es6代码先进行转换成es5的
  const { code: es5Code } = babel.transform(code, {
    presets: ['@babel/preset-env']
  })

  //把入口文件的文件名当做map的key
  let item = {
    key,
    deps: [],
    code: es5Code
  }
  depRelation.push(item)

  let ast = parse(code, {
    sourceType: 'module'
  })

  //遍历ast

  traverse(ast, {
    enter: path => {
      //如果发现当前语句是 import 就把inport的value 写入到依赖中去
      if (path.node.type === 'ImportDeclaration') {
        //当前文件的上一级目录 与获取到当前文件的依赖文件进行拼接。
        let depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
        //获取当前文件与根目录的相对路径
        const depProjectPath = getProjectPath(depAbsolutePath)
        // 把依赖写进 depRelation
        item.deps.push(depProjectPath)
        //拿到依赖文件的真实路径进行再一次依赖分析
        collectCodeAndDeps(depAbsolutePath)
      }
    }
  })
}

collectCodeAndDeps(resolve(projectRoot, 'index.js'))

writeFileSync('dist.js', generateCode())

function generateCode() {
  let code = ''
  //根据当前的依赖关系构建dist文件的一部分,把code变成fucntion
  code += 'var depRelation = [' + depRelation.map(item => {
    let { key, deps, code } = item
    return `{
      key: ${JSON.stringify(key)},
      deps: ${JSON.stringify(deps)},
      code: function(require, module, exports) {
        ${code}
      } 
    }`
  }).join(',') + '];\n'

  code += 'var modules = {};\n'
  code += `execute(depRelation[0].key)\n`
  code += `
  function execute(key) {
    if (modules[key]) { return modules[key] }
    var item = depRelation.find(i => i.key === key)
    if (!item) { throw new Error(\`\${item} is not found\`) }
    //把./b.js 转成 b.js 
    var pathToKey = (path) => {
      var dirname = key.substring(0, key.lastIndexOf('/') + 1)
      var projectPath = (dirname + path).replace(\/\\.\\\/\/g, '').replace(\/\\\/\\\/\/, '/')
      return projectPath
    }
    //执行code函数的时候内部如何处理require的逻辑,其实就是再把当前require的模块再执行一次
    var require = (path) => {
      return execute(pathToKey(path))
    }
    modules[key] = { __esModule: true }
    var module = { exports: modules[key] }
    item.code(require, module, module.exports)
    return modules[key]
  }
  `
  return code
}
//获取文件相对跟目录的相对路径
/*
C: \\Users\\code\\zf\\webpack\\01\\project - 01
C: \\Users\\code\\zf\\webpack\\01\\project - 01\\index.js

//得到的结果就是index.js
*/
function getProjectPath(path: string) {
  return relative(projectRoot, path).replace(/\\/g, '/')
}

现在我们进行以下操作,当然把这个文件用script的方式引入到html文件中也完全没有问题,打印的是modules 这个用于缓存代码执行过程中所加载过的模块。

image.png

目前还存在的问题

  1. 生成的代码中有多个重复的 _interopRequireDefault 函数
  2. 只能引入和运行 JS 文件,不能引入css
  3. 只能理解 import,无法理解 require
  4. 不支持插件
  5. 不支持配置入口文件和 dist 文件名

源码查看源码链接

总结

  1. 上一篇文章主要做的事情就是找出一段代码在执行的时候的依赖关系图。
  2. 这一篇文章主要做的事情就是,把找到的所有依赖,放到一个文件中,然后从入口文件开始执行完所有代码。

系列文章

Webpack入门到精通 一(AST、Babel、依赖)

Webpack入门到精通 二(核心原理)

Webpack入门到精通 三(Loader原理)

Webpack入门到精通 四(Plugin原理)

Webpack入门到精通 五(常用配置)