前端周刊5-ECMAScript引擎如何优化变量

March 19, 2025 / Administrator / 3阅读 / 0评论 / 分类: fe

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

#js(2)#ecmascript(1)

文章作者:Administrator

文章链接:http://halo.chenkeyan.com/archives/fe-weekly-5-ecmascript-engines-optimize-variables

版权声明:本博客所有文章除特别声明外,均采用CC BY-NC-SA 4.0 许可协议,转载请注明出处!


评论