前端周刊5-ECMAScript引擎如何优化变量
ECMAScript引擎如何优化变量
本文讲探讨ECMAScript引擎(以下简称es)如何存储变量、存储优化,以及作为es开发人员提升代码性能的技巧。
英文原文:https://boajs.dev/blog/2025/03/05/local-variables,本文基本保持原文的意思,加了部分自己的理解
本文是boa 对es的优化
本文包含类似于rust/js风格的伪代码
范围和变量
例1:
const a = 1;
console.log(a); // 1
{ // <- 块作用域的开始
const a = 2;
console.log(a); // 2
} // <- 块作用域的结束
我们声明了2个a,但是值为1的a在全局范围内声明的,值为2的a在块范围内声明的。
我们总是能在眼前的范围内找到变量,但是如果要用的变量不在眼前,该怎么办?
我们修改例1,会发生什么
例2:
const a = 1;
{
const b = 2;
console.log(a); // 1
console.log(b); // 2
}
在这个例子中,我们在块作用域查找a,然后在全局作用域查找a。
我们看一个更复杂的例子,
例3:
const a = 1;
console.log(a); // 1
function f() { // <- 函数作用域的开始
var a = 2;
console.log(a); // 2
{ // <- 块作用域的开始
let a = 3;
console.log(a); // 3
} // <- 块作用域的结束
console.log(a); // 2
} // <- 函数作用域的结束
f();
console.log(a); // 1
可以看到变量仅存在于各自的范围内。
块、函数都有范围。
存储变量
原始的方法
在开发ECMAScript引擎时,我们需要考虑如何存储和访问范围和变量。我们对存储数据结构的要求:
变量将标识符映射到值
一个范围内可以有多个具有唯一标识符的变量
范围可以具有外部范围
作用域中的变量适合典型的键值对存储,比如hashmap
struct Scope {
variables: HashMap<Identifier, Value>,
}
对于变量来说,是一个很好且简单的数据结构。而且大多数语言内置了hashmap,我们不需要做太多的工作去实现它。
接着我们添加嵌套范围的功能,由于所有范围是相同的,所以我们只需要构建一个自引用数据结构:
struct Scope {
variables: HashMap<Identifier, Value>,
outer: Scope
}
这个方法有效且很容易明白原理,数据结构很容易的映射了我们的变量和范围。Boa两年前就是用这种方法。
固定位置
hashmap这种数据结构有一些性能问题,我们必须为每个变量访问至少一次hashmap查找。这与访问内存中的固定位置相比,大多数hashmap会产生大量成本。当我们访问的变量不在当前范围内,这个问题会更严重,在最坏情况下,我们必须遍历所有范围,直到我们到达全局范围。
简单来说,就是计算机的内存中,通过固定的内存地址就可以直接访问数据。而哈希表查找,事先不知道内存地址,每次查找数据,需要访问一下哈希表,这个过程比访问固定位置的内存要慢的多。
如何找到一种不访问多个hashmap,又能定位到每个变量的方法呢?
在阅读代码时,我们可以利用我们对变量和作用域的理想数据结构,来理解每个变量的唯一性。我们可以为每个变量分配两个索引,使其唯一,并为其在内存中指定一个定义的位置。
scope index:声明变量的范围索引
variable index:变量在其范围内的索引
我们用具体的例子来说明:
const a = 1; // scope index: 0; variable index: 0
{
const b = 2; // scope index: 1; variable index: 0
const c = 3; // scope index: 1; variable index: 1
}
可以看到每个变量有两个索引,范围索引随着每个嵌套范围的增加而增加,变量索引随着特定范围的每个变量增加而增加。
因此,我们可以设计一种新的数据结构,允许我们仅仅根据这些访问两个索引去访问变量:
struct Scopes {
scope: Array<Scope>
}
struct Scope {
variables: Array<Value>
}
我们现在有一个二维数据,而不是自引用的数据结构。
虽然我们已经有了runtime所需要的数据结构了,但在实际运行代码之前,我们仍需要计算变量的索引位置。
因此我们在之前的结构上,稍作修改。并且我们不存储变量的值,只存储他的索引。
struct Scope {
index: u32,
variables: HashMap<Identifier, Variable>,
outer: Scope
}
struct Variable {
index: u32
}
虽然这种数据结构仍然是基于哈希表,但是我们只需要在运行代码前执行它一次,不必在运行时对每个变量进行查找。
我们通过具体的例子来查看区别:
struct Scope {
variables: HashMap<Identifier, Value>,
outer: Option<Scope>,
}
fn main() {
let global_scope = Scope {
variables: HashMap::new(),
outer: None,
};
let outer_scope = Scope {
variables: HashMap::new(),
outer: global_scope,
};
let inner_scope = Scope {
variables: HashMap::new(),
outer: outer_scope,
};
// 添加变量
inner_scope.variables.insert("a", 1);
outer_scope.variables.insert("b", 2);
global_scope.variables.insert("c", 3);
// 访问变量
let value_a = inner_scope.variables.get("a"); // 查找一次 HashMap
let value_b = outer_scope.variables.get("b"); // 查找一次 HashMap
let value_c = global_scope.variables.get("c"); // 查找一次 HashMap
}
struct Scopes {
scopes: Vec<Scope>,
}
struct Scope {
variables: Vec<Value>,
}
fn main() {
let global_scope = Scope {
variables: Vec::new(),
};
let outer_scope = Scope {
variables: Vec::new(),
};
let inner_scope = Scope {
variables: Vec::new(),
};
// 构建作用域数组
let scopes = Scopes {
scopes: vec![global_scope, outer_scope, inner_scope],
};
// 添加变量
scopes[0].variables.push(3); // 全局作用域中的变量 c
scopes[1].variables.push(2); // 外层作用域中的变量 b
scopes[2].variables.push(1); // 内层作用域中的变量 a
// 访问变量
let value_a = scopes[2][0]; // 直接通过索引访问,无需查找 HashMap
let value_b = scopes[1][0]; // 直接通过索引访问,无需查找 HashMap
let value_c = scopes[0][0]; // 直接通过索引访问,无需查找 HashMap
}
局部变量
我们进一步进行优化,
来看这个例子
例2.4.1
function addOne(a) {
const one = 1;
return one + a;
}
addOne(2);
现在,我们在范围内存储了a和one,然后在执行加法的时候访问他们。但是如果我们直接将变量存储在需要的地方,会怎么样?
比如这样,不通过one去存储,而是直接用1去做加法操作。
function addOne(a) {
return 1 + a;
}
addOne(2);
es引擎通常使用虚拟机(VM)来执行代码,虚拟机在操作数据时会使用专门的内存,比如说栈或寄存器,为了简化理解,我们使用寄存器来存储变量。
在es代码编译为虚拟机的同时,我们为每个变量分配寄存器,我们会将变量操作改为使用寄存器来访问变量,而不是通过作用域来访问。
//使用作用域访问
function foo() {
let a = 10; // 变量a在当前作用域
function bar() {
console.log(a); // 查找a,从bar的作用域向上查找,直到找到foo的作用域中的a
}
bar();
}
foo();
// 假设我们有一个虚拟机,使用寄存器来存储变量
function foo() {
let a = 10; // 变量a被分配到寄存器R1
function bar() {
console.log(R1); // 直接访问寄存器R1,而不是通过作用域查找a
}
bar();
}
foo();
嵌套函数
在测试更复杂的代码时,可能并不会按照预期工作。
我们看一下例2.5.1
function addOneBuilder() {
const one = 1;
return (a) => {
return one + a;
};
}
const addOne = addOneBuilder();
addOne(2);
代码本身的逻辑是正确的,但在某些虚拟机的实现中,由于对局部变量和嵌套函数的作用域处理不当,可能会导致错误。这段代码在运行时,根据具体实现的不同,可能会出现以下几种问题:
panic
(程序崩溃)错误的结果
甚至是不安全的内存访问
我们解释一下这里发生了什么:
调用函数addOneBuilder, 为one分配寄存器
值1被写入寄存器one变量里
函数addOneBuilder返回绑定到addOne到箭头函数
调用addOne为a分配寄存器
值为2写入a的寄存器
VM尝试访问one的寄存器,这就是我们出错的地方
在虚拟机中,寄存器是特定于某个函数的,也就是说,寄存器中的变量只在所在的函数范围内有效。在这个例子中,变量one只能在函数addOneBuilder中成功访问。
简单来说,
one的作用域仅限于addOneBuilder的函数内部
嵌套函数的访问,箭头函数addOne是在addOneBuilder内部定义的,它继承了one的作用域,能够访问one
虚拟机的寄存器优化,在某些虚拟机中,寄存器是函数局部变量,这意味着每个函数的局部变量只能在函数内部访问。如果虚拟机没有正确处理嵌套函数的作用域继承,这可能会导致one的值在addOne中无法正确访问。
因此:
Panic或其他错误结果,如果虚拟机没有正确处理one的作用域继承,尝试在addOne函数中访问one,就会引发错误
不安全的内存访问,某些情况下,如果没有处理好,虚拟机错误的访问了one的寄存器,可能会导致内存访问不安全
一但我们尝试在函数addOne中访问它,之前在编译阶段分配到寄存器中的值就不再是正确的了,这就是为什么这种优化成为局部变量或者函数局部变量。
现在我们知道嵌套函数中使用的变量不能作为局部变量。但这不应该阻止我们的优化。这样我们可以使用现有的scope来存储这些Scope,同时优化所有其他的Scope。
范围分析
查找其他函数访问的变量
在代码执行前,我们需要对变量进行分析,以确定哪些变量可以存储在寄存器中。
我们可以重用之前建立的基于哈希表的结构来表示作用域,因为这个结构已经能够表示变量的作用范围了。
但是为了使分析工作顺利进行,我们需要在作用域中添加一些额外的信息。特别是每个作用域需要被标记以指示它是否是函数作用域。
标记函数的作用域很重要,因为我们需要跟踪变量是否在嵌套函数中被访问。
此外,每个变量都需要一个标志来指示它是否可以是局部变量。
我们调整后的范围结构如下:
struct Scope {
index: u32,
function: bool, // <- 这是一个新字段
variables: HashMap<Identifier, Variable>,
outer: Scope,
}
struct Variable {
index: u32,
local: bool, // <- 这是一个新字段
}
在创建每个作用域并填充变量之后,我们遍历es代码。每当发现一个变量访问时,我们检查哪个作用域中的哪个变量被访问了。如果变量不在当前作用域,我们就会转到外层作用域。如果在找到变量的过程中遇到了任何一个作用域是函数作用域,我们会将该变量的local标志设置为fase。
伪代码:
// 定义 Scope 结构
class Scope {
constructor(index, outer, isFunction) {
this.index = index; // 作用域的索引
this.function = isFunction; // 是否是函数作用域
this.variables = new Map(); // 变量存储
this.outer = outer; // 外层作用域
}
addVariable(identifier) {
this.variables.set(identifier, new Variable(this.variables.size, true));
// 默认 local 为 true
}
}
// 定义 Variable 结构
class Variable {
constructor(index, isLocal) {
this.index = index; // 变量的索引
this.local = isLocal; // 是否是局部变量
}
}
// 示例代码
let globalScope = new Scope(0, null, false); // 全局作用域
let functionScope = new Scope(1, globalScope, true); // 函数作用域
// 填充变量
globalScope.addVariable("globalVar1");
globalScope.addVariable("globalVar2");
functionScope.addVariable("localVar1");
functionScope.addVariable("localVar2");
// 模拟访问变量
function accessVariable(scope, identifier) {
while (scope) {
if (scope.variables.has(identifier)) {
let variable = scope.variables.get(identifier);
if (scope.function) {
variable.local = false; // 如果访问变量的作用域是函数作用域,标记为 non-local
}
return variable;
}
scope = scope.outer; // 转到外层作用域
}
return null; // 变量不存在
}
// 示例访问变量
let variable = accessVariable(functionScope, "globalVar1");
console.log(variable); // 输出: Variable { index: 0, local: false }
variable = accessVariable(functionScope, "localVar1");
console.log(variable); // 输出: Variable { index: 0, local: true }
现在,让我们可视化示例的作用域:
function addOneBuilder() {
const one = 1;
return (a) => {
return one + a;
};
}
以下是范围分析之前,最开始的范围:
Scope {
function: true
variables: [
"a": {
index: 0,
local: true,
}
]
outer: Scope {
function: true
variables: [
"one": {
index: 0,
local: true,
}
]
}
}
现在我们开始分析范围,在访问箭头函数中的one变量,我们传递了箭头函数的函数访问。这表示此变量不能是局部变量:
Scope {
function: true
variables: [
"a": {
index: 0,
local: true, // <- 仍然是 `true`
}
]
outer: Scope {
function: true
variables: [
"one": {
index: 0,
local: false, // <- 设为 `false`
}
]
}
}
范围分析完之后,我们为虚拟机编译代码。当我们遇到可以是局部变量的变量时,我们会为它分配一个寄存器。对于不能是局部变量的变量时,我们使用旧的虚拟机操作(比如作用域链查找、堆栈)去存储范围。
其他例外情况
有时候我们不能使用局部变量,我们必须考虑到每个变量可能在其函数之外被访问的情况。虽然这里没有详细讨论每个具体的情况,但是通过作用域分析,我们可以找到所有可能的情况。
举一些简单的例子:
非严格函数
没有启用严格模式的函数会创建一个arguments,这个对象允许我们通过索引来访问和修改函数参数,而不需要使用参数的标识符。arguments对象与函数的参数变量时同步的。也就是说,通过arguments对象修改参数值,会直接影响参数变量的值。
function f(a) {
console.log(a); // 最开始
(() => {
arguments[0] = "modified";
})()
console.log(a); // 修改后
}
f("initial");
由于arguments对象的存在,我们无法确定参数变量是否是从函数外部访问的。这可能会导致一些不可预料的结构。
这里的解决办法是将可能通过映射arguments对象访问的每个arguments变量标记为非local。
eval
由于任何代码都可以在eval中执行,因此这种情况,我们无法对任何变量进行适当的范围分析。
直接使用eval的例子:
function f() {
const a = 1;
eval("function nested() {console.log(a)}; nested();");
}
f();
我们的解决方法是,在这种情况下,将scope中直接eval调用的每个变量标记为非local
with
with语句中的变量标识符不是静态的。变量标识符可以是对变量的访问,也可以是对对象属性的访问。
举个例子:
function f() {
const a1 = 1;
for (let i = 0; i < 2; i++) {
with ({ [`a${i}`]: 2 }) {
// [`a${i}`]是动态生成对象属性名,
// 当 i 为 0 时,obj 的属性名是 a0,值为 2。
// 如果 i 为 1,属性名则为 a1。
console.log(a1);
}
}
}
f();
在第一个循环中a1是变量。在第二个循环中a1是object属性。由于这个行为,在with语句中访问的每个变量都不能是local变量。
注:with
语句在现代 JavaScript 中已经很少使用
结论
在Boa中实现了local变量之后,我们整体基准测试提高了35%。特定的基准测试中,范围增加了70%。但是Boa还不是性能最高的引擎,可能还有其它与变量存储相关的优化我们还没有实现。
通过以上的内容,我相信你肯定掌握了一些实用的js编程技巧,以潜在的提高es代码的性能。
避免跨函数访问变量
始终使用严格
strict
模式永远不要直接使用eval!
永远不要使用with
跨函数访问变量的情况:
// 不推荐写法
let globalVar = 10;
function outerFunction() {
globalVar += 5;
innerFunction();
}
function innerFunction() {
console.log(globalVar); // 访问全局变量
}
outerFunction();
// 推荐写法
function outerFunction() {
let localVar = 10; // 变量在函数内部定义
localVar += 5;
innerFunction(localVar);
}
function innerFunction(value) {
console.log(value); // 通过参数传递变量
}
outerFunction();
版本号关注
Nuxt 3.16:增加新的create-nuxt来启动项目
Bun 1.25:有更好的NodeAPI兼容性,CSRF生成和验证
Astro 5.5
Transformer.js 3.4
文章作者:Administrator
文章链接:http://halo.chenkeyan.com/archives/fe-weekly-5-ecmascript-engines-optimize-variables
版权声明:本博客所有文章除特别声明外,均采用CC BY-NC-SA 4.0 许可协议,转载请注明出处!
评论