普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月10日首页

深入理解浏览器渲染流程

2026年4月10日 16:17

深入理解浏览器渲染流程

0. 事件循环复习

我们之前总结过:事件循环是主线程的工作方式,每执行完一个宏任务,就清空所有微任务,然后可能渲染页面,再取下一个宏任务。

重点来了:渲染到底是怎么发生的? 这就是本篇文章要讲的内容。


1. 为什么需要了解渲染流程?

你每天都在写 HTML、CSS、JS,但浏览器到底是怎么把它们变成屏幕上像素的?

搞懂渲染流程,你就能明白:

  • 为什么改 left/top 会卡,改 transform 却很丝滑
  • 为什么有些 CSS 属性改了开销大,有些开销小
  • 面试官问“重排重绘”时该怎么答

这是前端性能优化的基础,也是面试必考题。


2. 渲染流程五步走

浏览器拿到 HTML 和 CSS 后,会按顺序做这 5 件事:

步骤 名称 做了什么
1 构建 DOM 树 把 HTML 标签转成树形结构
2 构建 CSSOM 树 把 CSS 规则转成树形结构
3 构建渲染树 合并 DOM 和 CSSOM,过滤掉不可见元素
4 布局(Layout) 计算每个元素的位置和大小
5 绘制(Paint) 把像素画到屏幕上

第 4 步也叫 重排(Reflow),第 5 步也叫 重绘(Repaint)

如下图:

2.1 构建 DOM 树

浏览器从上到下解析 HTML,把标签转成树形结构的 DOM 对象。
例如:

<html>
  <body>
    <div>hello</div>
  </body>
</html>

会变成类似这样的结构(伪代码):

document
  └ html
      └ body
          └ div → text "hello"

注意<script> 标签会阻塞解析,因为 JS 可能修改 DOM。可以加 deferasync 避免阻塞。

2.2 构建 CSSOM 树

浏览器解析 CSS 文件或 <style> 标签内的样式,构建成 CSSOM 树(CSS 对象模型)。
CSSOM 记录了选择器与样式规则的对应关系,以及继承关系(比如 bodyfont-size 会传给子元素)。

CSS 不会阻塞 DOM 树的构建,但会阻塞渲染(因为需要完整的样式才能绘制)。

2.3 构建渲染树(重点)

渲染树 = DOM 树 + CSSOM 树,但会过滤掉不需要显示的东西。

具体操作:

  1. 只保留能看见的元素
    • display: none 的元素不进入渲染树(连占位都没有)
    • <head> 标签里的元素不进入渲染树
    • visibility: hidden 的元素进入渲染树(它占位置,只是看不见)
    • opacity: 0 的元素也会进入渲染树(透明也是可见的一种)
  2. 给每个节点附上计算好的样式
    从 CSSOM 里找到匹配的规则,经过层叠、继承、优先级计算,得到每个节点的最终样式。

示例:

<div style="display: none;">看不见我</div>
<div>看得见我</div>

渲染树里只有第二个 div,第一个直接被丢掉了。

为什么需要渲染树?
因为 DOM 树里有很多不参与页面绘制的节点(headscriptdisplay: none 的元素),直接拿着 DOM 树去布局会浪费性能。渲染树就是“最终要画到屏幕上的东西”的清单。

2.4 布局(Layout / 重排)

遍历渲染树,计算每个元素在屏幕上的精确位置和尺寸(宽、高、x、y)。
比如一个 div 宽度是父容器的 50%,就要算出实际像素值。

触发布局的情况

  • 首次渲染
  • 窗口 resize
  • 修改元素的几何属性**(宽/高/边距/位置)**
  • 添加/删除 DOM
  • 读取某些属性(offsetHeightgetComputedStyle 等)

布局是开销最大的步骤。

2.5 绘制(Paint / 重绘)

把每个元素画成像素:背景、边框、文字、阴影、图片等。
浏览器会把页面分成多个图层,分别绘制,最后合成。

触发绘制的情况

  • 改变背景色、文字颜色、边框颜色等(不影响位置)

3. 重排 vs 重绘(核心重点)

这两个概念必须分清。

对比项 重排(Reflow) 重绘(Repaint)
什么时候发生 改宽高、边距、位置、增删 DOM、改字体等 改颜色、背景、阴影、可见性等
开销 很大(重新计算位置) 中等(只重新涂色)
会触发另一个吗 会,重排一定导致重绘 不会,重绘不一定导致重排
优化建议 尽量避免,或用 transform 替代 可接受,但不要频繁

3.1 代码示例

//  坏:触发重排
box.style.width = '200px'
box.style.height = '200px'
box.style.margin = '10px'

//  好:合并修改,只触发一次重排
box.style.cssText = 'width:200px; height:200px; margin:10px;'

//  更好:用 transform 做动画,完全不触发重排/重绘
box.style.transform = 'translateX(100px)'

4. 哪些操作会触发重排?

  • width / height / margin / padding / border
  • font-size(文字大小影响盒子大小)
  • display(比如 noneblock
  • 添加或删除 DOM 元素
  • 改变窗口大小
  • 读取某些属性:offsetHeightoffsetTopscrollTopgetComputedStyle 等(浏览器被迫立即重排)

最后一条只是读一下,浏览器也得乖乖重排才能给你准确值。所以不要在循环里读这些属性。


5. 如何减少重排?

优化手段 说明
合并样式修改 cssText 或切换 class,不要一条一条改
让元素脱离文档流 position: absolutefixed,它的重排不影响别人
批量插入 DOM documentFragment 先组装好,再一次性插入
动画用 transform transform 走合成线程,不触发重排/重绘
避免读触发布局的属性 不要频繁读 offsetHeight 等,如果必须读,先读好存起来

5.1 批量插入 DOM 示例

//  坏:每次插入都触发重排
for (let i = 0; i < 100; i++) {
  document.body.appendChild(div)
}

//  好:用 fragment 一次性插入
const fragment = document.createDocumentFragment()
for (let i = 0; i < 100; i++) {
  fragment.appendChild(div)
}
document.body.appendChild(fragment)  // 只触发一次重排

6. transform 为什么快?

transform 不走布局和绘制,它直接进入合成阶段,由 GPU 处理。

简单理解:

  • left/top:改位置 → 触发重排 → 重绘 → 合成(主线程干,慢)
  • transform:跳过前两步 → 直接合成(合成线程干,快)

所以做动画时,能用 transform 就别用 left/top

/*  慢 */
.box {
  transition: left 0.3s;
  left: 0;
}
.box.active {
  left: 100px;
}

/*  快 */
.box {
  transition: transform 0.3s;
  transform: translateX(0);
}
.box.active {
  transform: translateX(100px);
}

7. 常见面试题

7.1 重排和重绘的区别?哪个更耗性能?

重排是重新计算位置和大小,开销大;重绘是重新涂色,开销中等。重排一定触发重绘,反之不一定。

7.2 哪些属性会触发重排?

widthheightmarginpaddingborderfont-sizedisplayposition 等。还有添加/删除 DOM、改窗口大小。

7.3 如何避免重排?

  • 合并样式修改
  • 使用 transform 做动画
  • 批量操作 DOM
  • 让元素脱离文档流

7.4 transformleft/top 有什么区别?

left/top 触发布局(重排),慢;transform 只触发合成,由 GPU 处理,快。

7.5 为什么有时候读 offsetHeight 会让页面变慢?

因为浏览器需要立即计算最新的布局才能返回准确值,这会强制重排。如果在循环里读,会反复触发重排,性能极差。


8. 总结一句话

浏览器渲染分五步:DOM 树 → CSSOM 树 → 渲染树 → 布局(重排)→ 绘制(重绘)。
重排慢,重绘快,动画用 transform最流畅。
优化核心:减少重排,合并操作,能用合成就合成。

昨天以前首页

执行上下文:变量提升、作用域与 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

❌
❌