简单玩一玩 Babel

我们知道 babel 实际上一款代码编译工具,也就是可以将源代码由一种形式编译成另外一种形式 。 其实 babel 工作的核心原理很简单:sourceCode - AST - targetCode,也就是将源代码转化成抽象语法树(AST),然后再将 AST 转化成目标代码。

疑问:为什么需要 AST(抽象语法树)这个中间过程?

其实主要是因为字符串不好操作。比如想要把所有的 let 编译成 var,简单一想可能字符串好像更好实现,直接 replace 就行了。其实不是的,这里面还需要考虑到作用域的问题。

那么有的朋友会说构造一种栈的数据结构来模拟作用域,遍历源码字符串去构建调用栈,最后再递归调用栈反解出目标代码。其实也是可以实现的,只不过这样的话就相当于是定制化开发,只解决针对 letvar 这一个业务场景提供解决方案,不具有通用性。

而 AST 其实是程序信息的另外一种表示方式,它是一种很贴合程序真实状态的表示方式,这样我们就可以很方便地修改程序信息。

总的来说:用字符串来表示程序信息,可以理解成是程序信息压缩后 的表示;而 AST 则更接近程序的真实状态。这两种数据结构都能承载完整的程序信息 ,但是他们的区别在于:修改程序信息的代价 不同。简单来说就是:在字符串上修改的算法实现更复杂,而在 AST 上修改的算法实现更简单

简单回顾一下:程序 = 数据结构 + 算法

我记得有一个结论:程序 = 数据结构 + 算法。我个人是这么理解的:

  1. 程序: 抽象来讲程序的本质:就是将一种类型的信息,转化成另外一种类型的信息(或者说将一种数据转化成另外一种数据)。
  2. 数据结构 数据结构其实就是信息(数据)的组织方式。数据结构的设计首先应该考虑承载需求实现所需要的完整信息,其次再是考虑配合算法优化而进行的结构上的优化。
  3. 算法 算法其实就是信息(数据)转换路径的描述。

程序设计的艺术就在于:找到一种较优的结构(空间复杂度),配合较优的算法(时间复杂度),从而实现原始信息到需求信息的高效转化 。当然一般来说空间复杂度和时间复杂度会存在一个相互约束的关系,比如空间复杂度低了,时间复杂度就高了,反之亦然,但是也有一些特殊的情况,比如 react 的 lane 模型,但更多的时候是两者之间的权衡艺术

将 let 编译成 var(浏览器兼容性)

虽然将 let 编译成 var 有成熟的 babel 插件(babel-plugin-transform-block-scoping)帮我们去完成,但是我们还先拿这个比较简单的来练练手。

思路分析

其实要想将 let 转化成 var 需要考虑的主要问题就是:处理块级作用域的处理(转换或者模拟)问题,至于声明关键字的替换(将 let 换成 var)那都不是问题。

解决这种问题有两种思路:一种是将块级作用打平,全部变成全局作用域,并且结合命名空间,解决命名冲突的问题;另一种是使用函数作用域模拟块级作用域,也就是把对应代码转化成立即执行函数,放到函数中去执行。

将块级作用打平,全部变成全局作用域

这样做面临的问题就是命名冲突,因为不同的作用域中,变量命名是自由的。那么如果对于变量名不加以处理就直接全部打平到全局作用域,很有可能会发生变量名重名的冲突。比如:

js 复制代码
// 原数据
let x = 1
{
  let x = 2
  console.log(x, '2')
}
console.log(x, '1')

// 打平后
var x = 1
{
  var x = 2
  console.log(x, '2')
}
console.log(x, '1')

解决这个问题的思路是利用命名空间,来解决命名冲突的问题。如下:

js 复制代码
var x_scope1 = 1
{
  var x_scope2 = 2
  console.log(x_scope2, '2')
}
console.log(x_scope1, '1')

具体 babel 代码如下:

js 复制代码
const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const { isBlockScoped } = require('@babel/types')

const file = fs.readFileSync('./source-let.js', 'utf-8')
const ast = parser.parse(file, { sourceType: 'module' })

traverse(ast, {
  // 只要匹配到声明关键字(let、const、var)节点,就会执行这个回调
  VariableDeclaration(path) {
    if (!isBlockScoped(path.node)) return

    // 先把声明关键字改成 var
    path.node.kind = 'var'

    // 获取当前关键字(let、const)声明的所有变量名称(比如:let x与let x,y,z 他们分别声明了 1、3 个变量)
    const bindingNames = Object.keys(path.getBindingIdentifiers())

    // 当前变量作用域
    const blockScope = path.scope

    // var 声明的作用域只有两种情况:全局作用域和函数作用域。
    const varScope =
      blockScope.getFunctionParent() || blockScope.getProgramParent()

    if (varScope !== blockScope) {
      for (const name of bindingNames) {
        let newName = name
        // 如果当前变量名称在全局有定义或者在父级作用域中存在,说明命名有冲突,需要重新命名
        if (
          blockScope.parent.hasBinding(name, { noUids: true }) ||
          blockScope.parent.hasGlobal(name)
        ) {
          newName = blockScope.generateUid(name)
          blockScope.rename(name, newName)
        }
      }
    }
  },
})

const { code } = generate(ast)

// 输出
fs.writeFileSync('./output-let.js', code)

使用函数作用域模拟块级作用域

也就是将块级作用域内的代码包裹到一个函数中去执行,再将函数变成一个立即执行函数。如下:

js 复制代码
let x = 1
;(function () {
  let x = 2
  console.log(x, '2')
})()
console.log(x, '1')

值得一提的是:需要注意一个细节,需要在立即执行函数之前添加一个分号,防止错误地与上行末尾代码结合,导致错误地把上一行代码末尾当成函数调用。比如

js 复制代码
let x = 1
console.log(x)(function () {
  console.log('hello world')
})()

// Uncaught TypeError: console.log(...) is not a function
js 复制代码
let x = 1
console.log(x)
;(function () {
  console.log('hello world')
})()

// 1
// hello world

具体 babel 代码实现如下:

js 复制代码
const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const {
  isFunction,
  callExpression,
  functionExpression,
  blockStatement,
  expressionStatement,
  isBlockScoped,
} = require('@babel/types')

const file = fs.readFileSync('./source-let.js', 'utf-8')

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

traverse(ast, {
  VariableDeclaration(path) {
    if (!isBlockScoped(path.node)) return
    // 把所有的块级作用域声明转化成 var
    path.node.kind = 'var'
  },
  BlockStatement(path) {
    const parentPath = path.parentPath
    if (isFunction(parentPath)) {
      return
    }
    // 获取当前块级作用域中的所有内容
    const statements = path.node.body
    const iife = callExpression(
      functionExpression(null, [], blockStatement(statements)),
      []
    )

    path.replaceWith(expressionStatement(iife))
  },
})

const { code } = generate(ast)

// 输出
fs.writeFileSync('./output-let-IIFE.js', code)

当然我们这是十分简易的版本,主要是体验一下 babel 的代码转化功能。let 转化成 var 实际要处理的情况还有很多,大家如果感兴趣可以去看看 babel-plugin-transform-block-scoping 的测试用例

将 import、require 编译成 __webpack_require__(webpack 源码相关)

背景说明

我们知道,无论是 esModule 的 import 还是 commonJs require,通过 webpack 的打包处理之后,最终都会转化成 __webpack_require__,而 __webpack_require__ 是 webpack 自己实现的一个加载模块的函数。接下来我们使用 babel 去帮我们把源码中的 import 和 require 语句转化成 __webpack_require__

代码实现

js 复制代码
// babel-import-require.js
const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const types = require('@babel/types')
const { isRequire } = require('./utils.js')

const file = fs.readFileSync('./source-import-require.js', 'utf-8')
const ast = parser.parse(file, { sourceType: 'module' })

// 遍历 ast,调用对应的钩子函数
traverse(ast, {
  // 将 import 转化成 __webpack_require__
  ImportDeclaration(path) {
    const { node } = path
    const moduleName = node.specifiers[0].local.name
    const filePath = node.source.value
    const newNode = types.variableDeclaration('const', [
      types.variableDeclarator(
        types.identifier(moduleName),
        types.callExpression(types.identifier('__webpack_require__'), [
          types.stringLiteral(filePath),
        ])
      ),
    ])
    path.replaceWith(newNode)
  },
  // 将 require 转化成 __webpack_require__
  VariableDeclarator(path) {
    const { node } = path
    // 如果是require,那么直接替换函数名即可
    if (isRequire(node)) {
      node.init.callee.name = '__webpack_require__'
    }
  },
})

const { code } = generate(ast)
// 输出
fs.writeFileSync('./output-import-require.js', code)

我们把判断 require 的逻辑抽离到 utils 中,如下:

js 复制代码
// utils.js
const isRequire = (node) => {
  return (
    node.init.type === 'CallExpression' && node.init.callee.name === 'require'
  )
}

module.exports = {
  isRequire,
}

将默认导出转化成具名导出(tree shaking)

背景说明

我们知道,对于 tree shaking,只有对于 import 的具名导出才有效。所以我们为了项目能够更好的 tree sharking,减小项目的打包体积,我们希望可以写一个 babel 插件来做这么两件事情:

  • 将文件的导出部分尽可能地修改成具名导出
  • 将文件的导入部分尽可能地修改成具名导入

导出部分

commonJs 导出

commonJs 导出分成 module.exportsexports,其实本质上他们是同一个对象。我们在对 module.exports 进行赋值操作时,实际上会覆盖 exports 对象。

我们先来看 module.exports,它可以分成两种情况:

  1. 非对象
js 复制代码
module.exports = 'hello'

对于这种情况,其实没有 tree-shaking 的空间。因为首先 module.exports 会覆盖 exports,那么所以整个模块导出的内容其实就只有 'hello' 这部分内容,所以没有什么优化空间。

我们就简单把他转化成 export default 'hello' 吧,具体代码如下:

js 复制代码
import { isModuleExports } from './utils'
// 核心代码
AssignmentExpression(path) {
  const { node } = path

  // module.exports
  if (isModuleExports(node)) {
    const exportDefaultDeclarationNode = exportDefaultDeclaration(node.right);
    path.replaceWith(exportDefaultDeclarationNode);
  }
}
  1. 对象
js 复制代码
const a = 1
module.exports = { a, b: 2 }

这种情况下,我们希望遍历导出对象的表层属性,然后分别转换成具名导出。但是这里需要注意一个问题:导出对象的属性可能来自于模块内的其他变量引用,也可能直接是字面量。

字面量的形式比较好处理,直接替换成具名导出即可,如下:

js 复制代码
export const b = 2

对于引用的情况,我们需要先找到被引用对象声明的位置,在将其转换成具名导出(我们使用一种比较取巧的方式,就是将所有的变量声明全部转换成具名导出)。所以 module.exports 的完整处理代码如下:

js 复制代码
VariableDeclaration(path) {
  // 如果是 export const a = 1,这种情况忽略
  if (isExportNamedDeclaration(path.parentPath.node)) {
    return
  }
  // 只考虑一个关键字只声明一个变量名的情况
  const declaration = path.node.declarations[0]
  // 创建一个导出语句
  exportStatement = exportNamedDeclaration(
    variableDeclaration(path.node.kind, [
      variableDeclarator(declaration.id, declaration.init),
    ]),
    []
  )
  path.replaceWith(exportStatement)
}

AssignmentExpression(path) {
  const { node, scope } = path
  // module.exports
  if (isModuleExports(node)) {
    // 默认导出内容不是对象
    if (!isObjectExpression(node.right)) {
      const exportDefaultDeclarationNode = exportDefaultDeclaration(node.right)
      path.replaceWith(exportDefaultDeclarationNode)
    } else {
      // 默认导出内容是一个对象
      const properties = node.right.properties
      properties.forEach((property) => {
        const { name } = property.value
        const isExist = scope.hasOwnBinding(name)
        if (!isExist) {
          // 说明是字面量,那么就创建一个变量声明,并导出
          const name = property.key.name
          const constDeclaration = variableDeclaration('const', [
            variableDeclarator(identifier(name), property.value),
          ])
          const exportDeclaration = exportNamedDeclaration(constDeclaration, [])
          programPath.pushContainer('body', exportDeclaration)
        }
      })
      path.remove()
    }
  }
}

let programPath = null
Program(path) {
  programPath = path
}

接下来我们看一下 exports.a = 1 的处理。其实 exports.a = 1 很好处理,直接转化成 export const a = 1 即可,代码如下:

js 复制代码
let isExistModuleExports = false
AssignmentExpression(path) {
  const { node, scope } = path
  // module.exports
  if (isModuleExports(node)) {
    isExistModuleExports = true
    // 其他代码
  }

  // exports
  if (isExports(node)) {
    if (isExistModuleExports) {
      path.remove()
      return
    }
    const name = node.left.property.name
    const isExist = scope.hasOwnBinding(name)
    if (!isExist) {
      const constDeclaration = variableDeclaration('const', [
        variableDeclarator(identifier(name), node.right),
      ])
      const exportDeclaration = exportNamedDeclaration(constDeclaration, [])
      programPath.pushContainer('body', exportDeclaration)
    }
    path.remove()
  }
}

esModule 导出

接下来我们再来看看 esModule 的导出需要进行哪些处理。首先关于具名导出我们肯定是不需要处理的,其实对于默认导出且导出内容不是一个普通对象的情况下其实也是不用处理的。

那么对于默认导出且导出内容是一个普通对象,这时候我们可能希望将该对象的所有属性转化成具名导出,代码如下:

js 复制代码
ExportDefaultDeclaration(path) {
  const { scope } = path
  if (isObjectExpression(path.node.declaration)) {
    const properties = path.node.declaration.properties
    properties.forEach((property) => {
      const { name } = property.value
      const isExist = scope.hasOwnBinding(name)
      if (!isExist) {
        // 说明是字面量,那么就创建一个变量声明,并导出
        const name = property.key.name
        const constDeclaration = variableDeclaration('const', [
          variableDeclarator(identifier(name), property.value),
        ])
        const exportDeclaration = exportNamedDeclaration(constDeclaration, [])
        programPath.pushContainer('body', exportDeclaration)
      }
    })
    path.remove()
  }
}

commonJs 导入

我们接下来看一看 commonJs 的导入需要进行哪些处理。我们的思路是:分两种情况,也就是使用解构赋值和不使用解构赋值。

对于使用解构赋值的情况,其实结构上已经和具名导入十分类似了,比如:const { a, b } = require('xx')import { a, b } from 'xx' 只要稍微调整一下就可以完成转换,代码如下:

js 复制代码
VariableDeclaration(path) {
  const { node } = path

  if (isRequire(path.node)) {
    const declaration = node.declarations[0]
    const source = declaration.init.arguments[0].value
    const importStatement = importDeclaration(
      declaration.id.properties.map((property) =>
        importSpecifier(property.value, property.key)
      ),
      stringLiteral(source)
    )
    path.replaceWith(importStatement)
    return
  }

  // 其他代码
}

对于不使用解构赋值的情况就比较复杂了。这种情况下需要在当前文件中收集被导入模块的使用情况,然后通过这些使用情况来生成对应的解构情况,同时还需要考虑命名冲突的问题。比如:

js 复制代码
const m = require('xx')
const sum = m.a + m.b
console.log(sum)

这种情况下我们肯定是想编译成如下这样:

js 复制代码
import { a, b } from 'xx'
const sum = a + b
console.log(sum)

所以我们应该在解构的时候给变量名加上命名空间前缀,编译成下面这样子:

js 复制代码
import { a as m_a, b as m_b } from 'xx'
const sum = m_a + m_b
console.log(sum)

但是需要注意的是,这里去除了命名空间 m,所以需要注意命名冲突的问题。所以总的来说,我们可以分成三个步骤:

第一步:当遍历到 require 时,收集节点句柄 path,方便后面操作。当前还无法直接替换成解构的形式,因为还不知道文件中到底使用了哪些属性,等到后面属性收集完成之后,才能再进行解构替换。

js 复制代码
const modulePathMap = {}
VariableDeclaration(path) {
  if (isRequire(path.node)) {
    // 使用了解构赋值 const { a, b } = require('xx')
    if (isObjectPattern(declaration.id)) {
      // 其他代码
    } else {
      // 没有使用解构赋值 const m = require('xx')
      // 1. 在这个位置只能收集
      // 2. 等到ast遍历结束后才能统一修改
      const moduleName = declaration.id.name
      modulePathMap[moduleName] = path
    }
    return
  }
}

第二步,遍历全部 member,成员访问操作符,收集导入模块被使用的所有属性,并且加上命名空间处理。

js 复制代码
const modulePropsMap = {}
MemberExpression(path) {
  const moduleName = path.node.object.name
  const propertyName = path.node.property.name
  if (modulePathMap[moduleName]) {
    const newNode = identifier(`${moduleName}_${propertyName}`)
    path.replaceWith(newNode)
  }

  // 记录使用了哪些变量
  if (!modulePropsMap[moduleName]) {
    modulePropsMap[moduleName] = new Set()
  }
  const propsSet = modulePropsMap[moduleName]
  propsSet.add(propertyName)
}

最后再根据这些收集到的属性来替换导入语句:

js 复制代码
// 将导入转化成解构形式
const work = () => {
  Object.entries(modulePathMap).forEach(([moduleName, path]) => {
    const source = path.node.declarations[0].init.arguments[0].value
    const propsSet = modulePropsMap[moduleName]
    const specifiers = [...propsSet].map((propertyName) =>
      importSpecifier(
        identifier(`${moduleName}_${propertyName}`),
        identifier(propertyName)
      )
    )
    const importStatement = importDeclaration(specifiers, stringLiteral(source))
    path.replaceWith(importStatement)
  })
}
work()

esModule 导入

对于 esModule 的导入,如果是具名导入这种情况我们不需要处理,只有对于默认导入的情况,我们需要将默认导入转化成具名导入。有了前面的基建,其实接下来要加的逻辑就很简单了,就是在 import 的遍历中,如果捕获到默认导入就将当前导入模块记录到 modulePathMap 中即可。代码如下:

js 复制代码
ImportDeclaration(path) {
  if (isDefaultImport(path.node)) {
    const moduleName = path.node.specifiers[0].local.name
    modulePathMap[moduleName] = path
  }
}

const isDefaultImport = (node) => {
  // import m from 'xx' 或 import * as m from 'xx'
  const { specifiers } = node
  return (
    (specifiers.length === 1 && isImportDefaultSpecifier(specifiers[0])) ||
    (specifiers.length === 1 && isImportNamespaceSpecifier(specifiers[0]))
  )
}

同时,由于 require 和 import 的语法差异,所以我们在获取 path 的时候,需要改造一下。

js 复制代码
const work = () => {
  Object.entries(modulePathMap).forEach(([moduleName, path]) => {
    const source =
      path.node.source?.value ||
      path.node.declarations[0].init.arguments[0].value
    // 其他代码
  })
}
work()

完整代码

js 复制代码
const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const {
  isObjectExpression,
  stringLiteral,
  variableDeclaration,
  variableDeclarator,
  identifier,
  exportNamedDeclaration,
  importDeclaration,
  importSpecifier,
  exportDefaultDeclaration,
  isExportNamedDeclaration,
  isObjectPattern,
} = require('@babel/types')
const {
  isModuleExports,
  isExports,
  isRequire,
  isDefaultImport,
} = require('./utils.js')

// const file = fs.readFileSync('./source-tree-shaking-export.js', 'utf-8')
const file = fs.readFileSync('./source-tree-shaking-import.js', 'utf-8')

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

const modulePathMap = {}
const modulePropsMap = {}
let programPath = null
let isExistModuleExports = false // 如果存在 module.exports,exports 导出会被覆盖,也就是此时exports的情况不需要处理

traverse(ast, {
  VariableDeclaration(path) {
    const { node } = path
    // 如果是 export const a = 1,这种情况忽略
    if (isExportNamedDeclaration(path.parentPath.node)) {
      return
    }

    if (isRequire(path.node)) {
      const declaration = node.declarations[0]
      // 使用了解构赋值 const { a, b } = require('xx')
      if (isObjectPattern(declaration.id)) {
        const source = declaration.init.arguments[0].value
        const importStatement = importDeclaration(
          declaration.id.properties.map((property) =>
            importSpecifier(property.value, property.key)
          ),
          stringLiteral(source)
        )
        // 用 import 语句替换原始的 const 声明
        path.replaceWith(importStatement)
      } else {
        // 没有使用解构赋值 const m = require('xx')
        // 1. 在这个位置只能收集
        // 2. 等到ast遍历结束后才能统一修改
        const moduleName = declaration.id.name
        modulePathMap[moduleName] = path
      }
      return
    }

    // 只考虑一个关键字只声明一个变量名的情况
    const declaration = path.node.declarations[0]
    // 创建一个导出语句
    exportStatement = exportNamedDeclaration(
      variableDeclaration(path.node.kind, [
        variableDeclarator(declaration.id, declaration.init),
      ]),
      []
    )
    path.replaceWith(exportStatement)
  },
  AssignmentExpression(path) {
    const { node, scope } = path
    // module.exports
    if (isModuleExports(node)) {
      isExistModuleExports = true
      // 默认导出内容不是对象
      if (!isObjectExpression(node.right)) {
        const exportDefaultDeclarationNode = exportDefaultDeclaration(
          node.right
        )
        path.replaceWith(exportDefaultDeclarationNode)
      } else {
        // 默认导出内容是一个对象
        const properties = node.right.properties
        properties.forEach((property) => {
          const { name } = property.value
          const isExist = scope.hasOwnBinding(name)
          if (!isExist) {
            // 说明是字面量,那么就创建一个变量声明,并导出
            const name = property.key.name
            const constDeclaration = variableDeclaration('const', [
              variableDeclarator(identifier(name), property.value),
            ])
            const exportDeclaration = exportNamedDeclaration(
              constDeclaration,
              []
            )
            programPath.pushContainer('body', exportDeclaration)
          }
        })
        path.remove()
      }
    }

    // exports
    if (isExports(node)) {
      if (isExistModuleExports) {
        path.remove()
        return
      }
      const name = node.left.property.name
      const isExist = scope.hasOwnBinding(name)
      if (!isExist) {
        const constDeclaration = variableDeclaration('const', [
          variableDeclarator(identifier(name), node.right),
        ])
        const exportDeclaration = exportNamedDeclaration(constDeclaration, [])
        programPath.pushContainer('body', exportDeclaration)
      }
      path.remove()
    }
  },
  // export default 处理
  ExportDefaultDeclaration(path) {
    const { scope } = path
    if (isObjectExpression(path.node.declaration)) {
      const properties = path.node.declaration.properties
      properties.forEach((property) => {
        const { name } = property.value
        const isExist = scope.hasOwnBinding(name)
        if (!isExist) {
          // 说明是字面量,那么就创建一个变量声明,并导出
          const name = property.key.name
          const constDeclaration = variableDeclaration('const', [
            variableDeclarator(identifier(name), property.value),
          ])
          const exportDeclaration = exportNamedDeclaration(constDeclaration, [])
          programPath.pushContainer('body', exportDeclaration)
        }
      })
      path.remove()
    }
  },
  Program(path) {
    programPath = path
  },

  ImportDeclaration(path) {
    if (isDefaultImport(path.node)) {
      const moduleName = path.node.specifiers[0].local.name
      modulePathMap[moduleName] = path
    }
  },

  MemberExpression(path) {
    const moduleName = path.node.object.name
    const propertyName = path.node.property.name
    if (modulePathMap[moduleName]) {
      const newNode = identifier(`${moduleName}_${propertyName}`)
      path.replaceWith(newNode)
    }

    // 记录使用了哪些变量
    if (!modulePropsMap[moduleName]) {
      modulePropsMap[moduleName] = new Set()
    }
    const propsSet = modulePropsMap[moduleName]
    propsSet.add(propertyName)
  },
})

// 将导入转化成解构形式
const work = () => {
  Object.entries(modulePathMap).forEach(([moduleName, path]) => {
    const source =
      path.node.source?.value ||
      path.node.declarations[0].init.arguments[0].value
    const propsSet = modulePropsMap[moduleName]
    const specifiers = [...propsSet].map((propertyName) =>
      importSpecifier(
        identifier(`${moduleName}_${propertyName}`),
        identifier(propertyName)
      )
    )
    const importStatement = importDeclaration(specifiers, stringLiteral(source))
    path.replaceWith(importStatement)
  })
}
work()

const { code } = generate(ast)
// 输出
// fs.writeFileSync('./output-tree-shaking-export.js', code)
fs.writeFileSync('./output-tree-shaking-import.js', code)

tree-shaking

既然我们都已经研究到这一步了,其实 tree-shaking 就是一件极其自然的事情了。其实现在我们的文件的导出内容已经几乎做到每一个 export 部分独立了。那么我们只需要针对这些 export 做一个资源 map,在导入的地方通过查找 map 就可以得到要导入的 export 那部分了。这样自然地就实现了 tree-sharking。

比如,以下这种情况:

js 复制代码
// export.js
const add = (a, b) => a + b
const minus = (a, b) => a - b
module.exports = {
  add,
  minus,
}
js 复制代码
// import.js
const m = require('./export')
console.log(m.add(1, 2))

我们希望最终的打包结果中,将 minus 函数删除。我们知道前面会将我们的代码可以将源代码转化成 esModule 的具名导入导出,如下这样:

js 复制代码
// export.js
export const add = (a, b) => a + b
export const minus = (a, b) => a - b
js 复制代码
// import.js
import { add as m_add } from './export'
console.log(m_add(1, 2))

所以我们的思路也就很简单了,只要在所有的导入语句遍历完成之后,生成模块内容使用记录,然后再遍历导出语句,将没有用到的导出语句 remove 掉即可。代码如下:

js 复制代码
const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const { isImportSpecifier } = require('@babel/types')

const exportFile = fs.readFileSync('./export.js', 'utf-8')
const importFile = fs.readFileSync('./import.js', 'utf-8')

const importAst = parser.parse(importFile, { sourceType: 'module' })
const exportAst = parser.parse(exportFile, { sourceType: 'module' })

const usedArr = []

traverse(importAst, {
  ImportDeclaration(path) {
    const { specifiers } = path.node
    // 检查是否是 import { a as m_a, b as m_b } from 'xx' 形式的 ImportDeclaration
    if (
      specifiers.length > 0 &&
      specifiers.every((specifier) => isImportSpecifier(specifier))
    ) {
      const usedNames = specifiers.map((v) => v.imported.name)
      usedArr.push(...usedNames)
    }
  },
})

traverse(exportAst, {
  ExportNamedDeclaration(path) {
    const varName = path.node.declaration.declarations[0].id.name
    if (!usedArr.includes(varName)) {
      path.remove()
    }
  },
})

const { code } = generate(exportAst)
// 输出
fs.writeFileSync('./output-export.js', code)

当然只是非常粗糙和简易的版本,这个代码有很多问题没有处理,这份代码仅仅只是提供一种思考。我还没有具体去看过 webpack、rollup 等工具 tree-shaking 的实现原理,我不确定这些打包工具的 tree-shaking 实现思路是否和上述代码类似,不过上述代码是我能直接想到的比较简单的方式,同时也欢迎大家讨论和指正。

参考

babel 手册

babel 官方文档

相关推荐
dot.Net安全矩阵4 分钟前
.NET内网实战:通过命令行解密Web.config
前端·学习·安全·web安全·矩阵·.net
Hellc00715 分钟前
MacOS升级ruby版本
前端·macos·ruby
前端西瓜哥24 分钟前
贝塞尔曲线算法:求贝塞尔曲线和直线的交点
前端·算法
又写了一天BUG25 分钟前
npm install安装缓慢及npm更换源
前端·npm·node.js
cc蒲公英38 分钟前
Vue2+vue-office/excel 实现在线加载Excel文件预览
前端·vue.js·excel
Java开发追求者39 分钟前
在CSS中换行word-break: break-word和 word-break: break-all区别
前端·css·word
好名字082143 分钟前
monorepo基础搭建教程(从0到1 pnpm+monorepo+vue)
前端·javascript
pink大呲花1 小时前
css鼠标常用样式
前端·css·计算机外设
Flying_Fish_roe1 小时前
浏览器的内存回收机制&监控内存泄漏
java·前端·ecmascript·es6
小小竹子1 小时前
前端vue-实现富文本组件
前端·vue.js·富文本