JavaScript
JavaScript进阶:DOM | BOM | JavaScript高级
1. DOM对象模型
DOM节点初步认识
- HTML家族大致继承图

- 代码验证
注释节点不属于"元素",所以它的原型链不经过Element和HTMLElement。它的路线更短:
* 继承链:Comment -> CharacterData -> Node -> EventTarget -> Object
* 注意:CharacterData是一个抽象接口,专门用于处理文本数据,文本节点(Text)也继承自它
总结关键点:
* 为什么div能用addEventListener?:因为它高层的祖先里有EventTarget
* 为什么div能用appendChild?:因为它高层的祖先里有Node
* 区别点:div具有Element身份(能用class/id),而注释和文本只有Node身份,没有标签属性
在JavaScript的DOM(文档对象模型)中,节点(Node)是构成HTML文档的最小单位。所有的内容(元素、文本、注释等)都是某种类型的节点。
它们都继承同一个基类Node,但根据用途和特性不同,主要分为以下几个核心类别:
- 元素节点(
Element Node):这是最常用的一种节点,对应HTML页面中的所有标签
- 特征:比如
<div>、<a>、<p>等; NodeType值:1(即:Node.ELEMENT_NODE);- 属性:拥有
id、className、attributes等属性;
- 属性节点(
Attribute Node):虽然属性是标签的一部分,但在DOM结构中,它们也可以被视为独立的节点
- 特征:元素的属性,如
class='active'或src='logo.png'; NodeType值:2(Node.ATTRIBUTE_NODE);- 注意:在现代开发中,通常直接通过
element.getAttribute()访问,很少将其作为独立节点进行操作。
从DOM4规范开始,Attr(属性)已经不再继承自Node了,尽管为了兼容性,浏览器依然保留了nodeType: 2这个属性
💡如果你在面试中被问到"DOM节点有哪些类型",你能说出nodeType: 2是属性节点,面试管会觉得你底层功底非常扎实。但在写业务代码时,记住用getAttribute即可。
- 文本节点(
Text Node):标签内部包含的所有文字内容
- 特征:包括换行、空格和实际的文字;
NodeType值:3(即Node.TEXT_NODE);注意:文本节点不能包含子节点;
- 注释节点(
Comment Node):HTML源代码中的注释部分
- 特征:
<!-- -->; NodeType值:8(即Node.COMMENT_NODE);
- 文档节点(
Document Node):整个文档的根节点,也就是JavaScript中的window.document
- 特征:代表整个
HTML文档,是 DOM 树的根节点,也是所有其他节点的宿主或创建者。 NodeType值:9(即Node.DOCUMENT_NODE)
- 文档类型节点(DocumentType Node):HTML文档开头的
<!DOCTYPE html>
NodeType值:10(即Node.DOCUMENT_TYPE.NODE)
总结:
- 在实际开发中,可以通过
nodeType属性来识别:
知识扩展:
DOCUMENT_FRAGMENT_NODE:11 -> 文档片段 -> 进阶常用(性能优化神器)
* 11号节点(DocumentFragment)的重要性甚至超过了8号和10号,它是一个"虚拟的容器",不在DOM树中。
* 如果你要往页面插入1000个<li>,直接循环插入会导致页面卡顿(重排1000次)。你可以先放进11号节点,最后一次性插入到<ul>。
* 效果:只触发1次重排,性能直接起飞。
1.1 元素中一些常用的属性
在DOM操作中,属性主要分为两大派系:"节点派(Node)"和元素派"(Element)"
节点派(Node Level) -- 不放过任何蛛丝马迹,这些属性定义在Node类上,它们对所有节点(元素、文本、注释)一视同仁
firstChild:获取第一个子节点(可能是个换行符或注释)lastChild:获取最后一个子节点nextSibling:获取紧邻在后面的兄弟节点- 通俗理解:如果
<div>后面紧跟着一段文字,那它就是这个div的nextSibling
- 通俗理解:如果
previousSibling:获取紧邻在前面的兄弟节点childNodes:返回所有子节点的集合(包含文本和注释)parentNode:获取父节点

元素派(Element Level) -- 只要标签,忽略杂质。这些属性定义在Element类上,在实际开发中,我们通常只想找"标签",所以这些属性更常用。它们会自动跳过文本和注释
内容与属性操作(Common Properties):除了找关系,还有一些高频操作属性
NodeType:我们在前面聊过,用来判断是元素(1)、文本(3)还是注释(8)nodeName:节点的名称。对于元素节点,它返回大写的标签名(如:"DIV")textContent:获取或设置节点及其所有后代的文本内容(忽略HTML标签)innerHTML:获取或设置元素内的HTML结构(这是Element特有的)attributes:获取元素的所有属性节点集合
nextSibling是"邻居",可能是个回车换行(文本节点),也可能是个注释
nextElementSibling是"下一个标签",它会跳过文本和注释,只找<div>、<span>这种在JavaScript中,childNodes和children返回的不是普通的数组,而是伪数组(Array-like)活的(Live Collection)
* el.children (返回HTMLCollection) 和 el.childNodes (返回 NodeList) 是实时更新的。如果你用 JS 往页面里又塞了一个 div,这个集合的 length 会自动 +1。如果在 for 循环里边遍历边删除,极其容易下标越界掉坑!
死的(Static Collection)
* document.querySelectorAll() (返回 NodeList) 是静态快照。获取那一瞬间页面上有多少个,它就永远是多少个,后续 DOM 的增删改不会影响它。
1.2 元素的增删改查(现代浏览器)
过去我们必须用 fragment 来避免多次回流。但在现代 DOM API 中,append() 支持传入多个参数或解构数组。你可以把 1000 个 <li> 存在一个真实数组里,最后一次性:ul.append(...liArray);这在底层同样只触发一次渲染,代码更简洁!
1.3 获取元素的属性(样式)信息
浏览器为了性能,通常会把多次修改的DOM的操作攒在一起(排队),然后一次性渲染。但是,当你调用getComputedStyle(el).width这种方法时,你是在向浏览器索要最精确、最实时的计算数值。为了给你准确的答案,浏览器不得不:
中断当前的优化队列。立即执行所有待处理的样式更改。重新计算整个布局(回流)。最后返回给你哪个数值。
这种"停下手头所有的活儿,先给你算个数"的行为,就是性能瓶颈的来源。
- 内联值:只能读取到你
自己亲自设置给元素的值,读取不到<style>设置的值。
扩展:还有一个更直接拿宽度的方法,不需要解析字符串(比如把 "300px" 转成数字):
el.offsetWidth:直接返回数字 300(包含边框和内边距)。el.clientWidth:直接返回数字(不含边框)。- 这
两个属性也是实时更新的,而且不需要管样式是内联还是外联。 - 注意: 获取到的
值是经过四舍五入的整数!!!
通过el.style设置的样式,等同于你在 HTML 源码里亲手写下了 style="..."。也就是说,执行 el.style.color = 'red' 后:<div style="color: red;"></div>
如果你设置的是复合属性(Shorthand),它可能会拆解成多个细分属性填入内联样式。 el.style.margin = '10px'; 可能会变成 style="margin-top: 10px; margin-right: 10px; margin-bottom: 10px; margin-left: 10px;"
💡 进阶小技巧:批量设置:el.style.cssText = "width: 100px; height: 100px; color: blue;";,这会直接覆盖掉整个 style 字符串。
如果只需要获取位置信息(更现代的方案) - 使用
el.getBoundingClientRect()
-
优点:它返回
元素相对于视口的大小及其位置。虽然它也会触发回流,但它一次性提供了width,height,top,left等所有数据,比多次调用getComputedStyle性能更好。 -
为什么
getBoundingClientRect()会比getComputedStyle更好?虽然getComputedStyle确实可以让你一次性拿到一个包含所有属性的对象,但这两者在浏览器底层的处理逻辑是不一样的:getComputedStyle(el):它返回的是解析后的CSS属性。如果你访问height,浏览器可能需要计算盒子模型、继承值、单位转换(em转px)等。最关键的是,它是针对单个特定属性进行解析的。如果你需要位置信息,它并不直观(比如:它给的是top的计算值,但这可能会受到position属性的严重影响);getBoundingClientRect():它返回的是元素在像素级坐标系中的精确位置和尺寸。核心优势:它是高度优化的。浏览器内部通常已经计算好了这些几何信息用于绘制。它返回的是一个简单的DOMRect对象,包含width、height、top、left、right、bottom,x、y。在获取几何布局信息时,它的语义更明确,执行效率通常也会更高。
尽量利用CSS变量,如果你需要获取某些数值来进行逻辑计算,不如反过来 -- 在JS中设置CSS变量,在CSS中使用它:
使用
ResizeObserver(监听尺寸变化) -- 如果你获取样式是为了监控元素大小的变化,千万不要用定时器去轮询getComputedStyle。
- 方案:使用
ResizeObserver。它是异步的,性能极高,不会阻塞渲染。 - 使用:具体用法查阅MDN文档。
性能优化的"黄金法则":读写分离,如果你必须使用getComputedStyle,请务必遵循先读后写的原则,避免布局抖动(Layout Thrashing)
如果你追求极致性能(比如在做 60fps 的复杂动画),甚至可以使用 FastDOM 这样的库,或者利用 requestAnimationFrame 将写操作推迟到下一帧。
想了解一下 requestAnimationFrame 是如何配合这些操作来进一步压榨性能的吗?
1.4 盒子几何属性
client系列:可视区域,这一组主要关注元素内部(不含边框)的空间
clientWidth/clientHeight:内容区域的宽度/高度- 计算公式:内容(
content) + 内边距(padding) - 注意:不包含
滚动条、边框(border)和外边距(margin)
- 计算公式:内容(
clientLeft/clientTop:边框的厚度clientLeft其实就是左边框border-left-widthclientTop其实就是上边框border-top-width- 注意:没有clientRight和clientBottm
offset系列:占位大小,这一组关注元素在页面上真实占用的空间
offsetWidth/offsetHeight:整个盒子的物理尺寸- 计算公式:
内容 + 内边距 + 边框 + 滚动条 - 主要:这是最常用的获取元素宽高的方法,因为它反映了元素
视觉上的真实大小
- 计算公式:
offsetLeft/offsetTop:偏移距离- 指的是该元素相对于
offsetParent(最近的定位父元素)的左侧/顶部的距离
- 指的是该元素相对于
offsetParent:这是offset系列的灵魂,如果不理解它,你的offsetLeft/Top永远算不准offsetParent:指当前元素在计算偏移量时,是相对于谁来算的- 规则:它会指向最近的
"拥有定位属性"的祖先元素。如果祖先都没定位,则指向<body>
scroll系列:滚动信息,这一组关注元素内部内容溢出的情况
scrollHeight:内容的总高度- 即使内容
被隐藏在滚动条下面,它也会计算在内。如果没有滚动条,它等于clientHeight
- 即使内容
scrollTop:被卷去的距离也就是滚动条向下滚动了多少。它是这一组里唯一可以手动赋值修改的属性,其他属性通常都是只读的
scrollWidth:元素内容实际的总宽度(包含由于溢出而不可见的部分)- 经典场景:
判断一个导航栏是否出现了水平滚动条 - 逻辑:如果
scrollWidth > clientWidth,说明内容溢出了,需要显示"更多按钮"
- 经典场景:
scrollLeft:内容向左滚动的距离- 经典场景:
制作横向滚动的图片轮播图
- 经典场景:
* 关于 getComputedStyle:读取上述这些属性(尤其是 offsetWidth 等)也会触发回流,因为浏览器必须计算布局才能给出精确像素。
* 单位:这些属性返回的都是纯数字(如 200),不带px。而 style.width 返回的是字符串(如 "200px")。
* 四舍五入:这些属性返回的通常是整数。如果你需要带小数点的极精确尺寸(比如缩放后的元素),请使用 getBoundingClientRect()。
* 它们都会导致回流(Reflow)。
补充:window.getSelection(),虽然它不属于"盒子模型",但是它是处理DOM时非常高频的非几何属性。
- 作用:
获取用户当前在页面上用鼠标抹黑(选中)的内容 - 场景:
做划词翻译、自定义右键菜单
🚀 极速:JS 变量缓存(完全脱离 DOM,最推荐)。
✅ 快速:内联样式 el.style.width(仅读属性,不触发渲染引擎重算。坑:拿不到 CSS 文件的值)。
⚠️ 较慢(触发回流):几何属性 offsetWidth / clientWidth。会强制浏览器刷新布局队列。注意:返回四舍五入的整数。
🐢 沉重(触发回流+高精度):getBoundingClientRect()。强制刷新布局并计算视口绝对坐标。优点:返回精确浮点数。
💤 最重(全量计算):getComputedStyle()。除了可能触发回流,还要解析复杂的层叠样式表(CSS Cascade),消耗最高。
1.5 浏览器窗口(Window)与页面文档(Viewport)之间的关系
这一组属性描述的是浏览器窗口(Window)与页面文档(Viewport)之间的关系。我们可以把它们想象成:窗户框架、玻璃面积和屋里的地毯
window.outer系列:浏览器的"外壳"
window.outerWidth / window.outerHeight- 含义:整个浏览器窗口的大小,包括窗口外框、标题栏、工具栏(如书签栏)、侧边栏等
- 场景:很少在普通网页开发中使用,通常用于弹出窗口(
window.open)的定位
window.inner系列:浏览器的"视口"
window.innerWidth / window.innerHeight- 含义:浏览器窗口中"可见区域(
Viewport)"的大小 - 重点:它包含滚动条的宽度(如果有的话)
- 含义:浏览器窗口中"可见区域(
document.documentElement系列:页面的"内容高度"
document.documentElement.clientWidth/height- 含义:
HTML文档的可视区域大小 - 区别:它不包含滚动条。所以
window.innerWidth永远比它大一点点(差一个滚动条的宽度)
- 含义:
document.documentElement.offsetWidth/Height- 含义:整个
HTML文档实际占位大小。如果页面内容很长,offsetHeight就会非常大
- 含义:整个
屏幕分辨率(Screen)
window.screen.width/height:显示器的总分辨率window.screen.availWidth/availHeight:屏幕的可利用区域(自动扣除电脑底部的任务栏或顶部的菜单栏)
窗口位置
window.screenX / screenY:浏览器窗口左上角相对于屏幕左上角的坐标
滚动位置(最常用)
window.pageXOffset / window.pageYOffset- 含义:文档在水平/垂直方向滚动的像素值
- 别名:
window.scrollX / window.scrollY(这是更现代的写法,完全等价)
* 做响应式断点判断时:优先使用 window.innerWidth,因为它最接近 CSS 媒体查询(Media Queries)的计算方式。
* 计算滚动条宽度时:用 window.innerWidth - document.documentElement.clientWidth。
* 判断页面是否滚到底部时: window.innerHeight + window.scrollY >= document.documentElement.scrollHeight
1.6 事件冒泡与捕获
在图中,绿色的 Td节点是 Target。这里有一个细节你可以在笔记中备注:
不分先后:在Td(目标元素)本身绑定的多个事件监听器,不遵循先捕获后冒泡的顺序。执行顺序:它只取决于你在代码中绑定的先后顺序。即使你一个设置了true(捕获),一个设置了false(冒泡),只要它们都绑在Td上,谁写在前面谁先运行。结论:早期浏览器存在的历史包袱,目标阶段不区分阶段只看绑定顺序。但请记住!在现代 DOM 标准中,目标元素也严格遵循“先捕获(true),后冒泡(false)”的顺序,与代码绑定先后无关!
补充两个不走“回头路”的事件
- 在你的图里,黄色箭头代表冒泡,但请备注:不是所有事件都会冒泡。
focus / blur:聚焦和失焦。mouseenter / mouseleave:鼠标移入移出。 这些事件只有捕获阶段和目标阶段,没有冒泡阶段。如果你在父元素上监听这些事件的冒泡,是永远等不到的。

- 如果我们都监听,那么会按照如下顺序来执行
- 捕获阶段(
Capturing phase):事件(从Window)向下走近元素 - 目标阶段(
Target phase):事件到达目标元素 - 冒泡阶段(
Bubbling phase):事件从元素上开始冒泡
- 捕获阶段(
event.target:“真凶”。 指的是真正触发事件的那个最深层的元素(比如你点的是按钮里的图标,它就是图标)。
event.currentTarget:“捕快”。 指的是当前正在处理(捕获或冒泡)这个事件的元素。也就是你用 addEventListener 绑定的那个元素。
💡 小技巧: 在事件函数内部,event.currentTarget 永远等于 this(如果你没用箭头函数的话)。
- 总结:可以通过
event.target来做事件委托。
event.stopPropagation(): 阻止事件向父级(冒泡)或子级(捕获)继续传递,但不阻止当前元素上绑定的其他同类型监听器执行。
event.stopImmediatePropagation(): “核武器”。不仅阻止向外传递,连当前元素上绑定的其他后续监听器也一并枪毙。
场景: 如果同一个按钮被不同的人绑了 3 个 click 事件,在第 1 个事件里调用这个方法,后面 2 个函数就不会执行了。
1.7 进阶
虽然我们 95% 的时间都在用冒泡,但捕获阶段存在的意义在于:让祖先元素拦截事件
- 应用场景:比如你做一个复杂的组件库,想在点击任何子元素之前先进行权限检查或统一日志记录,就可以在
Window或Body的捕获阶段截获事件。
2. JavaScript内置对象-Date
📅Date对象是JavaScript的内置对象,用于存储和操作日期与时间
2.1 Date对象的创建
创建Date对象是使用它的第一步。通常使用new Date()构造函数
2.2 核心需要记住的概念
掌握这些核心概念,能让您理解Date对象的工作原理
-
时间戳(
Timestamp):- 时间戳是一个数字,代表从
UTC1970年1月1日00:00:00开始经过的毫秒数。 - 他是计算机存储日期的标准方式,独立于时区。
- 时间戳是一个数字,代表从
-
获取时间戳的常用方法:
Date.now():获取当前时间戳(静态方法,最推荐);new Date().getTime():获取创建的Date实例的时间戳;new Date().valueOf():效果同上;
-
本地时间(
Local Time) vs. UTC时间(Universal Time)- 本地时间(
Local Time):基于用户计算机设置的时区显示的时间。Date对象大多数get和set方法(如:getHours(),setHours())默认操作的是本地时间。 - UTC时间(
Universal Coordinated Time):国际标准时间。用于确保时间在不同时区之间保持一致。所有带有UTC后缀的方法(如:getUTCHours(),setUTCHours())操作的是UTC时间。
- 本地时间(
2.3 重点需要学习的方法(Getter & Setters)
Date对象提供了大量的get(获取)和set(设置)方法,用于操作日期的各个部分
🚀 掌握的核心Getter方法(获取信息)
🔧掌握核心Setter方法(设置信息)
- Setter方法与Getter方法相应,用于修改Date对象的值。
2.4 日期格式化(重点)
虽然Date对象本身能返回日期,但格式通常不友好。现在JavaScript推荐使用Intl.DateTimeFormat来格式化日期
总结要点:
- 创建:用
new Date()创建实例,注意指定日期月份从0开始。 - 核心:时间戳是日期存储的底层基础,使用
Date.now()获取。 - 陷阱:
getMonth()返回值是0-11,getDay()返回值是0-6(周日是0)。 - 操作:使用
get...()获取,set...()修改。 - 格式化:推荐使用
toLocale*String()或Intl.DateTimeFormat进行友好展示。
2.5 Date的两个巨坑
"避坑:Date 对象的变异性 (Mutability)"
- 原生 Date 最大的设计败笔之一,就是它的 set 方法会直接修改原对象。
苹果设备(Safari/iOS)的字符串解析大坑
-
上面笔记里写了:new Date(dateString) 不推荐。这句话非常正确,但一定要把原因写上,因为以后你绝对会遇到!
-
"兼容性深坑:iOS 下的 Invalid Date":当后端传给你一个日期字符串 '2025-12-25 12:00:00' 时:
- 在 Chrome/安卓 上:new Date('2025-12-25 12:00:00') 完美解析。
- 在 Safari/iOS 上:直接返回 Invalid Date (页面显示 NaN)。
2.6 实战中如何使用Date
轻量级的时间库是标配
* 因为原生 Date 太难用了(格式化麻烦、计算麻烦、容易被修改),现在几乎没有任何商业项目会纯手写原生 Date 逻辑。
* 以前:大家用 Moment.js(现在已淘汰,因为体积太大)。
* 现在:全行业都在用 Day.js 或 date-fns。它们体积极小,API 超级优雅,且默认是不可变对象(Immutable)。
JS 官方的救赎:Temporal API
* ECMAScript 委员会也知道 Date 设计得很烂,所以他们正在推出一个全新的内置全局对象叫 Temporal(目前处于提案最终阶段,部分现代浏览器已经可以通过 flag 开启)。未来,前端将彻底抛弃 new Date()。
3. JavaScript中的this
在JavaScript中,this关键字是运行时绑定的,它的值取决于函数被调用的方式,而不是函数被定义的位置
3.1 this绑定规则
普通函数中的this规则
规则一:默认绑定(Default Binding)
- 什么是默认绑定? 当函数作为
独立函数被直接调用,且不包含任何绑定对象时
规则二:隐式绑定(Implicit Binding)
- 函数作为
对象的属性被调用,此时该函数被称之为方法
规则三:显示绑定(Explicit Binding)
- 规则:使用函数的内置方法
call(),apply()或bind()强制将this绑定到指定对象
规则四:new绑定(New Binding)
- 规则:当函数被用作
构造函数(即使用new关键字)来调用时
3.2 new关键字
🏗️new关键字的四个步骤
当你执行const instance = new ConstructorFunction(...)这行代码时,JavaScript引擎会在底层完成以下四件事:
-
创建全新的对象- 操作:
JavaScript引擎会凭空创建一个全新的、普通的、空的对象。 - 目的:这个新对象就是即将被构造函数定制和返回的实例。
- 操作:
-
原型连接([[Prototype]]链接)- 操作:这个新创建的对象会被链接到构造函数的原型(
ConstructorFunction.prototype)上。 - 目的:这使得新对象可以访问到构造函数原型上定义的所有属性和方法(即实现了原型链继承)。
- 例如:如果你在
ConstructorFunction.prototype.method = ...上定义了一个方法,那么新对象instance就可以通过原型链调用instance.method()。
- 例如:如果你在
- 操作:这个新创建的对象会被链接到构造函数的原型(
-
绑定this- 操作:这个新创建的对象会被绑定到构造函数调用中的this关键字上。(this的绑定就是在这个步骤完成的)
- 目的:在构造函数内部,所有对this属性的操作(例如:this.name = value)都会被添加到这个新对象上,从而实现对实例的初始化和定制。
返回新对象- 规则:构造函数执行完毕后,引擎会检查它的返回值
- 操作与目的:
- 如果构造函数没有显示地使用
return语句,或者return的是一个原始值(如字符串、数字、null、undefined),那么new表达式会自动返回在第一步创建的那个全新的对象(即this所指向的对象)。 - 如果构造函数显示地
return了一个非原始值(即一个对象),那么new表达式会忽略第一步创建的对象,而是返回这个显示返回的对象。(这种情况很少见,且不推荐,但这是规则的一部分)
- 如果构造函数没有显示地使用
3.3 规则优先级
默认绑定的优先级最低
- 毫无疑问,默认绑定的规则优先级是最低的,因为存在其他规则时,就会通过其他规则的方式来绑定this
显示绑定的优先级高于隐式绑定
new绑定优先级高于隐式绑定
new绑定优先级高于bind
* 在bind的内部实现中,它会检查当前函数是否被作为构造函数调用(通过 new.target 或 instanceof 判断)。
* 如果是普通的 bar() 调用,它用 obj。
* 如果是 new bar() 调用,它会主动让位,让 this 指向新创建的实例。
* 结论:这是 JS 引擎特意设计的,目的是为了让硬绑定的函数依然可以被当作构造函数来克隆。
内置函数的绑定思考
- 有些
内置函数会要求我们传入一个函数作为参数 - 但是我们自己并不会
显示的调用这些函数,而且JavaScript内部或者第三方库内部会帮助我们执行 - 那么,
这些函数中的this又是如何绑定的呢
this规则之外
- 忽略显示绑定
- 间接函数引用
3.4 箭头函数中的this规则
-
规则:
箭头函数(=>)没有自己的this绑定。 -
指向:
- 箭头函数的
this继承自它定义时所在的作用域(词法作用域)的this值。它不能被call(),apply()或bind()改变。
- 箭头函数的
不能new: 箭头函数没有[[Construct]]方法,也没有 prototype。执行new (() => {})会直接抛出TypeError。
没有arguments: 它也没有自己的参数伪数组,如果访问arguments,拿到的也是外层作用域的。
不能做Generator: 不能在函数体内部使用 yield 关键字。
4. 深入浏览器的渲染原理
浏览器内核
- 我们经常说的
浏览器内核指的是浏览器的排版引擎排版引擎(Layout Engine),也称之为浏览器引擎(Browser Engine)、页面渲染引擎(Rendering Engine)或样板引擎。- 也就是一个网页下载下来后,就是由我们的
渲染引擎来帮助我们解析的。
4.1 渲染引擎如何解析页面的呢?

解析一:HTML解析过程
- 因为
默认情况下服务器会给浏览器返回index.html文件,所以解析HTML是所有步骤的开始。 - 解析
HTML,会构建DOM Tree。
解析二:生成CSS规则
- 在解析过程中,如果遇到
CSS的link元素,那么会由浏览器负责下载对应的CSS文件。注意:下载CSS文件是不会影响DOM的解析的。
- 浏览器
下载完CSS文件后,就会对CSS文件进行解析,解析出对应的规则树。- 我们可以称之为
CSSOM(CSS Object Model, CSS对象模型)。
- 我们可以称之为
解析三:构建Render Tree
- 当有了
DOM Tree和CSSOM Tree后,就可以两个结合构建Render Tree了。- 注意一:
link元素是不会阻塞DOM Tree的构建过程,但是会阻塞Render Tree的构建过程。- 这是因为
Render Tree在构建时,需要对应的CSSOM Tree;
- 这是因为
- 注意二:
Render Tree和DOM Tree并不是一一对应的关系,比如对于display为none的元素,压根不会出现在render tree中。
- 注意一:
- 注意:如果在解析过程中遇到
<script>标签(尤其是不带async或defer属性的),它会同时阻塞DOM构建和CSSOM构建,因为它可能读取或修改两者。
解析四:布局(Layout)和绘制(Paint)
- 第四步是在渲染树(
Render Tree)上运行布局(Layout)以计算每个节点的几何体。- 渲染树会表示
显示哪些节点以及其他样式,但是不表示每个节点的尺寸、位置等信息。 布局是确定呈现树中所有节点的宽度、高度和位置信息。
- 渲染树会表示
解析五:🎨现代浏览器的渲染终结阶段:分层、绘制与合成
渲染流水线的最后几个步骤是将布局结果转化为最终的屏幕画面。现代浏览器(如Bink和Gecko)将这一过程分解为以下三个关键步骤,以实现硬件加速和更高的性能。
分层(Layering)
- 在执行
绘制之前,浏览器首先会将渲染树中的元素分配到不同的图层(Layers)中。- 某些拥有独立属性(如
transform、opacity)或使用特定CSS属性(如will-change、position: fixed)元素会被提升到独立的合成层。 - 目的:确保当这些元素发生变化时(例如动画),它们可以
独立于页面的其他部分进行重绘和移动,而不需要重新触发整个页面的布局(Layout)或绘制(Paint)。
- 某些拥有独立属性(如
绘制(Paint)
- 在
分层完成后,浏览器会执行绘制操作。功能:遍历每个独立的图层,生成一组用于描述如何绘制该图层内容的绘制指令列表(Paint Records/Display List)。内容:这些指令包括绘制文本、颜色、边框、阴影、图片等元素的可见部分。结果:这一步的结果不是屏幕上的像素,而是命令列表,例如:在(X,Y)坐标画一个20像素宽的蓝色矩形。
光栅化与合成(Rasterization and Compositing)
- 这是
最终画面显示到屏幕上的高性能阶段。光栅化(Rasterization):光栅化线程(Raster Thread)或GPU进程读取绘制指令列表,并将这些向量指令转换为实际的位图(Bitmaps),即像素点数据,并存储在GPU内存中。合成(Compositing):合成器线程(Compositor Thread)利用GPU来完成最终的画面合成。它负责将所有的准备好的独立图层(位图)在正确的位置和正确的顺序上合并为一个完整的最终图像。
现在高性能动画(如平移、缩放、不透明度变化)可以直接跳过Layout和Paint步骤,直接在合成器线程中操作图层,利用GPU高效地完成屏幕更新,这被称为Compositor Only动画,确保了流程的60FPS甚至更高帧率。
4.2 回流(Layout)与重绘(Paint)
♻️渲染性能优化: 回流(Layout)与重绘(Paint)
网页渲染设计三个主要步骤:布局(Layout) -> 绘制(Paint) -> 合成(Compositing)。其中,前两个步骤最容易引发性能问题
理解回流reflow:也可以称之为重排
第一次确定节点的大小和位置,称之为布局(Layout)之后对节点的大小、位置修改重新计算称之为回流。回流的本质是重新计算页面的几何属性(Geometry)- 它是
最耗性能的操作,因为一旦一个元素回流,通常会引起其子元素、兄弟元素,甚至整个文档的回流。
什么情况下会引起回流?
- 比如
DOM结构发生改变(添加、删除或修改DOM节点) - 比如
改变了布局(修改了width、height、padding等影响尺寸的CSS属性) - 比如
浏览器窗口变化:调整浏览器窗口大小(resize) - 比如
调用getComputedStyle方法获取尺寸,位置等信息 - 任何需要
获取元素的最新计算样式或几何信息的JavaScript属性或方法(如offsetWidth、scrollHeight、getClientRects()等)都会强制同步回流。这是浏览器为了保证数据的准确性而不得不进行的。
理解重绘repaint
- 第一次渲染内容称之为
绘制(paint) - 之后
重新渲染称之为重绘(重绘是重新绘制元素的可见属性) - 浏览器重新绘制元素的
可见外观,但其几何位置和大小没有变化。- 性能消耗
低于回流,但仍是同步操作。
- 性能消耗
什么情况下会引起重绘呢?
- 任何
只改变元素外观但位置不变的操作会触发重绘 - 比如修改
背景色、文字颜色、边框颜色、样式等非几何属性
回流一定会引起重绘,所以回流是一件很消耗性能的事情,所以在开发中要尽量避免发生回流。但重绘不一定导致回流:
-
修改样式时尽量
一次性修改 -
避免
样式抖动:不要再一个循环中连续读取集合属性(如offsetWidth)并修改样式,这样会导致浏览器不断执行强制同步回流 (Forced Synchronous Layout)。 -
最佳实践:尽量通过添加/切换CSS类来批量修改样式,或使用style.cssText一次性完成修改。 -
高效DOM操作
- 避免频繁操作DOM:对多个节点进行操作时,使用
DocumentFragment在内存中完成操作,然后一次性添加到DOM中。
- 避免频繁操作DOM:对多个节点进行操作时,使用
-
隔离回流范围
- 对
频繁发生动画的元素使用position: absolute或fixed,将其从文档流中脱离。这能将回流的影响范围隔离到该元素自身,避免波及整个页面。
- 对
-
最高性能动画(合成阶段)
- 优先使用
transform(例如translate,scale)和opacity属性进行动画。这些属性直接由GPU通过合成器线程处理,不会触发回流或重绘,只触发合成(Compositing),性能开销最小。 - 使用
will-change属性(如will-change: transform;)提前告知浏览器该元素将发生变化,促使浏览器将其提升到独立图层,确保动画流畅。
- 优先使用
-
分层确实可以提高性能,但是它以内存管理为代价,因此不应该作为web性能优化策略的一部分过度使用。
4.3 Script元素和页面解析的阻塞关系
-
核心现象:当浏览器在解析HTML构建DOM树的过程中,一旦遇到<script>元素(无defer/async),它会立即停止DOM树的构建。浏览器必须先将脚本下载下来,并立即执行。只有等脚本执行结束后,才会恢复HTML的解析和DOM树的构建。 -
为什么
需要这么做呢?一致性与安全性:JavaScript可能会操作DOM(甚至使用document.write修改HTML结构)。如果在DOM构建完成之前不暂停,脚本可能会操作尚未解析的元素,或者导致DOM结构与预期不符。性能考量:虽然现在看来阻塞会影响性能,但如果允许脚本在渲染后随意修改DOM,确实会引发大面积的重绘(Repaint)和回流(Reflow)。
-
但是这个也会往往带来
新的问题,特别是现代页面开发中:- 在
目前的开发模式(比如Vue、React),脚本往往比HTML页面更"重",处理时间需要更长。 - 所以会造成页面的
解析阻塞,在脚本下载、执行完成之前,用户在界面上什么都看不到。
- 在
-
为了解决这个问题,
script元素提供了两个属性(attribute),defer和asyncdefer属性(推迟执行):非阻塞下载:浏览器会在后台异步下载脚本,不会阻塞DOM树的构建。执行时机:脚本下载完成后不会立刻执行,而是等待DOM树构建完成(解析到底部)后,在DOMContentLoaded事件触发之前执行。保证顺序:如果有多个defer脚本,它们会严格按照在HTML中出现的顺序执行。注意:defer属性仅对带有src属性的外部脚本有效,对于内联脚本(直接写在<script>标签内的代码),defer会被忽略。
-
async属性:非阻塞下载:同样是异步下载,下载过程不阻塞DOM构建。- async是让一个脚本
完全独立的:- async脚本的下载过程是
后台进行的,不阻碍DOM构建;但一旦下载完成,脚本会立即执行,执行过程依然会占用主进程,此时若DOM尚未构建完成,解析会暂停。 - async脚本
不能保证顺序,它是独立下载、独立运行,不会等待其他脚本 - async
不可能保证在DOMContentLoaded之前或之后执行
- async脚本的下载过程是
-
总结与最佳实践:- 普通
<script>:解析到即暂停,下载并执行。 <script defer>:并行下载,文档解析完、DOMContentLoaded前执行(推荐默认使用)。<script async>:并行下载,下载完立刻执行(仅用于独立第三方脚本)
- 普通
4.4 总结与补充
- JavaScript会
等待CSSOM(重要):虽然JS阻塞DOM解析,但CSS也会阻塞JS的执行。因为JS可能会去查询元素的样式(例如:element.offsetWidth)。如果CSS还没下载解析完,浏览器为了保证JS获取到的样式是正确的,会推迟脚本执行,直到CSSOM构建完成。结论:CSS不直接阻塞DOM,但它阻塞JS,JS又阻塞DOM。- 但注意,
CSSOM 只会阻塞排在它后面的 JS。
Preload Scanner(预加载扫描器):现在浏览器很聪明,当主解析器停了,后台会有一个"预加载扫描器"继续往后看,把后面需要的CSS和JS以及图片提前下载下来。所以实际上下载往往是并行的,只是解析和执行是阻塞的。- type="module"的默认行为:现在开发(Vite, 现代浏览器)中常用
<script type="module">。值得记录的是:type="module"默认就是defer的行为。 - 在现代
Blink引擎的官方文档中,Render Tree的概念已被演进为Layout Tree,但面试时回答Render Tree依然是标准答案。 - “强制同步回流”的终极杀手:
布局抖动(Layout Thrashing)- 浏览器原本是很聪明的,它会把所有的
DOM修改放进一个“队列”里,等当前任务结束后批量执行回流。但是!如果你在修改DOM之后,立刻用JS去读取offsetWidth,浏览器为了给你返回最准确的值,不得不立即清空队列,当场执行一次完整的回流。如果在循环里这么干,帧率会直接跌谷底。
- 浏览器原本是很聪明的,它会把所有的
- 性能优化的杀手锏:
requestAnimationFrame (rAF)- 它的回调函数会精准地卡在浏览器下一次
Layout和Paint之前执行。相比于setTimeout或setInterval,rAF能保证动画和屏幕刷新率(通常是 60Hz)完美同步,绝对不会丢帧。
- 它的回调函数会精准地卡在浏览器下一次
4.5 知识扩展
5. 深入了解JavaScript执行原理
我们知道,浏览器内核是由两部分组成的,以Webkit为例:
-
WebCore:负责HTML解析、布局、渲染等相关工作; -
JavaScriptCore:解析、执行JavaScript代码; -
另一个强大的
JavaScript引擎就是V8引擎;
5.1 V8引擎的介绍
-
V8是用C++编写的Google开源的高性能JavaScript和WebAssembly引擎,它用于Chrome和Node.js等 -
JavaScript代码在V8中执行的过程:

5.2 V8引擎的架构
V8引擎本身的源码非常复杂,大概有超过100w行C++代码,通过了解它的架构,我们可以知道它是如何对JavaScript执行的
Scanner(扫描器):
- 作用:把
源代码(字符串)拆分成一个个的Token(词法分析)。 - 流程:
源代码->Scanner(生成Tokens)->Parser(生成AST)。 - V8引擎的Scanner介绍
Parse(解析器):
- Parse模块会将
Token转换成AST(抽象语法树),这是因为解释器无法直接理解字符串,需要结构化的树形数据。 - Lazy Parsing(惰性解析):为了提升
启动速度,V8默认采用惰性解析。- 如果函数只是声明但
未调用,V8只会进行Pre-parsing(预解析),仅检查语法错误,不生成完整的AST; - 只有函数
被调用时,才会生成该函数的完整AST。
- 如果函数只是声明但
- V8引擎的Parse模块
Ignition(解释器):
- Ignition是
V8的解释器,它负责将AST转换为ByteCode(字节码)。- 字节码
比机器码更轻量,利用减少内存占用,且能跨平台;
- 字节码
- 执行与收集信息:Ignition
逐行解释执行ByteCode。- 在执行过程中,它会收集
反馈信息(Feedback Vector)(例如:这个函数的参数总是int类型)。这些信息是后续TurboFan进行优化的关键依据。
- 在执行过程中,它会收集
- V8引擎的Ignition模块
TurboFan模块(优化编译器):
Speculative Optimization(推测优化):- 当一个函数被
多次调用,那么就会被标记为热点函数,TurboFan会利用Ignition收集的类型信息,假设类型不变,将ByteCode编译为高度优化的机器码,以此极大提高运行速度;
- 当一个函数被
Deoptimization(去优化):- 原因:
JavaScript是动态类型语言。如果后续执行中,参数类型发生了变化(例如sum函数一直处理number,突然传来string),之前生成的机器码(它是按照number写的指令)就无法处理了。 - 行为:V8会触发
Deoptimization。它会丢弃优化的机器码,回退到Ignition解释器,使用原来的ByteCode继续执行。这也是为什么我们需要尽量保持JavaScript对象结构稳定、变量类型一致的原因。
- 原因:
- TurboFan 的最强武器 ——
隐藏类 (Hidden Class)与内联缓存 (Inline Cache, IC)- ⚙️ V8 优化的
底层机制:隐藏类与内联缓存隐藏类 (Hidden Class / Map):JavaScript 是弱类型,没有C++/Java那样的类结构。为了快速查找对象的属性,V8 在后台悄悄为对象创建了“隐藏类”。如果两个对象拥有完全相同的属性名和赋值顺序,它们就共享同一个隐藏类,V8 就可以极快地定位内存。避坑指南:千万不要随意用delete删除对象的属性,或者在初始化后随意动态添加新属性。这会破坏隐藏类,导致 V8 触发Deoptimization(去优化),让代码运行速度断崖式下跌。
- ⚙️ V8 优化的
- V8引擎的TurboFan模块
❓ 深度思考:为什么要有ByteCode?
内存占用问题(Memory Usage):早期的V8(5.9版本之前)确实是直接转成机器码(Full-Codegen)。但手机端内存有限,机器码体积很大,容易导致内存溢出。ByteCode非常紧凑,体积小得多。启动速度(Startup Time):生成优化的机器码很慢,生成ByteCode很快。为了让页面快速显示,先生成ByteCode跑起来,后面再慢慢优化热点代码(这也是JIT -- Just In Time的精髓)。
💡 扩展:AST 的工程化意义:
AST不仅仅是V8引擎执行代码的中间产物。在现代前端工程化中,Babel(ES6 转 ES5)、ESLint(代码规范检查)、Webpack/Vite(打包构建)以及Prettier(代码格式化),它们的核心底层原理全都是把源代码解析成AST,对AST进行修改,再重新生成代码。
🎯 什么是 JIT (Just-In-Time) 编译?
- 在 V8 中,
Ignition(解释器,启动快)+TurboFan(编译器,执行快)的这套混合双打机制,就是大名鼎鼎的JIT即时编译技术。
5.3 JavaScript的执行过程
🖥️假如我们有下面一段代码,它在JavaScript中是如何被执行的呢?
JavaScript引擎会在执行代码之前,会在堆内存中创建一个全局对象:Global Object(GO)
- 全局对象(Global Object: GO)
- 创建时机:当JavaScript引擎首次创建全局执行环境(GEC)时生成。
- 存储位置:存放在堆内存(Heap)中。
- 作用:它是所有全局变量和全局函数的宿主(在非严格模式下通过var或函数声明创建的)。
- Go的主要内容:全局对象包含了JavaScript运行时环境提供了所有全局可用的功能。
- 内置对象:如String、Number、Date、Array、Math等。
- 全局函数和变量:如parseInt、NaN、undefined等。
- 宿主对象/方法:由运行环境提供
- 浏览器:setTimeout、console、document等。
- Node.js:process、require等。
- 自引用属性(window或global)
- 全局对象有一个指向自身的属性,以便在全局作用域内可用直接访问它。
- 在浏览器环境中,这个全局对象是Window,它有一个属性Window也指向它自身。
- 通用标准:globalThis是ES2020引入的标准属性,它始终指向当前环境下的全局对象,推荐使用它来代替Window或global。
- 全局对象有一个指向自身的属性,以便在全局作用域内可用直接访问它。
执行上下文(Execution Contexts)
- JavaScript引擎内部有一个执行上下文栈(Execution Context Stack,简称ECS),它是用于执行代码的调用栈。
- 执行上下文(EC)是JavaScript引擎运行时的一个环境,它包含了运行当前代码所需的一切:
- 环境/变量存储:词法/变量环境(用来存放变量和函数声明,其实体是GO或AO)
- 查找路径:作用域链(指导变量查找)
- 上下文引用:this绑定(确定当前执行环境的 this 绑定)
- EC是一个执行环境,它利用这些内部属性来执行函数对象中存储的代码。
- 全局代码为了被执行,会构建一个Global Execution Context(GEC)。GEC会被放入到ECS中执行。
- GEC被放入到ECS中里面包含两部分内容:
- 第一部分:在代码执行前,在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中。(在创建阶段,var变量是会被赋值的,但赋的值是默认值undefined)。
- 这个过程也称之为变量的作用域提升(Hoisting)
- 第二部分:在代码执行中,对变量进行赋值,或者执行其他的函数。
- 第一部分:在代码执行前,在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中。(在创建阶段,var变量是会被赋值的,但赋的值是默认值undefined)。
认识VO对象(Variable Object)
- 每一个执行上下文会关联一个VO(Variable Object,变量对象),变量和函数声明会被添加到这个VO对象中。
- 当全局代码被执行的时候,VO就是GO对象了。
全局代码执行过程(执行前)


🚀 函数的执行过程
当JavaScript引擎执行代码并遇到一个函数调用时,它会执行以下关键步骤:
- 创建函数执行上下文(FEC)
- 定义:引擎会为这个函数调用创建一个新的函数执行上下文(FEC)。
- 入栈:FEC会被立即压入执行上下文栈(EC Stack)的顶部,成为当前正在运行的执行上下文。
- EC Stack是一个LIFO(后进先出)结构,它决定了代码的执行顺序。
- 创建活动对象(AO) 🔑
- 当进入一个函数执行上下文时,会创建一个AO对象(Activation Object)。
- AO 在创建时,会首先初始化一个 arguments 对象,随后将形参、函数声明、变量声明依次添加进去。(注意顺序:参数 > 函数声明 > 变量声明)。
- 这个AO对象会作为执行上下文的VO来存放变量的初始化。
- VO变体:在函数执行上下文中,抽象的变量对象(VO)会具体化为活动对象(AO)。
- AO的初始化:AO在创建阶段就被初始化,它包含:
- arguments对象:包含函数调用时传入的参数(如arguments[0]、arguments[1]等)
- 形参(Parameters):它们作为AO的属性被创建,并赋值为调用时传入的实参
- 函数内部的声明:通过var声明的变量(初始化为undefined)和内部函数声明(完全提升)。
- 创建作用域链(Scope Chain)🔗
- 当进入到一个执行上下文时,执行上下文也会关联一个作用域链(Scope Chain)。作用域链是一个对象列表,用于变量标识符的求值。
- 作用:作用域链是FEC的一个内部属性,它是一个有序列表,用于确定在当前执行上下文中,对变量和函数的查找顺序。
- Scope Chain的内容:
- 当前AO/VO:列表的第一个对象始终是当前的活动对象(AO)或全局执行上下文中的全局对象(GO)
- 父级作用域:随后是函数定义时的父级词法环境(即父函数的AO或GO)
- 全局对象(GO):列表的末尾始终是全局对象(GO)
- 如果函数是多层嵌套的,那么这个作用域链的列表将会包含父级,父级的父级,直到GO。
- 变量查找过程:当JavaScript引擎需要查找一个变量的值时,它会沿着这个作用域链从前往后依次查找,直到找到第一个匹配的变量为止。
函数的作用域链在“定义”时就已经决定了,而不是“调用”时:
每一个函数对象内部都有一个隐藏属性 [[Scope]]。当函数被创建时,它就会把当时的父级作用域链存入这个属性中。当函数被调用并创建 FEC 时,它只需把当前的 AO 添加到这个 [[Scope]] 的顶端即可形成完整的作用域链。
VO 与 AO 的关系(抽象与具体):
VO(Variable Object)是一个抽象概念,在全局它表现为GO,在函数中它表现为AO。
与现代规范(Environment Record)的挂钩(⚠️ 规范术语演变):
虽然我们习惯称之为 AO/GO,但在 ES5+ 规范中,它们被统称为词法环境(Lexical Environment)下的环境记录(Environment Record)。
GO -> Global Environment Record。
AO -> Function Environment Record。
5.4 JavaScript的闭包
-
闭包的定义:
闭包(英文:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closure)。- 是在支持
头等函数的编程语言中,实现词法绑定的一种技术。 - 闭包在实现上是
一个结构体,它存储了一个函数和一个关联的环境(相当于一个符号查找表)。 - 闭包跟函数
最大的区别在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即使脱离了捕捉时的上下文,它也能照常运行。
-
MDN对JavaScript闭包的解释:
一个函数和对其周围状态(Lexical Environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(Closure)。- 也就是说,闭包让你可以在一个
内部函数 (Inner Function)中访问到其外层函数的作用域。 - 在JavaScript中,
每当创建一个函数,闭包就会在函数创建的同时被创建出来。
-
理解与总结:
- 一个
普通的函数function,如果它可以访问外层作用域的自由变量,那么这个函数和周围环境就是一个闭包。 从广义的角度来说:JavaScript中的函数都是闭包。从狭义的角度来说:JavaScript中的一个函数,如果访问了外层作用域的变量,那么它是一个闭包。
- 一个
1. 函数定义时: 引擎将函数定义时的父级词法环境的引用存储在函数对象堆内存中的[[Environment]](或称[[Scope]])内部属性中。
2. 外层函数执行完毕时: 正常情况下,外层函数的执行上下文会被销毁,其AO(活动对象)应该被垃圾回收。
3. 闭包的作用: 因为内存函数(闭包)的[[Evnironment]]仍然持有对这个AO的引用,垃圾回收机制发现这个AO仍然被引用,因此不会销毁他。(JavaScript引擎会做一些优化,只保留引用的哪些变量,会删掉那个没有引用的变量)这个被保留的AO就是闭包所捕获的"环境"或"周围状态"。

闭包的内存泄露
- 在上面的案例中,如果我们后续不再使用
add10函数了,那么该函数对象应该要被销毁掉,并且其引用着的父作用域AO也应该被销毁掉。 - 但是目前因为在全局作用域下的
add10变量对0x400的函数对象有引用,而0x400作用域中的AO(0x300)有引用,所以最终会造成这些内存都是无法被释放的。 - 所以我们经常说的
闭包会造成内存泄漏,其实就是刚才的引用链中的所有对象都是无法被释放的。
怎么解决这个问题呢
-
可以把
add10设置为null,这样就不会再对这个函数对象有0x400的引用,那么对应的AO对象0x300也就不可达了。 -
在GC的下一次检测中,它们就会被销毁掉。
-
“闭包本身不是泄漏,而是正常的内存常驻。只有当我们不再需要这个闭包,却依然保持着它的引用(比如全局变量持有),才会导致真正的内存泄漏。”
核心机制的进阶补充:闭包捕捉的是“引用”还是“值”
- 闭包捕获的是
外部变量的引用,而不是它在函数创建那一刻的快照值。
内存泄漏的更深层理解:现代引擎的优化
- 在
V8引擎中,这个被保留的AO实际上会变成一个Context对象。如果一个函数作用域里定义了100个变量,而你的闭包只用了其中1个,现代V8确实会尝试剔除那99个未使用的变量,以减轻内存压力。
闭包的“英雄时刻”:为什么要用它
私有化变量(Encapsulation):模仿类(Class)的私有属性,防止外部随意修改。柯里化与函数式编程(Currying):就像你案例里的add10,它本质上是产生了一个“有记忆”的函数。
5.5 JavaScript的内存管理
-
不管什么样的编程语言,在代码执行的过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存。
-
不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期:
- 第一步:分配申请你需要的内存(申请);
- 第二步:使用分配的内存(存放一些东西,比如对象等);
- 第三步:不需要使用时,对其进行释放;
-
不同的编程语言对于第一步和第三步会有不同的实现:
- 手动管理内存:比如C、C++等,都是需要手动来管理内存的申请和释放的(malloc和free函数);
- 自动管理内存:比如Java、JavaScript、Python等,它们有自动帮助我们管理内存;
-
对于开发者来说,JavaScript的内存管理是自动的、无形的。
- 我们创建的原始值、对象、函数...这一切都会占用内存;
- 但是我们并不需要手动来对它们进行管理,JavaScript引擎会帮助我们处理好它们;
虽然JavaScript的内存管理是自动的,但了解第三步释放是如何完成的非常重要:
释放机制:垃圾回收(Garbage Collection, GC)GC的目标:GC的目标是识别出哪些不再被引用(即程序中任何地方都无法访问到)的堆内存对象,并将其空间释放主流算法:现代的JavaScript引擎主要采用标记-清除(Mark-and-Sweep)算法以及优化版本
JavaScript的内存分配空间:
- JavaScript对于
原始数据类型内存的分配会在执行时,直接在栈空间进行分配。 - JavaScript对于
复杂数据类型内存的分配会在堆内存中开辟一块空间,并且将这块空间的地址返回给变量引用。 - "避坑指南:
现代引擎的优化"- 小整数 (SMI):确实直接存在
栈或寄存器里。 - 字符串 (String):虽然是
原始值,但在V8中通常存在堆里,因为字符串可能非常大。 - 闭包变量:即便是一个
数字(原始值),如果它被闭包引用了,它也会被提升到堆中(进入 Context 对象),否则函数出栈它就没了。 “通常情况下,基础类型的简单局部变量分配在栈中,而对象、数组以及复杂的原始值分配在堆中。”
- 小整数 (SMI):确实直接存在
常见的GC算法
引用计数(Reference counting)
- 当一个对象有一个引用指向它时,那么这个对象的引用就会+1。
- 当一个对象的引用为0时,这个对象就可以被销毁掉。
- 这个算法有一个很大的弊端就是会产生循环引用:

标记清除(Mark-Sweep)
- 标记清除的核心思路是可达性(Reachability)
- 这个算法是设置一个根对象(Root Object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象,对于哪些没有引用到的对象,就认为是不可用的
- 这个算法可以很好的解决循环引用的问题

- 整理:
问题:标记清除后,内存中会留下大量不连续的“碎片”(就像奶酪里的洞)。解决:Mark-Compact会在清除后,将所有存活的对象向内存的一端移动,让剩余空间连成一片。这避免了“明明有空间,却因为太碎片化而放不下大对象”的尴尬。
V8 的“分代回收” (Generational Collection)
- 现代 JS 引擎
不仅仅是简单的标记清除,它们基于一个著名的假设:“大部分对象在内存中存活的时间都很短”(越年轻的对象死得越快)。
V8 的分代策略,V8 将堆内存划分为两个主要区域
新生代 (Young Generation):- 存放生存
时间短的对象。 - 算法:使用
Scavenge算法(将内存平分为From和To两块,活着的复制到另一半,剩下的全删掉)。这非常快!
- 存放生存
老生代 (Old Generation):- 存放
生存时间长、或者从新生代“晋升”上来的对象。 - 算法:使用你笔记里的
Mark-Sweep(标记清除)和Mark-Compact(标记整理)。
- 存放
意外的全局变量:未声明的变量挂在window上。被遗忘的定时器:setInterval没被clearInterval,里面的变量会一直常驻。脱离 DOM 的引用:在JS里引用了一个DOM节点,后来这个节点被页面删除了,但JS里的变量没清空,整个节点及其子树都无法回收。
6. JavaScript函数增强知识
6.1 函数也是一个对象
- 在JavaScript中,函数也是一个对象,那么这个对象也会拥有一些属性,常用的属性如下:
arguments不包含默认值,这在现代JS(严格模式)中是对的,但在旧代码(非严格模式)中会有一个诡异的"联动"现象:非严格模式:arguments与形参是同步的。修改形参,arguments里面的值也会变。严格模式/带默认值/Rest参数:arguments变成一份纯粹的"原始快照"。修改形参,arguments不会跟着变。- 只要函数参数使用了
默认值、解构赋值、或Rest参数,该函数会自动进入一种类似“准严格模式”的状态,此时arguments不再与形参同步,无论你有没有写'use strict'。 - 箭头函数中的
arguments在node环境中,如果是在CMD模式下,node会在全局注入一个arguments参数。但在ESM模式下,会找不到直接报错。
6.2 纯函数的概念
纯函数的维基百科中的定义:
- 在程序设计中,若一个函数符合
以下条件,那么这个函数被称为纯函数:- 此
函数在相同的输入值时,需产生相同的输出; - 纯函数的
输出仅取决于输入值,与函数外部的隐藏信息或状态无关,且执行过程中不产生任何副作用(如I/O操作或外部状态修改); - 该函数不能有语义上可观察的函数副作用,诸如:
'触发事件',使输出设备输出,或更改输出值意外物件的内容等。
- 此
- 用
通俗易懂的语言总结一下就是:确定的输入,一定会产生确定的输出;- 函数在执行的过程中,
不能产生副作用;
- 副作用的概念:
- 在计算机科学中,也引用了副作用的概念,表示在执行一个函数时,除了返回函数值之外,还对调用函数产生了附加的影响,比如
修改了全局变量,修改参数或者改变外部的存储; 副作用往往使产生bug的温床;
- 在计算机科学中,也引用了副作用的概念,表示在执行一个函数时,除了返回函数值之外,还对调用函数产生了附加的影响,比如
- 副作用(Side Effects)的常见清单
在上面的笔记中提到了修改全局变量和外部存储,这很棒。在前端开发中,以下行为都属于副作用:
- 修改参数:比如直接修改传入的对象属性(这会导致外部引用该对象的地方莫名其妙的报错)。
- 发送网络请求:比如fetch('/api')。
- DOM操作:如document.getElementById('app').innerHTML = '...'。
- 控制台打印:连console.log其实也是副作用(它改变了I/O设备的输出状态)。
- 读取非局部变量:读取Date.now()或Math.random()(因为这会导致相同输入却得到不同输出)。
- 为什么说副作用是"Bug"的温床
想象一个大型的Vue项目
- 你调用了一个checkUser()函数,以为它只是返回布尔值。
- 结果这个函数内部偷偷修改了全局的UserInfo。
- 导致另一个组件的显示突然变了。
- 你去排查那个组件,发现代码没动过。
- 这种远程伤害会让调试变得极其痛苦。而纯函数是隔离的,它的影响只在return那一瞬间发生。
- 纯函数带来的"技术福利"
在面试的时候,如果能说出纯函数有哪些好处,档次瞬间提升:
- 可缓存性(Memoization):既然相同的输入一定得到相同的输出,我们就可以把结果缓存起来。
- Vue的computed计算属性:底层就是利用了这个原理,只有依赖项变了才重新计算;
- 可测试性:测试纯函数不需要模拟复杂的浏览器环境(DOM、网络),只需要输入几个值,看输出对不对。
- 并发/并行安全:因为纯函数不修改外部变量,所以多个任务同时运行也不会产生"竟态条件"。
- 一个典型的对比
非纯函数
纯函数
引用透明性 (Referential Transparency)
-
如果
一个函数是纯的,那么调用这个函数的表达式可以被它的输出结果直接替换,而不影响程序的任何行为。 -
案例: 如果
sum(2, 3)永远等于5,那么代码里所有的sum(2, 3)都可以直接换成5。这种特性让编译器优化(如内联优化)和代码重构变得极其安全。
副作用清单的“进阶”:不可变性 (Immutability)
为什么 React/Redux/Pinia 强制要求纯函数
在现代框架中,状态的改变是通过比较新旧对象来检测的。
- 如果你使用
“非纯函数”直接修改了旧对象的属性,对象的引用地址没变,框架可能检测不到变化,导致页面不刷新。 - 如果使用
“纯函数”返回一个新对象,框架通过Object.is(old, new)瞬间就能发现变化。这就是为什么Redux的Reducer必须是纯函数。
6.3 函数柯里化
柯里化也是属于函数式编程里面一个非常重要的概念:
- 是一种关于函数的
高阶函数。 - 它不仅被用于
JavaScript,还被用于其他编程语言。
维基百科的解释:
- 在
计算机科学中,柯里化(Currying),有译为卡瑞华或加里化。 - 是把
接收多个参数的函数,变成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数,而且返回结果的新函数的技术。 - 柯里化声称:
"如果你固定某些参数,你将得到接受余下参数的一个函数"
总结:
- 只传递给函数
一部分参数来调用它,让它返回一个函数去处理剩余的参数。 - 这个
过程就称之为柯里化。 - 柯里化是一种
函数的转换,将一个函数从可调用的f(a, b, c)转换为可调用的f(a)(b)(c)。 - 柯里化
不会调用函数。它只是对函数进行转换。
柯里化优势一:函数的职责单一
- 在
函数式编程中,我们其实往往希望一个函数处理的问题尽可能的单一,而不是将一大堆的处理过程交给一个函数来处理。 - 那么我们是否就可以将每次传入的参数在单一的函数中进行处理,处理完成后在下一个函数中再使用处理后的结果。
柯里化优势二 - 函数的参数复用
- 另外一个使用柯里化的场景是可以帮助我们复用参数逻辑:
- makeAdder函数要求我们传入一个num(并且如果我们需要的话,可以在这里对num进行一些修改)
- 在之后使用返回的函数时,我们不需要再继续传入num了
柯里化案例联系
柯里化高级 - 自动柯里化函数
- 使用箭头函数进行优化
如果面试管问:"你的柯里化函数能处理fn.length获取不到的带默认参数的清吗?"
你会发现:如果函数定义是function(a, b = 1, c) {},fn.length只有1。此时,柯里化会失效。应对策略:在实际工程中,我们会约定柯里化只用于参数确定的纯工具函数。如果遇到不确定的,手动写() => () => {}往往比通用的Currying工具函数更清晰。
概念进阶:柯里化 (Currying) vs 偏函数 (Partial Application)
柯里化 (Currying):将一个n元函数转换成n个一元函数。必须连续调用n次才能得到结果。偏函数 (Partial Application):固定一个函数的部分参数,返回一个更少元的函数。比如固定了1个参数,剩下的2个参数可以一次性传入。联系:柯里化通常是通过偏函数来实现的,但柯里化要求更“彻底”(每次只传一个)。
性能层面的“冷思考”
闭包积压:柯里化每调用一次就会产生一个新的 FEC(函数执行上下文)和AO(活动对象)。在参数非常多或高频调用的场景下,这会产生大量的闭包。执行开销:相比于直接调用f(a, b, c),柯里化需要经过多次函数嵌套调用,这在CPU密集型场景下会有微小的性能损失。结论:柯里化是为了代码的逻辑表达力和复用性,但在极端追求性能的底层库中要慎用。
自动柯里化函数的“防御性”增强
fn.length无法获取带默认值参数的长度,这确实是Currying工具函数的软肋。除了手动写箭头函数,你还可以给面试官提供一个“暴力但有效”的方案:- 面试
加分项:手动指定参数长度 - 修改
Currying函数,允许用户手动传入一个arity(参数个数):
补充:柯里化的“反向”操作
- 有时候我们拿到了一个
柯里化的函数,却想一次性调用它。这在函数式编程中叫Uncurrying(反柯里化)。虽然不常用,但了解它能让你的体系更完整。
6.4 组合函数概念的理解
组合(Compose)函数是在JavaScript开发过程中一种对函数的使用技巧和模式:
- 比如我们需要对
某一个数据进行函数的调用,执行两个函数fn1和fn2,这两个函数是依次执行的; - 那么如果
每次我们都需要进行两个函数的调用,操作上就会显得重复; - 那么
是否可以将这两个函数组合起来,自动依次调用呢? - 这个过程就是对
函数的组合,我们称之为组合函数(Compose Function);
实现组合函数
1. 一个关键的行业习惯:执行方向
在数学和主流函数式库(如Lodash、Redux)中,compose的执行顺序通常是从右往左(Right-to-Left)
- 原因:它
模拟的是数学中的复合函数f(g(h(x)))。 - 上面的实现:目前是
"从左往右"执行的(这在有些库里叫pipe而不是compose)。
"既然从左往右更符合直觉",为什么像Redux这样得库还要坚持从右往左呢?
- 因为这样
更符合声明式编程得习惯。在Redux中,我们希望最外层的中间件先拦截Action,最内层的中间件最后处理。这种从右往左的嵌套,在代码表达上就像是给dispatch穿上一层层的"外壳",最右边的函数是最内核的操作。 - Pipe版:符合
人类直觉的流水线 - Compose版本:符合
数学定义的标准实现
2. 现代ES6进阶的写法:reduce
3. 为什么要用组合函数(实战场景)
进阶思考:异步组合 (Async Compose)
- 在真实业务(如
Node.js中间件)中,函数往往是异步的(返回 Promise)。 - 如果
fn1和fn2都是async函数,普通的compose会失效(因为它会把Promise对象传给下一个函数,而不是结果)。我们需要一个Async Pipe:
- 这种
模式在处理“先查数据库、再调接口、最后写日志”的业务流程时极其强大。
6.5 with与eval
如果不打算做编译器、不打算研究20年前的老古董项目,完全没必要把这些作为重点去记
- 为什么它们是"禁区"?
- 性能杀手(最核心的原因)
- JavaScript引擎(如V8)在执行代码前会进行静态分析优化。引擎会预先确定变量在哪个作用域,从而实现极快的访问:
- Eval/With的破坏性:由于它们可以在运行时动态修改作用域(eval能凭空变出一个变量,with能把对象属性变成变量),引擎无法进行静态优化;
- 后果:一旦使用了这两个东西,引擎会直接放弃对整段代码的优化,导致运行速度断崖式下跌;
- 安全隐患(XSS的帮凶)
- eval会执行任何传给它的字符串。如果这段字符串来自用户输入(比如URL参数或评论区),攻击者就可以在你的网站上执行任意恶意脚本;
- 代码逻辑混乱
- with会让作用域变得极其模糊,连你自己都不知道这个变量是来自外部,还是来自with绑定的那个对象;
- 它们现在的处境
- 严格模式("use strict";):
- with在严格模式下是直接禁用的(报语法错误);
- eval在严格模式下有自己的作用域,不再能随意修改外部变量;
- 构建工具(Vite/Webpack):
- 如果你在Vue3项目中写了这些,打包工具通常会给出警告,甚至可能导致混淆代码时出错;
- 虽然业务开发不用,但有一个场景它们还没"死透":沙箱环境(Sandbox)
- 冷知识:在写一些低代码平台或插件系统中,为了让用户输入的代码在一个受限的环境里运行,开发者会组合with和Proxy来实现一个简单的沙箱,防止用户代码污染全局window。
深度补充:V8 为什么怕它们?(隐藏类与作用域缓存)
- V8 的 Inline Cache (IC) 失效,V8 引擎为了提速,会通过隐藏类(Hidden Class)缓存变量的偏移量。
- 正常情况:引擎知道 name 变量就在当前作用域的第 2 个槽位,直接去取就行。
- 有了 with/eval:引擎在执行前完全无法确定 name 到底是指向外部变量,还是指向 with(obj) 里的 obj.name。
- 后果:引擎不得不放弃所有预编译优化,退回到最原始的、最慢的“逐级作用域链查找”模式。
知识对比:eval() vs new Function()
- 在提到“沙箱”或“动态执行代码”时,面试官经常会问:“既然 eval 不好,那 new Function 呢?”
- 结论:如果你真的需要动态执行一段字符串代码(比如解析模板引擎),new Function 通常是比 eval 更好的选择,因为它对局部作用域的污染更小
补充:沙箱(Sandbox)的经典组合
- 你提到的“Proxy + with”实现沙箱,是目前前端最顶级的面试题之一(比如 Single-spa 或 Qiankun 微前端框架的底层原理)。
- 这里的 with 充当了“传送门”,而 Proxy 充当了“守卫”,两者结合实现了一个简易的代码隔离环境。
纠一个小细节:eval 的两种调用方式
- 这是 JS 里一个非常阴险的特性,建议作为“冷知识”存入笔记:
- 直接调用 eval():在当前作用域执行,能访问局部变量。
- 间接调用 (0, eval)():在全局作用域执行。很多库为了安全,会故意写成 (0, eval)(str) 来确保代码不会偷看局部变量。
6.6 严格模式
严格模式(Strict Mode)它不是什么过时的老古董,而是现代JavaScript的基础运行标准(Vue3、React的源码全是基于严格模式运行的)
严格模式:JS的"防错过滤器":
- 消除静默错误(把"坑"变"报错")
- 在普通模式下,有些错误JS会装作没看见,让程序带着Bug跑。严格模式下,这些会直接抛出错误:
- 禁止意外创建全局变量:比如由于笔误把count = 10写成了cont = 10,普通模式会直接在window上挂个新属性,严格模式则报ReferenceError。
- 写保护:给只读属性赋值、给不可扩展的对象添加属性,都会直接报错。
- 优化性能(引擎的最爱)
- 正如我们之前聊到的eval时提到的,严格模式让代码更加可预测。
- 禁用with:强制让作用域在编译阶段就确定下来;
- 参数唯一性:要求函数参数名不能重复;
- 优化V8引擎执行效率:由于排除了很多不确定因素,V8引擎可以更深层次地进行脱水优化,提升代码运行速度;
- 增强安全性(保护全局环境)
- 禁止this指向全局:在严格模式下,如果一个函数被直接调用(非方法调用),其内部的this是undefined,而不是window。
- 避坑指南:这是防止你在函数内部不小心通过this.name = 'xxx'修改了全局变量;
- 为未来铺路(ES6+的前哨站)
- 保留字锁定:严格模式下,一些未来可能成为关键字的词(如implements,interface,let,package等)被禁止作为变量名。
- 块级作用域的规范化:让代码的行为更符合ES6之后的逻辑直觉。
深度联动:严格模式下的 this 变化
-
在非严格模式下,如果你调用构造函数漏写了new: function Person() { this.name = 'Mio' }->Person()这会导致window.name莫名其妙变成了'Mio'。 -
在严格模式下,this是undefined,执行this.name会直接抛出TypeError。这种“报错”其实是在保护你的全局环境不被污染。
严格模式对 arguments 的“去耦”
- 这是你之前“函数增强”笔记的完美补充。在严格模式下,arguments 对象的行为发生了本质变化:
- 不再追踪参数变化:
- 禁止使用 arguments.callee:在严格模式下访问 callee(指向函数自身)会报错。这有利于引擎进行内联优化,因为引擎不再需要维护这个复杂的递归引用。
性能优化的真相:静默错误变异常
- 你提到“引擎的最爱”,这里有一个非常具体的点:
-
在非严格模式下,JS引擎在执行每一行赋值操作时,都要去检查:这个属性是不是只读的?这个变量是不是全局的?如果报错了,引擎还得想办法“静默处理”掉它。 -
在严格模式下,这些检查变成了硬性的语法约束。引擎可以假设开发者写出的代码是“合规”的,从而跳过大量的运行时检查逻辑,直接进入高速执行轨道。
严格模式的“怪癖” —— 删不掉的变量
- 在严格模式下,delete 操作符也变得“诚实”了:
- 非严格模式:var a = 1; delete a; 返回 false(但静默失败,变量还在)。
- 严格模式:delete a; 会直接抛出 SyntaxError。它强制告诉你:通过变量声明定义的标识符是不能被删除的。
作用域的微调:eval 的隔离
- 你在 with/eval 笔记里提到了这一点,这里可以写得更具体:
- 严格模式下的 eval 拥有独立作用域。这意味着在 eval("var x = 10") 之后,你在外部是访问不到这个 x 的。这极大地增加了代码的可预测性,防止了动态代码“偷梁换柱”修改你的局部变量。
7. JavaScript对象的增强知识
7.1 对象属性描述符
之前我们的属性都是直接定义在对象内部,或者直接添加到对象内部的,但是这样来做的时候我们就不能对这个属性进行一些限制,比如这个属性是否可以通过delete删除,这个属性是否能被for-in遍历的时候被遍历出来呢
如果我们想对一个属性进行比较精准的操作控制,那么我们就可以使用属性描述符。通过属性描述符可以精确的添加或修改对象的属性。属性描述符需要使用Object.defineProperty来对属性进行添加或修改
属性描述符:
- 数据属性(Data Properties)描述符(Descriptor)
- 存取属性(Accessor访问器 Properties)描述符(Descriptor)
- 关于
set/get与writable/value的并存问题
- 在
JavaScript中,属性描述符分为两类:数据描述符(Data Descriptor)和存取描述符(Accessor Descriptor)数据描述符:拥有value和writable;存取描述符:拥有get和set;
底层原因:value和get都在定义"如何取值",writable和set都在定义"如何存值"。如果你同时定义了value和get,JS引擎就糊涂了。"我到底该直接给你那个值,还是该运行那个函数来算个值给你?"。报错现场:如果你尝试混合使用,浏览器会直接甩给你一个TypeError:Invalid property descriptor. Cannot both specify accessors and a value or writable attribute.。
- 关于
set/get必须配合"外部变量"
- 结论:是的,必须有一个
"避风港"变量。 - 如果在
set内部直接对当前属性赋值,或者在get内部直接读取当前属性,就会触发递归调用,最终导致栈溢出(Maximun call stack size exceeded)。 - 解决方案:常见的两种
"避风港" - 方案A:使用
隐藏变量(约定俗成的下划线)这是最常用的做法,在对象内部开辟一个"私有空间"。
- 方案B:
利用闭包(更安全,外部不可见)
- 笔记总结:
-
对象属性描述符(Property Descriptor)
互斥性:数据描述符(value/writable)与存取描述符(get/set)不可共存;死循环风险:在get/set中操作属性本身会导致递归崩溃,需配合闭包变量或私有属性(_prop)使用;配置项:别忘了还有enumerable(是否可枚举)和configurable(是否可删除或修改描述符);
-
既然
Object.defineProperty有这么多限制(比如必须手动搞个外部变量、不能监听新增属性、不能监听数组下标),Vue3为什么转向了Proxy?- 在
Proxy中,你不再需要为每个属性手动创建"外部变量",因为它代理的是整个对象。
- 在
7.2 对象加锁
JS对象保护等级表

- 它们都是"浅层保护"(Shallow)
- 这是最容易翻车的地方。如果你冻结了一个对象,但对象内部还嵌套了另一个对象,内部的对象依然是可以修改的。
- 面试加分点:如果要实现真正意义上的冻结,需要参考"深拷贝"的思路,写一个deepFreeze(递归冻结)
- 配置(Configurable)的本质
- Object.seal和Object.freeze都会把属性的configurable设置为false。这不仅意味着不能删除,还意味着你不能再通过Object.defineProperty去修改该属性的特性(比如把enumerable从true改成false)。
- 严格模式下的表现
在非严格模式下,如果你尝试修改一个被freeze的对象,它只会静默失败(不报错,但改不动)。但在严格模式下('use strict'),它会直接抛出TypeError。这是现代框架(Vue/React)推荐的行为。
如果你尝试修改一个 writable: false 的属性,非严格模式下也是静默失败。这是 JavaScript 历史遗留的“静默失败”特性,不仅限于被冻结的对象。
- 对应的检测方法
别忘了这三个成对出现的"体检"API:
- Object.isExtensible(obj):是否还能加属性;
- Object.isSealed(obj):是否被密封了;
- Object.isFrozen(obj):是否被冻结了;
- 总结:
- 什么时候用preventExtensions?当你希望一个配置对象的键(Key)是固定的,不希望其他开发者往里面乱塞东西时。
- 什么时候用seal?当你定义了一个严格的数据模型(比如一个老师的实例),既不准加新属性,也不准删掉已有属性时。
- 什么时候用freeze?性能优化:在Vue2中展示巨大的静态列表(如长篇小说内容、历史记录单),冻结后Vue不再对其进行依赖追踪,性能暴涨。
- 常量保护:定义全局配置常量时;
核心概念补充 (让笔记更具深度)
- 默认值的陷阱 (必考题)
- 直接给对象赋值(obj.name = 'test')和通过 Object.defineProperty 定义属性,它们的默认描述符是完全不同的。
- Vue2 响应式原理与数组的局限性
- 你提到了 Object.defineProperty 的限制,引出了 Proxy。可以把这部分写得更具体一点,因为这是高频面试题:
- 为什么 Vue2 无法监听对象新增/删除属性? 因为 defineProperty 是对具体属性进行拦截。属性不存在,就无从拦截。所以 Vue2 需要提供 $set 和 $delete API。
- 为什么 Vue2 对数组的监听很弱? 理论上 defineProperty 可以监听数组的索引(比如 arr[0]),但如果数组有 10000 项,为每个索引设置 getter/setter 性能消耗巨大。因此 Vue2 放弃了监听数组索引,转而重写了数组的 7 个变更方法(push, pop, shift, unshift, splice, sort, reverse)。
- 手写 deepFreeze (深度冻结)
8. JavaScript中的面向对象
8.0 扩展知识
提问:在JavaScript中,访问对象属性都是调用get方法吗?
- 结论:从
ECMAScript规范的角度来看,是的。但从引擎执行的物理层面来看,不一定。
- 规范层面:一切皆
[[Get]]
- 在
ECMAScript规范中,当你执行obj.name时,底层会调用一个名为[[Get]]的内部隐藏方法(Internal Slot)。 - 不管你有没有手动定义
get访问器,引擎都会按照以下逻辑运行:检查属性描述符:如果该属性定义了get方法(存取描述符),则执行该函数并返回结果;默认行为:如果没有定义get,它会寻找该属性的value(数据描述符);原型链查找:如果当前对象找不到,就去原型链(proto)上重复这个过程。
- 所以,在抽象逻辑上,你可以理解为:
任何属性访问最终都会触发一个"取值动作",这个动作在规范里统称为[[Get]]。
- 引擎层面:性能优化的
"快车道"
- 虽然
规范里写着要走一套复杂的[[Get]]流程,但如果你定义的是一个像{ name: "Mio"}这样最简单的对象,V8引擎不会傻傻地去跑一遍完整的查找函数。隐藏类(Hidden Classes/Shapes):引擎会记录对象的结构;偏移量(Offset):引擎直接计算出name属性在内层中的物理位置,然后直接取值,跳过所有多余的逻辑判断;
- 结论:对于
普通属性,引擎走的是"内存直达";只有当你手动定义了get,它才会切换到"函数调用"模式。
- 一个极具深度的面试点:Proxy里的get拦截
- 在这个场景下,不管obj是不是一个普通的对象,Proxy都会把obj.name这个动作强制转换为对拦截器get函数的调用(调用的是proxy)。
get与[[Get]]的区别
get(访问器属性):是你手动在代码里写的get name() { ... }。[[Get]](内部操作):是JS引擎内部的抽象动作。即便你没写get,引擎也在执行[[Get]]操作。
- 总结:
- 直觉上:是对的,所有的
读取操作都是在Get。 - 技术上:区分
数据属性(简单取值)和访问器属性(执行函数)。 - 进阶上:理解
[[Get]]内部操作是理解Proxy拦截机制的钥匙。
提问:那是不是有明确写了get的对象,会比没有的性能差呢?
- 答案是对的。明确写了
get(访问器属性)确实比普通属性(数据属性)性能要差。 - 在
V8等现代JS引擎中,这二者的处理路径完全不同。
- 为什么
get会慢一些?
我们可以把这两种访问方式对比成"直取"和"走流程":
-
普通属性:
直达内存(快),对于const obj = { name: "Mio" },引擎会为它创建一个隐藏类(Hidden Class),并记录name属性相对于对齐起始地址的偏移量(Offset)。- 动作:
找到内存地址->读取数据; - 优化:引擎可以使用
内联缓存(Inline Cache)。下次访问时,直接去内存那个坑位拿数据,快如闪电;
- 动作:
-
get访问器:
函数调用(慢),对于const obj = { get name() { return "Mio" } },引擎必须执行以下步骤:- 保存
当前执行环境:把当前的栈信息存起来; - 创建
函数执行上下文:进入get函数内部; - 执行
代码逻辑:即使里面只有一行return; 销毁上下文并返回;- 动作:
触发函数调用->压栈->执行->出栈;
- 保存
- 差多少?(量化概念)
在微基准测试(Micro-benchmarks)中,访问get属性通常比访问普通属性慢2到10倍
- 但是(非常重要):这个
"慢"是纳秒级别的。在实际的业务开发(如:Vue的组件逻辑),这种差距几乎可以忽略不计,因为网络请求、DOM操作、甚至是一个简单数组的map消耗的时间都比这大得多。
- 既然慢,为什么Vue/React还要
大规模使用呢?
- 既然
性能差,为什么现代框架还要把所有数据都包装成getter/setter(Vue2)或Proxy(Vue3)?- 因为
"监控"比"速度"更重要:- Vue2:通过
Object.defineProperty劫持get,是为了依赖收集(知道谁在用这个数据); - Vue3:通过
Proxy拦截get,是为了实现惰性响应式(只有你访问深层对象时,才去代理深层对象);
- Vue2:通过
- 这就是典型的
"空间/时间换功能"。框架牺牲了一点微小的读取性能,换取了强大的自动化数据绑定功能。
- 因为
- 性能优化小案例:
- 总结:
get的性能确实比普通访问差,但它是"高级功能的门票"。没有它,我们就没有Vue的响应式,也没有计算属性(Computed)。
补充:为什么要有Reflect.get的receiver?
- 既然聊到了这里,我们必须把之前的那个
"夺命面试题"补全。为什么不直接使用target[key]?
- 核心逻辑:如果不传
receiver, 当发生继承时,this绑定会出错(指向了老祖宗person而不是当前的student)。Reflect.get的存在就是为了保证this永远指向最初触发访问器的那个对象。
* 在探讨get访问器与普通属性的性能差异时,你提到了隐藏类(Hidden Classes)。可以进一步补充
* 在JavaScript这种没有固定形状的动态语言里,V8为了追求类似C++ 的执行速度,会在运行时悄悄给对象“塑形”`。
* 当你写出 const obj = { name: "Mio" } 时,引擎在底层构建了一个类似 C++结构体(Struct)的内存映射,利用内联缓存(Inline Cache, IC)记录下 name属性的精确内存偏移量。
* 所以读取普通属性就是一次指针偏移的寻址操作,而触发 get访问器则强行打破了这种极速寻址,退化为了函数调用的开销。
8.1 认识对象原型
在JavaScript中,每个对象属性都有一个特殊的内置属性[[prototype]],这个特殊的对象可以指向另外一个对象。
那么这个对象有什么用呢?当我们通过引用对象的属性key来获取一个value时,它会触发[[Get]]的操作。这个操作会首先检查该对象是否有对应的属性,如果有的话就使用它。如果对象中没有该属性,那么会访问对象[[prototype]]内置属性指向的对象上的属性。
获取这个对象的方式有两种:
- 方式一:通过对象的
__proto__属性可以获取到(但是这个是早期浏览器自己添加的,存在一定的兼容性问题)。 - 方式二:通过
Object.getPrototypeOf方法可以获取到。
8.2 认识函数的原型
所有的函数(除了箭头函数)都有一个prototype属性(也称之为显示原型)(注意:不是__proto__),这个属性只有函数才拥有,对象是没有这个属性的。(虽然函数也是对象的一种)
8.3 回看new操作符
我们之前学过,当对一个构造函数使用new关键字的时候,可以创建一个对象。
步骤如下:
-
在
内存中创建一个新的对象(空对象)。 -
这个对象内部的
[[prototype]]属性会被赋值为该构造函数的prototype属性。 -
绑定
this: 将构造函数内部的this绑定到这个新创建的对象上,并执行函数体(为对象添加属性)。 -
返回值处理: 如果
构造函数没有显式返回一个非原始值的对象,则默认返回刚刚创建的这个新对象;如果显式返回了其他对象,则覆盖原来的新对象。
那么也就意味着我们通过Person构造函数创建出来的所有对象的[[prototype]]属性都指向Person.prototype。
8.4. constructor属性
事实上,原型对象上面是有一个属性的:constructor。默认情况下,原型上都会添加一个属性叫做constructor,这个constructor指向当前的函数对象。
8.5. 原型链总结
根据前面的知识,我们将画一幅图来直观的查看下对象与构造函数的关系:

- 通过
__proto__.__proto这条链条,我们称之为原型链。
8.6. 重写原型对象
如果我们需要在原型添加过多的属性,通常我们会重写整个原型对象:
8.7. 面向对象的特性 - 继承
继承可以帮助我们将重复的代码和逻辑抽取到父类中,子类只需要直接继承过来使用即可;在很多编程语言中,继承也是多态的前提。
我们来观察前面的那一幅图,可以发现,继承我们需要创建一个对象,让子类的原型链指向父类的原型链。

Object是所有类的父类:从上面的图中,我们可以看出,原型链的最顶层的原型对象就是Object的原型对象
8.8. 函数本质也是一个对象
函数本身也是一个对象,它是Function构造函数的实例。所有函数本身也有一个隐式原型(__proto__)。里面的东西和上面的概念也是一样的
由于function本身也是一个对象,所以我们可以直接给他添加属性,添加的属性称之为类属性或类方法(在现代开发中,被称为静态成员Static Members )
一个容易被忽略的细节:继承静态属性
8.9. 对象方法补充
instanceof的伪代码
关于 instanceof 的手写代码实现非常优雅。可以加一个防御性注释:在现代环境检测中,如果对象使用了 Symbol.hasInstance,原生的 instanceof 会优先触发该 Symbol 方法,这也是改变 instanceof 默认行为的唯一后门。
8.10. ES6 class关键字
ES6的class并不是一种全新的继承模型,而是原型链继承的语法糖。它让对象原型的写法更加清晰、更像面向对象编程。
本质:是一个特殊的函数。 特点:
- 不可提升:必须点定义再使用(存在暂时性死区)。
- 严格模式:类体内的代码默认运行在strict mode。
- 不可枚举:类中定义的方法默认是不可枚举(这点比ES5手动改原型更好)。
继承(Inheritance)
- 继承的底层双链条逻辑:
- 当你写下class Student extends Person,引擎自动完成了:
- 实例链:Student.prototype.proto === Person.prototype(继承方法);
- 静态链:Student.proto === Person(继承静态属性)
- 当你写下class Student extends Person,引擎自动完成了:
ES6继承 VS. ES继承
- 创建顺序反向:
- ES5:先创建子类实例this,在通过Parent.call(this)去增强它;
- ES6:先由父类构造函数完成实例创建(super),再由子类修饰。这就是为什么ES6才能完美继承原生Array或Error的原因。
- 静态成员继承:
- ES5:默认不继承静态属性,除非手动设置proto;
- ES6:原生支持静态属性/方法继承;
进阶:私有属性(Private Fields)
避坑:
- 忘记super():在派生类的constructor中,在使用this之前必须调用super()。
- 方法丢失this:类方法如果被结构出来单独调用(例如:
const { sayHi } = p),this会指向undefined。建议使用箭头函数定义属性或使用.bind(this)。 - 计算属性名:class支持动态方法名,如
[methodName]() { ... }。
可视化 ES6 的“双重链条”(补充至 8.10 class 继承)
你非常精准地指出了 ES6 继承中的“实例链”和“静态链”。这是 ES6 class 语法糖比 ES5 原型链更完美的地方。用一句直白的话概括:
实例链: 决定了 Student 的实例能调用 Person 实例的方法。
静态链: 决定了 Student 类本身能调用 Person 类的静态方法(比如 Student.isHuman())。
8.11. ES6的继承与ES5继承的区别
在ES5中,实例是由子类Child先创建出来的,然后再通过Parent.call(this)把父类的属性强行"塞"进子类的实例里。
顺序:先创建子类的this -> 再修饰(借用父类属性)。
局限性:由于this是先由子类生成的,子类无法预先知道父类的某些特殊行为(比如原生对象Array、RegExp的内部属性),所以ES5很难完美继承原生内置类。
ES6的class机制完全相反。它要求必须先调用super(),这本质上是先让父类创建出实例,然后再由子类的构造函数来加工这个实例。
顺序:先由父类创建this -> 子类接收加工
强制要求:在constructor中,必须先写super()才能使用this,否则报错。
优势:由于this是从父类"降生"的,子类可以完美继承父类的所有特性,包括内置类(如Array)的私有属性。
为什么ES5无法完美继承Array,而ES6可以呢?因为Array的length等内部属性是定义在父类实例上的。ES5是先创建子类this,此时这个this只是个普通对象,不是真正的数组实例。而ES6是先由Array构造函数创建出真正的数组this,子类在继承,所以能获得数组的所有原生魔法。
总结:ES5继承是"子类对自己进行整容,使其长得像父类";而ES6继承是"父亲生了个孩子,孩子在父亲的基础上进行二次发育"。
8.12. 多态
-
核心定义:多态的本质是解耦。它允许你编写通用的代码,而不需要关心对象具体的类型,只要对象拥有约定的方法即可。
-
JavaScript多态的三种表现形式:
- 基于继承的多态(最经典):子类重写(Override)父类的方法。当调用相同名字的方法时,不同子类表现出不同的行为;
- 鸭子类型(Duck Typing - 动态语言的精髓):这是JS多态最强大的地方。"如果它走起来像鸭子,叫起来也像鸭子,那它就是鸭子"。两个对象之间不需要有继承关系,只要它们拥有相同名称的方法,就可以被同样对待;
- 运算符/方法的多态(内建多态):同一个方法在不同的内置对象上有不同的实现;
- 例如:toString()方法在Array、Object、Number上返回的结果完全不同。
- 例如:+运算符在数字间是加法,在字符串间是拼接。
- 为什么JavaScript不需要"接口"?在Java中,你必须声明implements Shape才能确保有draw()方法。在JavaScript中:
- 动态检查:引擎在运行时直接去找方法,找到了就执行,找不到才报错;
- 灵活性:我们可以随时给对象增加方法来满足多态的需求;
- 多态在实战的应用:取代冗长的if-else
鸭子类型与强类型语言的对比
在 Java 或 TypeScript 这样强调类型安全的语言中,多态通常需要通过明确声明的接口(Interface)来作为约束契约(比如 class Dog implements Animal),系统在编译阶段就会检查类是否具备对应的方法。
而 JavaScript 彻头彻尾的“鸭子类型”意味着它的多态是隐式的。对象之间不需要建立严格的继承树,只要在运行时具备相应的行为签名,契约就自然成立。这让系统设计彻底摆脱了冗长的类型声明,是动态语言高扩展性的灵魂所在。
JS多态的灵魂总结:
- 表现:子类重写父类方法,或不同对象拥有同名方法。
- 前提:JS是动态类型,不需要显示的接口声明,天然支持"鸭子类型"。
- 价值:消除复杂的条件分支(if/switch),提高代码的可维护性和扩展性。

