Recently, in the frontend development field, Svelte is increasingly being mentioned alongside React and Vue. Interestingly, when you search ‘React Vue’ on Google, Svelte is now often recommended at the top, instead of the traditionally mentioned Angular.
The essence of Svelte lies in efficient compilation, creating optimized JavaScript code for manipulating the DOM.
Understanding Svelte properly, much like understanding React requires knowledge of the Virtual DOM, necessitates knowing how the Svelte Compiler works.
This article explains based on the source code of Svelte@5.0.0-next.25. Although the internal codebase has changed a lot since v5, the overall flow is similar to v3 and v4, so it should be a relevant reference.
How the Svelte Compiler Works
The compilation process of Svelte can be divided into three main stages:
- Parse
- Analyze
- Transform
The Svelte Compiler source code (for version 3 here) gives us intuitive hints. The overall flow is as follows:
const source = fs.readFileSync('App.svelte');
// 1. Parse, converting Svelte code into AST
const ast = parse(source);
// 2. Analyze, tracking internal dependencies within the component
const analysis = analyze_component(ast);
// 3. Transform, creating code blocks and fragments
const compiled = transform_component(analysis, source);
fs.writeFileSync('App.js', compiled.js.code);
fs.writeFileSync('App.css', compiled.css.code);
js
Reading the App.svelte
source code, it goes through a series of processes to ultimately generate App.js
and App.css
files. Let’s take a detailed look at each step.
1. Parsing, Converting Svelte Code into AST
const ast = parse(source);
js
Abstract Syntax Tree (AST) is a widely used data structure in compilers. Simply put, it’s a tree structure representing the relationships between codes.
By converting the Svelte source code into an AST, the compiler understands the relationships between the codes. The Svelte file’s script, HTML, and CSS codes are separated to form a single component object (root). Below is a diagram for easy understanding.
Let’s examine what happens at the compiler source code level.
Executing parse(source)
creates a Parser
object, returning an AST.
const ast = new Parser(source).root;
js
Inside the Parser
constructor, the fragment
function is used to recursively parse itself.
/** @type {ParserState} */
let state = fragment;
while (this.index < this.template.length) {
state = state(this) || fragment;
}
js
Inside the fragment
, functions like element
, tag
, and text
are executed based on conditions to extract information from the source code.
import element from './element.js';
import tag from './tag.js';
import text from './text.js';
export default function fragment(parser) {
if (parser.match('<')) {
return element;
}
if (parser.match('{')) {
return tag;
}
return text;
}
js
Areas wrapped in script
tags are parsed through read_script
, which internally uses the acorn
parser. acorn
, known as “A tiny, fast JavaScript parser,” is widely used in libraries that handle JS source code, such as webpack
and eslint
.
Other areas use Svelte Compiler’s own parsing logic. When encountering a style
tag, read_style
is used, and various utilities interpret Svelte’s unique syntaxes like {#each list as item}
and <svelte:component>
. It also records warnings and errors for invalid code and accessibility (a11y) violations.
The final result is a JSON structure like this:
{
html: { type, start, end, children }
css: { type, start, end, attributes, children, content }
instance: { type, start, end, context, content }
module: { type, start, end, context, content }
}
python
For more details, you can check the AST output
tab on Svelte REPL.
2. Analyzing and Tracking Component Internal Dependencies
const analysis = analyze_component(ast);
js
The next step involves extracting information for performing various actions of a component from the created AST. It’s worth noting that each Svelte file represents a single component.
The primary properties of a component can be summarized as follows, with reference to the original code:
const analysis = {
root: scope_root,
module,
instance,
template,
stylesheet: new Stylesheet({...}),
// Various compile options
runes,
warnings,
reactive_statements: new Map(),
binding_groups: new Map(),
slot_names: new Set(),
...
};
js
a. scope_root
const scope_root = new ScopeRoot();
js
ScopeRoot
is an object that serves as the highest scope of the component. Internally, it uses a Set
data structure to ensure the uniqueness of identifiers such as variables and functions.
b. module
, instance
function js(...) {
const { scope, scopes } = create_scopes(...);
return { ast, scope, scopes };
}
const module = js(root.module, scope_root, false, null);
const instance = js(root.instance, scope_root, true, module.scope);
js
The ASTs of the instance script and module script are traversed to identify all areas where a variable is referenced, thereby knowing all situations where a variable change might occur. During the process, lower scopes of the script are created, referencing scope_root
to assign unique identifiers to the variables.
Here’s a quick overview of the differences between module and instance in Svelte:
- module
- Defines the state and logic shared between components.
- Can be declared with
<script context="module">
. - Think of it as an area where global variables are declared.
- You cannot write
reactive
code within this area.
- instance
- Defines the unique state and logic of the component.
- This is where you typically write your script.
c. template
const { scope, scopes } = create_scopes(root.fragment, ...);
const template = { ast: root.fragment, scope, scopes };
js
root.fragment
refers to the HTML AST and is traversed to understand the scope within the markup area. Syntax such as {data}
, {#if}
, {#each}
, {@const}
, class:
and others are applicable here.
d. stylesheet
const stylesheet = new Stylesheet({
ast: root.css,
filename: options.filename ?? '<unknown>',
component_name,
get_css_hash: options.cssHash,
});
js
The Stylesheet
object traverses the CSS AST, selecting CSS selectors used within the component scope. If a CSS selector is declared using :global(...)
or -global-
, it is separately recorded. For more on these syntaxes, refer to the official documentation.
Several optimization tasks are carried out based on the collected analysis
information:
analysis.stylesheet.validate(analysis);
for (const element of analysis.elements) {
analysis.stylesheet.apply(element);
}
analysis.stylesheet.reify(options.generate === 'client');
js
- Duplicate global CSS selectors and those not used within the component scope are removed.
- CSS selectors used within the component scope are hashed into the
.svelte-xxx
format, preventing collisions with selectors of the same name.
e. Final Traversal
The component state is optimized by traversing the AST again, based on the obtained analysis
information.
walk(
/** @type {import('#compiler').SvelteNode} \*/ (ast),
/** @type {import('./types').AnalysisState} \*/ (state),
merge(
set_scope(scopes),
validation_runes,
runes_scope_tweaker,
common_visitors,
),
);
js
- validation_runes
- Checks for incorrect assignments or update expressions.
- Verifies the declaration and exporting of variables.
- Checks the validity of the new
reactive
syntax,runes
.
- runes_scope_tweaker
- Adjusts the scope.
- Sets the scope and binding type for variable declarations that use specific patterns.
- Moves variables or functions without state changes outside the instance.
- common_visitors
- Handles
directive
andbinding
related directives. - Processes general HTML and Svelte-specific elements.
- Deals with event-related attributes and determines whether event delegation or hoisting is required.
- Handles
3. Transforming, Creating Code Blocks and Fragments
const compiled = transform_component(analysis, source);
js
Finally, the Svelte compiler undergoes a transformation process to generate rendering code.
In this process, the logic for generating code for SSR (Server-Side Rendering) and CSR (Client-Side Rendering) differs. The compiler uses two functions, server_component
and client_component
, to create code optimized for each scenario.
const program =
options.generate === 'server'
? server_component(analysis, options)
: client_component(source, analysis, options);
js
Characteristics of SSR
In SSR, a component is rendered only once, and there is no lifecycle for the component.
Thus, server_component
focuses on creating template literals
. It adds code blocks using visitors like javascript_visitors
and template_visitors
.
Characteristics of CSR
In CSR, constant interaction with the DOM is required, and components need to have a lifecycle.
Therefore, client_component
consists of more varied and complex visitors. Even the same javascript_visitors
includes additional processing logic for functions during traversal.
Generating Code
return {
js: print(program, { sourceMapSource: options.filename }),
css:
analysis.stylesheet.has_styles && !analysis.inject_styles
? analysis.stylesheet.render(options.filename, source, options.dev)
: null,
// ...
};
js
Ultimately, the print
and render
functions are executed to generate js and css code.
This generated code is passed to bundlers and transformed into browser-ready bundles through plugins like vite-plugin-svelte
and svelte-loader
.
In Conclusion
Svelte identifies code dependencies at the build stage through its compiler.
It traverses the source code multiple times, analyzing the dependencies between HTML, JS, and CSS, and generating code to efficiently handle the DOM. This is a key factor that makes it stand out following VDOM-based frameworks like React and Vue, which identify dependencies at runtime.
While I have diligently followed up on the compiler source code to write this article, it feels like I have only scratched the surface of the compiler’s workings. If given the chance, I would like to delve deeper into how the compiled Svelte code operates.