初探webpack--浅尝原理

730 阅读4分钟

关键词

AST、Babel、自定义loader、自定义webpack

何为AST?

AST全称是AbStract Syntax tree,也就是抽象语法树,它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构

对于前端来讲,它就好似一个很复杂的对象,里面包含着各种各种的keyvalue,对应着源代码中的不同部分。

通过借助在线工具 astexplorer,我们可以很方便的查看源代码转以后的AST长啥样。

因为后面会自定义一个给async/await函数自动封装一个try catchloader,所以就以下方代码为例分析AST中常用字段的含义

async function func() {
   try {
       let res = await asyncFunc()
        // do something
   } catch (e) {
   	console.log(e)
   }
}

image.png

(ps: 因为后面需要用@babel/parser将源代码转译为AST,所以这里使用的是@babel/parser的转译规则)

首先async function,是一个异步函数声明语句,它对应的是图中的1,FunctionDeclarationAST中,declaration即声明语句,比如const a = 1let a = 1就是VariableDeclaration,而class A {}就是ClassDeclaration

对于async function来讲,**它与其他普通的function声明函数对比就是它的async属性值为true,**而普通声明函数值为false

它的body部分也就是函数块儿,**是一个BlockStatement,这代表一个语句块,是可以独立执行的单位。**函数块儿是BlockStatement,与之类似的有try {} catch(e){},是一个TryStatementfor (let i = 0;i < 10;i ++) {}是一个ForStatement

而对于tryStatement来讲,它内部有三大块儿,try是一块,catch是一块,finally又是一块,也就是图中的5、6、7。当然他们在其他语句中没有普遍性,但在后续的自定义loader中,它们是很重要的标识符。

在日常开发中,try模块儿中一般会放着比较复杂的逻辑,我们将其展开来看

image.png

会发现,try中其实也是一个BlockStatement,这与其祖先节点FunctionDeclarationBlockStatement性质一致,只是里面装的东西不同。

在其内部,有个VariableDeclaration对应的源代码的let声明语句。

在声明语句内部,我们找到了AwaitExpression,**它是一个表达式,它与Statement的区别就是,它执行完之后有返回值。**例如this关键字是ThisExpression1 + 2BinaryExpression

AST能干嘛?

我们知道可通过某些工具将源代码转为AST这样的树结构,也就是前端意义上的对象。那既然是对象,我们肯定可以使用某些api来操作这个对象,也就代表我们能在代码的编译层面对代码做一些手脚了,这也就是webpackloader的实现原理,甚至也是webpack的实现原理。

那有这么强大的工具吗?有的,babel

什么是babel?

Babel 是一个 JavaScript 编译器

  • 语法转换
  • 通过 Polyfill 方式在目标环境中添加缺失的特性 (通过引入第三方 polyfill 模块,例如 core-js
  • 源码转换(codemods)

我们需要用到babel下几个重要的插件

  • @babel/parser

    能将源代码转译为 AST

  • @babel/traverse

    转译后的AST非常复杂,我们怎么能找到想要操作的节点呢?@babel/traverse可以对AST深度遍历,当遍历到我们想要找的节点后,会抛出钩子执行我们注入的回调函数

  • @babel/types

    当我们找到目标节点后,我们想对该节点修修剪剪,做一些操作,这时候就需要用到@babel.types这样一个工具库,它里面有很多工具函数供我们使用

  • @babel/core

    能将AST转为源代码

自定义loader

需求:

async/await是目前较佳的异步请求函数封装方案,但是它不如Promise那样能用catch灵活的处理异常,所以一般会在async函数内,await语句外套一个try/catch语句来捕获异常。每次都写一遍try/catch很麻烦,现在需要有个loader能为打包后的async函数自动加上try/catch处理异常

实现:

image.png

1、parser转译:

const parser = require("@babel/parser");
const { getOptions } = require("loader-utils"); // 'loader-utils'中的getOptions可拿到由配置文件传入的options

module.exports = function(source) {
  // 获取配置文件传入的options
  let options = getOptions(this)
  let ast = parser.parse(source, {
    sourceType: "module", // 支持es6module
    plugins: ["dynamicImport"] // 支持动态import
  })
  // 解析传入的catchCode生成catchNode节点,便于挂载到catchClause上
  let catchNode = parser.parse(options.catchCode).program.body
}

loader其实就是一个工具函数,接收源代码进行处理,再返回处理后的源代码

2、traverse遍历找到目标节点:

首先分析一下待处理的源代码

async function func() {
    let res = await asyncFunc()
    console.log(res)
}

image.png 我们的目标就是要通过awaitExpression向外查找,查找到async函数FunctionDeclarationBlockStatement,这两者之间就是该插入TryStatement的位置

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types"); // 引入@babel/types
const loaderUtils = require("loader-utils");

module.exports = function(source) {
  let options = getOptions(this)
  let ast = parser.parse(source, {
    sourceType: "module",
    plugins: ["dynamicImport"]
  })
  let catchNode = parser.parse(options.catchCode).program.body

  traverse(ast, {
    AwaitExpression(path) {
      // 向外查找async函数的BlockStatement
      while (path && path.node) {
        // 父节点
        let parentPath = path.parentPath;
        // 是BlockStatement 且 父节点是async函数
        if ( t.isBlockStatement(path.node) && t.isFunctionExpression(parentPath.node, { async: true })) {
          
          // 插入TryStatement



        // 已经有了try/catch直接退出
        } else if ( t.isBlockStatement(path.node) && t.isTryStatement(parentPath.node)) {
          return
        }
        // 向外遍历
        path = parentPath
      }
    }
  })
}

3、插入TryStatement:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types"); // 引入@babel/types
const loaderUtils = require("loader-utils");

module.exports = function(source) {
  let options = getOptions(this)
  let ast = parser.parse(source, {
    sourceType: "module",
    plugins: ["dynamicImport"]
  })
  let catchNode = parser.parse(options.catchCode).program.body

  traverse(ast, {
    AwaitExpression(path) {
      while (path && path.node) {
        let parentPath = path.parentPath;
        if ( t.isBlockStatement(path.node) && t.isFunctionExpression(parentPath.node, { async: true })) {
          // 新建tryStatement
          let tryAst = t.tryStatement(
            // 将async函数的BlockStatement替换成try语句的内容
            path.node,
            // 替换catchClause
            t.catchClause(
              t.identifier('e'),
              t.blockStatement(catchNode)
            )
          )
          // 替换当前节点
          path.replaceWithMultiple(tryAst)
          return
        // 已经有了try/catch直接退出
        } else if ( t.isBlockStatement(path.node) && t.isTryStatement(parentPath.node)) {
          return
        }
        // 向外遍历
        path = parentPath
      }
    }
  })
}

4、core转回源代码:

// core转回源代码
return core.transformFromAstSync(ast, null).code

5、完整代码:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const core = require("@babel/core");
const { getOptions } = require("loader-utils"); // 'loader-utils'中的getOptions可拿到由配置文件传入的options

module.exports = function (source) {
  // 获取配置文件传入的options
  let options = getOptions(this)
  let ast = parser.parse(source, {
    sourceType: "module", // 支持es6module
    plugins: ["dynamicImport"] // 支持动态import
  })
  // 解析传入的catchCode生成catchNode节点,便于挂载到catchClause上
  let catchNode = parser.parse(options.catchCode).program.body

  traverse(ast, {
    AwaitExpression(path) {
      // 向外查找async函数的BlockStatement
      while (path && path.node) {
        // 父节点
        let parentPath = path.parentPath
        // 是BlockStatement 且 父节点是async函数
        if ( t.isBlockStatement(path.node) && t.isFunctionExpression(parentPath.node, { async: true }) ) {
          // 新建tryStatement
          let tryAst = t.tryStatement(
            // 将async函数的BlockStatement替换成try语句的内容
            path.node,
            // 替换catchClause
            t.catchClause(
              t.identifier('e'),
              t.blockStatement(catchNode)
            )
          )
          // 替换当前节点
          path.replaceWithMultiple(tryAst)
          return
          // 已经有了try/catch直接退出
        } else if ( t.isBlockStatement(path.node) && t.isTryStatement(parentPath.node)) {
          return
        }
        // 向外遍历
        path = parentPath
      }
    }
  })
  // core转回源代码
  return core.transformFromAstSync(ast, null).code
}

6、使用:

module: {
	rules: [
		{
			test: /\.js$/,
			use: [
				{
					loader: resolve(__dirname, 'loaders/asyncAddTry.js'),
					options: {
						catchCode: `console.log('error!')`
					}
				}
			]
		}
	]
}

自定义简易webpack

ps:只实现了基础的打包功能

简易webpack主要构建流程:

  • 读取配置文件(webpack.config.js)中的关键参数,构建相关属性
  • 处理文件之间的依赖关系,生成依赖关系图
  • 构造编译器(打包后的bundle文件),识别依赖关系图
  • 将编译器写入出口目录中

1、构建Compiler类,读取配置文件参数

class Compiler {
  constructor (options) {
    const { entry, output } = options
    this.entry = entry
    this.output = output
    this.modules = [] // 记录依赖的所有文件
  }
  // 启动编译
  run () {
    // do something
  }
}

2、处理文件之间的依赖关系,生成依赖关系图

既然要构建依赖关系图,首先肯定要明确图中的每个节点要包含哪些必要属性

  • 文件路径

    知道依赖的是那个文件

  • 文件代码

    经过处理的,开箱即用的代码

  • 文件依赖

    知道当前文件又依赖哪些文件,便于整个依赖关系图的展开

const fs = require("fs")
const path = require("path")
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const { transformFromAst } = require("@babel/core")

const Parser = {
  // 获取AST
  getAst: path => {
    const content = fs.readFileSync(path, "utf-8")
    return parser.parse(content, {
      sourceType: "module" // 可识别import语法
    })
  },
  // 获取依赖
  getDependecies: (ast, filename) => {
    const dependecies = {}
    traverse(ast, {
      ImportDeclaration({ node }) {
        // 返回目录
        const dirname = path.dirname(filename)
        // 找到根目录相对于文件的位置
        const filepath = "./" + path.join(dirname, node.source.value)
        dependecies[node.source.value] = filepath
      }
    })
    return dependecies
  },
  // 将ast转译为浏览器可识别的代码
  getCode: ast => {
    const { code } = transformFromAst(ast, null, {
      presets: ["@babel/preset-env"]
    })
    return code
  }
}


class Compiler {
  constructor(options) {
    const { entry, output } = options
    this.entry = entry
    this.output = output
    this.modules = []
  }
  run () {
    // 构建入口文件节点
    const info = this.build(this.entry)
    this.modules.push(info)
    this.modules.forEach(({ dependecies }) => {
      // 该文件有依赖
      if (dependecies) {
        for (const dependency in dependecies) {
          // 递归调用拿到所有依赖的文件对象
          this.modules.push(this.build(dependecies[dependency]))
        }
      }
    })
    // 组合成依赖关系图
    const dependencyGraph = this.modules.reduce(
      (graph, item) => ({
        ...graph,
        // 以路径做key,便于后续解析
        [item.filename]: {
          dependecies: item.dependecies,
          code: item.code
        }
      }),
      {}
    )
  }
  // 生成单个节点
  build (filename) {
    const { getAst, getDependecies, getCode } = Parser
    const ast = getAst(filename)
    const dependecies = getDependecies(ast, filename)
    const code = getCode(ast)
    return {
      filename,
      dependecies,
      code
    }
  }
}

3、构造编译器(打包后的bundle文件),识别依赖关系图

this.generate(dependencyGraph)
generate (arr) {
	// 拿到出口路径
	const filePath = path.join(this.output.path, this.output.filename)
	const bundle = `(function(graph){
		function require(moduleId){ 
			function localRequire(relativePath){
				return require(graph[moduleId].dependecies[relativePath])
			}
			var exports = {};
			(function(require,exports,code){
				eval(code)
			})(localRequire,exports,graph[moduleId].code)
			return exports
		}
			require('${this.entry}')
	})(${JSON.stringify(arr)})`
}

提炼一下核心代码

(function(graph) {
    function require(moduleId) {
      	// 重定义require函数,递归遍历依赖
        function localRequire(relativePath) {
            return require(graph[moduleId].dependecies[relativePath])
        }
        var exports = {};
        (function(require, exports, code) {
            eval(code)
        })(localRequire, exports, graph[moduleId].code)
      	// 导出exports传给require语句赋值的变量
        return exports
    }
    require('./src/index.js')
})({
    "./src/index.js": {
        "dependecies": {
            "./hello.js": "./src/hello.js",
            "./add.js": "./src/add.js"
        },
        "code": "\"use strict\";\n\nvar _hello = require(\"./hello.js\");\n\nvar _add = require(\"./add.js\");\n\nconsole.log((0, _hello.say)('webpack'));\nconsole.log((0, _add.add)(1, 2));"
    },
    "./src/hello.js": {
        "dependecies": {},
        "code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.say = say;\n\nfunction say(name) {\n  return \"hello \".concat(name);\n}"
    },
    "./src/add.js": {
        "dependecies": {},
        "code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.add = add;\n\nfunction add(num1, num2) {\n  return num1 + num2;\n}"
    }
})

4、将编译器写入出口目录中

const fs = require("fs")
const del = require('del')

generate (arr) {
	// 拿到出口路径
	const filePath = path.join(this.output.path, this.output.filename)
	const bundle = `(function(graph){
		function require(moduleId){ 
			function localRequire(relativePath){
				return require(graph[moduleId].dependecies[relativePath])
			}
			var exports = {};
			(function(require,exports,code){
				eval(code)
			})(localRequire,exports,graph[moduleId].code)
			return exports
		}
		require('${this.entry}')
	})(${JSON.stringify(arr)})`
	// 先删掉
	del.sync([this.output.path])
	// 新建目录
	fs.mkdirSync(this.output.path)
	// 写入文件
	fs.writeFile(filePath, bundle, (err) => {
		console.log(err)
	});
}

5、使用

const Complier = require("./Compiler");
const options = require("./webpack.config");
new Complier(options).run();

完整代码

const fs = require("fs")
const del = require('del')
const path = require("path")
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const { transformFromAst } = require("@babel/core")

const Parser = {
  // 获取AST
  getAst: path => {
    const content = fs.readFileSync(path, "utf-8")
    return parser.parse(content, {
      sourceType: "module" // 可识别import语法
    })
  },
  // 获取依赖
  getDependecies: (ast, filename) => {
    const dependecies = {}
    traverse(ast, {
      ImportDeclaration({ node }) {
        // 返回目录
        const dirname = path.dirname(filename)
        // 找到根目录相对于文件的位置
        const filepath = "./" + path.join(dirname, node.source.value)
        dependecies[node.source.value] = filepath
      }
    })
    return dependecies
  },
  // 将ast转译为浏览器可识别的代码,为何不用源代码
  getCode: ast => {
    const { code } = transformFromAst(ast, null, {
      presets: ["@babel/preset-env"]
    })
    return code
  }
}

class Compiler {
  constructor(options) {
    const { entry, output } = options
    this.entry = entry
    this.output = output
    this.modules = []
  }
  // 构建启动
  run () {
    const info = this.build(this.entry)
    this.modules.push(info)
    this.modules.forEach(({ dependecies }) => {
      // 该文件有依赖
      if (dependecies) {
        for (const dependency in dependecies) {
          // 递归调用拿到所有依赖的文件对象
          this.modules.push(this.build(dependecies[dependency]))
        }
      }
    })
    // 组合成依赖图
    const dependencyGraph = this.modules.reduce(
      (graph, item) => ({
        ...graph,
        [item.filename]: {
          dependecies: item.dependecies,
          code: item.code
        }
      }),
      {}
    )
    console.log('依赖图', dependencyGraph)
    this.generate(dependencyGraph)
  }
  build (filename) {
    const { getAst, getDependecies, getCode } = Parser
    const ast = getAst(filename)
    // 拿到文件对应的依赖
    const dependecies = getDependecies(ast, filename)
    // 拿到经过@babel/core处理的浏览器可识别的代码
    const code = getCode(ast)
    return {
      filename,
      dependecies,
      code
    }
  }
  generate (arr) {
    // 拿到出口路径
    const filePath = path.join(this.output.path, this.output.filename)
    const bundle = `(function(graph){
      function require(moduleId){ 
        function localRequire(relativePath){
          return require(graph[moduleId].dependecies[relativePath])
        }
        var exports = {};
        (function(require,exports,code){
          eval(code)
        })(localRequire,exports,graph[moduleId].code)
        return exports
      }
      require('${this.entry}')
    })(${JSON.stringify(arr)})`
    // 先删掉
    del.sync([this.output.path])
    // 新建目录
    fs.mkdirSync(this.output.path)
    // 写入文件
    fs.writeFile(filePath, bundle, (err) => {
      console.log(err)
    })
  }
}

module.exports = Compiler;