How to Analyze and Optimize Frontend Bundle Size with a DIY JS Parser

This article walks through measuring bundle growth, identifying duplicate dependencies and missing tree‑shaking, building a Go‑based JavaScript parser, generating a dependency graph, handling conditional imports, applying dead‑code elimination, and finally using the tool to shrink frontend bundle size.

NetEase Cloud Music Tech Team
NetEase Cloud Music Tech Team
NetEase Cloud Music Tech Team
How to Analyze and Optimize Frontend Bundle Size with a DIY JS Parser

Why Bundle Size Matters

Bundle size is a key factor for frontend performance. HTTP Archive data shows that while mobile page load time dropped 38.6% from 2017 to 2022, JavaScript startup time grew by 211.1% because JS files keep getting larger.

Analysis Goals

We focus on JavaScript files and aim to discover:

Duplicate package names and versions

Dependency paths for each duplicate

Potential size reduction if duplicates are resolved

We also check whether packages are correctly configured for tree‑shaking.

Duplicate Dependencies

Duplicate dependencies occur when a project pulls in multiple incompatible versions of the same package, for example:

{"C": "^1.0.0"}
{"C": "^2.0.0"}

Other scenarios include locked versions in package-lock.json that contain conflicting versions.

Tree‑shaking Requirements

Modern bundlers like Webpack can eliminate unused code if the following conditions are met:

Imports use static import syntax

Modules are ES modules (ESM)

Packages are marked sideEffectFree Our goal is to list packages that are ESM but lack the side‑effect‑free flag and estimate the size after proper tree‑shaking.

Generating the Dependency Graph

The first step is to collect information about every module. The algorithm is:

Prepare a queue containing entry file paths.

Pop a file from the queue, resolve its real path.

Read the file content and parse import statements.

Push newly discovered module paths into the queue.

Repeat until the queue is empty.

This graph is the foundation for all later analyses.

Building a JavaScript Parser in Go (Mole)

We implement a recursive‑descent parser called mole in Go. The parser supports:

ES2021 syntax

JSX

TypeScript 1.8

Each grammar production gets a corresponding Go function. For example, the PrimaryExpression rule translates to:

PrimaryExpression[Yield, Await] :
    this
    IdentifierReference[?Yield, ?Await]
    Literal

Parsing functions are straightforward wrappers that match tokens and build AST nodes.

Example Parsing Function

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

AST Walker

The walker traverses the AST to extract import/export statements and require calls. It supports two patterns:

Listener : automatic depth‑first traversal with callbacks after each node.

Visitor : manual control over traversal order.

For an AssignExpr node the visitor looks like:

func VisitAssignExpr(node parser.Node, key string, ctx *VisitorCtx) {
  n := node.(*parser.AssignExpr)
  CallVisitor(N_EXPR_ASSIGN_BEFORE, n, key, ctx)
  defer CallVisitor(N_EXPR_ASSIGN_AFTER, n, key, ctx)
  VisitNode(n.Lhs(), "Lhs", ctx)
  if ctx.WalkCtx.Stopped() { return }
  VisitNode(n.Rhs(), "Rhs", ctx)
}

Macro System for Code Generation

We use go:generate to process special comments like // #[macro]. The macro syntax is:

Macro := '#[' CallSequence ']'
CallSequence := CallExpr (',' CallExpr)*
CallExpr := CallWithoutArg | CallWithArgs
...

During generation the macro expands into concrete visitor functions, reducing boiler‑plate for the ~4000 lines of traversal code.

Collecting Imports and Conditional Imports

Listeners are added for import, export, and require nodes. A require is recorded only when:

The identifier is still bound to the built‑in require.

Exactly one argument is present and it is a string literal.

Conditional imports appear in if statements, binary expressions, or conditional expressions. We first evaluate the test expression using an expression evaluator that supports basic arithmetic and logical operators, then traverse only the branch whose condition evaluates to true.

Expression Evaluator Example

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

ee.addListener(NodeAfterEvent(parser.N_EXPR_BIN), func(node parser.Node, key string, ctx *VisitorCtx) {
  n := node.(*parser.BinExpr)
  rhs := ee.pop()
  lhs := ee.pop()
  switch n.Op() {
  case parser.T_ADD:
    ee.push(lhs + rhs)
  // other operators omitted for brevity
  }
})

Module Resolution Algorithms

We implement the classic Node.js resolution steps for CommonJS, then extend them for ESM and browser environments. The algorithm handles:

Core modules

Absolute and relative paths

Package imports (e.g., # prefixes)

Node_modules lookup

For ESM we also support file:, data:, and node: specifiers, and we respect package.json fields such as browser and exports.

tsconfig Path Mapping

Path aliases from tsconfig.json are converted to regular expressions. For example, the alias "@utils/*": ["./src/utils/*"] becomes the regex \@utils/(.*?), and the captured group replaces the * placeholder during resolution.

Tree‑shaking and Side‑Effect‑Free Detection

We build a module‑level call graph, mark reachable top‑level statements, and compute the size before and after dead‑code elimination (DCE). Packages that declare sideEffectFree in their package.json are treated as safe for removal; otherwise, statements marked with /*#__PURE__*/ are considered pure.

Tool Demonstration

A minimal molecast.json config specifies entry points and optional flags such as target platform, extensions, and side‑effect‑free modules. Running the analysis: npx molecast -ana -pkgsize produces a JSON report containing duplicate modules, their versions, size estimates, and detailed module metadata (file path, import/export relationships, parse and walk timings).

Why Go?

Go compiles to native code, has a simple memory layout, and offers natural multi‑core support, making it faster than JavaScript for CPU‑intensive static analysis tasks. Its concise grammar and excellent documentation also lower the learning curve for frontend engineers.

Conclusion

We rebuilt a JavaScript parser, an AST walker, an expression evaluator, and a module resolver, then applied DCE to produce a practical bundle‑size analysis tool. The guide demonstrates how bundlers work internally and provides a foundation for building custom analysis pipelines.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

frontendbundlemodule-resolutionTree Shakingdead code eliminationdependency graphgo-parser
NetEase Cloud Music Tech Team
Written by

NetEase Cloud Music Tech Team

Official account of NetEase Cloud Music Tech Team

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.