普通视图

发现新文章,点击刷新页面。
昨天以前首页

执行上下文:变量提升、作用域与 this 底层机制

2026年4月7日 14:55

深入理解 JavaScript 执行上下文

1. 为什么需要执行上下文?

JavaScript 代码在执行前,引擎会先进行一次解析(Parsing)。这一步要完成:

  • 语法检查:有没有 SyntaxError
  • 变量/函数声明的收集:确定当前作用域中有哪些标识符。
  • 作用域规则的建立:决定变量从哪找、函数能否提前调用。

这些信息需要被存储在一个“环境盒子”里,以便在后续执行阶段使用。这个“环境盒子”就是执行上下文(Execution Context)

简单来说:执行上下文是 JS 引擎在代码执行前,为当前运行环境创建的执行环境结构, 用于记录变量、函数声明、作用域链以及 this 的绑定规则。

2. 执行上下文的类型

类型 说明 数量 何时销毁
全局执行上下文 (GEC) 最外层环境,浏览器中即 window 对象。 只有一个 页面关闭时
函数执行上下文 (FEC) 每次调用函数时创建。 每次调用创建一个 函数执行完毕后
eval 执行上下文 eval() 内的代码。 不常用

3. 执行上下文的生命周期

每个执行上下文都经历两个阶段:创建阶段执行阶段。如下图:

3.1 创建阶段(Creation Phase)

这是引擎“读懂代码”的阶段,主要做三件事:

  1. 创建变量对象(Variable Object, VO)
    • 收集当前作用域中所有 var 声明的变量 → 提升并初始化为 undefined
    • 收集所有函数声明 → 提升并完整保存函数体(可提前调用)。
    • 收集 letconst 声明的变量 → 提升但不初始化,存入词法环境并进入 暂时性死区(TDZ)

ES6 后,let/const 存储在独立的“词法环境”中,但理解上仍可认为“提升但不可访问”。

  1. 创建作用域链(Scope Chain)
    • 当前上下文的变量对象 + 所有父级上下文的变量对象。
    • 决定了变量查找的顺序:从当前开始,逐级向外,直到全局。
  2. 确定this的值
    • 全局上下文this创建阶段就永久绑定为全局对象(浏览器 window),执行阶段不会改变。
    • 函数上下文this创建阶段仅预留位置,不赋值,实际值在执行阶段(函数被调用时),由调用方式动态确定(普通调用、对象方法、call/apply/bind、构造函数、箭头函数等规则不同)。
    • 特殊:箭头函数无自身 this,继承外层词法作用域的 this

3.2 执行阶段(Execution Phase)

  • 代码逐行执行,变量被赋实际值,函数被调用,表达式求值。
  • 当执行到 let/const 声明行时,变量才完成初始化(离开 TDZ)。

4. 调用栈(Call Stack)

调用栈是 JS 引擎用来跟踪函数调用顺序的机制,遵循 后进先出(LIFO) 原则。如下图:

示例

function inner() { console.log('inner'); }
function outer() { inner(); }
outer();

栈变化过程

  1. 程序启动 → 压入 全局上下文
  2. 调用 outer() → 压入 outer 上下文
  3. outer 中调用 inner() → 压入 inner 上下文
  4. inner 执行完 → 弹出 inner 上下文
  5. outer 执行完 → 弹出 outer 上下文
  6. 页面关闭 → 弹出 全局上下文

5. 变量提升详解

5.1 var 的提升

console.log(a);  // undefined
var a = 10;

编译后等价于:

var a;           // 提升并初始化为 undefined
console.log(a);  // undefined
a = 10;

5.2 函数声明的提升(完整提升)

greet();         // 输出 "Hello"
function greet() {
  console.log("Hello");
}

函数声明连同函数体一起提升,所以可以在声明前使用。

5.3 letconst 的提升(暂时性死区)

console.log(b);  // ReferenceError: Cannot access 'b' before initialization
let b = 20;

let/const 也会提升,但从代码块开始到声明语句之间是 暂时性死区(TDZ),访问会报错。

5.4 函数表达式不提升

greet2();        // TypeError: greet2 is not a function
var greet2 = function() {
  console.log("Hi");
};

var greet2 提升为 undefined,调用时还不是函数。

5.5 函数声明与 var 声明的优先级

当同一作用域中同时存在函数声明var** 变量声明**(同名)时,函数声明的提升优先级更高

console.log(typeof foo);   // "function"
function foo() {}
var foo = 1;
console.log(typeof foo);   // "number"

编译阶段

  • 函数声明 function foo() {} 被提升,foo 指向函数。
  • var foo 声明被忽略(因为同名标识符已存在)。

执行阶段

  • 第一行输出 "function"
  • 执行到 var foo = 1 时,赋值覆盖为 1,第二行输出 "number"

规则:函数声明会覆盖同名的 var 变量声明(但不会覆盖后续赋值)。反过来,var 声明不会覆盖已存在的函数声明。

6. 变量环境 vs 词法环境(ES6+)

概念 存放内容 提升行为
变量环境 var 声明、函数声明 创建阶段初始化为 undefined 或函数引用
词法环境 letconst、块级作用域内的声明 提升但不初始化(TDZ)

查找变量时,先查词法环境,再查变量环境。

7. 执行上下文与闭包

闭包的本质:内部函数持有外部函数变量对象的引用,即使外部函数已执行完毕

function outer() {
  let word = 'Hello';
  function inner() {
    console.log(word);
  }
  return inner;
}
const fn = outer();
fn();  // 输出 'Hello'

原理

  • outer 执行时创建了变量对象(包含 word)。
  • inner 定义时,其内部属性 [[Scope]] 记录了当前作用域链(即 outer 的变量对象)。
  • outer 执行完毕弹出调用栈,但 inner 仍引用着 outer 的变量对象,所以 word 不会被回收。
  • 调用 fn() 时,inner 通过 [[Scope]] 找到 word,输出 'Hello'

8. 经典面试题

8.1 变量提升优先级(再次强调)

console.log(typeof foo);   // ?
function foo() {}
var foo = 1;
console.log(typeof foo);   // ?

答案"function""number"

8.2 暂时性死区陷阱

console.log(typeof x);   // ?
let x = 1;

答案ReferenceError(不是 "undefined")。
解释let x 的 TDZ 导致访问即报错,不会执行 typeof 运算。

8.3 循环中的 varlet

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:3 3 3

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:0 1 2

解释var 函数作用域,所有回调共享同一个 ilet 块级作用域,每次迭代创建新绑定。

8.4 执行上下文数量

function A() {
  function B() { }
  B();
}
A();

答案:3 个(全局 + A + B)。

9. 总结一句话

执行上下文是 JS 引擎在执行前为代码创建的环境盒子,用于存储变量、函数声明、作用域链和 this。它解释了变量提升、作用域、闭包等核心行为。var** 提升并初始化为 undefined,函数声明完整提升且优先级高于 varlet/const 提升但不初始化(TDZ)。调用栈以后进先出的方式管理函数执行顺序。

掌握执行上下文,你就掌握了 JS 作用域、闭包和 this 的底层原理。

vue3响应式机制的理解

2026年4月5日 15:10

深入理解 Vue3 响应式机制

1. 为什么需要响应式?

在传统的 jQuery 开发中,数据变化后需要手动操作 DOM 更新视图:

let count = 0
$('#btn').click(() => {
  count++
  $('#count').text(count)   // 手动更新
})

这样做的问题:显然代码繁琐,逻辑分散,难以维护

Vue 的响应式系统解决了这个问题:数据变化 → 视图自动更新。
开发者只需要关注数据,剩下的交给 Vue。

2.从 vue2 的响应式原理开始

2.1 核心:Object.defineProperty

Vue2 通过 Object.defineProperty 劫持对象的属性读写。

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      console.log(`读取 ${key}:`, val)
      return val
    },
    set(newVal) {
      if (newVal !== val) {
        console.log(`设置 ${key}:`, newVal)
        val = newVal
        // 触发视图更新
      }
    }
  })
}

const obj = { name: '张三' }
defineReactive(obj, 'name', obj.name)
obj.name = '李四'   // 触发 set
console.log(obj.name) // 触发 get

2.2 Vue2 的痛点

问题 原因 解决方案
无法监听新增属性 defineProperty 需要预先定义属性 Vue.set(obj, 'newProp', value)
无法监听删除属性 没有 delete 拦截 Vue.delete(obj, 'prop')
数组索引赋值不更新 arr[0] = 1 不会触发 setter 使用 $set 或重写的数组方法
修改数组 length 不更新 arr.length = 0 无拦截 使用 arr.splice(0)
初始化性能差 需要递归遍历所有属性 无解,Vue3 用 Proxy 解决

2.3 Vue2 如何处理数组?

Vue2 重写了数组的 7 个变异方法:push, pop, shift, unshift, splice, sort, reverse
当你调用这些方法时,Vue 能感知到变化并更新视图。但直接通过索引修改或修改 length 就无法检测。

// Vue2 中
this.arr[0] = 1      // 不更新
this.arr.length = 0  // 不更新
this.arr.push(1)     // 更新
this.$set(this.arr, 0, 1)  // 更新

3. Vue3 的响应式原理:Proxy 全面升级

3.1 核心流程(一句话概括)

用 Proxy 代理数据,读取时收集依赖(track),修改时派发更新(trigger)。

整个流程拆解为 4 步:

  1. reactive() 将普通对象包装成 Proxy 代理对象
  2. 当读取属性时,get 拦截器调用 track,记录“当前正在执行的副作用函数(effect)”
  3. 当修改属性时,set 拦截器调用 trigger,找出所有依赖该属性的 effect,逐个执行
  4. 执行 effect 时重新读取属性,再次触发 track,形成闭环

流程图:

3.3 track与trigger 的最小实现(理解依赖收集的核心)

javascript

let activeEffect = null                 // 当前正在执行的副作用函数
const targetMap = new WeakMap()         // 存储所有对象的依赖关系

function track(target, key) {
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) targetMap.set(target, (depsMap = new Map()))
  let dep = depsMap.get(key)
  if (!dep) depsMap.set(key, (dep = new Set()))
  dep.add(activeEffect)
}

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (dep) dep.forEach(effect => effect())
}

4. Vue2 vs Vue3 响应式对比

对比维度 Vue2 (Object.defineProperty) Vue3 (Proxy)
拦截能力 只能拦截 get / set 可拦截 13 种操作(get, set, delete, has, ownKeys...)
新增属性 无法监听,需 $set 直接赋值 obj.newProp = 1 即可
删除属性 无法监听,需 $delete 直接 delete obj.prop 即可
数组索引修改 arr[0]=1 不更新 可更新
数组 length 修改 arr.length=0 不更新 可更新
初始化性能 递归遍历所有属性,对象越大越慢 惰性代理,访问到才处理,初始化快
支持数据结构 普通对象、数组(需 hack) 对象、数组、Map、Set、WeakMap 等
代码复杂度 需要递归、重写数组方法、单独处理新增/删除 逻辑统一在 Proxy handler 中

5. ref 与 reactive 详解

5.1 核心困惑:为什么不能只用 reactive

直接原因Proxy 只能代理对象,不能代理基本类型(数字、字符串、布尔、null、undefined)。这是因为 Proxy 的设计本质是拦截对象的属性访问、修改等行为,而基本类型是“值类型”,不是对象,没有任何可拦截的属性,无法完成代理逻辑。
如果你写 reactive(0),Vue 会报错。

实际开发场景:我们经常需要管理一个计数器、一个开关状态,这些是基本类型。所以必须有一个方案来处理基本类型的响应式。

5.2 ref 的本质:单值响应式包装器

ref`的核心作用:把任意类型的值(基本类型 / 对象)包装成一个带 value访问器的响应式对象。

真实简化原理(接近 Vue 源码)
class RefImpl {
  constructor(rawValue) {
    this._rawValue = rawValue // 原始值
    this._value = rawValue    // 响应式值
    this.__v_isRef = true     // 标记是 ref
  }

  get value() {
    // 收集依赖
    track(this, 'value')
    return this._value
  }

  set value(newVal) {
    // 更新 + 触发更新
    this._rawValue = newVal
    this._value = toReactive(newVal)
    trigger(this, 'value')
  }
}

  function ref(value) {
    return new RefImpl(value)
  }

5.3 对比表格

特性 reactive ref
支持数据类型 对象、数组 任意类型(基本类型 + 对象)
返回结构 Proxy 代理对象 RefImpl 实例(带 .value)
访问方式 直接访问属性 state.xxx 必须用 .value
底层实现 ES6 Proxy class + getter/setter
响应式范围 深度响应式 单层响应式,对象自动走 reactive
解构丢失响应 否(因为始终是同一个 ref 对象)

5.5 常见误区与正确理解

误区1: ref 是专门给基本类型用的,对象必须用 reactive
事实: ref 也可以接收对象,内部会调用 reactive。所以你可以全程用 ref,只是要写很多 .value

误区2: reactive 返回的对象和原对象不一样,ref 返回的对象和原值也不一样。
事实: 两者都返回代理对象。reactive 代理原对象;ref 代理包装对象。

误区3: ref.value 是多余的。
事实: 因为 ref 的本质是 { value } 对象的代理,所以必须通过 .value 访问包装对象内部的属性。这是语法代价,换来了对基本类型的支持。

6. 常用响应式 API 速查表

API 用途 示例
reactive 创建响应式对象/数组 const state = reactive({ count: 0 })
ref 创建响应式基本类型(或对象) const count = ref(0)count.value++
computed 计算属性(缓存) const double = computed(() => state.count * 2)
watch 监听指定数据源 watch(() => state.count, (val) => {...})
watchEffect 自动收集依赖,立即执行 watchEffect(() => console.log(state.count))
toRefs 解构时保持响应式 const { name } = toRefs(state)

6.1 computed和watch的区别

  • computed:懒加载,产生新值,有缓存,如果依赖不变调用缓存不重新计算,适用于过滤列表
  • watch:执行副作用,无缓存,可以获取新旧值,适用于异步请求

6.2 watch和watchEffect

  • watch:手动指定监听源,懒执行,除非immediate:true
  • watchEffect:函数内所有的响应式数据都被自动收集,立即执行,不能获取旧值

7. 经典面试题

7.1 为什么 Vue2 不能检测数组索引和 length 变化?

因为 Object.defineProperty 无法拦截这些操作。Vue2 只能通过重写数组方法(push/pop 等)来 hack,但直接 arr[0]=1arr.length=0 无法检测到。

7.2 Vue3 如何解决数组问题?

Proxyset 拦截器可以捕获所有属性设置,包括数字索引和 length。所以直接修改即可触发更新。

7.3 ref 为什么需要 .value?能去掉吗?

不能去掉。因为 ref 返回的是一个包装对象 { value } 的代理,要访问内部的值就必须通过 .value。模板中不需要是因为编译器自动添加了 .value

7.4 下面代码中,修改 state.count 会触发视图更新吗?

const state = reactive({ count: 0 })
let { count } = state
count = 1

不会。因为解构后的 count 是普通数字,不再响应式。需要使用 toRefs

const { count } = toRefs(state)
count.value = 1   // 正确触发更新

8. 总结一句话

Vue2 用 defineProperty 劫持属性,有诸多限制;Vue3 用 Proxy 全面代理,配合 track/trigger 实现响应式。reactive 直接代理对象,ref 包装基本类型后再代理,两者本质相通。记住:对象用 reactive,基本类型用 ref,解构用 toRefs

❌
❌