Hermes 语义分析:基于 hermes_estree 的 JavaScript 名称解析与作用域分析模块深度解析
2026/9/24 16:54:45 网站建设 项目流程
  • 语言运行时
  • 编译器
  • 移动开发

【免费下载链接】hermes

A JavaScript engine optimized for running React Native.

项目地址:https://gitcode.com/gh_mirrors/hermes/hermes
点击查看免费下载

导读

hermes_semantic_analysis是 Hermes 项目中用 Rust 实现的 JavaScript 语义分析 crate,负责在hermes_estree生成的 AST 之上完成名称解析(name resolution)与作用域信息(scope information)的构建,为后续的 Flow/TypeScript 类型检查等高级语义功能铺路。本文将以 crate 的 README 为主线,结合其 analyzer.rs、scope_manager.rs、scope_view.rs 与 analysis_test.rs 源码,完整还原其架构设计、核心数据结构、两阶段分析算法、TDZ(暂时性死区)检测原理与测试方式,帮助读者理解"如何用 Rust 给 JavaScript 做静态语义分析"这一经典问题在 Hermes 中的落地实现。

模块定位:在 hermes_estree AST 之上做语义分析

crate 的 README 用三句话划定了它的边界:

Implements semantic analysis of JavaScript name resolution and scope information over thehermes_estreeAST. Eventually this analysis will be extended to cover Flow and TypeScript in addition to the core JS and JSX language.

NOTE: this analysisassumes strict modeand does not support legacy non-strict semantics.

从中可以提炼出三个关键定位:

  1. 输入是hermes_estree的 AST。该 crate 不自己做语法解析,而是消费同工作区内hermes_estreecrate 定义的 ESTree 节点类型(ProgramStatementExpressionPattern等),通过hermes_estree::Visitortrait 完成遍历。
  2. 输出是"名称解析 + 作用域信息",即把每一个标识符引用(Reference)绑定到它对应的声明(Declaration),并把所有声明组织进正确的词法作用域(Scope)层级中。
  3. 当前阶段只覆盖核心 JS 与 JSX,且假定严格模式。非严格模式下的历史遗留语义(如with语句、隐式全局变量等)不在支持范围内,未来才计划扩展到 Flow 与 TypeScript。

从仓库结构看,该 crate 位于 unsupported/hermes/crates/ 下的 Rust workspace 中,与hermes_parserhermes_estreehermes_diagnosticshermes_utils等并列,属于 Hermes "Rust bindings"(见 unsupported/hermes/Cargo.toml 的 workspace 声明)中的语义分析环节,其位置也呼应了 README 中 "unsupported" 前缀所代表的试验性/演进中属性。

四个核心语义实体:Scope、Declaration、Reference、Label

scope_manager.rs 定义了语义分析的全部核心数据结构。整个分析结果由四种实体组成,每种实体都有对应的强类型 ID(newtype,内部是usize):

实体ID 类型语义关键字段
Scope(作用域)ScopeId词法作用域,形成树状层级kindparentdeclarationsreferenceschildren
Declaration(声明)DeclarationId一个标识符的声明(let/const/var/function/class/import…)kindnamescope
Reference(引用)ReferenceId一次对标识符的读取/写入kind(Read/Write/ReadWrite)、declaration(绑定目标)、scope
Label(标签)LabelIdbreak/continue 的跳转目标kind(Loop/Other)、scopename

其中Scope的定义(scope_manager.rs#L472-L480)直接体现了"作用域树"的建模方式:每个作用域持有自己的声明表(IndexMap<String, DeclarationId>,按名字快速查找)、引用列表和子作用域列表,并通过parent指回父作用域:

pub struct Scope { pub id: ScopeId, pub kind: ScopeKind, pub parent: Option<ScopeId>, pub declarations: IndexMap<String, DeclarationId>, pub references: Vec<ReferenceId>, pub children: Vec<ScopeId>, }

ScopeManager(scope_manager.rs#L24-L41)作为总容器,用若干Vec顺序存储全部实体,同时维护四张"AST 节点 → 语义信息"的映射表:

pub struct ScopeManager { root: ScopeId, globals: IndexMap<String, DeclarationId>, scopes: Vec<Scope>, labels: Vec<Label>, declarations: Vec<Declaration>, references: Vec<Reference>, node_scopes: IndexMap<AstNode, ScopeId>, node_labels: IndexMap<AstNode, LabelId>, node_declarations: IndexMap<AstNode, DeclarationId>, node_references: IndexMap<AstNode, ReferenceId>, diagnostics: Vec<Diagnostic>, }

注意node_scopes等映射的键是AstNode,它本质上是 AST 节点的指针地址(见 scope_manager.rs#L541-L557 的AstNode(PointerAddress)),这样可以在不修改 AST 的情况下,把语义信息"挂"回任意 AST 节点——例如node_declaration(node)可以查询某个标识符节点对应哪个声明,break_label(node)可以查询某个break语句跳向哪个标签。

分析流程:两阶段算法(收集 → 解析)

入口函数位于 analyzer.rs#L42-L46:

pub fn analyze(ast: &Program, options: AnalyzeOptions) -> ScopeManager { let mut analyzer = Analyzer::new(ast, options); analyzer.visit_program(ast); analyzer.complete() }

整体采用两阶段(two-pass)算法,这也是静态语义分析工具(如 ESLint 的eslint-scope、Babel 的 scope 分析)常见的做法:

阶段一:visitor 遍历,收集"未决引用"

Analyzer实现了hermes_estree::Visitortrait,以深度优先方式遍历整个 AST。遍历期间:

  • 遇到声明节点,立即调用manager.add_declaration(...)在当前(或提升后的)作用域登记声明;
  • 遇到引用节点,不立即解析,而是构造一个UnresolvedReference压入self.unresolved列表(analyzer.rs#L225-L240)。

UnresolvedReference结构体(analyzer.rs#L60-L72)记录了这个引用所在的作用域、AST 节点、名字、引用类型、源码范围和"创建该引用时下一条声明的 ID"(next_declaration,后面 TDZ 检测会用到):

pub struct UnresolvedReference { pub scope: ScopeId, pub ast: AstNode, pub name: String, pub kind: ReferenceKind, pub range: SourceRange, pub next_declaration: DeclarationId, }

阶段二:complete() 统一解析

遍历结束后调用complete()(analyzer.rs#L87-L106),此时所有声明都已登记完毕,因此可以逐个回溯解析引用:

fn complete(mut self) -> ScopeManager { for reference in self.unresolved { if let Some(declaration) = self.manager.lookup_reference( reference.scope, &reference.name, reference.next_declaration, ) { let id = self.manager.add_reference(reference.scope, reference.kind, declaration.id); self.manager.node_references.insert(reference.ast, id); } else { self.manager.diagnostics.push(Diagnostic::invalid_syntax( "Undefined variable", reference.range, )); } } self.manager }

解析成功则将引用登记到对应作用域,并建立node_references映射;解析失败则产生"Undefined variable"语法诊断,这正是静态分析报错(如"未定义变量")的来源。诊断类型统一使用hermes_diagnostics::Diagnostic

作用域栈的管理

遍历过程中,Analyzercurrent: ScopeId维护"当前作用域",通过enter_scope/close_scope成对进出(analyzer.rs#L166-L177),并提供了enter(kind, f)便捷封装:进入新作用域 → 执行闭包 → 关闭作用域(analyzer.rs#L156-L164)。close_scope中还有assert_eq!(self.current, id)断言,用于在调试期捕捉作用域进出不匹配的编码错误。

作用域体系:九种 ScopeKind

scope_manager.rs#L459-L470 定义了完整的词法作用域种类:

ScopeKind触发节点说明
Global脚本(Script)根非模块脚本的根作用域
Module模块(Module)根模块顶层作用域,import仅允许在此声明
Function函数声明/表达式/箭头函数形参 + 函数体
Class类声明/表达式类体作用域
Block块语句{}普通词法块
CatchClausecatch子句(带形参时)捕获参数专属作用域
Forfor/for-in/for-of仅当循环初始化使用let/const时创建
Switchswitch语句存放所有 case
StaticBlock类的静态初始化块类静态块作用域

根作用域的kind由源码类型决定(scope_manager.rs#L50-L55):

let root_kind = match source_type { SourceType::Module => ScopeKind::Module, SourceType::Script => ScopeKind::Global, };

实现中有几个值得注意的作用域细节:

  • 函数体不额外建 Block 作用域visit_function(analyzer.rs#L179-L210)对函数体块直接遍历其语句列表,注释明确说明"Skip calling visit_block_statement to avoid creating an extra block scope",避免函数作用域与函数体块作用域重复。
  • 形参中的this不产生声明:函数形参遍历时跳过名为this的参数(analyzer.rs#L182-L190),因为它不声明变量也不可能有默认值。
  • for 循环的let/const初始化单独建作用域visit_for_statement(analyzer.rs#L687-L715)与visit_for_in_of(analyzer.rs#L304-L335)都会在初始化语句是VariableDeclarationKind::Var时跳过建作用域,否则创建ScopeKind::For,这精确模拟了for (let i ...)i的词法隔离语义。
  • catch形参单独建作用域visit_catch_clause(analyzer.rs#L618-L633)只有在带形参时才enter(ScopeKind::CatchClause, ...),不带形参则直接访问 body 块,避免多余的块级作用域。

声明语义:八种 DeclarationKind 与变量提升

scope_manager.rs#L496-L506 定义了声明种类:

pub enum DeclarationKind { Global, Class, Const, Var, Let, Function, CatchClause, Import, }

其中Var/Let/Const由 ESTree 的VariableDeclarationKind直接转换而来(scope_manager.rs#L508-L516)。

提升规则(hoisting)

add_declaration的核心逻辑是先确定声明被挂到哪个作用域,即get_scope_for_declaration(scope_manager.rs#L378-L411):

  • Let/Const/Import/CatchClause/Class就地声明在当前位置的作用域;
  • Var/Function向上提升(hoist)到最近的Function/Global/Module/StaticBlock作用域,这准确实现了"var提升到函数顶部"的 JS 语义。

重复声明检查

add_declaration(scope_manager.rs#L290-L376)实现了严格的重复声明规则,注释中给出了两个典型冲突示例:

  • function() { {let a; var a;} }:在声明所在作用域就冲突;
  • function() { let a; { var a; } }var提升到含冲突let的作用域时冲突。

具体规则:

  • var可以被重复声明任意多次,后续声明等价于对原声明的重新赋值(var a = 1; var a = 2;等价于var a; a = 1; a = 2;),因此重复的var直接复用原声明的 ID
  • var不能与同一作用域(或提升目标作用域)中的let/const/class/import/function/catch形参等块级声明冲突(通过is_block_scoped_declaration判定,scope_manager.rs#L435-L445),否则产生"Duplicate declaration"诊断;
  • 其余声明种类在同一作用域内重复声明即报错,但解析时仍绑定到第一个声明——注释说明:一旦出现错误,语义结果本身已无效,关键是不要再因此产生二次的"找不到声明"误报。

另外,visit_import_declaration_specifier(analyzer.rs#L339-L374)会在非 Module 作用域遇到import声明时立即报告"import declarations are only allowed at the top-level of a module"诊断,三种 import specifier(默认、具名、命名空间)统一只登记local标识符(具名导入的imported被忽略,因为它引用的是被导入模块中的名字)。

引用语义:Read / Write / ReadWrite 与赋值处理

scope_manager.rs#L526-L531 定义了引用的三种种类:

pub enum ReferenceKind { Read, Write, ReadWrite, }

analyzer.rs中对"哪些标识符是引用、哪些只是名字"做了非常细致的区分,这是 ESTree 的一个经典坑:同一个Identifier节点既可能是变量引用,也可能只是属性名/字符串名(如x.y中的x是引用,而y不是)。visit_identifier(analyzer.rs#L717-L732)的注释明确说明:所有非变量引用的Identifier都被上游访问器刻意跳过,因此走到这里的一定是变量读取:

fn visit_identifier(&mut self, ast: &hermes_estree::Identifier) { Analyzer::visit_reference_identifier( self, &ast.name, AstNode::from(ast), ReferenceKind::Read, ast.range, ); }

visit_assignment_expression(analyzer.rs#L497-L587)对赋值运算符做了分流:

  • =简单赋值:左侧若是Pattern(解构赋值),调用visit_declaration_pattern(..., None)——None表示"这不是声明而是重新赋值",因此内部会将其视为ReferenceKind::Write的引用(见 analyzer.rs#L242-L265);左侧若是MemberExpression链(如a.b.c = x),则展开到最内层.object,把真正的根标识符记为Read,计算属性则正常访问;
  • 复合赋值/更新运算符(+=-=等):左侧必须是标识符,记为ReferenceKind::ReadWrite(先读后写);若左侧不合法则报诊断,但仍继续访问右侧以尽量发现其中的错误。

解构模式的处理集中在visit_declaration_pattern(analyzer.rs#L267-L302),递归处理IdentifierArrayPatternObjectPattern(含计算属性键、Rest 元素)、RestElementAssignmentPattern(带默认值,默认值表达式按普通表达式访问)。函数形参的声明也走这条路径,因此形参解构、默认值都能被正确登记。

标签解析:break / continue / labeled statement

break/continue的跳转目标解析是语义分析的另一块内容,相关实体为LabelLabelKind(scope_manager.rs#L482-L494):

pub enum LabelKind { Loop, Other }
  • visit_labeled_statement(analyzer.rs#L734-L751)根据被标注的语句是否为for/for-in/for-of/while/do-while,把标签标记为LabelKind::LoopLabelKind::Other,并通过enter_label把标签压入self.labels栈;
  • 循环与switch语句会创建匿名标签add_anonymous_label),使无标签的break/continue也能找到最近的跳转目标;
  • lookup_break/lookup_continue(analyzer.rs#L118-L154)从标签栈顶向内查找:带名字的break label;要求精确匹配,无名字的则取最近的标签;
  • visit_break_statement/visit_continue_statement(analyzer.rs#L600-L663)解析成功后把标签 ID 关联到break/continue节点及其标签节点;continue还额外校验目标标签必须是LabelKind::Loop,否则报"Invalid continue statement, the named label must be for a loop";找不到目标则分别报 "Non-syntactic break/continue" 诊断。

TDZ(暂时性死区)检测:利用声明顺序做静态近似

crate 内置了一个轻量的 TDZ 违规检测,原理非常巧妙,利用的是"引用创建时点与声明时点的顺序关系"。

回忆UnresolvedReference中的next_declaration字段(analyzer.rs#L67-L71):它是"该引用被创建时,下一条将要产生的声明的 ID"。由于ScopeManagerVec顺序存储声明,声明 ID 的递增顺序就反映了声明在代码中的出现顺序。

解析阶段lookup_reference(scope_manager.rs#L204-L248)在沿作用域链向上查找声明时,维护一个tdz_limit

if let Some(tdz_limit) = tdz_limit { if (declaration.kind == DeclarationKind::Let || declaration.kind == DeclarationKind::Const) && id.0 >= tdz_limit.0 { return None; } }

也就是说:如果在引用之后才出现的let/const声明被该引用命中(id >= next_declaration),就认为这是 TDZ 违规(引用发生在其初始化之前),解析返回None,最终产生未定义变量诊断。

一个关键的修正细节是:跨函数作用域时清空tdz_limit(scope_manager.rs#L230-L238)。因为函数内的引用即使文本上写在外部let声明之前,实际执行时(函数被调用时)该声明可能早已初始化,不能一律判为 TDZ。这是对function f() { return x; } let x = 1;这类合法代码的准确处理。

全局变量注入:AnalyzeOptions

分析结果的质量依赖于"哪些名字是预定义的全局变量"。入口参数AnalyzeOptions(analyzer.rs#L48-L51)只有一个字段:

#[derive(Debug, Default)] pub struct AnalyzeOptions { pub globals: Vec<String>, }

ScopeManager::new(scope_manager.rs#L50-L88)会把globals中的每个名字注册为挂在根作用域下的DeclarationKind::Global声明,并维护globals: IndexMap<String, DeclarationId>索引。lookup_reference沿作用域链找不到任何声明时,最后会查这张全局表(scope_manager.rs#L240-L245):

if let Some(id) = self.globals.get(name) { let declaration = self.declaration(*id); return Some(declaration); } return None;

测试用例 analysis_test.rs#L25-L40 展示了典型的注入集合:

let mut analysis = analyze( &result.ast, AnalyzeOptions { globals: vec![ "Array".to_string(), "Boolean".to_string(), "console".to_string(), "global".to_string(), "Math".to_string(), "Number".to_string(), "setInterval".to_string(), "setTimeout".to_string(), "String".to_string(), ], }, );

凡是未声明又不在 globals 中的标识符,都会被判定为未定义变量——这也解释了为什么测试集合中必须显式列出宿主环境提供的全局 API。实际使用时,应根据目标运行环境(浏览器、Node、Hermes 运行时等)扩充这份清单。

只读调试视图:ScopeManagerView 与 Debug 输出

为了便于调试与快照测试,crate 在 scope_view.rs 中提供了只读视图层,全部以引用方式借用ScopeManager,不持有所有权,也不可变:

  • ScopeManagerView(scope_view.rs#L22-L47):顶层视图,提供root()globals()
  • ScopeView(scope_view.rs#L48-L166):作用域视图,提供id()kind()parent()declarations()references()children()is_descendant_of()
  • DeclarationView(scope_view.rs#L185-L221)与ReferenceView(scope_view.rs#L223-L266):分别暴露声明的 id/名字/种类/所在作用域,与引用的 id/种类/作用域/绑定的声明。

ScopeManager::debug()(scope_manager.rs#L90-L92)返回ScopeManagerView,且ScopeManagerDebug实现委托给它(scope_manager.rs#L43-L47)。各 View 均实现了详尽的Debug——例如ScopeView会递归打印声明、引用(含绑定的声明名)与子作用域,使得println!("{:#?}", analysis.debug())就能输出整棵作用域树的结构化文本。这既是日常调试手段,也是下方快照测试的核心输出物。

测试体系:fixture 快照测试

crate 的测试集中在 analysis_test.rs,采用insta 快照测试配合 fixture 目录扫描:

#[test] fn fixtures() { glob!("fixtures/**.js", |path| { let input = std::fs::read_to_string(path).unwrap(); let result = parse(&input, path.to_str().unwrap(), ParserFlags::default()).unwrap(); let mut analysis = analyze(&result.ast, AnalyzeOptions { globals: vec![...] }); let mut output = String::new(); writeln!(&mut output, "{:#?}", analysis.debug()).unwrap(); let diagnostics = analysis.diagnostics(); for diagnostic in diagnostics { writeln!(&mut output, "{:#?}", diagnostic).unwrap(); println!("{:?}", Report::new(diagnostic) .with_source_code(NamedSource::new(path.to_string_lossy(), input.clone()))); } assert_snapshot!(format!("Input:\n{input}\n\nAnalysis:\n{output}")); }); }

测试流程清晰地演示了 crate 的端到端集成方式

  1. hermes_parser::parseParserFlags::default())把 JS 源码解析为hermes_estreeProgramAST;
  2. 调用hermes_semantic_analysis::analyze获得ScopeManager
  3. 通过analysis.debug()Debug输出得到完整作用域树文本,同时收集analysis.diagnostics()打印诊断(用miette::Report渲染为带源码上下文的错误报告);
  4. 与 insta 快照比对,任何分析行为的变化(作用域结构、绑定关系、诊断文本)都会导致快照 diff 失败。

assert_snapshot!把"输入源码 + 分析输出"整体存档,这意味着作用域树的每个细节都被纳入了回归保护。依赖方面,Cargo.toml 显示该 crate 的运行时依赖仅四个 workspace crate:hermes_diagnostics(诊断)、hermes_estree(AST 类型)、hermes_utils(指针地址等工具)与indexmap(保序 Map);hermes_parserinstamietteserde_json为 dev-dependencies,仅用于测试。

构建与运行

该 crate 是 unsupported/hermes Rust workspace(members = ["crates/*"])的一员,需要在 workspace 根目录下执行 cargo 命令:

# 在仓库的 unsupported/hermes 目录下 cargo build -p hermes_semantic_analysis # 编译 cargo test -p hermes_semantic_analysis # 运行快照测试

注意 workspace 的[profile.release](unsupported/hermes/Cargo.toml#L40-L48)沿用了较高的发布优化配置(opt-level = 3lto = "fat"codegen-units = 1panic = "abort"),并特意为 insta 及其 diff 库(similar)在 dev profile 下开启opt-level = 3以加速快照测试——这是 Rust 工具链中针对快照测试性能的常见调优手法。

作为依赖方接入时,只需在业务 crate 的Cargo.toml中声明hermes_semantic_analysis = { workspace = true }(或path = "crates/hermes_semantic_analysis"),即可使用其公开 API:analyzeAnalyzeOptions,以及ScopeManager上的各类查询方法(scopedeclarationreferencelabelnode_scopenode_declarationnode_referencebreak_labelcontinue_labelis_descendant_ofdiagnostics等)。

当前限制与后续规划

结合 README 与源码中的 TODO 标记,可以清晰看到该模块的边界与演进方向:

  1. 严格模式假设:README 明确声明不兼容 legacy 非严格语义,因此with语句、非严格模式下的隐式全局赋值等都不会被正确建模,这是当前最重要的使用前提;
  2. Flow / TypeScript 扩展:目前仅覆盖核心 JS 与 JSX,README 说明未来将扩展到 Flow 与 TypeScript——考虑到 Hermes 官方文档中 TS 语法的剥离与类型检查是活跃方向,该 crate 很可能是其 Rust 侧语义基础设施;
  3. 尚未记录的 JSX pragma:源码中visit_jsxfragment留有TODO: record the pragmasvisit_jsxopening_element也有TODO: record jsx pragma if root_name is not an FBT name(analyzer.rs#L837-L856),说明 JSX pragma 相关的语义信息收集尚未完成;
  4. 成员表达式展开逻辑待简化visit_assignment_expression中对成员表达式链的展开逻辑标有TODO: revisit and maybe revert this to just visit ast.left normally(analyzer.rs#L509-L512),属于已知的实现细节待优化项。

从源码结构看,可以推断该 crate 采用"AST 不可变、语义信息旁挂"的架构(AstNode基于指针地址),未来扩展 Flow/TS 时只需增加新的 visitor 分支,而无需改动ScopeManager的核心数据结构——这是其面向演进的架构设计使然。

结语

hermes_semantic_analysis用约 900 行 Rust 代码,完整实现了 JavaScript 静态语义分析中最硬核的部分:九类词法作用域的精确构建、八类声明的登记与var/function提升、三类引用的收集与回溯解析、break/continue标签绑定,以及基于声明序号的静态 TDZ 检测,并配以快照测试保证行为可回归。对于想了解"如何用 Rust 为 JavaScript 构建 scope 分析"的读者,analyzer.rs、scope_manager.rs 与 analysis_test.rs 是顺序阅读的最佳路径;而它"两阶段解析 + 指针地址旁挂语义信息 + 只读视图调试"的整体架构,也为任何 AST 驱动的静态分析工具提供了可复用的设计范式。

  • 语言运行时
  • 编译器
  • 移动开发

【免费下载链接】hermes

A JavaScript engine optimized for running React Native.

项目地址:https://gitcode.com/gh_mirrors/hermes/hermes
点击查看免费下载

相关推荐

上一篇:华为光猫配置洞察终极方案:从黑箱运维到透明管理的完整指南
下一篇:如何一键获取9大网盘直链:告别限速困扰的完整解决方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询