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.
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]
LiteralParsing 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
