JavaScript 里 for 循环小括号()里变量的作用域问题?

for(var i=0;i<10;i++){
setTimeout(()=>{
console.log(i)
},100)
}

如上图,这个函数会打印 10次 10 的原因我是知道的,在 for的小括号(var i=0 ) 相当于声明了一个全局变量。


但是疑问的点在下: 当我声明时换成了 let

for(let i=0;i<10;i++){
setTimeout(()=>{
console.log(i)
},100)
}

那么结果就是 0,1,2... 这结果的原因大概我知道是 let 块级作用域造成的,但是我无法总结比较好的语言去解释这一现象。

问题一:请问上图 let 造成的现象该如何准确描述呢?

问题二:如果说 for 的小括号声明的变量是同一作用域,那 let 不是应该不允许重复声明吗?

问题三:站在编译器的角度,for 循环的原理是什么呢?

还望各位先生不吝赐教

阅读 1.7k
2 个回答

在回答问题之前必须得说明这些现象不是编译器的feature而是编译器按照es标准去实现了这些现象至于具体是怎么实现的这完全取决于编译器作者,不同的js engine之间的实现思路可能大相径庭,所以在这里如果你不想了解底层实现,则只需要记住平时看到的解释比如而使用 let 声明时,是每一个循环独立的变量 i。 在这里我以quickjs(一个实现了ES2020的嵌入式js engine)为例解释一下上面的现象是如何实现在js engine中实现的。

1. 字节码

quickjs会把javascript先编译为字节码然后才开始运行, 字节码就像是更高层次的汇编,普通的汇编(在这里忽略的汇编为机器码这一步)是直接运行在x86,arm等平台的机器上的,而这里的js字节码是运行在quickjs虚拟机上的,至于为什么需要字节码,以及什么是字节码请自行了解,基本上要了解let,var有什么区别只去要看一下他们生成的字节码有什么区别即可

2. var生成的字节码

对于

function a() {
  for (var i = 0; i < 10; i++) {
    setTimeout(() => {
      console.log(i)
    }, 100)
  }
}

生成的字节码为

js/let.js:3: function: <null>
  closure vars:
    0: i local:loc0 var
  stack_size: 3
  opcodes:
;; () => {
;;       console.log(i)

        get_var console
        get_field2 log
        get_var_ref0 0: i
        call_method 1

;;     }

        return_undef


js/let.js:1: function: a
  locals:
    0: var i
  stack_size: 3
  opcodes:
;; function a() {
;;   for (var i = 0; i < 10; i++) {

        push_0 0
        put_loc0 0: i
    2:  get_loc0 0: i
        push_i8 10
        lt
        if_false8 23

;;     setTimeout(() => {

        get_var setTimeout

;;       console.log(i)
;;     }, 100)

        fclosure8 0: [bytecode <null>]
        push_i8 100
        call2 2
        drop

;;   }

        inc_loc 0: i
        goto8 2

;; }

   23:  return_undef


js/let.js:1: function: <eval>
  locals:
    0: var <ret>
  stack_size: 1
  opcodes:
        check_define_var a,64
        fclosure8 0: [bytecode a]
        define_func a,0
        get_var a
        call0 0
        set_loc0 0: "<ret>"
        return

3. let生成的字节码

function a() {
  for (let i = 0; i < 10; i++) {
    setTimeout(() => {
      console.log(i)
    }, 100)
  }
}
js/let.js:3: function: <null>
  closure vars:
    0: i local:loc0 let
  stack_size: 3
  opcodes:
;; () => {
;;       console.log(i)

        get_var console
        get_field2 log
        get_var_ref_check 0: i
        call_method 1

;;     }

        return_undef


js/let.js:1: function: a
  locals:
    0: let i [level:2 next:-1]
  stack_size: 3
  opcodes:
;; function a() {
;;   for (let i = 0; i < 10; i++) {

        set_loc_uninitialized 0: i
        push_0 0
        put_loc0 0: i
        close_loc 0: i
    8:  get_loc_check 0: i
        push_i8 10
        lt
        if_false8 40

;;     setTimeout(() => {

        get_var setTimeout

;;       console.log(i)
;;     }, 100)

        fclosure8 0: [bytecode <null>]
        push_i8 100
        call2 2
        drop

;;   }

        close_loc 0: i
        get_loc_check 0: i
        post_inc
        put_loc_check 0: i
        drop
        goto8 8
   40:  close_loc 0: i

;; }

        return_undef


js/let.js:1: function: <eval>
  locals:
    0: var <ret>
  stack_size: 1
  opcodes:
        check_define_var a,64
        fclosure8 0: [bytecode a]
        define_func a,0
        get_var a
        call0 0
        set_loc0 0: "<ret>"
        return

4. 字节码对比

字节码很长我们主要关注put_loc指令这个指令就代表给局部变量赋值,如果我们仔细对比一下就会发现每次对i赋值后(在代码中i = 0;和i++),let版本的字节码中会多一条close_loc指令
image.png
让我们到源码里看看close_loc干了些什么,经过寻找我们可以看到虚拟机在碰到close_loc指令是会去执行close_lexical_var函数

static void close_lexical_var(JSContext *ctx, JSStackFrame *sf, int idx, int is_arg)
{
    ...
    list_for_each_safe(el, el1, &sf->var_ref_list) {
        if (var_idx == var_ref->var_idx && var_ref->is_arg == is_arg) {
            var_ref->value = JS_DupValue(ctx, sf->var_buf[var_idx]);
            var_ref->pvalue = &var_ref->value;
            ...
        }
    }
}

其中var_ref->value = JS_DupValue(ctx, sf->var_buf[var_idx]);这一句代码复制了一遍变量i的值,注意在这里i是个数字所以var_buf[var_index]中存的就是数字本身,如果是引用类型存的是一个指针,所以真相大白,每次对i的修改都会复制一份当前的变量值,所以每次在遇到fclosure8指令创建闭包时,就能捕获到一个新复制变量,这个变量保存着当前i迭代次数的值

下面我们可以正式开始回答你问的三个问题了

问题一:请问上图 let 造成的现象该如何准确描述呢?
正如网上所描述的你可以理解为每依次for循环都i是一个全新的变量,当然这种情况只对能放在数字这种原始值有效比如下面的代码,还是会打印十次10,应为它十次循环复制的十个指针,这十个指针完全指向相同的值,而是当数字时,由于可以直接放在变量插槽中所以复制的数字本身

function a() {
  for (let i = {i: 0}; i.i < 10; i.i++) {
    setTimeout(() => {
      console.log(i.i)
    }, 100)
  }
}

问题二:如果说 for 的小括号声明的变量是同一作用域,那 let 不是应该不允许重复声明吗?
这种限制往往只是js语言层面上的,对于编译器实现者来说不会存在这种限制,如果你了解计算机体系模型就会了解到一种存在于上层应用的限制在下一层往往不存在,最好的例子你可以了解一下java中volatile与内存屏障上层看起来习以为常的东西,它的底层未必是

问题三:站在编译器的角度,for 循环的原理是什么呢?
在编译器中for会被编译成目标平台的逻辑跳转指令。在这里,我们以x86为例
一个打印1-9的C代码是这样的,在这里我故意不用for语句而把他写成等价的goto语句,方便对比
for(init; test; update) body

int main(int argc)
{
  {
    int i;
  init:
    i = 0;

  test:
    if (i < 10)
      goto body;
    else
      goto done;

  body:
    printf("%d\n", i);

  update:
    i++;

    goto test;

  done:;
  }
}

他对应的汇编是这样的(删除了无关的语句),可以看到基本逻辑和上面的C代码无异

main:
.LFB0:
.L2:
    movl    $0, -4(%rbp)  # i = 0
.L3:
    cmpl    $9, -4(%rbp)  # 测试i < 9,设置eflags
    jg    .L9               # 从eflags中的标志位判断如果i大于9则跳到.L9
    nop
.L5:                              # 调用printf
    movl    -4(%rbp), %eax  
    movl    %eax, %esi
    leaq    .LC0(%rip), %rdi
    movl    $0, %eax
    call    printf@PLT
.L7:
    addl    $1, -4(%rbp)         # i++
    jmp    .L3                      # goto .L3
.L9:
    nop
.L6:
    movl    $0, %eax
    ret

算是一个很经典的题了,就是作用域的问题。可以直接看 阮一峰老师 在 ECMAScript 6入门 中关于 let 的讲解。

简单的来说,使用 var 的时候时整个 for 循环共用一个变量 i,而使用 let 声明时,是每一个循环独立的变量 i


本文参与了SegmentFault 思否面试闯关挑战赛,欢迎正在阅读的你也加入。
撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题
logo
Microsoft
子站问答
访问
宣传栏