关于前端:傻瓜方式分析前端应用的体积

44次阅读

共计 21671 个字符,预计需要花费 55 分钟才能阅读完成。

图片起源:cdn77.com

本文作者:xsy

前端利用的 bundle 体积是影响利用性能的次要方面之一,咱们看下取自 HTTP Archive – Loading Speed 的两幅截图

大略是得益于设施性能以及带宽的晋升,2022 年挪动端页面资源的加载耗时相较 2017 年升高了 38.6%

JS 启动耗时指的是脚本被解析执行的耗时,随着前端利用承载了越来越多的性能,页面的 JS 文件体积逐步减少,这就导致了浏览器须要破费更多的工夫去解析脚本。2022 年挪动端的 JavaScript 启动耗时相较 2017 年减少了 211.1%

联合下面两幅图,咱们能够失去一个简略的论断:单纯地依赖硬件性能以及带宽的晋升、来瓜熟蒂落地进步利用性能是不行的,咱们须要自主地对利用的体积进行一些剖析和优化

接下来咱们将一起以傻瓜模式来剖析利用的体积

剖析指标

前端利用的体积形成包含:

  1. JavaScript 文件
  2. CSS 文件
  3. 图片、字体等其余资源文件

为了简化问题,咱们将临时只关注 JS 文件的体积

有两个比拟直白次要起因会造成 JS 文件体积的收缩:

  1. 包反复依赖
  2. 未正确配置 Tree-shaking

包反复依赖

包反复依赖指的是我的项目中援用了同一个包的多个不兼容的版本。产生这个问题的几个次要可能场景为:

  • 我的项目中间接或者间接地引入了两个不兼容的版本

    比方我的项目引入了 A 和 B,A 和 B 又别离依赖 {"C": "^1.0.0"}{"C": "^2.0.0"}

  • 我的项目中的某个依赖锁了版本

    比方我的项目中引入了 A 和 B,A 和 B 又别离引入了 {"C": "1.0.1"}{"C": "^1.0.3"}

  • 我的项目中的 package-lock.json 文件未及时更新,蕴含了一些不兼容的版本

为了不便后续后优化,咱们的指标是剖析出上面的内容:

  • 反复的包名和版本号
  • 各个反复版本的依赖门路
  • 若包的反复依赖问题失去解决,预计可缩小的体积

Tree-shaking

当初的打包工具,比方 Webpack 反对对利用体积进行 Tree-shaking 优化,将未应用的代码从最终后果中剔除

Tree-shaking 须要满足一些前置条件:

  1. 包的被导入地位应用的是 static import
  2. 包自身应用的是 ESM – ECMAScript modules
  3. 包标记了本身为 side-effect-free

咱们的指标是:

  • 剖析出哪些包应用了 ESM,然而未设置 side-effect-free
  • 若这些包正确配置了 Tree-shaking 后的体积

明确了指标后,咱们就筹备进入施工环节

Dependency Graph

任何剖析工作的第一步都是先收集信息。为了剖析利用的体积,咱们须要先收集出利用内模块的依赖图(Dependency Graph)

咱们能够应用上面的步骤来生成依赖树

  1. 筹备队列 $Queue_{pending}$,队列中的元素为待剖析模块的门路 $module_{path}$
  2. 将剖析的入口文件都退出到 $Queue_{pending}$ 中
  3. 从 $Queue_{pending}$ 中弹出一个元素 $module_{path}$,对其使用模块门路算法 $module\_resolve_\_algo$ 定位模块的文件系统门路 $filepath_{module}$
  4. 载入 $filepath_{module}$ 地位的文件内容,剖析其中的导入语句,失去被导入的模块 $module_{path}’$ 持续退出到 $Queue_{pending}$ 中
  5. 如果 $Queue_{pending}$ 中还有未解决的元素,则持续第 3 步,否则进入第 6 步
  6. 完结剖析

生成依赖图也是打包工具外部的首要工作,并且为了解析文件中的导入语句以失去 $module_{path}’$,还须要一个 JS 解析器,这些很容易就让人联想到 Webpack 和 Babel

既然是傻瓜模式,轮子是不能不造的,同时为了保障轮子的纯度,咱们在造轮子的过程中必须尽可能的造轮子,于是咱们能够先实现 JS 解析器,而后实现下面的依赖树生成工作

语法解析器

咱们抉择用 Go 来实现一个 JS 解析器 mole,因为 Go 并不是文本的配角,所以在最初一节解释为什么抉择 Go

编译的基本知识是必备的,受限于篇幅就不开展了。如果需要的话,能够参考我之前整顿的 应用 JavaScript 来实现解释器和编译器系列教程

实现解析器之前,咱们需划定一个待反对的语法范畴:

  • ES2021
  • JSX
  • TypeScript 1.8

因为 JSX 和 TS 的语法是在 JS 语法上的加强,所以咱们先实现 JS 语法的解析,而后在其根底上减少对 JSX 和 TS 的反对。为了简略,咱们将手动的结构一个递归降落的解析器

解析器的编写是比拟一个机械的过程,首先参照 Language Spec 中的 Production rules 写出它们对应的解析函数

比方,生产式 PrimaryExpression 的大抵模式为:

PrimaryExpression[Yield, Await] :
    this
    IdentifierReference[?Yield, ?Await]
    Literal
  • 解析器中会为每个生产式都编写对应的解析函数。比方 PrimaryExpression 就会对应一个函数 primaryExpr = () => PrimaryExpression
  • 生产式左边的每一行各示意一个开展状况,它们能够为另一个生产式或者终结符。比方 PrimaryExpression 能够开展为 thisIdentifierReferenceLiteral
  • 生产式左边的小写结尾的示意终结符,也就是不能持续开展了,大写则示意另一个生产式,进而也会有各自的解析函数,比方 Literal 会有一个对应的解析函数 literal = () => Literal

对应到解析器实现中的代码构造相似:

function identRef() {}
function literal() {}

function primaryExpr() {if (tok === T_THIS) return new ThisExpr();
  if (tok === IDENT) return identRef();
  return literal();}

依据下面的形式结构完解析器时会发现一个问题 – 解析函数互相调用的层数太深。比方,为了解析加法运算,须要通过上面的函数调用:

Expression
  -> AssignmentExpression
    -> ConditionalExpression
      -> ShortCircuitExpression
        -> LogicalORExpression
          -> LogicalANDExpression
            -> BitwiseORExpression
              -> BitwiseXORExpression
                -> BitwiseANDExpression
                  -> EqualityExpression
                    -> RelationalExpression
                      -> ShiftExpression
                        -> AdditiveExpression

咱们花些工夫利用 [Operator-precedence parser
](https://en.wikipedia.org/wiki…) 即可优化下面的解析过程

在解析的过程中,咱们经常需判断 $lookahead$ 是否为某个 Token 来进入不同的解析函数(比方 tok === T_THIS),而其中的一些 Token 存在歧义,比方:

  • ( in JS

    • (a,b) 示意 ParenthesizedExpression 蕴含了一个 SequenceExpression
    • (a, b) => {} 示意箭头函数的定义,括号中的内容为 ArrowFormalParameters
  • { in JS

    • ({a: b}) 示意 ParenthesizedExpression 蕴含了一个 ObjectLiteral
    • ({a: b}) => {} 示意箭头函数的定义,括号中的内容为 ArrowFormalParameters
  • < in TS

    1. a < b 示意 RelationalExpression
    2. a<b>() 示意 TS 中的 Function Calls
    3. <a>b 示意 TS 中的 Type Assertions
    4. <a /> 示意 JSX 的 JSXElement
  • ( in TS

    • var a: ({a = c}: string | number) => number = 1 示意箭头函数类型中形参列表的括号
    • var a: (string | number) = 1 示意 ParenthesizedType

当遇到歧义时,咱们就须要借助上下文信息。为了尽量避免 Backtracking,遇到歧义时咱们首先做上面的尝试:

  • 对于有歧义的语法,咱们先筹备它们的超集。比方 $P1$ 和 $P2$ 的超集为 $P_{1,2} = P1 \cup P2$
  • 而后咱们在呈现歧义的中央,应用 $P_{1,2}$ 进行解析
  • 后续呈现可能辨别 $P1$ 和 $P2$ 的上下文信息时,再做 $P_{1,2} \to P1 \mathbin{|}P_{1,2} \to P2$ 的转换

比方下面的「{ in JS」的歧义就实用于这个形式 – 咱们先按 ParenthesizedExpression(ObjectLiteral) 和 ArrowFormalParameters 的超集进行解析,而后依据依据是否呈现 => 来做 unboxing

当遇到一些简单的歧义时,则须要引入一些限度条件以及 Backtracking。比方上文提到的 TS 中符号 < 的歧义:

  • 在开启 JSX 的状况下,禁用 Type Assertions 语法(应用 a as b 代替),这样就解决了 34 之间的歧义,这也是 tsc 的行为
  • a < ba<b>() 之间的歧义则须要应用到 Backtracking,大抵逻辑为:

    a < b < c
      ▲
      └── mark
    1. 遇到 < 先保留以后的解析状态 $mark_{parsingState}$
    2. 而后按 TypeArguments 进行解析,尝试匹配右侧的 >,如果匹配胜利,则进入第 4 步,否则进入第 3 步
    3. 复原之前保留的状态 $mark_{parsingState}$,按 RelationalExpression 持续进行解析,如果匹配胜利,则进入第 4 步,否则抛出语法错误,终止解析过程
    4. 歧义实现打消

能够看到 Backtracking 的形式会升高解析的性能,这也是 Go 中应用 [] 作为泛型参数(丑得有理有据)的次要起因:

import "golang.org/x/exp/constraints"

func GMin[T constraints.Ordered](x, y T) T {
  if x < y {return x}
  return y
}

咱们应用 AST 作为解析后果的承载形式,并遵循 ESTree 中的定义,以使得解析器的处理结果能够被更多的工具生产

对于解析器的最初一点是通过 3656 个单测确保解析后果的准确性,并且不便解析后续性能迭代后进行回归测试

咱们能够通过上面的代码来感触下解析器的应用形式:

package main

import (
  "bytes"
  "encoding/json"
  "fmt"
  "log"

  "github.com/hsiaosiyuan0/mole/ecma/estree"
  "github.com/hsiaosiyuan0/mole/ecma/parser"
  "github.com/hsiaosiyuan0/mole/span"
)

func main() {
  // imitate the source code you want to parse
  code := `console.log("hello world")`

  // create a Source instance to handle to the source code
  s := span.NewSource("", code)

  // create a parser, here we use the default options
  opts := parser.NewParserOpts()
  p := parser.NewParser(s, opts)

  // inform the parser do its parsing process
  ast, err := p.Prog()
  if err != nil {log.Fatal(err)
  }

  // by default the parsed AST is not the ESTree form because the latter has a little redundancy,
  // however Mole supports to convert its AST to ESTree by using the `estree.ConvertProg` function
  b, err := json.Marshal(estree.ConvertProg(ast.(*parser.Prog), estree.NewConvertCtx()))
  if err != nil {log.Fatal(err)
  }

  // below is nothing new, we just print the ESTree in JSON form
  var out bytes.Buffer
  json.Indent(&out, b, "","  ")
  fmt.Println(out.String())
}

AST Walker

有了语法解析器,就能够将 JS 源码转换成对等的 AST 模式,不过为了提取其中的模块导入语句,咱们还须要遍历 AST

咱们应用树形构造来寄存解析的后果,因为它能够不便地体现语法元素之间档次关系。比方 2 * 3 + 4 有相似上面的构造:

    node
    / | \
 node +  4
 / | \
2  *  3

对于树形构造的遍历有两种次要的模式:Listener 和 Visitor

Listener 模式下会主动帮咱们做深度优先的遍历,在遇到不同的节点的时候,调用咱们提供的 handler:

Visitor 模式下咱们能够本人管制遍历的程序,比方:

  • 遍历的形式(深度优先还是广度优先,尽管通常是前者)
  • 遍历的节点程序,或者跳过某些节点

图片援用自 ANTLR Basics

Listener 和 Visitor 模式的抉择是依据需要而定的,因而咱们的 AST Walker 会同时反对这两种模式

Visitor 模式是能够灵便管制节点遍历程序的,咱们能够先实现 Visitor 模式,而后在它的根底上提供一个 Listener 的实现

咱们以 AssignExpr 节点为例,来看一下遍历节点的形式,首先它的构造为:

type AssignExpr struct {
  typ   NodeType
  op    TokenValue // 赋值表达式的符号,比方 `=`,`+=` 等
  lhs   Node       // 等号右边的节点
  rhs   Node       // 等号左边的节点
}

相应的遍历形式如下:

func VisitAssignExpr(node parser.Node, key string, ctx *VisitorCtx) {n := node.(*parser.AssignExpr)

  CallVisitor(N_EXPR_ASSIGN_BEFORE, n, key, ctx)      // 为了反对 listen before 事件
  defer CallVisitor(N_EXPR_ASSIGN_AFTER, n, key, ctx) // 为了反对 listen after 事件

  VisitNode(n.Lhs(), "Lhs", ctx) // 默认先遍历等号右边
  if ctx.WalkCtx.Stopped() {return}

  VisitNode(n.Rhs(), "Rhs", ctx) // 而后遍历等号左边
  if ctx.WalkCtx.Stopped() {return}
}

咱们的解析器所反对的语法范畴中,蕴含相似 AssignExpr 这样的节点大略有 123 个,手动的编写这些代码有几个弊病:

  • 首先是一个量不少的机械性工作
  • 因为遍历的程序是和节点定义相干的,节点如果进行了调整,那么对于的办法也须要进行调整。比方后续的 ECMA 语法调整可能会使得咱们须要对某些节点减少一些子元素

这时候咱们就须要依赖 Go 语言的元编程能力 go:generate,尽管看起来没有一些带宏的语言那么酷炫,但设计却挺灵便,咱们能够借助它实现一个简略的宏性能

咱们先简略设计一下这样一个简略的宏性能的语法:

Macro := '#[' CallSequence ']'
CallSequence := CallExpr (',' CallExpr)*
CallExpr := CallWithoutArg | CallWithArgs
CallWithoutArg := GoIdent
CallWithArgs := GoIdent '(' Args? ')'
Args := Arg (',' Arg)*
Arg := GoIdent | GoBasicLit | True | False | GoSelectorExpr
GoBasicLit := GoInt | GoFloat | GoString

而后咱们约定下宏能够在源码中呈现的地位:

  • 紧挨着构造体定义下面一行的正文
  • 紧跟着构造体或者枚举字段前面的正文

比方:

// #[macro]
type BinExpr struct {lhs Node // #[macro, macro1()]
  rhs Node // #[macro], some other comments
}

const (N_PROG // #[macro]
)

确定了宏的语法之后,咱们能够应用上面的步骤来解析宏定义:

  1. 利用 go/parser 来解析咱们的 Go 源文件,失去 Go AST
  2. 遍历 Go AST 中的指标节点 – 即两个地位的正文,依照下面的宏定义的语法来解析正文的内容
  3. 将解析好的信息收集起来,交由另外的代码生成程序进行生产

比方 AssignExpr 为例,减少了宏后变为:

// #[visitor(Lhs,Rhs)]
type AssignExpr struct {
  typ   NodeType
  op    TokenValue
  lhs   Node
  rhs   Node
}

咱们的宏零碎设计是解耦的,分为解析和代码生成两局部。对于下面的代码,解析局部收集到的信息为:

  • AssignExpr 应用了一个名为 visitor 的宏函数
  • 并且 visitor 宏函数有两个参数 LhsRhs

代码生成局部则能够依据理论的需要进行代码的生成,比方对于 visitor 来说,就是生成下面的 VisitAssignExpr 函数,并顺次调用节点上的 LhsRhs 两个办法

后续咱们的节点有变动时,只须要批改宏定义并从新生成对应的遍历代码即可。这样简略设计的宏零碎帮忙咱们主动生成了 4000 多行 用于节点遍历的代码

有了 AST Walker 之后,咱们就能够通过上面的形式来收集到 JS 文件内的模块导入信息:

package main

import (
  "bytes"
  "encoding/json"
  "fmt"
  "log"

  "github.com/hsiaosiyuan0/mole/ecma/estree"
  "github.com/hsiaosiyuan0/mole/ecma/parser"
  "github.com/hsiaosiyuan0/mole/span"
)

func main() {
  // imitate the source code you want to parse
  code := `
  import {Button} from "ui"

  export * from "./util"

  function f() {
    var require = a
    require('a.js')
  }
`
  // create a Source instance to handle to the source code
  s := span.NewSource("", code)

  // create a parser, here we use the default options
  opts := parser.NewParserOpts()
  p := parser.NewParser(s, opts)

  // inform the parser do its parsing process
  ast, err := p.Prog()
  if err != nil {log.Fatal(err)
  }

  errs := make([]*NotPermittedSyntaxErr, 0)
  ctx := walk.NewWalkCtx(ast, p.Symtab())

  // 1. collect the import statements
  walk.AddNodeAfterListener(&ctx.Listeners, parser.N_STMT_IMPORT, &walk.Listener{
    Id: "parseDep",
    Handle: func(node parser.Node, key string, ctx *walk.VisitorCtx) {n := node.(*parser.ImportDec)
      // ...
    },
  })

  // 2. collect the export statements
  walk.AddNodeAfterListener(&ctx.Listeners, parser.N_STMT_EXPORT, &walk.Listener{
    Id: "parseDep",
    Handle: func(node parser.Node, key string, ctx *walk.VisitorCtx) {n := node.(*parser.ExportDec)
      // ...
    },
  })

  // 3. since `import` is keyword instead of variable, collect the import points directly
  walk.AddNodeAfterListener(&ctx.Listeners, parser.N_IMPORT_CALL, &walk.Listener{
    Id: "parseDep",
    Handle: func(node parser.Node, key string, ctx *walk.VisitorCtx) {candidates[node] = node
    },
  })

  // 4. collect the require calls first, which will be filtered by below condition judgement
  candidates := map[parser.Node]parser.Node{}
  walk.AddNodeAfterListener(&ctx.Listeners, parser.N_EXPR_CALL, &walk.Listener{
    Id: "parseDep",
    Handle: func(node parser.Node, key string, ctx *walk.VisitorCtx) {n := node.(*parser.CallExpr)
      s := ctx.WalkCtx.Scope()
      callee := n.Callee()
      args := n.Args()

      isRequire := astutil.GetName(callee) == "require" && s.BindingOf("require") == nil &&
        len(args) == 1 && args[0].Type() == parser.N_LIT_STR

      if isRequire {candidates[node] = node
      }
    },
  })

  walk.VisitNode(ast, "", ctx.VisitorCtx())
}

下面代码中须要解释的内容为:

  • 对于 importexport 语句引入的模块比拟好解决,间接在对应的节点监听函数中提取信息即可,也就是 123 地位的内容
  • 对于 require 来说,咱们在其节点监听函数中做了一个判断:

    • 源码中没有从新绑定 require
    • 且有且仅有一个实参,同时类型为字符串

    满足下面两个条件时,才提取实参字符串信息作为被导入的模块

另外对于 import()require 的模式导入的模块,其属于有条件导入,比方上面的模式:

if (process.env.NODE_ENV === "production") {module.exports = require("./cjs/react-dom.production.min.js");
} else {module.exports = require("./cjs/react-dom.development.js");
}

下面的代码在构建时,打包工具会依据环境变量 NODE_ENV 的不同值导入不同的模块,因而咱们来须要可能辨认这样的有条件导入(Conditional imports)

Conditional imports

有条件导入次要波及上面三种语句或表达式:

  • IfStmt,比方上一节中的例子
  • BinExpr,比方 process.env.NODE_ENV && require('./a.js')
  • CondExpr, 比方 process.env.NODE_ENV ? require('./a.js') : null

其余一些应用频率不高,比方 SwitchStmt 也可能会蕴含有条件导入,能够临时先不反对

对有条件导入的反对能够分为两步:

  • 对条件表达式进行计算
  • 依据计算的后果联合节点本身的语义来遍历不同的子节点

以 IfStmt 为例:

if (process.env.NODE_ENV === "production") {
  // Cons
  module.exports = require("./cjs/react-dom.production.min.js");
} else {
  // Alt
  module.exports = require("./cjs/react-dom.development.js");
}

咱们须要:

  • 先可能计算其 Test 节点的表达式 process.env.NODE_ENV === 'production' 的值
  • 而后依据计算结果抉择遍历 Cons 节点还是 Alt 节点

Expr Evaluator

表达式求值器的作用是对条件表达式进行计算,咱们须要反对上面的运算类型:

  • 根本的算数运算,比方 加减乘除
  • 根本的逻辑运算,比方 逻辑与、或、取反

咱们能够利用 AST Walker 来实现一个简略的求值器:

  • 应用 Listener 模式,将操作数压入操作数栈
  • 在对应的运算节点中弹出操作数,依据节点的操作符对操作数进行计算,并将后果压入操作数栈

上面咱们以表达式 1 + 2 的计算为例进行解释

首先咱们须要监听数字字面量的节点信息,而后在回调中将代表数值的字符串转换成对于的数值并压入操作数栈:

ee.addListener(walk.NodeAfterEvent(parser.N_LIT_NUM),
  func(node parser.Node, key string, ctx *walk.VisitorCtx) {n := node.(*parser.NumLit)
    i := parser.NodeToFloat(n, ee.p.Source())
    ee.push(i)
  })

因为 Listener 模式的遍历是深度优先的,所以会优先遍历数字字面量节点 12,使得操作数栈中的内容变为:

 栈底 -> 栈顶
1, 2

而后会遍历到 BinExpr 中,咱们在对应的回调中讲操作数弹出,并依据操作符对操作数进行计算,并将后果压入操作数栈:

ee.addListener(walk.NodeAfterEvent(parser.N_EXPR_BIN),
  func(node parser.Node, key string, ctx *walk.VisitorCtx) {n := node.(*parser.BinExpr)
    rhs := ee.pop()  // 2
    lhs := ee.pop()  // 1

    switch n.Op() {
    case parser.T_EQ, parser.T_EQ_S:
      // ...
    case parser.T_NE, parser.T_NE_S:
      // ...
    case parser.T_ADD:
      ee.push(Add(lhs, rhs)) // 1 + 2
    case parser.T_SUB:
      // ...
    case parser.T_MUL:
      // ...
    case parser.T_DIV:
      // ...
    }
  })

计算完结后,操作数栈中停留的 3 即为表达式的计算结果

咱们再以 process.env.NODE_ENV 为例,其 AST 的模式为:

     memExpr_1
    /       \
 memExpr_2   ident
 /     \        |
ident  ident  NODE_ENV
 |       |
process env

咱们会监听 memExpr 和 ident 的事件,同样因为深度优先的遍历形式,节点的遍历程序将为:

process -> env -> memExpr_2 -> NODE_ENV -> memExpr

相似 process 这样的变量定义,由求值器的调用方传入。在 ident 的回调中,咱们会获得变量 process 的值压入操作数栈:

valueOfProcess

在 ident 的回调中,如果满足上面的条件:

  1. 其父节点为 memExpr
  2. 且节点本身为作为 memExpr 的 prop 属性
  3. 且不是计算属性

则将 ident 对应的字符串压入操作数栈:

valueOfProcess, 'env'

memExpr_2 的回调中,咱们只需将代表对象和属性名的操作数弹出,并将属性值 valueOfProcess['env'] 从新压入操作数栈即可。memExpr_1 的计算也是相似的步骤,咱们就不赘述了

SwitchBranch

有了表达式求值器之后,下一步咱们将须要依据求值后果抉择遍历的节点。咱们有三种须要解决的节点类型:

  • IfStmt
  • BinExpr
  • CondExpr

因为这三种类型的节点构造不尽相同,咱们能够先将它们对立转换成一个名为 SwitchBranch 的构造来简化后续的操作:

type SwitchBranch struct {
  negative bool
  test     parser.Node
  body     parser.Node
}

其中的字段含意为:

  • test 示意条件表达式
  • body 示意条件成立时须要持续遍历的节点
  • negative 示意是否须要对 test 的后果进行取反

那么对于 IfStmt 来说:

if (Test) Cons else Alt

则能够示意成两个 SwitchBranch:

  • SwitchBranch(false, Test, Cons)
  • SwitchBranch(true, Test, Alt)

咱们能够在 Visitor 模式下自顶向下遍历节点,在节点回调中通过 SwitchBranch 构造来筛选须要持续遍历的下一层节点:

func CollectNodesInTrueBranches(node parser.Node, typ []parser.NodeType, vars map[string]interface{}, p *parser.Parser) []parser.Node {ret := []parser.Node{}
  wc := walk.NewWalkCtx(node, nil)

  walkTrueBranches := func(node parser.Node, key string, ctx *walk.VisitorCtx) {
    // 节点筛选:
    // 1. 对立成 SwitchBranch
    // 2. 条件表达式求值
    // 3. 删选出命中的节点
    subs := SelectTrueBranches(node, vars, p)
    for _, sub := range subs { // 持续遍历命中的节点
      walk.VisitNode(sub, key, ctx)
    }
  }

  // 订阅节点事件,在回调中通过 walkTrueBranches 过滤待持续遍历的节点
  walk.SetVisitor(&wc.Visitors, parser.N_STMT_IF, walkTrueBranches)
  walk.SetVisitor(&wc.Visitors, parser.N_EXPR_BIN, walkTrueBranches)
  walk.SetVisitor(&wc.Visitors, parser.N_EXPR_COND, walkTrueBranches)

  for _, t := range typ {
    walk.AddNodeAfterListener(&wc.Listeners, t, &walk.Listener{
      Id: "CollectNodesInTrueBranches",
      Handle: func(node parser.Node, key string, ctx *walk.VisitorCtx) {ret = append(ret, node)
      },
    })
  }

  walk.VisitNode(node, "", wc.VisitorCtx())

  return ret
}

Module resolution

在依赖图的构建步骤的形容中,提到了 $module\_resolve_\_algo$ 用于定位模块的门路,咱们将实现几种常见的算法:

  • CJS – CommonJS
  • ESM – ES modules,在 CJS 的根底上做了调整
  • Browser,在 CJS 的根底上做了调整

下面的链接点击后能够看到残缺的算法形容,以 CommonJS 的节选为例:

require(X) from module at path Y
1. If X is a core module,
   a. return the core module
   b. STOP
2. If X begins with '/'
   a. set Y to be the filesystem root
3. If X begins with './' or '/' or '../'
   a. LOAD_AS_FILE(Y + X)
   b. LOAD_AS_DIRECTORY(Y + X)
   c. THROW "not found"
4. If X begins with '#'
   a. LOAD_PACKAGE_IMPORTS(X, dirname(Y))
5. LOAD_PACKAGE_SELF(X, dirname(Y))
6. LOAD_NODE_MODULES(X, dirname(Y))
7. THROW "not found"

LOAD_AS_FILE(X)
1. If X is a file, load X as its file extension format. STOP
2. If X.js is a file, load X.js as JavaScript text. STOP
3. If X.json is a file, parse X.json to a JavaScript Object. STOP
4. If X.node is a file, load X.node as binary addon. STOP

...
...

实现时咱们只须要复刻算法形容中的步骤即可

因为 ESM 和 Browser 都是在 CJS 的根底上做的调整,咱们能够先复刻 CJS 的算法逻辑,而后减少上 ESM 和 Browser 的调整即可

ESM 的一个显著的调整是减少了 URLs 的反对:

// file: URLs
import "./foo.mjs?query=1"; // loads ./foo.mjs with query of "?query=1"
import "./foo.mjs?query=2"; // loads ./foo.mjs with query of "?query=2"

// data: imports
import 'data:text/javascript,console.log("hello!");';
import _ from 'data:application/json,"world!"'assert {type:"json"};

// node: imports
import fs from "node:fs/promises";

import 语句中的字符串称之为 specifier,那么对于 URLs 的反对来说:

  • file: URLs 的 specifier 只须要在 CJS 的根底上减少对 URL 的解析即可
  • data: imports 的 specifier 只需在 CJS 的根底上减少对 MIME 的辨认即可,因为其是内联的形式,只需校验格局即可
  • node: imports 的 specifier 只需在 CJS 的内置模块汇合中减少它们的 ESM 版本即可

ESM 和 CJS 并不是齐全兼容的,比方 ESM 不会扫描目录上面的 index 文件,并且尚不反对省略文件后缀。为了和大家理论代码中的应用形式相贴合,咱们的 ESM 实现将是一个宽松的版本使之尽量和 CJS 兼容

另外一些 ESM 中实现性的性能,比方 HTTPS and HTTP imports 和 loaders 就先不反对了

Browser 的实现绝对 CJS 的调整次要是在 package.json 中减少了一个 browser 字段,不便包作者指定一些用于浏览器环境下的包产物。Browser 曾经能够被 CJS 和 ESM 共有的 Conditional exports 所代替,但为了兼容一些旧的包,咱们还是会反对下 Browser 算法

最初在 Resolve 时,咱们能够采纳并行的形式,比方:

import "./a.js";
import "b";

当咱们解析了下面的代码后,能够并行的持续模块 ./a.jsb 的门路计算

tsconfig

除了下面的算法外,tsconfig(jsconfig)中的 Path mapping 也会影响模块门路的抉择,比方咱们常会在 Path mapping 中做一些门路 Alias:

{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {"@utils/*": ["./src/utils/*"],
      "@components/*": ["./src/components/*"],
      "@hooks/*": ["./src/hooks/*"]
    }
  }
}

除了对 Path mapping 的反对外,咱们还须要反对 extends 也就是配置文件的继承

在对 tsconfig 中的解析中,咱们并不需要实现对 Files、Include 和 Exclude 的反对,因为这几个选项的由来是用于和 tsc 交互的。tsc 抉择输出文件的模式是,全量的扫描工程目录下的文件,而后编译它们,这也是为什么 tsc 不做额定设定的话,会为每个 x.ts 文件都生成同级目录下的 x.js 文件,通过这几个选项就能够在调整全量扫描的行为,比方能够通过 Exclude 将一些文件排除在全量扫描的范畴之外

Files 和 Include 能够让 tsc 不做全量的扫描,而 Exclude 仅影响 Include 的内容,这里的「仅影响」很重要,并且通常会被谬误了解,比照上面两个例子:

  • Exclude 中标记了 b.ts 要剔除在外,然而 Include 中蕴含了 a.ts,而 a.ts 中引入了 b.ts,那么 b.ts 仍然会被编译
  • Include 中同时蕴含了 a.ts(这次 a.ts 未引入 b.ts)和 b.ts,而 Exclude 中标记了 b.ts,那么这次 b.ts 会被剔除在外

咱们的利用体积剖析工具的工作模式和打包工具相似 – 用户指定一个或者多个 entry points,咱们会以这些 entry points 为终点,剖析文件的依赖,仅对波及到的文件进行解决,因而就无需思考 Files、Include 和 Exclude 了

对于 paths 的解决,咱们能够简略将它们通过字符串替换转换成非法的正则表达式,而后利用 Go 的正则包进行解决即可,比方:

$utils/*

$ 在正则中有本人的语义,咱们须要进行转移,通过解决后变成:

\$utils/(.*?)

转换成正则后,咱们就能够通过正则来匹配输出的 specifier,如果满足减少咱们将 (.*?) 匹配到的内容替换掉门路中的 * 即可

builtin modules

咱们须要将内置模块的名称都收集起来,而后在匹配 specifier 的时候判断其是否为内置模块,免得在遇到应用了内置模块时抛出找不到模块的谬误

node 的内置模块的收集能够查看 @types/node 包中的内容

react-native 内置模块能够参考 metro – lazy-imports.js

Tree-shaking

Tree-shaking 又称为 DCE(Dead code elimination),上面简称 DCE。因为打包工具有对 DCE 的反对,咱们的剖析工具也须要反对这样的性能,以使得剖析的后果更加的精确

在进行 DCE 之前,咱们须要先收集模块间的援用关系,下图为其个别模式:

图中蕴含的信息为:

  • Entry 示意入口文件,M1 和 M2 别离示意两个不同的模块。这里的一个模块能够艰深地了解为一个 JS 文件
  • 模块中大写字母示意模块导出的内容,小写字母示意模块公有内容(内部无奈应用)
  • Entry 中应用了 M1 导出的 A
  • M1 中的 A 应用了 M2 导出的 C,以及外部的 d
  • M2 中的 D 应用了其外部的 e

确定了模块援用关系后,第二遍咱们就能够以 Entry 为入口,标记触达的内容:

咱们通过绿色示意从 Entry 开始能够触达的内容,通过灰色示意能够被 DCE 的内容。有个例外就是红色标记的 D,它不能够被 DCE,因为咱们不确定 e 是否有副作用(side-effects)

副作用就是除了计算以外还做了其余的事件,在 JS 中,呈现副作用的可能性十分多,比方:

  • e 外部可能设置了全局变量,而设置的内容可能会被其余模块应用
  • const a = b + c,尽管看起来是计算,但 b 可能是一个全局对象上的 Getter

这样的动态性,导致很难在动态阶段剖析语句是否蕴含副作用。从打包工具的角度,它的优化行为必须是激进式的(Conservative),也就是「拿不准的就不干」,因为干了就可能出错,出错就很难调试排查,反而升高了效率

针对 D 这样的问题,当初的打包工具要求开发者通过正文 /*#__PURE__*/ 手动标记表达式无副作用:

export const D = /*#__PURE__*/ e();

咱们能够进一步将 DCE 的执行步骤整顿为:

  1. 假如模块外部最顶层语句的定义为:

    $TopmostStmt = \{VarDecStmt, FunDecStmt, ImportDec, ExportDec, ExprStmt \}$

    这也是咱们以后执行 DCE 的最小颗粒度(函数外部的 dead-code 暂不思考)

  2. 每个模块都有函数 $OwnedOf$:

    $OwnedOf: f(x) = TopmostStmt_{x},\ x \in TopmostStmt$

    $x$ 为模块内的 TopmostStmt,使用 $OwnedOf$ 后能够失去给定 $x$ 所持有的 $TopmostStmt_{x}$ 元素

    比方下面例子中的 M1 使用 $OwnedOf(A) = \{C, d\}$

  3. 以 $Entry$ 为终点,遇到的模块导入记为 $Import(x, M)$ 进入下一步
  4. 调用模块的 $OwnedOf(x)$ 办法,失去 $x$ 持有的 $TopmostStmt$,记为 $TopmostStmt’$,
  5. 对于 $TopmostStmt’$ 中的每个元素 $x’$,标记其为 $Alive$ 并持续调用 $OwnedOf$ 办法:

    $\forall x’ \in TopmostStmt’, MarkAlive(x’), TopmostStmt^{\prime\prime}=OwnedOf(x’)$

    如果 $TopmostStmt^{\prime\prime}$ 不为空集合 $\emptyset$ 则对其中的元素持续第 4 步,否则进入下一步

  6. 如果导入的模块 $M$ 中还蕴含其余导入,则持续第 4 步,否则进入下一步
  7. 对所有模块的标记实现后,从新计算标记为 $Alive$ 的元素体积(源码字符大小):

    $A = \{isAlive(TopmostStmt_0),isAlive(TopmostStmt_1),…,isAlive(TopmostStmt_{|A|}) \}$

    $Size_{DCE} = \sum_{x=0}^{|A|} Sizeof(x)$

side-effect-free

在 Webpack 没有反对 side-effect-free 之前,有一些包会应用自定义的包门路改写插件,比方 babel-plugin-import,它的性能演示为:

import {Button} from 'antd';
ReactDOM.render(<Button>xxxx</Button>);

      ↓ ↓ ↓ ↓ ↓ ↓

var _button = require('antd/lib/button');
ReactDOM.render(<_button>xxxx</_button>);

当打包工具反对了 side-effect-free 后,包本身只需 标记 那些没有副作用的文件即可,用户就不再须要应用门路改写插件了

对于那些不反对 DCE 的打包工具,比方 metro,下面的自定义包门路改写依然是有须要的,那么咱们的剖析工具是否须要反对自定义的包门路改写呢

对于 import {Button} from 'antd'; 来说,导入的是模块内的 antd/index.js 文件,假如这个文件是 ESM 的:

// antd/index.js
export Button from "./button";
export Icon from "./icon";

那么如果打包工具胜利执行了 DCE 后,icon 天然会被排除在外,成果和通过自定义门路改写是一样的

然而当 antd/index.js 文件为 CJS 时:

// antd/index.js
module.exports.Button = require("./button");
module.exports.Icon = require("./icon");

因为导入语句的副作用,使得打包工具无奈进行 DCE,这时自定义的门路改写就会发挥作用

而理论场景中少数为第一种状况,因而咱们不用反对自定义的门路改写,只需注意的是在不反对 DCE 的场景(ReactNative)下给出 CJS 的包体积而不是 ESM 的

因而咱们在计算包体积的时候,会同时计算两份,即 CjsSize 和 EsmSize:

  • 在反对 DCE 的场景下,咱们会依据包是否正确配置了 side-effect-free 来展现 CjsSize 或者 EsmSize,并且 EsmSize 能够作为正确配置后的体积进行展现
  • 在不反对 DCE 的场景下,咱们能够提供配置,让一些应用了自定义门路改写的模块展现 EsmSize

    {
      "target": "react-native",
      "entries": ["./index.ios.js"],
      "extensions": [],
      "sideEffectsFreeModules": [
        "@music/dolphin-core",
        "@music/dolphin-core-biz",
        "@music/dolphin-icons"
      ]
    }

成果演示

咱们曾经实现了信息收集的性能,前面基于信息的解决,比方辨认出反复的包因为比较简单就不赘述了,间接看一下成果

咱们须要先在待剖析利用中通过 molecast.json 文件进行简略的配置:

{
  "depGraph": {
    "entries": ["./src/page/*/index.jsx"]
  }
}

比方下面的配置中指定了入口文件的地位,咱们也能够指定更多的配置:

{
  "depGraph": {
    "target": "react-native", /* 利用的类型:web, node, react-native */
    "entries": ["./index.ios.js"],

    // 带尝试的文件后缀列表
    // JS 利用默认为 ".js", ".jsx", ".mjs", ".cjs", ".json"
    // 标记了 TS 后默认为 ".ts", ".tsx", ".js", ".jsx", ".mjs", ".d.ts", ".json", ".node"
    "extensions": [],

    // 是否为应用了 ts 的利用,默认会依据是否存在 tsconfig.json 来辨认
    "ts": true,

    // 预约义的变量
    "definedVars": {
      "process": {
        "env": {"NODE_ENV": "production"}
      }
    },

    // 显式地标记 side-effect-free 的模块
    "sideEffectsFreeModules": ["@music/sth-core"]
  }
}

而后在利用根目录中运行:

npx molecast -ana -pkgsize

咱们以后只反对 *nix 平台,Windows 则能够尝试在 WSL 下运行

运气好的话会看到文件名相似 mole-pkg-analysis-1663478942.json 的剖析后果文件,内容为:

{
  // 剖析时应用的配置项
  "options": {},

  // 剖析破费的工夫
  "elapsed": 238,

  // 反复依赖的模块
  "dupModules": [
    {
      "name": "@music/sth",
      "size": 80409,
      "versions": [
        {
          "id": 78,           // 模块的 id,能够在 modules 检索出模块的具体信息
          "version": "2.2.8", // 模块的版本
          "size": 33948       // 模块的体积
        },
        {
          "id": 334,
          "version": "1.3.24",
          "size": 46461
        }
      ]
    }
  ],

  // 反复依赖的导入门路
  "importInfo": {},

  // 模块信息
  "modules": {
    "101": {
      "id": 101,
      "name": "", // 模块的名称,如果是 umbrella 模块的话会展现 package.json 中的 name"version":"", // 模块的版本,如果是 umbrella 模块的话会展现 package.json 中的 version
      "file": "/app/client/components/order-pay-layer/QRCode.js", // 模块磁盘门路
      "size": 1125, // 模块的体积 bytes
      "dceSize": 865, // DCE 后的体积
      "strict": false, // 是否应用了 strict mode
      "entry": false, // 是否是入口文件
      "umbrella": 18, // 模块所属 umbrella 模块
      "cjs": false, // 是否是 cjs
      "cjsList": [], // 应用了 cjs 的子模块列表
      "esmList": [], // 应用了 esm 的子模块列表
      "sideEffectFree": false, // 是否是 umbrella 模块,且在 package.json 中设置了 sideEffectFree
      "inlets": [ // 哪些模块导入了该模块
        {
          "lhs": 57,
          "rhs": 101
        }
      ],
      "outlets": [ // 该模块导入了哪些模块
        {
          "lhs": 101,
          "rhs": 22
        },
      ],
      "owners": { // 该模块被哪些模块以何种模式依赖,比方这里示意被 import _default from "xxx" 的模式导入
        "57": ["default"]
      },
      "extsMap": { // 该模块导入的模块名称到 id 的映射
        "@music/mobile-react-toast": 26,
        "@utils/fetch": 19,
        "react": 22,
        "react-dom": 46
      },
      "topmostStmts": [ // 模块中的顶层的语句的依赖关系
        {
          "id": 747324309719,
          "nodeType": "ImportDec",
          "src": "react",
          "range": [
            174,
            215
          ],
          "alive": true,
          "sideEffect": false,
          "owners": [1425929143395],
          "owned": []}
      ],
      "parseTime": 215559, // 模块解析耗时,单位 Nanoseconds
      "walkDepTime": 67877, // 模块 AST walking 耗时,单位 Nanoseconds
      "walkTopmostTime": 134729 // 剖析顶层语句依赖耗时,单位 Nanoseconds
    }
  },

  // 剖析过程中的解析谬误
  "parserErrors": [],

  // 剖析过程中的模块门路计算错误
  "resolveErrors": [],

  // 剖析过程中的超时谬误
  "timeoutErrors": []}

基于下面的剖析后果咱们能够再做一些可视化的数据展现

为什么抉择 Go

程序执行的快慢次要看 CPU 及 内存的利用率,编程语言力不例外。从后果上咱们能够通过收罗网络上各种 benchmark 得出 Go 比 JS 执行快的论断

像编程语言这样蕴含泛滥组件的程序,很难通过一些简略的指标去剖析出它们为什么快和慢,简略列举下 Go 与 JS 的不同还是能够的:

  • Go 间接编译,JS 须要 JIT 热身。得益于在指令生成阶段的优化,在一些小的 benchmark 我的项目中可能 JS 的体现可能会更好
  • Go 对象的 Memory layouts 相比 JS 更加的简略,JS 中寄存 JIT 生成的指令所以造成的内存占用也不容小觑
  • Go 对多核的反对应用上比 JS 更加的天然,效率上也更高

除了比 JS 快之外,Go 也比较简单

对于 Go 比较简单,我的论据基于 Here We Go Again: Why Is It Difficult for Developers to Learn Another Programming Language?(简称 Why)

Why 文基于 StackOverflow 大数据以及资深开发者问卷调查,试图解释哪些环节导致开发者认为一门语言比拟难

我总结出上面几点:

  • 开发者习惯对照以往的教训来学习新语言
  • 基于上一点,当新语言与过往教训的交加越少时,学习难度越大,比方咱们在初识 JS 时遇到 === var closure
  • 文档、示例和社区很重要,因为开发者通常会抉择一种用到哪学到哪(哪里不会点哪里)的形式学习新语言,所以须要足够多的材料让搜索引擎能出现更丰盛的后果
  • 开发环境配置难度(IDE 性能易用、丰富性),很显然代码究竟还是要用来写的,如果卡在配置环境阶段体验必定要打折扣

而在前端开发者的角度:

  • 次要接触的语言就是 JS,Go 与 JS 相似,都是 C 语言格调的且带 GC 的语言,并且 Go 的语法规定更简略

    • Go Grammar
    • JS Grammar
  • Go 的文档丰盛且集中 go.dev
  • 应用 VSCode 简略配置就能够失去一个 IDE,参考 Go in Visual Studio Code

下面就是一点点对于我为什么抉择 Go 的解释,仅供参考。心愿大家不要陷入到探讨语言好坏的激辩漩涡中,也心愿有相似语言疑虑的同学能够从本人对这些语言的实在编程体验中失去本人称心的答案

结尾

咱们从新编写了语法解析器,AST Walker,Expr Evaluator,复刻了 Module resolution 算法,实现了顶层语句的 DCE,最终产生了一份剖析后果,是货真价实的傻瓜模式

心愿本文能够起到抛砖引玉的作用,让大家对打包或者剖析工具的外部工作形式有所理解,或者参考文中的形式实现本人的剖析工具。受限于本身能力,文中如有不足之处还请大家斧正,欢送大家一起学习交换

本文公布自网易云音乐技术团队,文章未经受权禁止任何模式的转载。咱们长年招收各类技术岗位,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!

正文完
 0