How V8 Parses JavaScript: Preparse, Lazy Compilation and the Parse Cache
V8 parses a script twice: a fast preparse that only finds function boundaries, then a full parse when the function is actually called. The preparse leaves behind a byte blob that lets V8 skip nested functions entirely.
TL;DR: V8 does not parse a function when it sees it — it preparses it, which means “run the scanner over the whole body, throw away every AST node, and write down where the nested functions are”. The result is a small byte blob (varints and two-bit “quarters”) attached to the
SharedFunctionInfoasPreparseData. When the function is finally called, the full parser runs, and the blob lets it skip the nested functions outright instead of re-parsing them.
Parsing JavaScript is not the hard part of running JavaScript. Executing it is. But the cost of parsing is paid eagerly, at script load, while the benefit is only realised if a given function is ever called. A 2 MB bundle with 4000 functions, of which the running code path touches 40, is 99 % dead weight at parse time.
V8’s answer is a two-phase parser, and — this is the part that surprises people — the two phases are not “parse” and “compile”. They are preparse and parse, and the gap between them is bridged by a byte blob.
Two parsers, one grammar
The tree has a Parser and a PreParser, and they share almost all of
their code through the CRTP base ParserBase<Impl>
(src/parsing/parser-base.h). The PreParser is not a separate
implementation of JavaScript; it is the same recursive-descent grammar
with every AST-producing call replaced by a no-op. Look at how the
preparser handles a template literal, for instance:
// src/parsing/preparser.h:986
struct TemplateLiteralState {};
V8_INLINE TemplateLiteralState OpenTemplateLiteral(int pos) {
return TemplateLiteralState();
}
V8_INLINE void AddTemplateExpression(TemplateLiteralState* state,
const PreParserExpression& expression) {}
V8_INLINE void AddTemplateSpan(TemplateLiteralState* state, bool should_cook,
bool tail) {}Nothing is built. The point of the preparser is not to build an AST, it is to find the ends of things and to validate the grammar on the way.
Both live in the same Parser object, and the parser owns a separate zone
for the preparser’s temporary allocations:
// src/parsing/parser.h:1106
LocalIsolate* local_isolate_;
ParseInfo* info_;
Scanner scanner_;
Zone preparser_zone_;
PreParser* reusable_preparser_;The PreParser is reused across functions, so its zone and its
std::vector scratch buffers are allocated once per parse, not once per
function:
// src/parsing/parser.h:254
PreParser* reusable_preparser() {
if (reusable_preparser_ == nullptr) {
reusable_preparser_ = new PreParser(
&preparser_zone_, &scanner_, stack_limit_, ast_value_factory(),
pending_error_handler(), runtime_call_stats_, v8_file_logger_,
flags(), parsing_on_main_thread_);
reusable_preparser_->set_allow_eval_cache(allow_eval_cache());
if (has_module_in_scope_chain()) {
reusable_preparser_->set_has_module_in_scope_chain();
}
preparse_data_buffer_.reserve(128);
}
return reusable_preparser_;
}That reserve(128) is the whole preparse-data buffer, pre-sized for the
typical case of a function with a handful of nested functions.
The scanner is a separate machine
Before you can preparse, you have to tokenise, and V8’s tokeniser is a
distinct object (Scanner) that the parser drives. It is not a lexer
inside the parser; it is a small state machine with its own buffer of
characters and its own buffer of tokens.
The token buffer is a four-slot ring, which is why the parser can look three tokens ahead without re-scanning:
// src/parsing/scanner.h:795
const TokenDesc& next_next_next() const { return *next_next_next_; }
TokenDesc* current_; // desc for current token (as returned by Next())
TokenDesc* next_; // desc for next token (one token look-ahead)
TokenDesc* next_next_; // desc for the token after next (after peek())
TokenDesc* next_next_next_; // desc for the token after next of next (after
Scanner::Next() rotates those four slots, and only scans a new token when
the ring has run dry:
// src/parsing/scanner.cc:163
Token::Value Scanner::Next() {
// Rotate through tokens.
TokenDesc* previous = current_;
current_ = next_;
// Either we already have the next token lined up, in which case next_next_
// simply becomes next_. In that case we use current_ as new next_next_ and
// clear its token to indicate that it wasn't scanned yet. Otherwise we use
// current_ as next_ and scan into it, leaving next_next_ uninitialized.
if (V8_LIKELY(next_next().token == Token::kUninitialized)) {
DCHECK(next_next_next().token == Token::kUninitialized);
next_ = previous;
// User 'previous' instead of 'next_' because for some reason the compiler
// thinks 'next_' could be modified before the entry into Scan.
previous->after_line_terminator = false;
Scan(previous);
} else {Note the comment: the code is written to keep the common case (ring empty,
scan one token) on the fast path, and the four-way rotation is what lets
PeekAhead() and PeekAheadAhead() work without unbounded lookahead.
Strings and templates are different kinds of token
Token::Value distinguishes a plain string from the two template tokens,
because a template is a sequence of tokens, not one:
// src/parsing/token.h:60
/* BEGIN Template */
/* ES6 Template Literals */
T(kTemplateSpan, nullptr, 0)
T(kTemplateTail, nullptr, 0)
/* END Template */A kTemplateSpan ends with ${ and is followed by an expression; a
kTemplateTail ends the literal. The scanner keeps two buffers for a
template: the cooked characters and the raw characters, because
`\n` is a newline in the cooked form and a backslash-n in the raw
form, and the raw form is what String.raw needs.
The string scanner is the hot loop of the whole tokeniser, and it is written to bulk-copy ASCII runs rather than character by character:
// src/parsing/scanner.cc:561
Token::Value Scanner::ScanString() {
base::uc32 quote = c0_;
next().literal_chars.Start();
while (true) {
// Consumed ASCII characters are bulk-appended to the literal buffer via
// the range callback. Non-ASCII characters terminate the range scan and
// are handled by the outer loop below (via the final AddLiteralChar),
// which keeps the common all-ASCII scan fast.
AdvanceUntilRange(
[](base::uc32 c0) {
if (V8_UNLIKELY(static_cast<uint32_t>(c0) > kMaxAscii)) return true;
return MayTerminateString(character_scan_flags[c0]);
},
[this](const uint16_t* start, const uint16_t* end) {
next().literal_chars.AddRangeFromUtf16(start, end);
});character_scan_flags is a 256-entry lookup table, indexed by ASCII
character, that says whether the character can terminate a string. One
table lookup per character instead of a chain of comparisons.
Surrogate pairs are handled one level down, in the character stream, by combining a lead surrogate with the following trail surrogate before the scanner ever sees it:
// src/parsing/scanner.h:610
bool CombineSurrogatePair() {
DCHECK(!unibrow::Utf16::IsLeadSurrogate(kEndOfInput));
if (unibrow::Utf16::IsLeadSurrogate(c0_)) {
base::uc32 c1 = source_->Advance();
DCHECK(!unibrow::Utf16::IsTrailSurrogate(kEndOfInput));
if (unibrow::Utf16::IsTrailSurrogate(c1)) {
c0_ = unibrow::Utf16::CombineSurrogatePair(c0_, c1);
return true;
}
source_->Back();
}
return false;
}This is why an identifier containing an astral character is a single
uc32 in the literal buffer but two uint16_ts in the final string: the
scanner works in code points, the string works in code units.
Preparse: the parser decides to skip the body
The decision to preparse rather than fully parse is made in
Parser::ParseFunctionLiteral, and it is a small boolean with a lot of
conditions behind it:
// src/parsing/parser.cc:2922
// Determine whether we can lazy parse the inner function. Lazy compilation
// has to be enabled, which is either forced by overall parse flags or via a
// ScopedModification.
const bool can_preparse = parse_lazily();
// Determine whether we can post any parallel compile tasks. Preparsing must
// be possible, there has to be a dispatcher, and the character stream must be
// cloneable.
const bool can_post_parallel_task =
can_preparse && info()->dispatcher() &&
scanner()->stream()->can_be_cloned_for_parallel_access();
// If parallel compile tasks are enabled, and this isn't a re-parse, enable
// parallel compile for the subset of functions as defined by flags.
bool should_post_parallel_task =
can_post_parallel_task && !flags().is_reparse() &&
((is_eager_top_level_function &&
flags().post_parallel_compile_tasks_for_eager_toplevel()) ||
(is_lazy && flags().post_parallel_compile_tasks_for_lazy()));
// Determine whether we should lazy parse the inner function. This will be
// when either the function is lazy by inspection, or when we force it to be
// preparsed now so that we can then post a parallel full parse & compile task
// for it.
const bool should_preparse =
can_preparse && (is_lazy || should_post_parallel_task);Note the last clause: V8 will preparse a function it intends to compile eagerly, precisely so that the full parse can be shipped to a background thread. The preparse is not only a “skip” mechanism; it is also the half-price stage that makes parallel compilation possible.
If the preparse fails, the parser rewinds and does a full parse:
// src/parsing/parser.cc:2990
bool did_preparse_successfully =
should_preparse &&
SkipFunction(function_literal_id, function_name, kind,
function_syntax_kind, scope, &num_parameters,
&function_length, &produced_preparse_data);
if (!did_preparse_successfully) {
// If skipping aborted, it rewound the scanner until before the lparen.
// Consume it in that case.
if (should_preparse) Consume(Token::kLeftParen);
should_post_parallel_task = false;
ParseFunction(&body, function_name, pos, kind, function_syntax_kind, scope,
&num_parameters, &function_length, &has_duplicate_parameters,
&expected_property_count, &suspend_count,
arguments_for_wrapped_function);The one heuristic: “is this function about to be called?”
V8 does not use a size threshold to decide what to preparse. It uses exactly one syntactic signal: was this function preceded by an open parenthesis? If so, it is probably an IIFE, and it is about to run, so there is no point in being clever.
The signal is captured by FunctionState::set_next_function_is_likely_called:
// src/parsing/parser-base.h:483
void set_next_function_is_likely_called() {
next_function_is_likely_called_ = !v8_flags.max_lazy;
}and it is set the moment the parser sees a ( followed by function:
// src/parsing/parser-base.h:2245
// Heuristically try to detect immediately called functions before
// seeing the call parentheses.
if (peek() == Token::kFunction ||
(peek() == Token::kAsync && PeekAhead() == Token::kFunction)) {
function_state_->set_next_function_is_likely_called();
}The flag then decides two things. First, whether the function is compiled eagerly:
// src/parsing/parser.cc:2863
FunctionLiteral::EagerCompileHint eager_compile_hint =
function_state_->next_function_is_likely_called() || is_wrapped ||
parsing_dynamic_function_declaration_ ||
(info()->flags().compile_hints_magic_enabled() &&
scanner()->SawMagicCommentCompileHintsAll()) ||
(info()->flags().compile_hints_per_function_magic_enabled() &&
scanner()->HasPerFunctionCompileHint(compile_hint_position))
? FunctionLiteral::kShouldEagerCompile
: default_eager_compile_hint();Second — and this is the interesting one — whether its nested functions can be skipped wholesale later. In the preparser:
// src/parsing/preparser.cc:275
int function_literal_id = GetNextInfoId();
bool skippable_function = false;
// Start collecting data for a new function which might contain skippable
// functions.
{
PreparseDataBuilder::DataGatheringScope preparse_data_builder_scope(this);
skippable_function = !function_state_->next_function_is_likely_called() &&
preparse_data_builder_ != nullptr;Read that twice. A function is skippable — meaning “the next time we parse this, we may jump over its body entirely” — only if it is not likely to be called immediately. If it is likely to be called, V8 preparses it in full detail, because it is going to be needed anyway and there is no point in throwing the information away.
That is a genuinely odd design if you think of preparse as “the cheap approximation”. It is not an approximation. It is a cache: the preparser collects exactly the information that a later full parse would have collected, in a compressed form, so the later parse can skip work it has already done.
Preparse data: the blob you can skip a function with
The file src/parsing/preparse-data.h opens with the design note, and it
is worth reading in full because it is the clearest statement of intent in
the whole parser:
// src/parsing/preparse-data.h:45
When parsing the code the first time, eager_outer is parsed and lazy_inner
(and everything inside it) is preparsed. When lazy_inner is called, we don't
want to parse or preparse skip_me again. Instead, we want to skip over it,
since it has already been preparsed once.
In order to be able to do this, we need to store the information needed for
allocating the variables in lazy_inner when we preparse it, and then later do
scope allocation based on that data.
We need the following data for each scope in lazy_inner's scope tree:
For each Variable:
- is_used
- maybe_assigned
- has_forced_context_allocation
For each Scope:
- inner_scope_calls_eval_.Three bits per variable, one bit per scope. That is the entire payload, plus a per-function header with the positions and the arity.
The header is five varints and one quarter-byte:
// src/parsing/preparse-data.cc:286
bool PreparseDataBuilder::SaveDataForSkippableFunction(
PreparseDataBuilder* builder) {
DeclarationScope* function_scope = builder->function_scope_;
// Start position is used for a sanity check when consuming the data, we could
// remove it in the future if we're very pressed for space but it's been good
// at catching bugs in the wild so far.
byte_data_.WriteVarint32(function_scope->start_position());
byte_data_.WriteVarint32(function_scope->end_position());
bool has_data = builder->HasData();
bool length_equals_parameters =
function_scope->num_parameters() == builder->function_length_;
uint32_t has_data_and_num_parameters =
HasDataField::encode(has_data) |
LengthEqualsParametersField::encode(length_equals_parameters) |
NumberOfParametersField::encode(function_scope->num_parameters());
byte_data_.WriteVarint32(has_data_and_num_parameters);
if (!length_equals_parameters) {
byte_data_.WriteVarint32(builder->function_length_);
}
byte_data_.WriteVarint32(builder->num_inner_functions_);
uint8_t language_and_super =
LanguageField::encode(function_scope->language_mode()) |
UsesSuperField::encode(function_scope->uses_super_property());
byte_data_.WriteQuarter(language_and_super);
return has_data;
}The varint encoding is the same one the value serializer uses, and the “quarter” is a two-bit field packed four-per-byte:
// src/parsing/preparse-data.cc:212
void PreparseDataBuilder::ByteData::WriteQuarter(uint8_t data) {
DCHECK(!is_finalized_);
DCHECK_LE(data, 3);
if (free_quarters_in_last_byte_ == 0) {
#ifdef DEBUG
// Save a marker in debug mode.
Add(kQuarterMarker);
#endif
Add(0);
free_quarters_in_last_byte_ = 3;
} else {
--free_quarters_in_last_byte_;
}
uint8_t shift_amount = free_quarters_in_last_byte_ * 2;
DCHECK_EQ(byte_data_->at(index_ - 1) & (3 << shift_amount), 0);
(*byte_data_)[index_ - 1] |= (data << shift_amount);
}The language mode and the “uses super” flag are two independent bits, and they are squeezed into the same two-bit quarter as the encoding of the language mode. That is a level of bit-packing that only makes sense if you know how tight the budget is — and the budget is tight, because the whole point is to keep the blob small enough that it is cheaper to store it than to re-parse the function.
The size budget is spelled out in the constants:
// src/parsing/preparse-data.h:89
static const size_t kSkippableFunctionMinDataSize =
4 * kVarint32MinSize + 1 * kUint8Size;
// A skippable function writes up to 5 Varint32s (start_position,
// end_position, has_data_and_num_parameters, function_length when
// length != num_parameters, and num_inner_functions) plus 1 Quarter
// (language_and_super).
static const size_t kSkippableFunctionMaxDataSize =
5 * kVarint32MaxSize + 1 * kUint8Size;In a release build a varint is 1 to 5 bytes, so a skippable function costs between 5 and 26 bytes of header. That is the price of being able to skip an entire function body.
From blob to SharedFunctionInfo
The blob is not stored on the SharedFunctionInfo directly. It is stored in
an UncompiledData object, because the SharedFunctionInfo also needs to
remember the start and end position of the function it describes, and
because a function may additionally have a pending background compile job:
// src/objects/shared-function-info.cc:621
template <typename IsolateT>
void SharedFunctionInfo::CreateAndSetUncompiledData(IsolateT* isolate,
FunctionLiteral* lit) {
DCHECK(!lit->shared_function_info()->HasUncompiledData(isolate));
DirectHandle<UncompiledData> data;
ProducedPreparseData* scope_data = lit->produced_preparse_data();
if (scope_data != nullptr) {
Handle<PreparseData> preparse_data = scope_data->Serialize(isolate);
if (lit->should_parallel_compile()) {
data = isolate->factory()->NewUncompiledDataWithPreparseDataAndJob(
lit->GetInferredName(isolate), lit->start_position(),
lit->end_position(), preparse_data);
} else {
data = isolate->factory()->NewUncompiledDataWithPreparseData(
lit->GetInferredName(isolate), lit->start_position(),
lit->end_position(), preparse_data);
}
} else {Four variants: with or without preparse data, with or without a compile job. The “without preparse data” case is the one where the function had no skippable nested functions, and there is nothing to save.
Lazy compilation: the trampoline and the runtime
Nothing compiles a lazy function until it is called. The call goes through a trampoline that spots the “not compiled yet” state and calls into the runtime:
// src/runtime/runtime-compiler.cc:228
RUNTIME_FUNCTION(Runtime_CompileLazy) {
HandleScope scope(isolate);
DCHECK_EQ(1, args.length());
DirectHandle<JSFunction> function = args.at<JSFunction>(0);
StackLimitCheck check(isolate);
if (V8_UNLIKELY(
check.JsHasOverflowed(kStackSpaceRequiredForCompilation * KB))) {
return isolate->StackOverflow();
}The stack check is not decoration: the full parse and bytecode generation need stack space, and V8 refuses to compile rather than blow the stack.
Compiler::Compile then re-parses the function — and this is where the
preparse blob is handed back to the parser:
// src/codegen/compiler.cc:3085
parse_info.set_consumed_preparse_data(ConsumedPreparseData::For(
isolate, handle(shared_info->uncompiled_data_with_preparse_data(isolate)
->preparse_data(),
isolate)));
}
// Parse and update ParseInfo with the results.
if (!parsing::ParseAny(&parse_info, shared_info, isolate,
parsing::ReportStatisticsMode{true})) {ConsumedPreparseData is the read side of the blob, and it is a strict
sequential reader: the parser asks for “the data for the skippable function
that starts at this position”, and the reader asserts that the position
matches:
// src/parsing/preparse-data.cc:590
// The skippable function *must* be the next function in the data. Use the
// start position as a sanity check.
typename ByteData::ReadingScope reading_scope(this);
CHECK(scope_data_->HasRemainingBytes(
PreparseByteDataConstants::kSkippableFunctionMinDataSize));
int start_position_from_data = scope_data_->ReadVarint32();
CHECK_EQ(start_position, start_position_from_data);CHECK_EQ, not DCHECK_EQ. This code runs in release builds, because a
mismatch here means the preparser and the parser have diverged and the
whole skipping mechanism is unsound. It is a cheap tripwire that has
“been good at catching bugs in the wild”, as the comment in the writer
side says.
The skip
With the data in hand, the parser can jump over a function body entirely. The scanner is seeked forward to the recorded end position and the closing brace is simply consumed:
// src/parsing/parser.cc:3098
function_scope->outer_scope()->SetMustUsePreparseData();
function_scope->set_is_skipped_function(true);
function_scope->set_end_position(end_position);
scanner()->SeekForward(end_position - 1);
Expect(Token::kRightBrace);
SetLanguageMode(function_scope, language_mode);
if (uses_super_property) {
function_scope->RecordSuperPropertyUsage();
}
SkipInfos(num_inner_infos);
function_scope->ResetAfterPreparsing(ast_value_factory_, false);
return true;SeekForward(end_position - 1) is the whole trick: the scanner does not
tokenise the skipped body, it just moves its position. The function
still gets a SharedFunctionInfo, a scope, correct arity, correct
language mode — it just has no body yet. When it is called, it gets
parsed, and its own children get skipped using its blob.
That is the recursion: the blob is a tree, one node per function, and parsing a function consumes one node and hands the rest to its children.
The compile cache
The blob above is the preparse cache: it lives on the
SharedFunctionInfo and describes one function. The other cache is
bigger and dumber: it maps source text to a compiled top-level
SharedFunctionInfo, so that evaluating the same string twice does not
parse it twice.
V8 keeps separate sub-caches for scripts, evals and regular expressions, because the same source text means different things in each:
// src/codegen/compilation-cache.h:18
// The compilation cache consists of several sub-caches: one each for evals and
// scripts, which use this class as a base class, and a separate generational
// sub-cache for RegExps. Since the same source code string has different
// compiled code for scripts and evals, we use separate sub-caches for different
// compilation modes, to avoid retrieving the wrong result.
The script cache is a hash table keyed by the source string. The eval
cache is keyed by a tuple — source, the enclosing function’s
SharedFunctionInfo, the language mode and the position of the eval call
— because eval in two different places can mean different things.
The eval cache has an unusual two-phase insert. The first time a source string is evaluated, only a hash of it goes into the table, mapping to a lifetime counter; the second time, the real entry replaces it:
// src/objects/compilation-cache-table.h:129
// Eval code only gets cached after a second probe for the
// code object. To do so, on first "put" only a hash identifying the
// source is entered into the cache, mapping it to a lifetime count of
// the hash. On each call to Age all such lifetimes get reduced, and
// removed once they reach zero. If a second put is called while such
// a hash is live in the cache, the hash gets replaced by an actual
// cache entry. Age also removes stale live entries from the cache.
The rationale is that most eval calls happen once, in a loop, or in code
that is generated and thrown away. Caching them eagerly would pin source
strings in memory for nothing. The placeholder survives
kHashGenerations = 10 GCs before it is dropped.
The script cache has a different ageing rule: it does not remove entries whose bytecode has been flushed, it just blanks the value:
// src/codegen/compilation-cache.cc:66
void CompilationCacheScript::Age() {
DisallowGarbageCollection no_gc;
if (IsUndefined(table_)) return;
Tagged<CompilationCacheTable> table = Cast<CompilationCacheTable>(table_);
for (InternalIndex entry : table->IterateEntries()) {
Tagged<Object> key;
if (!table->ToKey(entry, &key)) continue;
DCHECK(IsWeakFixedArray(key));
Tagged<Object> value = table->PrimaryValueAt(entry);
if (!IsUndefined(value)) {
Tagged<SharedFunctionInfo> info = Cast<SharedFunctionInfo>(value);
// Clear entries after Bytecode was flushed from SFI.
if (!info->HasBytecodeArray()) {The key survives so that the Script object — which holds the source
string — is not collected. That matters for
how V8 flushes code and bytecode:
a script whose bytecode was flushed can be recompiled from the same
source without a second trip through the parser.
When the preparse is thrown away
The preparser is not a validator. It is a scanner with a grammar, and the
grammar check it performs is deliberately partial — the source of
PreParser::PreParseFunction says so:
// src/parsing/preparser.cc:238
// Preparsing checks a JavaScript program and emits preparse-data that helps
// a later parsing to be faster.
// See preparser-data.h for the data.
// The PreParser checks that the syntax follows the grammar for JavaScript,
// and collects some information about the program along the way.
// The grammar check is only performed in order to understand the program
// sufficiently to deduce some information about it, that can be used
// to speed up later parsing. Finding errors is not the goal of pre-parsing,
// rather it is to speed up properly written and correct programs.
// That means that contextual checks (like a label being declared where
// it is used) are generally omitted.
But sometimes the preparser does hit an error, and sometimes it cannot work out where the error is. When that happens the preparse is thrown away entirely and the function is re-parsed by the real parser, purely so that the error message can point at the right character:
// src/parsing/parser.cc:3134
} else if (pending_error_handler()->has_error_unidentifiable_by_preparser()) {
// Make sure we don't re-preparse inner functions of the aborted function.
// The error might be in an inner function.
allow_lazy_ = false;
mode_ = PARSE_EAGERLY;
DCHECK(!pending_error_handler()->stack_overflow());
// If we encounter an error that the preparser can not identify we reset to
// the state before preparsing. The caller may then fully parse the function
// to identify the actual error.
bookmark.Apply();
if (!private_name_scope_iter.Done()) {
private_name_scope_iter.GetScope()->ResetUnresolvedPrivateNameTail(
unresolved_private_tail);
}
function_scope->ResetAfterPreparsing(ast_value_factory_, true);
pending_error_handler()->clear_unidentifiable_error();
return false;bookmark.Apply() rewinds the scanner to the position it had before the
preparse started. allow_lazy_ = false and mode_ = PARSE_EAGERLY make
sure the whole enclosing function, and not just the offending inner one,
is parsed for real — because the error might be in a nested function and
the preparser cannot pinpoint it.
The error bookkeeping itself is deliberately “first error wins”, by position:
// src/parsing/pending-compilation-error-handler.cc:81
void PendingCompilationErrorHandler::ReportMessageAt(int start_position,
int end_position,
MessageTemplate message,
const char* arg) {
DCHECK((start_position == -1 && end_position == -1) ||
(start_position != -1 && start_position <= end_position));
if (has_pending_error_ && end_position >= error_details_.start_pos()) return;
has_pending_error_ = true;
error_details_ = MessageDetails(start_position, end_position, message, arg);
}A later error that starts before the recorded one replaces it. That is the right rule for a parser: the error nearest the beginning of the source is the one the user most likely meant.
Trade-offs
What it buys. Parse time is proportional to the code that actually runs, not to the code that is loaded. A 4 MB bundle whose hot path is 3 % of it pays for 3 % of it at startup. The preparse data blob is what makes the second phase cheap: without it, compiling a lazy function would mean re-scanning everything inside it, and the preparse would have saved nothing.
What it costs.
Two passes over the source. The preparse scans every function body once, and the full parse scans the hot ones again. For a function that is called, the source is tokenised roughly twice. V8 buys this back with the
(-heuristic, which keeps IIFEs — the functions most likely to be called — out of the double-scan path entirely.Memory. Every lazily parsed function carries an
UncompiledDatawith a position range and, usually, a preparse blob. On a large bundle that is tens of thousands of small heap objects.The blob is a correctness liability. It is a hand-rolled serial format with a sequential reader and
CHECKassertions in release builds. A divergence between preparser and parser is a crash, not a wrong answer, and theScopeNeedsDatapredicate is the place where the two can disagree about the shape of a scope tree.Debugging gets harder. The debugger asks for a re-parse, and it has to cope with the possibility that the re-parse fails because the preparser and the parser disagreed:
cpp// src/debug/debug-scopes.cc:135 // A failed reparse indicates that the preparser has diverged from the // parser, that the preparse data given to the initial parse was faulty, or // a stack overflow. // Silently fail by presenting an empty context chain.The
(-heuristic is a heuristic.(function(){ ... })as an expression statement is compiled eagerly even if it is never called. The cost of getting it wrong is bounded — one function’s worth of eager compilation — but it is a real cost on codebases that use IIFEs as module wrappers, which is most codebases.
The bottom line
V8’s parser is a two-stage machine with an explicit cache between the stages. Stage one is the preparser: same grammar, no AST, and it produces a compact byte blob describing (a) where every nested function starts and ends, and (b) the three bits per variable that the later scope allocation needs. Stage two is the real parser, which runs only for functions that are actually called, and which uses the blob to skip the nested functions rather than re-scan them.
The mental model to keep: preparse data is not a summary of the AST. It is a recorded transcript of the work the parser would otherwise have to redo, encoded in varints and two-bit quarters, and it is the reason a lazy function compiles faster than a cold one.
The thing people get wrong
“JavaScript is parsed lazily” is usually taken to mean “the source is not read until it is needed”. It is not. Every function body in the file is tokenised at load time, whether or not it is ever called. What is lazy is the AST and the bytecode.
So --no-lazy does not save you the tokenisation pass, and a bundle
cannot avoid being scanned by making its functions lazy. It only avoids
building the trees. And the preparse blob is the reason that distinction
matters: the preparser is not a cheaper approximation of the parser, it
is the parser with the expensive part removed and the reusable part
written down.