JavaScript

JavaScript进阶:DOM | BOM | JavaScript高级

1. DOM对象模型

DOM节点初步认识

  • HTML家族大致继承图

DOM对象模型

层级类名 (Constructor)提供的核心功能
第一层 (最底端)HTMLDivElement专门针对 div 的属性(虽然 div 属性不多)。
第二层HTMLElement所有 HTML 标签共有的属性:style, id, className, title
第三层Element所有元素(含 XML/SVG)共有的能力:getAttribute(), querySelector(), children
第四层Node所有节点共有的基础:parentNode, childNodes, appendChild(), nodeType
第五层EventTarget事件机制:addEventListener(), removeEventListener(), dispatchEvent()
第六层 (最顶端)ObjectJavaScript 所有对象的共同祖先:toString(), hasOwnProperty()
  • 代码验证
HTML继承关系
const el = document.createElement("div");
const protoChain = [];
let curr = el;

/* Object.getPrototypeOf() 静态方法返回指定对象的原型(即内部 `[[Prototype]]` 属性的值) */
while (Object.getPrototypeOf(curr)) {
  curr = Object.getPrototypeOf(curr);
  /* obj.constructor.name 打印构造函数的名称 */
  protoChain.push(curr.constructor.name);
}

console.log(protoChain.join(" -> "));
// 输出通常为: HTMLDivElement -> HTMLElement -> Element -> Node -> EventTarget -> Object
Tip

注释节点不属于"元素",所以它的原型链不经过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,但根据用途和特性不同,主要分为以下几个核心类别:

  1. 元素节点(Element Node):这是最常用的一种节点,对应HTML页面中的所有标签
  • 特征:比如<div><a><p>等;
  • NodeType值:1(即:Node.ELEMENT_NODE);
  • 属性:拥有idclassNameattributes等属性;

  1. 属性节点(Attribute Node):虽然属性是标签的一部分,但在DOM结构中,它们也可以被视为独立的节点
  • 特征:元素的属性,如class='active'src='logo.png'
  • NodeType值:2(Node.ATTRIBUTE_NODE);
  • 注意:在现代开发中,通常直接通过element.getAttribute()访问,很少将其作为独立节点进行操作。
html
<div class="box container">box</div>

<script>
  const boxEl = document.querySelector(".box");

  console.log(boxEl.attributes.getNamedItem("class").nodeType); // 2
  console.log(boxEl.attributes.getNamedItem("class").nodeName); // class
  console.log(boxEl.attributes.getNamedItem("class").nodeValue); // box container
</script>
小提示

    从DOM4规范开始,Attr(属性)已经不再继承自Node了,尽管为了兼容性,浏览器依然保留了nodeType: 2这个属性

    💡如果你在面试中被问到"DOM节点有哪些类型",你能说出nodeType: 2是属性节点,面试管会觉得你底层功底非常扎实。但在写业务代码时,记住用getAttribute即可。


  1. 文本节点(Text Node):标签内部包含的所有文字内容
  • 特征:包括换行、空格和实际的文字;
  • NodeType值:3(即Node.TEXT_NODE);
  • 注意:文本节点不能包含子节点;
HTML
console.log(boxEl.childNodes);

  1. 注释节点(Comment Node):HTML源代码中的注释部分
  • 特征:<!-- -->;
  • NodeType值:8(即Node.COMMENT_NODE);

  1. 文档节点(Document Node):整个文档的根节点,也就是JavaScript中的window.document
  • 特征:代表整个HTML文档,是 DOM 树的根节点,也是所有其他节点的宿主或创建者。
  • NodeType值:9(即Node.DOCUMENT_NODE)

  1. 文档类型节点(DocumentType Node):HTML文档开头的<!DOCTYPE html>
  • NodeType值:10(即Node.DOCUMENT_TYPE.NODE)

总结:

节点类型对应常量nodeTypenodeNamenodeValue
元素节点Node.ELEMENT_NODE1标签名 (大写)null
属性节点Node.ATTRIBUTE_NODE2属性名属性值
文本节点Node.TEXT_NODE3#text文本内容
注释节点Node.COMMENT_NODE8#comment注释内容
文档节点Node.DOCUMENT_NODE9#documentnull
  • 在实际开发中,可以通过nodeType属性来识别:
JavaScript
const myElement = document.querySelector("div");

if (myElement.nodeType === Node.ELEMENT_NODE) {
  console.log("这是一个元素节点");
}

知识扩展:

javascript
const ul = document.getElementById("myList");
/* 1. 创建一个“文档片段”容器 (nodeType: 11) */
const fragment = document.createDocumentFragment();

for (let i = 0; i < 1000; i++) {
  const li = document.createElement("li");
  li.textContent = `项目 ${i}`;
  /* 2. 先把子节点塞进这个“虚拟容器”里 */
  /* 此时页面没有任何变化,不会重排 */
  fragment.appendChild(li);
}

/* 3. 最后一步:一次性把容器里的所有东西搬到 ul 中 */
/* ✅ 无论里面有 1000 个还是 1 万个点,浏览器只触发 1 次重排 */
/* “阅后即焚”:当你将一个 DocumentFragment 插入到 DOM 树中时,片段本身会被“销毁”,只有它的子节点会被添加到目标位置。 */
/* 性能优越:它继承自 Node,但由于它没有父节点,因此操作它时不会导致任何文档排版或渲染的性能开销。 */
ul.appendChild(fragment);
知识扩展

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>后面紧跟着一段文字,那它就是这个divnextSibling
  • previousSibling:获取紧邻在前面的兄弟节点
  • childNodes:返回所有子节点的集合(包含文本和注释)
  • parentNode:获取父节点

    元素派(Element Level) -- 只要标签,忽略杂质。这些属性定义在Element类上,在实际开发中,我们通常只想找"标签",所以这些属性更常用。它们会自动跳过文本和注释

节点派属性 (含文本)元素派对应属性 (仅标签)功能描述
firstChildfirstElementChild获取第一个子元素标签
lastChildlastElementChild获取最后一个子元素标签
nextSiblingnextElementSibling获取下一个兄弟元素标签
previousSiblingpreviousElementSibling获取上一个兄弟元素标签
childNodeschildren获取所有子元素标签
parentNodeparentElement获取父元素

内容与属性操作(Common Properties):除了找关系,还有一些高频操作属性

  • NodeType:我们在前面聊过,用来判断是元素(1)、文本(3)还是注释(8)
  • nodeName:节点的名称。对于元素节点,它返回大写的标签名(如:"DIV"
  • textContent:获取或设置节点及其所有后代的文本内容(忽略HTML标签)
  • innerHTML:获取或设置元素内的HTML结构(这是Element特有的)
  • attributes:获取元素的所有属性节点集合
html
<div id="parent">
  <p>你好</p>
</div>

<script>
  const parent = document.getElementById("parent");

  console.log(parent.firstChild); // 可能是个换行文本节点 (#text)
  console.log(parent.firstElementChild); // <p>标签 (直接跳过换行和注释)

  const p = parent.querySelector("p");
  console.log(p.nextSibling); // 换行文本节点
  console.log(p.nextElementSibling); // null (后面没有标签了)

  parent.innerHTML = ""; /* 包含的 HTML 标签会被解析为真实的 DOM 元素 */
  parent.textContent = ""; /* 原封不动写入 */
  console.log(parent.outerHTML); /* <div id="parent"> <p>你好</p> </div> */
</script>
划重点:nextSibling vs. nextElementSibling

  nextSibling是"邻居",可能是个回车换行(文本节点),也可能是个注释

  nextElementSibling"下一个标签",它会跳过文本和注释,只找<div><span>这种在JavaScript中,childNodeschildren返回的不是普通的数组,而是伪数组(Array-like)
补充:集合的‘死与活’(高频面试题)

活的(Live Collection)

    * el.children (返回HTMLCollection) 和 el.childNodes (返回 NodeList) 是实时更新的。如果你用 JS 往页面里又塞了一个 div,这个集合的 length 会自动 +1。如果在 for 循环里边遍历边删除极其容易下标越界掉坑!

死的(Static Collection)

    * document.querySelectorAll() (返回 NodeList) 是静态快照。获取那一瞬间页面上有多少个,它就永远是多少个,后续 DOM增删改不会影响它。


1.2 元素的增删改查(现代浏览器)

JavaScript
/* 1. 创建一个元素 */
const h2El = document.createElement('h2')

/* 2. 插入元素 */
const boxEl = document.querySelector('.box')
boxEl.append(h2El) // 插入到最后一个子元素的后面
boxEl.prepend(h2El) // 插入到第一个子元素的前面
boxEl.after(h2El) // 同级别元素,自身的下一个
boxEl.before(h2El) // 同级别元素,自身的上一个
boxEl.replaceWith(h2El, ...) // 替换

/* 3. 克隆元素 */
const newH2El = h2El.cloneNode(true) // option:是否深度克隆

/* 4. 删除元素 */
h2El.remove()
扩展:DocumentFragment 的现代写法

    过去我们必须用 fragment避免多次回流。但在现代 DOM API 中,append() 支持传入多个参数或解构数组。你可以把 1000 个 <li> 存在一个真实数组里,最后一次性:ul.append(...liArray);这在底层同样只触发一次渲染,代码更简洁!


1.3 获取元素的属性(样式)信息

    浏览器为了性能,通常会把多次修改的DOM的操作攒在一起(排队),然后一次性渲染。但是,当你调用getComputedStyle(el).width这种方法时,你是在向浏览器索要最精确、最实时的计算数值。为了给你准确的答案,浏览器不得不:

  • 中断当前的优化队列。
  • 立即执行所有待处理的样式更改。
  • 重新计算整个布局(回流)。
  • 最后返回给你哪个数值。

这种"停下手头所有的活儿,先给你算个数"的行为,就是性能瓶颈的来源。

属性方法来源是否实时更新性能开销
el.style.width仅限 HTML 标签上的 style="..."是(仅限内联值)极低(不触发回流)
getComputedStyle所有 CSS 来源计算后的最终像素值是(最精确的值)高(强制回流/重绘)
  • 内联值:只能读取到你自己亲自设置给元素的值,读取不到<style>设置的值。
html
<style>
  .box {
    width: 100px;
    height: 100px;
    background-color: pink;
  }
</style>

<div class="box"></div>

<script>
  const boxEl = document.querySelector(".box");
  /* 
    原因: el.style 对象反映的是 HTMLElement 上的 style 属性。而不是浏览器最终渲染出来的计算结果。浏览器还没那么勤快到把 CSS 文件的内容自动同步到这个对象里。
  */
  console.log(boxEl.style.width); /* 打印将会为空 */
  boxEl.style.width = "200px";
  console.log(boxEl.style.width); /* 200px */
</script>

扩展:还有一个更直接拿宽度的方法,不需要解析字符串(比如把 "300px" 转成数字):

  • el.offsetWidth:直接返回数字 300(包含边框和内边距)。
  • el.clientWidth:直接返回数字(不含边框)。
  • 两个属性也是实时更新的,而且不需要管样式是内联还是外联
  • 注意: 获取到的是经过四舍五入的整数!!!
Tip

    通过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,浏览器可能需要计算盒子模型、继承值、单位转换(empx)等。最关键的是,它是针对单个特定属性进行解析的。如果你需要位置信息,它并不直观(比如:它给的是top的计算值,但这可能会受到position属性的严重影响);
    • getBoundingClientRect():它返回的是元素在像素级坐标系中的精确位置尺寸。核心优势:它是高度优化的。浏览器内部通常已经计算好了这些几何信息用于绘制。它返回的是一个简单的DOMRect对象,包含widthheighttopleftrightbottom,xy。在获取几何布局信息时,它的语义更明确,执行效率通常也会更高。

尽量利用CSS变量,如果你需要获取某些数值来进行逻辑计算,不如反过来 -- 在JS中设置CSS变量,在CSS中使用它:

JavaScript
/* 设置变量 */
/* 这样你就直接直到这个值是 20px,不需要再去读取DOM获取了 */
document.documentElement.style.setProperty("--main-padding", "20px");

使用ResizeObserver(监听尺寸变化) -- 如果你获取样式是为了监控元素大小的变化,千万不要用定时器去轮询getComputedStyle

  • 方案:使用ResizeObserver。它是异步的,性能极高,不会阻塞渲染。
  • 使用:具体用法查阅MDN文档。

性能优化的"黄金法则":读写分离,如果你必须使用getComputedStyle,请务必遵循先读后写的原则,避免布局抖动(Layout Thrashing)

重点:布局抖动
/*
  总结:
    * 回流的元凶不是循环,而是“读写交替”。
    * 只要你在读取之前修改了 DOM,下一次读取就会逼迫浏览器回流。
*/

/* 错误示范:循环触发多次回流 */
list.forEach((el) => {
  const h = getComputedStyle(el).height; // 读(回流!)
  el.style.marginBottom = h; // 写
});

/* 可以用Vue的Computed来理解! */
/* 正确示范:只触发一次回流 */
// 1. 统一读:浏览器只在第一次读取时回流一次,后续读取会复用缓存的布局信息
const heights = list.map((el) => getComputedStyle(el).height);
// 2. 统一写:浏览器把这些修改全部放入队列,最后只触发一次回流
list.forEach((el, i) => {
  el.style.marginBottom = heights[i];
});
扩展知识

    如果你追求极致性能(比如在做 60fps 的复杂动画),甚至可以使用 FastDOM 这样的库,或者利用 requestAnimationFrame 将写操作推迟到下一帧

    想了解一下 requestAnimationFrame 是如何配合这些操作来进一步压榨性能的吗?


1.4 盒子几何属性

client系列: 可视区域,这一组主要关注元素内部(不含边框)的空间

  • clientWidth/clientHeight:内容区域的宽度/高度
    • 计算公式:内容(content) + 内边距(padding)
    • 注意:不包含滚动条、边框(border)和外边距(margin)
  • clientLeft/clientTop:边框的厚度
    • clientLeft其实就是左边框border-left-width
    • clientTop其实就是上边框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):事件从元素上开始冒泡
JavaScript
const boxEl = document.querySelector(".box");

/*
  event.bubbles:(Boolean) 告诉你这个事件是否会冒泡。有些事件(如 focus, blur, scroll)是不会冒泡的,这时候在父元素监听就没用了。
  event.key / keyCode:如果是键盘事件(keydown),这是判断用户按了哪个键的核心属性。
  event.timeStamp:事件触发的时间戳(毫秒),常用于计算两次点击的间隔(判断双击)。
*/

boxEl.addEventListener(
  "click",
  function (event) {
    console.log("事件类型:", event.type);
    console.log("事件阶段:", event.eventPhase);
    console.log("事件元素中位置:", event.offsetX, event.offsetY);
    console.log("事件客户端中的位置:", event.clientX, event.clientY);
    console.log("事件页面中的位置:", event.pageX, event.pageY);
    console.log("事件在屏幕中的位置:", event.screenX, event.screenY);
    event.stopPropagation(); /* 阻止事件继续传递,到此为止 */
    event.preventDefault(); /* 阻止默认事件,比如a标签不会发生连接跳转 */
    console.log(event.target); /* 发生事件的那个元素 */
    console.log(event.currentTarget); /* 处理事件的那个元素 */
  },
  true /* 第三个参数设置为true,开启捕获 */,
);
属性对视角(从哪开始算 0,0)包含滚动距离吗?常用场景
offsetX/Y触发事件的元素左上角画布绘图、点击图片局部
clientX/Y浏览器视口(Viewport)左上角固定定位元素的交互
pageX/Y整个 HTML 页面左上角拖拽元素、判断在文档中的绝对位置
screenX/Y物理显示器屏幕左上角N/A弹出多窗口、游戏手柄交互
两个“Target”的生死恩怨

  event.target:“真凶”。 指的是真正触发事件的那个最深层的元素(比如你点的是按钮里的图标,它就是图标)。

  event.currentTarget:“捕快”。 指的是当前正在处理(捕获或冒泡)这个事件的元素。也就是你用 addEventListener 绑定的那个元素。

  💡 小技巧: 在事件函数内部,event.currentTarget 永远等于 this(如果你没用箭头函数的话)。

  • 总结:可以通过event.target来做事件委托。

进阶:stopPropagation vs stopImmediatePropagation

    event.stopPropagation(): 阻止事件向父级(冒泡)或子级(捕获)继续传递,但不阻止当前元素上绑定的其他同类型监听器执行

    event.stopImmediatePropagation(): “核武器”不仅阻止向外传递连当前元素上绑定的其他后续监听器也一并枪毙

    场景: 如果同一个按钮被不同的人绑了 3 个 click 事件,在第 1 个事件里调用这个方法,后面 2 个函数就不会执行了。


1.7 进阶

虽然我们 95% 的时间都在用冒泡,但捕获阶段存在的意义在于:让祖先元素拦截事件

  • 应用场景:比如你做一个复杂的组件库,想在点击任何子元素之前先进行权限检查或统一日志记录,就可以在 WindowBody 的捕获阶段截获事件。

2. JavaScript内置对象-Date

📅Date对象是JavaScript的内置对象,用于存储和操作日期与时间


2.1 Date对象的创建

创建Date对象是使用它的第一步。通常使用new Date()构造函数

构造方式作用示例
new Date()创建当前日期和时间。这是最常见的用法let now = new Date()
new Date(year, moth, ...)指定日期和时间。注意:月份是从0开始计数的(0-11)let christmas = new Date(2025, 11, 25);(2025年12月25日)
new Date(milliseconds)基于毫秒数创建日期。毫秒数是从1970年1月1日00:00:00UTC起算的时间戳let pastDate = new Date(0);(1970-01-01)
new Date(dateString)基于日期字符串创建(不推荐作为主要方式,因为浏览器解析可能不一致)let d = new Date("2025-10-20");

2.2 核心需要记住的概念

掌握这些核心概念,能让您理解Date对象的工作原理

  • 时间戳(Timestamp):

    • 时间戳是一个数字,代表从UTC 1970年1月1日00:00:00开始经过的毫秒数。
    • 他是计算机存储日期的标准方式,独立于时区。
  • 获取时间戳的常用方法:

    • Date.now():获取当前时间戳(静态方法,最推荐);
    • new Date().getTime():获取创建的Date实例的时间戳;
    • new Date().valueOf():效果同上;
  • 本地时间(Local Time) vs. UTC时间(Universal Time)

    • 本地时间(Local Time):基于用户计算机设置的时区显示的时间。Date对象大多数getset方法(如:getHours()setHours())默认操作的是本地时间。
    • UTC时间(Universal Coordinated Time):国际标准时间。用于确保时间在不同时区之间保持一致。所有带有UTC后缀的方法(如:getUTCHours()setUTCHours())操作的是UTC时间。

2.3 重点需要学习的方法(Getter & Setters)

Date对象提供了大量的get(获取)和set(设置)方法,用于操作日期的各个部分

🚀 掌握的核心Getter方法(获取信息)

方法返回值备注
getFullYear()/getUTCFullYear()年份(四位数)
getMonth()/getUTCMonth()月份(0-11)核心陷阱!0代表一月,11代表十二月
getDate()/getUTCDate()一个月中的第几天(1-31)
getDay()/getUTCDay()一周中的第几天(0-6)核心陷阱!0代表星期日,6代表星期六
getHours()/getUTCHours()小时(0-23)
getMinutes()/getUTCMinutes()分钟(0-59)
getSeconds()/getUTCSeconds()秒(0-59)
getTime()时间戳(毫秒)

🔧掌握核心Setter方法(设置信息)

  • Setter方法与Getter方法相应,用于修改Date对象的值。
javascript
/*
  setFullYear(year, month, date)
  setMonth(month, date):注意,传入的月份也是0-11
  setDate(date)
  ...以及所有带UTC后缀的对应方法
*/

/* 小案例:计算三天后的日期 */
let today = new Date();

let currentDay = today.getDate(); /* 获取当前日期 */

today.setDate(
  currentDay + 3,
); /* 设置日期为当前日期 + 3天,JS会自动处理月份/年份的进位 */

console.log(today); /* 输出三天后的日期 */

2.4 日期格式化(重点)

虽然Date对象本身能返回日期,但格式通常不友好。现在JavaScript推荐使用Intl.DateTimeFormat来格式化日期

方法作用示例
toLocaleDateString()根据本地环境格式化日期部分d.toLocaleDateString('zh-CN');(如2025/12/25)
toLocaleTimeString()根据本地环境格式化时间部分
toLocaleString()根据本地环境格式化日期和时间
Intl.DateTimeFormat更强大、更细致的国际化格式化工具new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'long' }).format(d)

总结要点:

  • 创建:用new Date()创建实例,注意指定日期月份从0开始。
  • 核心:时间戳是日期存储的底层基础,使用Date.now()获取。
  • 陷阱:getMonth()返回值是0-11getDay()返回值是0-6(周日是0)。
  • 操作:使用get...()获取,set...()修改。
  • 格式化:推荐使用toLocale*String()Intl.DateTimeFormat进行友好展示。

2.5 Date的两个巨坑

"避坑:Date 对象的变异性 (Mutability)"

  • 原生 Date 最大的设计败笔之一,就是它的 set 方法会直接修改原对象。
Date
const d1 = new Date();
const d2 = d1; // 只是引用赋值
d2.setFullYear(2030);

console.log(d1.getFullYear()); // 竟然也变成了 2030!

/* 为什么可怕? 在 Vue 或 React 中,如果你直接修改了状态(State)里的 Date 对象,往往不会触发视图更新(因为对象的内存地址没变)。 */
/* 正确姿势:永远先复制,再修改。 */
const futureDate = new Date(today.getTime()); // 利用时间戳克隆一个新对象
futureDate.setDate(futureDate.getDate() + 3);

苹果设备(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)。
Date
/* 
  原因:早期的 iOS 引擎严格遵守 RFC 2822 标准,不认识横杠 -,只认识斜杠 /。
  万能解法:在丢给 new Date() 之前,用正则把横杠替换成斜杠。 
*/
const safeString = "2025-12-25 12:00:00".replace(/-/g, "/");
const d = new Date(safeString); // 所有浏览器完美兼容

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

  • 什么是默认绑定? 当函数作为独立函数被直接调用,且不包含任何绑定对象时
JavaScript
/*
  this指向:
    - 在非严格模式下,this指向全局对象(浏览器中是window或global)
    - 在严格模式('use strict')下,this被绑定到undefined
*/

/* 案例一 */
function foo() {
  console.log(this); /* Window*/
}

foo();

/* 案例二 */
function test1() {
  console.log(this); /* Window*/
  test2();
}

function test2() {
  console.log(this); /* Window*/
  test3();
}

function test3() {
  console.log(this); /* Window*/
}

test1();

/* 案例三 */
function foo(func) {
  func();
}

var obj = {
  name: "Mio",
  bar: function () {
    console.log(this);
  },
};

foo(obj.bar); /* Window*/

规则二:隐式绑定(Implicit Binding

  • 函数作为对象的属性被调用,此时该函数被称之为方法
JavaScript
/*
  指向:
    - this将会指向调用该方法的那个对象
*/

/* 案例一 */
function foo() {
  console.log(this);
}

var obj = {
  name: "Mio",
  foo: foo,
};

obj.foo(); /* obj对象 */

/* 案例二 */
function foo() {
  console.log(this);
}

var obj1 = {
  name: "obj1",
  foo: foo,
};

var obj2 = {
  name: "obj2",
  obj1: obj1,
};

obj2.obj1.foo(); /* obj1对象 */

/* 
  注意:如果将方法赋值给另一个变量,再通过变量调用(就变成了独立函数调用)
  结果:那么this绑定会丢失,退化成默认绑定
*/

/* 案例三 */
function foo() {
  console.log(this);
}

var obj1 = {
  name: "Mio",
  foo: foo,
};

var bar = obj1.foo;
bar(); /* Window */

规则三:显示绑定(Explicit Binding

  • 规则:使用函数的内置方法call(), apply()bind()强制将this绑定到指定对象
JavaScript
/*
  指向:
    * this将明确指向call/apply/bind的第一个参数
*/

/*
  其中:apply再指定了this对象之后,后面的参数以数组指定,而call则为普通函数参数那样传递
*/

/* 案例一 */
function foo(arg1, arg2) {
  console.log(this, arg1, arg2);
}

foo.call({ name: "obj" }, "YQZ", "Mio"); /* {name: 'obj'} YQZ Mio */
foo.apply({ name: "obj" }, ["YQZ", "Mio"]); /* {name: 'obj'} YQZ Mio */

/* 案例二 */
/* 如果我们希望一个函数总是显示的绑定到一个对象上,可以使用bind方法,它会返回一个新的函数 */
function foo(arg1, arg2, arg3) {
  console.log(this, arg1, arg2, arg3);
}

var obj = { name: "Mio" };

var bar = foo.bind(obj, 1, 2);
bar(); /* obj对象 1 2 undefined */
bar(1); /* obj对象 1 2 1 */
bar(1, 2); /* obj对象 1 2 1 */

规则四:new绑定(New Binding

  • 规则:当函数被用作构造函数(即使用new关键字)来调用时
JavaScript
/*
  指向:
    * this绑定到新创建的那个对象实例
*/

/* 案例一 */
function Foo(a) {
  this.a = a; /* 此时的this就是新创建的实例对象 */
}

const bar = new Foo(4);
console.log(bar.a); /* 4 */

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)都会被添加到这个新对象上,从而实现对实例的初始化和定制。
javascript
function Car(color) {
  this.color = color; /* 这里的this就是新创建的Car实例 */
}

const myCar = new Car("red");
  • 返回新对象
    • 规则:构造函数执行完毕后,引擎会检查它的返回值
    • 操作与目的:
      • 如果构造函数没有显示地使用return语句,或者return的是一个原始值(如字符串数字nullundefined),那么new表达式自动返回在第一步创建的那个全新的对象(即this所指向的对象)。
      • 如果构造函数显示地return了一个非原始值(即一个对象),那么new表达式会忽略第一步创建的对象,而是返回这个显示返回的对象。(这种情况很少见,且不推荐,但这是规则的一部分)

3.3 规则优先级

默认绑定的优先级最低

  • 毫无疑问,默认绑定的规则优先级是最低的,因为存在其他规则时,就会通过其他规则的方式来绑定this

显示绑定的优先级高于隐式绑定

javascript
var obj = {
  name: "Mio",
  foo: function () {
    console.log(this);
  },
};

obj.foo.apply({ name: "YQZ" }); /* {name: "YQZ"} */

new绑定优先级高于隐式绑定

javascript
var obj = {
  name: "Mio",
  foo: function () {
    console.log(this);
  },
};

new obj.foo(); /* foo {} */

new绑定优先级高于bind

javascript
/*
  new绑定和call,apply是不允许同时使用的,所以不存在谁的优先级更高
  new绑定可以和bind一起使用,new绑定优先级更高
*/

var obj = {
  name: "Mio",
  foo: function () {
    console.log(this);
  },
};

const bar = obj.foo.bind({ name: "bar" });
new bar(); /* foo {} */

底层逻辑:为什么 new 能改变 bind 后的 this?

    * 在bind的内部实现中,它会检查当前函数是否被作为构造函数调用(通过 new.targetinstanceof 判断)。

    * 如果是普通的 bar() 调用,它用 obj

    * 如果是 new bar() 调用,它会主动让位,让 this 指向新创建的实例。

    * 结论:这是 JS 引擎特意设计的,目的是为了让硬绑定的函数依然可以被当作构造函数来克隆。


内置函数的绑定思考

  • 有些内置函数会要求我们传入一个函数作为参数
  • 但是我们自己并不会显示的调用这些函数,而且JavaScript内部或者第三方库内部会帮助我们执行
  • 那么,这些函数中的this又是如何绑定的呢
javascript
/* 案例一 */
setTimeout(function () {
  console.log(this); /* Window */
}, 1000);

/* 案例二 */
var names = ["abc", "cba", "nba"];
var obj = { name: "why" };

names.forEach(function (item) {
  console.log(this); /* 三次obj对象 */
}, obj); /* 第二个参数指定this */

/* 案例三 */
var box = document.querySelector(".box");

box.onclick = function () {
  console.log(this === box); /* true */
};

this规则之外

  • 忽略显示绑定
javascript
/*
  情况一:如果在显示绑定中,我们传入一个null或者undefined,那么这个显示绑定会被忽略,使用默认规则
*/
function foo() {
  console.log(this);
}

var obj = {
  name: "why",
};

foo.call(obj); /* obj对象 */
foo.call(null); /* Window */
foo.call(undefined); /* Window */

var bar = foo.bind(null);
bar(); /* Window */
  • 间接函数引用
javascript
function foo() {
  console.log(this);
}

var obj1 = {
  name: "obj1",
  foo: foo,
};

var obj2 = {
  name: "obj2",
};

/* 需要加一个分号 */
obj1.foo(); /* obj1对象 */

(obj2.foo = obj1.foo)(); /* Window */

3.4 箭头函数中的this规则

  • 规则:箭头函数(=>)没有自己的this绑定。

  • 指向:

    • 箭头函数的this继承自它定义时所在的作用域(词法作用域)this值。它不能被call(),apply()bind()改变。
javascript
var obj3 = {
  a: 5,
  foo: function () {
    /* 外出this指向obj3(隐式绑定) */
    var arrow = () => {
      console.log(this.a);
    };
    arrow();
  },
};

obj3.foo(); /* 箭头函数的this继承了函数的this,指向obj3,输出5 */

箭头函数避坑指南

    不能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 TreeCSSOM Tree后,就可以两个结合构建Render Tree了。
    • 注意一:link元素是不会阻塞DOM Tree的构建过程,但是会阻塞Render Tree的构建过程。
      • 这是因为Render Tree在构建时,需要对应的CSSOM Tree
    • 注意二:Render TreeDOM Tree并不是一一对应的关系,比如对于display为none的元素,压根不会出现在render tree中。
  • 注意:如果在解析过程中遇到<script>标签(尤其是不带asyncdefer属性的),它会同时阻塞DOM构建和CSSOM构建,因为它可能读取修改两者。

解析四:布局(Layout)和绘制(Paint)

  • 第四步是在渲染树(Render Tree)上运行布局(Layout)以计算每个节点的几何体。
    • 渲染树会表示显示哪些节点以及其他样式,但是不表示每个节点的尺寸、位置等信息
    • 布局是确定呈现树中所有节点的宽度、高度和位置信息

解析五:🎨现代浏览器的渲染终结阶段:分层、绘制与合成

  • 渲染流水线的最后几个步骤将布局结果转化为最终的屏幕画面现代浏览器(如BinkGecko)将这一过程分解为以下三个关键步骤,以实现硬件加速更高的性能

分层(Layering)

  • 在执行绘制之前,浏览器首先会将渲染树中的元素分配到不同的图层(Layers)中。
    • 某些拥有独立属性(如transformopacity)或使用特定CSS属性(如will-changeposition: fixed)元素会被提升到独立的合成层
    • 目的:确保当这些元素发生变化时(例如动画),它们可以独立于页面的其他部分进行重绘和移动,而不需要重新触发整个页面的布局(Layout)或绘制(Paint)。

绘制(Paint)

  • 分层完成后,浏览器会执行绘制操作。
    • 功能: 遍历每个独立的图层,生成一组用于描述如何绘制该图层内容绘制指令列表(Paint Records/Display List)
    • 内容: 这些指令包括绘制文本、颜色、边框、阴影、图片等元素的可见部分。
    • 结果: 这一步的结果不是屏幕上的像素,而是命令列表,例如:在(X, Y)坐标画一个20像素宽的蓝色矩形

光栅化与合成(Rasterization and Compositing)

  • 这是最终画面显示到屏幕上的高性能阶段
    • 光栅化(Rasterization): 光栅化线程(Raster Thread)GPU进程读取绘制指令列表,并将这些向量指令转换为实际的位图(Bitmaps),即像素点数据,并存储在GPU内存中。
    • 合成(Compositing): 合成器线程(Compositor Thread)利用GPU来完成最终的画面合成。它负责将所有的准备好的独立图层(位图)正确的位置正确的顺序上合并为一个完整的最终图像
核心变化

    现在高性能动画(如平移缩放不透明度变化)可以直接跳过LayoutPaint步骤,直接在合成器线程操作图层,利用GPU高效地完成屏幕更新,这被称为Compositor Only动画,确保了流程的60FPS甚至更高帧率。


4.2 回流(Layout)与重绘(Paint)

    ♻️渲染性能优化: 回流(Layout)重绘(Paint)

    网页渲染设计三个主要步骤:布局(Layout) -> 绘制(Paint) -> 合成(Compositing)。其中,前两个步骤最容易引发性能问题


理解回流reflow:也可以称之为重排

  • 第一次确定节点的大小和位置,称之为布局(Layout)
  • 之后对节点的大小、位置修改重新计算称之为回流。
  • 回流的本质是重新计算页面的几何属性(Geometry)
  • 它是最耗性能的操作,因为一旦一个元素回流,通常会引起其子元素兄弟元素,甚至整个文档的回流。

什么情况下会引起回流?

  • 比如DOM结构发生改变(添加删除修改DOM节点
  • 比如改变了布局(修改了widthheightpadding等影响尺寸的CSS属性)
  • 比如浏览器窗口变化:调整浏览器窗口大小(resize)
  • 比如调用getComputedStyle方法获取尺寸,位置等信息
  • 任何需要获取元素的最新计算样式几何信息的JavaScript属性或方法(如offsetWidth、scrollHeight、getClientRects()等)都会强制同步回流。这是浏览器为了保证数据的准确性不得不进行的。

理解重绘repaint

  • 第一次渲染内容称之为绘制(paint)
  • 之后重新渲染称之为重绘(重绘是重新绘制元素的可见属性
  • 浏览器重新绘制元素的可见外观,但其几何位置大小没有变化。
    • 性能消耗低于回流,但仍是同步操作

什么情况下会引起重绘呢?

  • 任何只改变元素外观位置不变的操作会触发重绘
  • 比如修改背景色文字颜色边框颜色样式非几何属性

回流一定会引起重绘,所以回流是一件很消耗性能的事情,所以在开发中要尽量避免发生回流。但重绘不一定导致回流

  • 修改样式时尽量一次性修改

  • 避免样式抖动:不要再一个循环中连续读取集合属性(如offsetWidth)并修改样式,这样会导致浏览器不断执行强制同步回流 (Forced Synchronous Layout)

  • 最佳实践: 尽量通过添加/切换CSS类批量修改样式,或使用style.cssText一次性完成修改。

  • 高效DOM操作

    • 避免频繁操作DOM:对多个节点进行操作时,使用DocumentFragment在内存中完成操作,然后一次性添加到DOM中。
  • 隔离回流范围

    • 频繁发生动画的元素使用position: absolutefixed,将其从文档流中脱离。这能将回流的影响范围隔离到该元素自身,避免波及整个页面
  • 最高性能动画(合成阶段)

    • 优先使用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)deferasync

    • defer属性(推迟执行)
      • 非阻塞下载: 浏览器会在后台异步下载脚本,不会阻塞DOM树的构建
      • 执行时机: 脚本下载完成后不会立刻执行,而是等待DOM树构建完成(解析到底部)后,在DOMContentLoaded事件触发之前执行。
      • 保证顺序: 如果有多个defer脚本,它们会严格按照在HTML中出现的顺序执行
      • 注意: defer属性仅对带有src属性外部脚本有效,对于内联脚本(直接写在<script>标签内的代码)defer会被忽略
  • async属性:

    • 非阻塞下载: 同样是异步下载,下载过程不阻塞DOM构建
    • async是让一个脚本完全独立的:
      • async脚本的下载过程是后台进行的,不阻碍DOM构建;但一旦下载完成,脚本会立即执行执行过程依然会占用主进程,此时若DOM尚未构建完成,解析会暂停
      • async脚本不能保证顺序,它是独立下载独立运行不会等待其他脚本
      • async不可能保证在DOMContentLoaded之前或之后执行
  • 总结与最佳实践

    • 普通<script>:解析到即暂停,下载并执行。
    • <script defer>并行下载文档解析完DOMContentLoaded前执行(推荐默认使用)
    • <script async>并行下载下载完立刻执行(仅用于独立第三方脚本)
事件名称触发时机你的代码能做什么?
interactive (状态)HTML 解析完成,DOM 树构建完毕此时可以访问 DOM 节点,但某些子资源还在加载
DOMContentLoadedHTML 完全加载并解析完毕,不等待图片、样式表最常用的点。绑定点击事件、初始化 UI 组件
load (window.onload)所有资源(图片、iframe、样式表)全部加载完成此时可以获取图片的真实宽高,或者关闭“加载中”动画
beforeunload用户准备离开页面(关闭标签或刷新)弹窗询问:“你确定要离开吗?数据尚未保存”

4.4 总结与补充

  • JavaScript会等待CSSOM(重要):虽然JS阻塞DOM解析,但CSS也会阻塞JS的执行因为JS可能会去查询元素的样式(例如:element.offsetWidth)。如果CSS还没下载解析完,浏览器为了保证JS获取到的样式是正确的会推迟脚本执行直到CSSOM构建完成
    • 结论: CSS不直接阻塞DOM,但它阻塞JSJS又阻塞DOM
    • 但注意,CSSOM 只会阻塞排在它后面的 JS
  • Preload Scanner(预加载扫描器): 现在浏览器很聪明,当主解析器停了,后台会有一个"预加载扫描器"继续往后看,把后面需要的CSSJS以及图片提前下载下来。所以实际上下载往往是并行的只是解析和执行是阻塞的
  • type="module"的默认行为:现在开发(Vite, 现代浏览器)中常用<script type="module">。值得记录的是:type="module"默认就是defer的行为。
  • 在现代 Blink 引擎的官方文档中,Render Tree 的概念已被演进为 Layout Tree,但面试时回答 Render Tree 依然是标准答案。
  • “强制同步回流”的终极杀手:布局抖动(Layout Thrashing)
    • 浏览器原本是很聪明的,它会把所有的 DOM 修改放进一个“队列”里,等当前任务结束后批量执行回流。但是!如果你在修改 DOM 之后,立刻用 JS 去读取 offsetWidth,浏览器为了给你返回最准确的值,不得不立即清空队列,当场执行一次完整的回流如果在循环里这么干,帧率会直接跌谷底
  • 性能优化的杀手锏:requestAnimationFrame (rAF)
    • 它的回调函数会精准地卡在浏览器下一次 LayoutPaint 之前执行。相比于 setTimeoutsetIntervalrAF 能保证动画屏幕刷新率(通常是 60Hz)完美同步绝对不会丢帧

4.5 知识扩展

扩展知识

  浏览器的工作方式此文章最初发布于2011年,可能其中大部分内容已不再准确。但依旧有参考价值

  深入现代网络浏览器Chrome的blog文章


5. 深入了解JavaScript执行原理

我们知道,浏览器内核是由两部分组成的,以Webkit为例:

  • WebCore: 负责HTML解析、布局渲染等相关工作;

  • JavaScriptCore: 解析、执行JavaScript代码;

  • 另一个强大的JavaScript引擎就是V8引擎;


5.1 V8引擎的介绍

  • V8是用C++编写的Google开源的高性能JavaScript和WebAssembly引擎,它用于ChromeNode.js

  • JavaScript代码在V8中执行的过程:

V8引擎介绍


5.2 V8引擎的架构

    V8引擎本身的源码非常复杂,大概有超过100wC++代码,通过了解它的架构,我们可以知道它是如何对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引擎的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代码的执行过程
/*
  第一阶段:全局预编译(创建 GEC)
    创建 GO 对象:包含 String, Date 等,以及 name: undefined, num1: undefined, foo: 0x123(地址)。
    创建 GEC:将 VO 指向 GO,建立作用域链。
    压栈:ECS 压入 GEC。
*/

/*
  第二阶段:全局执行阶段
    逐行执行赋值:name = "Mio", num1 = 20...
    执行到 foo():触发函数调用。
*/

/*
  第三阶段:函数执行阶段(创建 FEC)
    创建 AO:包含 arguments, name: undefined。
    创建 FEC:VO 指向 AO,**作用域链(Scope Chain)**初始化为 [AO, GO]。
    压栈:ECS 压入 FEC(在 GEC 之上)。
    执行:name 被赋值为 "foo",打印 "foo"。
    出栈:foo 执行完毕,FEC 弹出栈,AO 若无闭包引用则会被标记回收。
*/

var name = "Mio";
function foo() {
  var name = "foo";
  console.log(name);
}

var num1 = 20;
var num2 = 30;
var result = num1 + num2;

console.log(result);

foo();

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。

javascript
/*
  它是所有全局变量和全局函数的宿主这句话理解如下:
    * 宿主(Host)的含义:在这里,“宿主”可用理解为容器或家。全局对象(GO)就是一个大型的容器,用来存放所有在程序最外层(即全局作用域)声明的变量和函数。
*/

/*
  如何成为宿主?
    * 当JavaScript引擎进入代码的执行阶段之前,会有一个预处理阶段(也称为提升,Hoisting)
    *  在这个阶段,所有通过var声明的变量和所有函数声明(function funcName() {})会被“提升”到它们的当前作用域顶部。
    *  在全局作用域中,被提升的这些变量和函数实际上是以属性的方式被添加到了全局对象(GO)上。
*/

/*
  因此,globalFoo,globalVar和globalFunc并不是凭空存在的,它们是作为globalThis(全局对象)的属性而存在的。全局对象就是它们的宿主。
*/

var globalFoo = "globalFoo";

console.log(globalThis.globalVar); /* globalVar */
console.log(globalThis.Func); /* undefined*/
console.log(globalThis.globalFoo); /* globalFoo */

var globalVar = "globalVar";
function Func() {
  return "globalFunc";
}

执行上下文(Execution Contexts)

  • JavaScript引擎内部有一个执行上下文栈(Execution Context Stack,简称ECS),它是用于执行代码的调用栈。
  • 执行上下文(EC)是JavaScript引擎运行时的一个环境,它包含了运行当前代码所需的一切:
    1. 环境/变量存储:词法/变量环境(用来存放变量和函数声明,其实体是GO或AO)
    2. 查找路径:作用域链(指导变量查找)
    3. 上下文引用:this绑定(确定当前执行环境的 this 绑定)
  • EC是一个执行环境,它利用这些内部属性来执行函数对象中存储的代码。
  • 全局代码为了被执行,会构建一个Global Execution Context(GEC)。GEC会被放入到ECS中执行。
  • GEC被放入到ECS中里面包含两部分内容:
    • 第一部分:在代码执行前,在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中。(在创建阶段,var变量是会被赋值的,但赋的值是默认值undefined)。
      • 这个过程也称之为变量的作用域提升(Hoisting)
    • 第二部分:在代码执行中,对变量进行赋值,或者执行其他的函数。

认识VO对象(Variable Object)

  • 每一个执行上下文会关联一个VO(Variable Object,变量对象),变量和函数声明会被添加到这个VO对象中。
  • 当全局代码被执行的时候,VO就是GO对象了。

全局代码执行过程(执行前)

JavaScript
var name = "why";

function foo() {
  var name = "foo";
  console.log(name);
}

var num1 = 20;
var num2 = 30;
var result = num1 + num2;

foo();

🚀 函数的执行过程

当JavaScript引擎执行代码并遇到一个函数调用时,它会执行以下关键步骤:

  1. 创建函数执行上下文(FEC)
  • 定义:引擎会为这个函数调用创建一个新的函数执行上下文(FEC)。
  • 入栈:FEC会被立即压入执行上下文栈(EC Stack)的顶部,成为当前正在运行的执行上下文。
    • EC Stack是一个LIFO(后进先出)结构,它决定了代码的执行顺序。
  1. 创建活动对象(AO) 🔑
  • 当进入一个函数执行上下文时,会创建一个AO对象(Activation Object)。
  • AO 在创建时,会首先初始化一个 arguments 对象,随后将形参、函数声明、变量声明依次添加进去。(注意顺序:参数 > 函数声明 > 变量声明)。
  • 这个AO对象会作为执行上下文的VO来存放变量的初始化。
    • VO变体:在函数执行上下文中,抽象的变量对象(VO)会具体化为活动对象(AO)。
    • AO的初始化:AO在创建阶段就被初始化,它包含:
      • arguments对象:包含函数调用时传入的参数(如arguments[0]、arguments[1]等)
      • 形参(Parameters):它们作为AO的属性被创建,并赋值为调用时传入的实参
      • 函数内部的声明:通过var声明的变量(初始化为undefined)和内部函数声明(完全提升)。
  1. 创建作用域链(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就是闭包所捕获的"环境""周围状态"


javascript中的闭包
function makeAdder(count) {
  function adder(num) {
    return count + num;
  }
  return adder;
}

var add10 = makeAdder(10);
console.log(add10(5));

闭包

闭包的内存泄露

  • 在上面的案例中,如果我们后续不再使用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 对象)否则函数出栈它就没了
    • “通常情况下,基础类型的简单局部变量分配在栈中,而对象、数组以及复杂的原始值分配在堆中。”

常见的GC算法

引用计数(Reference counting)

  • 当一个对象有一个引用指向它时,那么这个对象的引用就会+1。
  • 当一个对象的引用为0时,这个对象就可以被销毁掉。
  • 这个算法有一个很大的弊端就是会产生循环引用:

引用计数


标记清除(Mark-Sweep)

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

引用计数

  • 整理:
    • 问题:标记清除后,内存中会留下大量不连续“碎片”(就像奶酪里的洞)。
    • 解决Mark-Compact 会在清除后,将所有存活的对象内存的一端移动,让剩余空间连成一片。这避免了“明明有空间,却因为太碎片化而放不下大对象”的尴尬。

V8 的“分代回收” (Generational Collection)

  • 现代 JS 引擎不仅仅是简单的标记清除,它们基于一个著名的假设:“大部分对象在内存中存活的时间都很短”(越年轻的对象死得越快)

V8 的分代策略,V8 将堆内存划分为两个主要区域

  • 新生代 (Young Generation):
    • 存放生存时间短的对象。
    • 算法:使用Scavenge算法(将内存平分为 FromTo 两块,活着的复制到另一半,剩下的全删掉)。这非常快
  • 老生代 (Old Generation):
    • 存放生存时间长、或者从新生代“晋升”上来的对象。
    • 算法:使用你笔记里的 Mark-Sweep(标记清除)Mark-Compact(标记整理)

哪些情况会导致 GC “不敢”回收内存
  • 意外的全局变量: 未声明的变量挂在 window 上。
  • 被遗忘的定时器: setInterval 没被 clearInterval,里面的变量会一直常驻。
  • 脱离 DOM 的引用:JS 里引用了一个 DOM 节点,后来这个节点被页面删除了,但 JS 里的变量没清空,整个节点及其子树都无法回收。

6. JavaScript函数增强知识

6.1 函数也是一个对象

  • 在JavaScript中,函数也是一个对象,那么这个对象也会拥有一些属性,常用的属性如下:
JavaScript中的函数是一个对象
function foo(arg1, arg2, arg3 = 3, arg4, ...args) {
  /* arguments是一个传递给函数的参数,是一个类数组对象 */
  /* 里面不包含形参给的默认值,只包传递给此函数的参数 */
  /* 如果直接调用foo(), 那么打印的将会是空对象 */
  console.log(arguments);
}

/* 函数的名称 */
console.log(foo.name);

/*
  规则优先级:
    * 普通形参:计入
    * 有默认值的参数:不计入,且它之后的所有参数也都不计入
    * Rest参数(...args):永远不计入
*/

/* 参数的参数个数,注意:只记录有默认值之前的个数,也不包括rest参数,这里是2 */
console.log(foo.length);

const bar = () => {
  /* 箭头函数是不绑定arguments的,推荐使用rest参数(...args) */
  /* 这里会往上层作用域找arguments,如果没有,将会报错 */
  console.log(arguments);
};
Arguments的陷阱:严格模式 vs. 非严格模式
  • arguments不包含默认值,这在现代JS(严格模式)中是的,但在旧代码(非严格模式)中会有一个诡异的"联动"现象:
    • 非严格模式: arguments与形参是同步的。修改形参,arguments里面的也会变。
    • 严格模式/带默认值/Rest参数: arguments变成一份纯粹的"原始快照"。修改形参,arguments不会跟着变。
    • 只要函数参数使用了 默认值解构赋值、或 Rest参数,该函数会自动进入一种类似“准严格模式”的状态,此时 arguments 不再与形参同步,无论你有没有写 'use strict'
    • 箭头函数中的argumentsnode环境中,如果是在CMD模式下,node会在全局注入一个arguments参数。但在ESM模式下,会找不到直接报错
特性arguments (旧时代)...args (现代)
类型类数组对象 (Array-like)真正的数组 (Array)
箭头函数不支持(捕获外层)完美支持
数组方法需要 Array.from 转换直接使用 map/filter/reduce
语义包含所有参数只包含“剩余”的参数,更灵活

6.2 纯函数的概念

纯函数的维基百科中的定义:

  • 在程序设计中,若一个函数符合以下条件,那么这个函数被称为纯函数
    • 函数相同的输入值时,需产生相同的输出
    • 纯函数的输出仅取决于输入值,与函数外部的隐藏信息或状态无关,且执行过程中不产生任何副作用(如 I/O 操作或外部状态修改);
    • 该函数不能有语义上可观察的函数副作用,诸如:'触发事件',使输出设备输出,或更改输出值意外物件的内容等。
  • 通俗易懂的语言总结一下就是:
    • 确定的输入,一定会产生确定的输出
    • 函数在执行的过程中,不能产生副作用
  • 副作用的概念:
    • 在计算机科学中,也引用了副作用的概念,表示在执行一个函数时,除了返回函数值之外,还对调用函数产生了附加的影响,比如修改了全局变量,修改参数或者改变外部的存储;
    • 副作用往往使产生bug的温床

  1. 副作用(Side Effects)的常见清单

在上面的笔记中提到了修改全局变量和外部存储,这很棒。在前端开发中,以下行为都属于副作用:

  • 修改参数:比如直接修改传入的对象属性(这会导致外部引用该对象的地方莫名其妙的报错)。
  • 发送网络请求:比如fetch('/api')。
  • DOM操作:如document.getElementById('app').innerHTML = '...'。
  • 控制台打印:连console.log其实也是副作用(它改变了I/O设备的输出状态)。
  • 读取非局部变量:读取Date.now()或Math.random()(因为这会导致相同输入却得到不同输出)。

  1. 为什么说副作用是"Bug"的温床

想象一个大型的Vue项目

  • 你调用了一个checkUser()函数,以为它只是返回布尔值。
  • 结果这个函数内部偷偷修改了全局的UserInfo。
  • 导致另一个组件的显示突然变了。
  • 你去排查那个组件,发现代码没动过。
  • 这种远程伤害会让调试变得极其痛苦。而纯函数是隔离的,它的影响只在return那一瞬间发生。

  1. 纯函数带来的"技术福利"

在面试的时候,如果能说出纯函数有哪些好处,档次瞬间提升:

  • 可缓存性(Memoization):既然相同的输入一定得到相同的输出,我们就可以把结果缓存起来。
    • Vue的computed计算属性:底层就是利用了这个原理,只有依赖项变了才重新计算;
  • 可测试性:测试纯函数不需要模拟复杂的浏览器环境(DOM、网络),只需要输入几个值,看输出对不对。
  • 并发/并行安全:因为纯函数不修改外部变量,所以多个任务同时运行也不会产生"竟态条件"。

  1. 一个典型的对比

非纯函数

非纯函数
let discount = 0.8;
function getPrice(price) {
  return price * discount; // 依赖外部变量,且外部变量可能随时被改
}

纯函数

纯函数
function getPrice(price, discount) {
  return price * discount; // 结果完全由参数决定
}

引用透明性 (Referential Transparency)

什么是引用透明性?
  • 如果一个函数是纯的,那么调用这个函数的表达式可以被它的输出结果直接替换,而不影响程序的任何行为

  • 案例: 如果 sum(2, 3) 永远等于 5,那么代码里所有的 sum(2, 3) 都可以直接换成 5。这种特性让编译器优化(如内联优化)代码重构变得极其安全

副作用清单的“进阶”:不可变性 (Immutability)

类别会修改原数组(不纯/有副作用)不修改原数组(纯/返回新数组)
添加/删除push、pop、shift、unshiftconcat、[...arr, item]
截取/转换splice (超级大坑)slice (纯净)
排序/反转sort、reversetoSorted、toReversed (ES2023 新特性)
映射/过滤map、filter、reduce

为什么 React/Redux/Pinia 强制要求纯函数

纯函数与快照(Snapshot)

现代框架中,状态的改变是通过比较新旧对象来检测的。

  • 如果你使用“非纯函数”直接修改了旧对象的属性,对象的引用地址没变,框架可能检测不到变化,导致页面不刷新
  • 如果使用“纯函数”返回一个新对象,框架通过 Object.is(old, new) 瞬间就能发现变化。这就是为什么 Redux Reducer 必须是纯函数

6.3 函数柯里化

柯里化也是属于函数式编程里面一个非常重要的概念:

  • 是一种关于函数的高阶函数
  • 它不仅被用于JavaScript,还被用于其他编程语言

维基百科的解释:

  • 计算机科学中,柯里化(Currying),有译为卡瑞华加里化
  • 是把接收多个参数的函数,变成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数,而且返回结果的新函数的技术。
  • 柯里化声称:"如果你固定某些参数,你将得到接受余下参数的一个函数"

总结:

  • 只传递给函数一部分参数调用它,让它返回一个函数处理剩余的参数
  • 这个过程就称之为柯里化
  • 柯里化是一种函数的转换,将一个函数从可调用的f(a, b, c)转换为可调用的f(a)(b)(c)
  • 柯里化不会调用函数。它只是对函数进行转换

柯里化优势一:函数的职责单一

  • 函数式编程中,我们其实往往希望一个函数处理的问题尽可能的单一,而不是将一大堆的处理过程交给一个函数来处理
  • 那么我们是否就可以将每次传入的参数在单一的函数中进行处理,处理完成后在下一个函数中再使用处理后的结果。
柯里化函数
/* 
  案例一:传入的参数分别进行如下处理:
    * 将传入第一个参数的值进行加2
    * 将传入第二个参数的值进行乘2
    * 将传入的第三个参数进行平方
    * 将这些参数进行相加返回
*/
function foo(x) {
  x = x + 2;
  return function (y) {
    y = y * 2;
    return function (z) {
      z = z ** 2;
      return x + y + z;
    };
  };
}
console.log(foo(1)(2)(3));

柯里化优势二 - 函数的参数复用

  • 另外一个使用柯里化的场景是可以帮助我们复用参数逻辑:
    • makeAdder函数要求我们传入一个num(并且如果我们需要的话,可以在这里对num进行一些修改)
    • 在之后使用返回的函数时,我们不需要再继续传入num了
柯里化的运用
function makeAdder(num) {
  return function (count) {
    return num + count;
  };
}

var add5 = makeAdder(5);
add5(10);
add5(100);

var add10 = makeAdder(10);
add10(10);
add10(100);

/* 如果不再使用,将函数设置为null,可防止内存泄漏 */
add5 = null;
add10 = null;

柯里化案例联系

柯里化函数实战场景
/* 这里我们演示一个案例,需求是打印一些日志 */
const log = (date) => (type) =>
  (message = console.log(
    `[${date.getHours()}:${date.getMinutes()}][${type}][${message}]`,
  ));

const logNow = log(new Date());
logNow("DEBUG")("轮播图Bug");
logNow("DEBUG")("点击无效Bug");
logNow("FEATURE")("添加新功能");

const logNowDebug = log(new Date())("DEBUG");
logNowDebug("轮播图Bug");
logNowDebug("点击无效Bug");

柯里化高级 - 自动柯里化函数

自动柯里化函数
/* 将接收多个参数的函数,转成柯里化函数 */
function Currying(fn) {
  function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function (...args2) {
        return curried.apply(this, args.concat(args2));
      };
    }
  }

  return curried;
}
  • 使用箭头函数进行优化
优化版本
function Currying(fn) {
  return function curried(...args) {
    /* 1. 当已经传入的参数数量 >= 函数本身期待的参数数量时,执行原函数 */
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      /* 2. 否则,返回一个新的函数,继续接收剩余参数 */
      return (...args2) => curried.apply(this, [...args, ...args2]);
    }
  };

  return curried;
}

Tip

如果面试管问:"你的柯里化函数能处理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(参数个数)
防御性编程
function Currying(fn, arity = fn.length) {
  // 允许手动指定长度
  return function curried(...args) {
    if (args.length >= arity) {
      return fn.apply(this, args);
    } else {
      return (...args2) => curried.apply(this, [...args, ...args2]);
    }
  };
}
// 即使 foo 有默认参数导致 length 只有 1,我们也可以强制它柯里化 3 层
const curriedFoo = Currying(fooWithDefault, 3);

补充:柯里化的“反向”操作

  • 有时候我们拿到了一个柯里化的函数,却想一次性调用它。这在函数式编程中叫Uncurrying(反柯里化)。虽然不常用,但了解它能让你的体系更完整

6.4 组合函数概念的理解

组合(Compose)函数是在JavaScript开发过程中一种对函数的使用技巧模式

  • 比如我们需要对某一个数据进行函数的调用,执行两个函数fn1fn2,这两个函数依次执行的;
  • 那么如果每次我们都需要进行两个函数的调用,操作上就会显得重复
  • 那么是否可以将这两个函数组合起来自动依次调用呢?
  • 这个过程就是对函数的组合,我们称之为组合函数(Compose Function);
组合函数
function double(num) { return num * 2 }
function square(num) { return num ** 2 }

function(fn1, fn2) { return function(x) { return fn2(fn1(x)) } }

var calcFn = compose(double, square)
console.log(calcFn(20))

实现组合函数

JavaScript组合函数
/* 我们需要考虑更加复杂的情况:比如传入更多的函数,再调用compose函数时,传入了更多的参数 */
function compose(...fns) {
  const length = fns.length;
  for (let i = 0; i < length; i++) {
    const fn = fns[i];
    if (typeof fn !== "function") {
      throw new TypeError("Expected a function");
    }
  }

  /* 取出所有的函数依次调用 */
  return function (...args) {
    /* 先获取到第一次执行的结果 */
    let index = 0;
    let result = length ? fns[index].apply(this, args) : args;
    while (++index < length) {
      result = fns[index].call(this, result);
    }
    return result;
  };
}

1. 一个关键的行业习惯:执行方向

数学主流函数式库(如LodashRedux)中,compose的执行顺序通常是从右往左(Right-to-Left)

  • 原因:它模拟的是数学中复合函数f(g(h(x)))
  • 上面的实现:目前是"从左往右"执行的(这在有些库里叫pipe而不是compose)。
组合函数
/* 如果实现一个标准意义上的compose,可以把循环改为从后往前 */
function compose(...fns) {
  const length = fns.length;
  // ... 之前的校验代码 ...

  return function (...args) {
    // 1. 下标从最后一个开始
    let index = length - 1;
    // 2. 第一次执行最右边的函数(接收多参数)
    let result = length ? fns[index].apply(this, args) : args;
    // 3. 循环向前跑,直到 index 变为 0
    while (--index >= 0) {
      result = fns[index].call(this, result);
    }
    return result;
  };
}
Tip

"既然从左往右更符合直觉",为什么像Redux这样得库还要坚持从右往左呢?

  • 因为这样更符合声明式编程得习惯。在Redux中,我们希望最外层的中间件先拦截Action最内层的中间件最后处理。这种从右往左的嵌套,在代码表达上就像是给dispatch穿上一层层的"外壳"最右边的函数是最内核的操作
  • Pipe版:符合人类直觉的流水线
  • Compose版本:符合数学定义标准实现

2. 现代ES6进阶的写法:reduce

组合函数
/* 在面试中,如果你能用一行reduce写出compose,面试管会觉得你对数组高阶方法的运用已经出神入化了 */
function compose(...fns) {
  if (fns.length === 0) return (arg) => arg;
  if (fns.length === 1) return fns[0];

  /* 这里的逻辑:将函数两两包装 */
  return fns.reduce(
    (a, b) =>
      (...args) =>
        b(a(...args)),
  );
}

3. 为什么要用组合函数(实战场景)

组合函数实战场景
/* 假设处理一个"老师名称"需要经过三个步骤 */
/*
  1. 去掉首位空格(trim)
  2. 转成大写(toUpperCase)
  3. 加上前缀Professor: (addPrefix)
*/

/* 1. 不用组合 */
const res = addPrefix(toUpperCase(trim("   why   ")));
console.log(res);

/* 2. 使用compose */
const formatName = compose(trim, toUpperCase, addPrefix);
console.log(formatName("   why    "));

进阶思考:异步组合 (Async Compose)

  • 在真实业务(如 Node.js 中间件)中,函数往往是异步的(返回 Promise)。
  • 如果fn1fn2都是async函数,普通的compose会失效(因为它会把 Promise 对象传给下一个函数,而不是结果)。我们需要一个Async Pipe:
Async_Pipe
const pipeAsync =
  (...fns) =>
  (input) =>
    fns.reduce((chain, func) => chain.then(func), Promise.resolve(input));
  • 这种模式在处理“先查数据库、再调接口、最后写日志”的业务流程时极其强大

6.5 with与eval

如果不打算做编译器、不打算研究20年前的老古董项目,完全没必要把这些作为重点去记

  1. 为什么它们是"禁区"?
  • 性能杀手(最核心的原因)
  • JavaScript引擎(如V8)在执行代码前会进行静态分析优化。引擎会预先确定变量在哪个作用域,从而实现极快的访问:
    • Eval/With的破坏性:由于它们可以在运行时动态修改作用域(eval能凭空变出一个变量,with能把对象属性变成变量),引擎无法进行静态优化;
    • 后果:一旦使用了这两个东西,引擎会直接放弃对整段代码的优化,导致运行速度断崖式下跌;
  • 安全隐患(XSS的帮凶)
    • eval会执行任何传给它的字符串。如果这段字符串来自用户输入(比如URL参数或评论区),攻击者就可以在你的网站上执行任意恶意脚本;
  • 代码逻辑混乱
    • with会让作用域变得极其模糊,连你自己都不知道这个变量是来自外部,还是来自with绑定的那个对象;

  1. 它们现在的处境
  • 严格模式("use strict";):
    • with在严格模式下是直接禁用的(报语法错误);
    • eval在严格模式下有自己的作用域,不再能随意修改外部变量;
  • 构建工具(Vite/Webpack):
    • 如果你在Vue3项目中写了这些,打包工具通常会给出警告,甚至可能导致混淆代码时出错;

  1. 虽然业务开发不用,但有一个场景它们还没"死透":沙箱环境(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 呢?”
特性eval(str)new Function(str)
作用域访问当前局部作用域(最危险)只访问全局作用域(相对安全)
性能极差,破坏闭包优化稍微好点,因为它是一个独立的函数对象
严格模式受限,但依然能访问局部变量默认拥有独立的作用域
  • 结论:如果你真的需要动态执行一段字符串代码(比如解析模板引擎),new Function 通常是比 eval 更好的选择,因为它对局部作用域的污染更小

补充:沙箱(Sandbox)的经典组合

  • 你提到的“Proxy + with”实现沙箱,是目前前端最顶级的面试题之一(比如 Single-spa 或 Qiankun 微前端框架的底层原理)。
Sandbox
function sandbox(code, ctx) {
  const proxy = new Proxy(ctx, {
    has: () => true, // 强制拦截所有变量查找
    get: (target, key) => (key in target ? target[key] : undefined),
  });
  // 利用 with 改变作用域链,配合 Proxy 拦截,让 code 只能访问 ctx 里的东西
  with (proxy) {
    eval(code);
  }
}
  • 这里的 with 充当了“传送门”,而 Proxy 充当了“守卫”,两者结合实现了一个简易的代码隔离环境。

纠一个小细节:eval 的两种调用方式

  • 这是 JS 里一个非常阴险的特性,建议作为“冷知识”存入笔记:
    • 直接调用 eval():在当前作用域执行,能访问局部变量。
    • 间接调用 (0, eval)():在全局作用域执行。很多库为了安全,会故意写成 (0, eval)(str) 来确保代码不会偷看局部变量。

6.6 严格模式

    严格模式(Strict Mode)它不是什么过时的老古董,而是现代JavaScript的基础运行标准(Vue3、React的源码全是基于严格模式运行的)


严格模式:JS的"防错过滤器":

  1. 消除静默错误(把"坑"变"报错")
  • 在普通模式下,有些错误JS会装作没看见,让程序带着Bug跑。严格模式下,这些会直接抛出错误:
    • 禁止意外创建全局变量:比如由于笔误把count = 10写成了cont = 10,普通模式会直接在window上挂个新属性,严格模式则报ReferenceError。
    • 写保护:给只读属性赋值、给不可扩展的对象添加属性,都会直接报错。

  1. 优化性能(引擎的最爱)
  • 正如我们之前聊到的eval时提到的,严格模式让代码更加可预测。
    • 禁用with:强制让作用域在编译阶段就确定下来;
    • 参数唯一性:要求函数参数名不能重复;
    • 优化V8引擎执行效率:由于排除了很多不确定因素,V8引擎可以更深层次地进行脱水优化,提升代码运行速度;

  1. 增强安全性(保护全局环境)
  • 禁止this指向全局:在严格模式下,如果一个函数被直接调用(非方法调用),其内部的this是undefined,而不是window。
    • 避坑指南:这是防止你在函数内部不小心通过this.name = 'xxx'修改了全局变量;

  1. 为未来铺路(ES6+的前哨站)
  • 保留字锁定:严格模式下,一些未来可能成为关键字的词(如implements,interface,let,package等)被禁止作为变量名。
  • 块级作用域的规范化:让代码的行为更符合ES6之后的逻辑直觉。

深度联动:严格模式下的 this 变化

防止忘记new的安全阀
  • 在非严格模式下,如果你调用构造函数漏写了new: function Person() { this.name = 'Mio' } -> Person()这会导致window.name莫名其妙变成了'Mio'。

  • 在严格模式下thisundefined,执行this.name会直接抛出TypeError。这种“报错”其实是在保护你的全局环境不被污染。


严格模式对 arguments 的“去耦”

  • 这是你之前“函数增强”笔记的完美补充。在严格模式下,arguments 对象的行为发生了本质变化:
  • 不再追踪参数变化:
javascript
function foo(a) {
  "use strict";
  a = 20;
  console.log(a); // 20
  console.log(arguments[0]); // 10 (如果是普通模式,这里会跟着变成 20)
}
foo(10);
  • 禁止使用 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)
configurableenumerablevaluewritablegetset
数据描述符可以可以可以可以不可以不可以
存取描述符可以可以不可以不可以可以可以

对象属性描述符
/* Object.defineProperty */
/* Object.defineProperty()方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象 */

/* 
  Object.defineProperty可接收三个参数
    * obj要定义属性的对象
    * prop要定义或修改的属性的名称或Symbol
    * descriptor要定义或修改的属性描述符
    * 返回值:被传递给函数的对象
*/

const obj = {};

/* 对象数据描述符 */
Object.defineProperty(obj, "name", {
  /*
     1. 不能被 delete 删除。
     2. 不能重新配置(重新调用 Object.defineProperty)。一旦设为 false,你就不能再把它改回 true,也不能修改 enumerable。唯一的例外是:你可以把 writable 从 true 改成 false(但不能反向)。
  */
  configurable: true,
  enumerable: true /* 是否可枚举,能否被for...in或Object.keys等获取 */,
  writable: true /* 是否可写,就是修改 */,
  value: "name" /* 这个属性默认返回值 */,
});

/* 对象存取描述符 */
let _age = 18;
Object.defineProperty(obj, "age", {
  configurable: true,
  enumerable: true,
  set(value) {
    _age = value;
  },
  get() {
    return _age;
  },
});

/* 扩展 */
console.log(
  Object.getOwnPropertyDescriptor(obj, "age"),
); /* 获取obj对象中的age的属性描述符配置 */
console.log(
  Object.getOwnPropertyDescriptors(obj),
); /* 获取obj对象中全部属性的属性描述符配置 */

  1. 关于 set/getwritable/value 的并存问题
  • JavaScript中,属性描述符分为两类:数据描述符(Data Descriptor)存取描述符(Accessor Descriptor)
    • 数据描述符:拥有valuewritable;
    • 存取描述符:拥有getset;
  • 底层原因:value和get都在定义"如何取值",writable和set都在定义"如何存值"。如果你同时定义了value和get,JS引擎就糊涂了。"我到底该直接给你那个值,还是该运行那个函数来算个值给你?"。
  • 报错现场:如果你尝试混合使用,浏览器会直接甩给你一个TypeError:Invalid property descriptor. Cannot both specify accessors and a value or writable attribute.

  1. 关于set/get必须配合"外部变量"
  • 结论:是的,必须有一个"避风港"变量。
  • 如果在set内部直接对当前属性赋值,或者在get内部直接读取当前属性,就会触发递归调用,最终导致栈溢出(Maximun call stack size exceeded)
  • 解决方案:常见的两种"避风港"
  • 方案A:使用隐藏变量(约定俗成的下划线)这是最常用的做法,在对象内部开辟一个"私有空间"
JavaScript
const obj = {
  _age: 18,
};

Object.defineProperty(obj, "age", {
  get() {
    return this._age;
  },
  set(newValue) {
    this._age = newValue;
  },
});
  • 方案B:利用闭包(更安全,外部不可见)
JavaScript
function defineReactive(obj, key, val) {
  /* val就在这个闭包作用域里,成了真正的"外部变量" */
  Object.defineProperty(obj, key, {
    get() {
      return val;
    },
    set(newVal) {
      if (newVal !== val) {
        val = newVal;
      }
    },
  });
}

  1. 笔记总结:
  • 对象属性描述符(Property Descriptor)

    • 互斥性: 数据描述符(value/writable)存取描述符(get/set)不可共存;
    • 死循环风险:get/set中操作属性本身会导致递归崩溃,需配合闭包变量或私有属性(_prop)使用;
    • 配置项: 别忘了还有enumerable(是否可枚举)configurable(是否可删除或修改描述符)
  • 既然Object.defineProperty有这么多限制(比如必须手动搞个外部变量、不能监听新增属性、不能监听数组下标),Vue3为什么转向了Proxy

    • Proxy中,你不再需要每个属性手动创建"外部变量",因为它代理的是整个对象

7.2 对象加锁

JS对象保护等级表

  1. 它们都是"浅层保护"(Shallow)
  • 这是最容易翻车的地方。如果你冻结了一个对象,但对象内部还嵌套了另一个对象,内部的对象依然是可以修改的。
JavaScript
const obj = { info: { age: 18 } };
Object.freeze(obj);

obj.info.age = 20; /* 成功!内部对象没被冻结 */
  • 面试加分点:如果要实现真正意义上的冻结,需要参考"深拷贝"的思路,写一个deepFreeze(递归冻结)

  1. 配置(Configurable)的本质
  • Object.seal和Object.freeze都会把属性的configurable设置为false。这不仅意味着不能删除,还意味着你不能再通过Object.defineProperty去修改该属性的特性(比如把enumerable从true改成false)。

  1. 严格模式下的表现

在非严格模式下,如果你尝试修改一个被freeze的对象,它只会静默失败(不报错,但改不动)。但在严格模式下('use strict'),它会直接抛出TypeError。这是现代框架(Vue/React)推荐的行为。

如果你尝试修改一个 writable: false 的属性,非严格模式下也是静默失败。这是 JavaScript 历史遗留的“静默失败”特性,不仅限于被冻结的对象。


  1. 对应的检测方法

别忘了这三个成对出现的"体检"API:

  • Object.isExtensible(obj):是否还能加属性;
  • Object.isSealed(obj):是否被密封了;
  • Object.isFrozen(obj):是否被冻结了;

  1. 总结:
  • 什么时候用preventExtensions?当你希望一个配置对象的键(Key)是固定的,不希望其他开发者往里面乱塞东西时。
  • 什么时候用seal?当你定义了一个严格的数据模型(比如一个老师的实例),既不准加新属性,也不准删掉已有属性时。
  • 什么时候用freeze?性能优化:在Vue2中展示巨大的静态列表(如长篇小说内容、历史记录单),冻结后Vue不再对其进行依赖追踪,性能暴涨。
    • 常量保护:定义全局配置常量时;

核心概念补充 (让笔记更具深度)

  1. 默认值的陷阱 (必考题)
  • 直接给对象赋值(obj.name = 'test')和通过 Object.defineProperty 定义属性,它们的默认描述符是完全不同的。
javascript
const obj = {};

// 方式一:直接赋值
obj.a = 1;
// 此时 a 的描述符默认全是 true
// { value: 1, writable: true, enumerable: true, configurable: true }

// 方式二:defineProperty
Object.defineProperty(obj, "b", { value: 2 });
// 此时 b 的描述符默认全是 false!
// { value: 2, writable: false, enumerable: false, configurable: false }

  1. 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)。

  1. 手写 deepFreeze (深度冻结)
javascript
function deepFreeze(obj) {
  // 1. 先冻结当前对象
  Object.freeze(obj);

  // 2. 遍历所有属性
  Object.getOwnPropertyNames(obj).forEach((prop) => {
    const val = obj[prop];
    // 3. 如果属性值是对象(且不是 null),递归冻结
    if (val !== null && typeof val === "object") {
      deepFreeze(val);
    }
  });

  return obj;
}

8. JavaScript中的面向对象

8.0 扩展知识

提问:在JavaScript中,访问对象属性都是调用get方法吗?

  • 结论:从ECMAScript规范的角度来看,是的。但从引擎执行物理层面来看,不一定

  1. 规范层面:一切皆[[Get]]
  • ECMAScript规范中,当你执行obj.name时,底层会调用一个名为[[Get]]内部隐藏方法(Internal Slot)
  • 不管你有没有手动定义get访问器,引擎都会按照以下逻辑运行:
    • 检查属性描述符:如果该属性定义了get方法(存取描述符),则执行该函数并返回结果
    • 默认行为:如果没有定义get,它会寻找该属性的value(数据描述符)
    • 原型链查找:如果当前对象找不到,就去原型链(proto)重复这个过程
  • 所以,在抽象逻辑上,你可以理解为:任何属性访问最终都会触发一个"取值动作",这个动作在规范里统称为[[Get]]

  1. 引擎层面:性能优化的"快车道"
  • 虽然规范里写着要走一套复杂的[[Get]]流程,但如果你定义的是一个像{ name: "Mio"}这样最简单的对象,V8引擎不会傻傻地去跑一遍完整的查找函数
    • 隐藏类(Hidden Classes/Shapes):引擎会记录对象的结构
    • 偏移量(Offset):引擎直接计算出name属性内层中的物理位置,然后直接取值跳过所有多余的逻辑判断
  • 结论:对于普通属性,引擎走的是"内存直达";只有当你手动定义了get,它才会切换到"函数调用"模式。

  1. 一个极具深度的面试点:Proxy里的get拦截
javascript
const obj = { name: "why" };

const proxy = new Proxy(obj, {
  get(target, key, receiver) {
    console.log(`拦截到了对${key}的访问`);
    return Reflect.get(target, key, receiver);
  },
});

console.log(proxy.name); /* 拦截触发 */
  • 在这个场景下,不管obj是不是一个普通的对象,Proxy都会把obj.name这个动作强制转换为对拦截器get函数的调用(调用的是proxy)。

  1. get[[Get]]的区别
  • get(访问器属性):是你手动在代码里写的get name() { ... }
  • [[Get]](内部操作):是JS引擎内部的抽象动作。即便你没写get,引擎也在执行[[Get]]操作。

  1. 总结:
  • 直觉上:是对的,所有的读取操作都是在Get
  • 技术上:区分数据属性(简单取值)访问器属性(执行函数)
  • 进阶上:理解[[Get]]内部操作是理解Proxy拦截机制的钥匙。

提问:那是不是有明确写了get对象,会比没有性能差呢?

  • 答案是对的。明确写了get(访问器属性)确实比普通属性(数据属性)性能要
  • V8现代JS引擎中,这二者的处理路径完全不同。

  1. 为什么get会慢一些?

我们可以把这两种访问方式对比成"直取""走流程"

  • 普通属性:直达内存(快),对于const obj = { name: "Mio" },引擎会为它创建一个隐藏类(Hidden Class),并记录name属性相对于对齐起始地址偏移量(Offset)

    • 动作:找到内存地址 -> 读取数据
    • 优化:引擎可以使用内联缓存(Inline Cache)。下次访问时,直接去内存那个坑位拿数据,快如闪电
  • get访问器:函数调用(慢),对于const obj = { get name() { return "Mio" } }引擎必须执行以下步骤

    • 保存当前执行环境:把当前的信息存起来;
    • 创建函数执行上下文:进入get函数内部;
    • 执行代码逻辑:即使里面只有一行return;
    • 销毁上下文并返回
    • 动作:触发函数调用 -> 压栈 -> 执行 -> 出栈

  1. 差多少?(量化概念)

微基准测试(Micro-benchmarks)中,访问get属性通常比访问普通属性210

  • 但是(非常重要):这个"慢"纳秒级别的。在实际的业务开发(如:Vue的组件逻辑),这种差距几乎可以忽略不计,因为网络请求DOM操作、甚至是一个简单数组的map消耗的时间都比这大得多

  1. 既然慢,为什么Vue/React还要大规模使用呢?
  • 既然性能差,为什么现代框架还要把所有数据都包装成getter/setter(Vue2)Proxy(Vue3)
    • 因为"监控""速度"更重要:
      • Vue2:通过Object.defineProperty劫持get,是为了依赖收集(知道谁在用这个数据)
      • Vue3:通过Proxy拦截get,是为了实现惰性响应式(只有你访问深层对象时,才去代理深层对象)
    • 这就是典型的"空间/时间换功能"。框架牺牲了一点微小的读取性能换取了强大的自动化数据绑定功能

  1. 性能优化小案例:
javascript
/*
  1. 普通属性:受引擎"隐藏类"和"内联缓存"优化,访问效率极高。
  2. 访问器属性(get):本质是函数调用,存在压栈/出栈开销。
  3. 实战建议:在高性能计算(如处理10万级数据循环)中,尽量避免在循环体内部频繁访问`get`属性。可以先将其缓存到局部变量中。
*/

/* 反例:性能差 */
for (let i = 0; i < 100000; i++) {
  sum += hugeObject.computedProp; /* 每次循环都跑一遍get函数 */
}

/* 正例:性能好 */
const val = hugeObject.computedProp; /* 只跑一次get */
for (let i = 0; i < 100000; i++) {
  sum += val;
}

/* --------------------------------- */

console.time("foo");
const obj = {
  get name() {
    return "xxx";
  },
};
for (let i = 0; i < 100000; i++) {
  obj.name;
}

console.timeEnd("foo");

console.time("bar");
const obj1 = {
  name: "xxx",
};
for (let i = 0; i < 100000; i++) {
  obj1.name;
}

console.timeEnd("bar");

  1. 总结:
  • get的性能确实比普通访问差,但它是"高级功能的门票"。没有它,我们就没有Vue的响应式,也没有计算属性(Computed)

补充:为什么要有Reflect.getreceiver?

  • 既然聊到了这里,我们必须把之前的那个"夺命面试题"补全。为什么不直接使用target[key]?
javascript
const person = {
  _name: "why",
  get name() {
    return this._name; // 注意这里的 this
  },
};

const proxy = new Proxy(person, {
  get(target, key, receiver) {
    // 如果这里用 target[key],那么 person 里的 this 指向的是原对象 person
    // 如果这里用 Reflect.get(target, key, receiver),this 会被正确指向代理对象 proxy
    return Reflect.get(target, key, receiver);
  },
});

const student = { _name: "student" };
Object.setPrototypeOf(student, proxy); // student 继承自代理对象

console.log(student.name);
  • 核心逻辑:如果不传receiver, 当发生继承时,this绑定会出错(指向了老祖宗person而不是当前的student)。Reflect.get的存在就是为了保证this永远指向最初触发访问器的那个对象

V8底层优化的极致理解

    * 在探讨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关键字的时候,可以创建一个对象。

步骤如下:

  1. 内存中创建一个新的对象(空对象)

  2. 这个对象内部的[[prototype]]属性会被赋值为该构造函数的prototype属性。

  3. 绑定 this: 将构造函数内部的 this 绑定到这个新创建的对象上,并执行函数体(为对象添加属性)

  4. 返回值处理: 如果构造函数没有显式返回一个非原始值的对象,则默认返回刚刚创建的这个新对象;如果显式返回了其他对象,则覆盖原来的新对象

那么也就意味着我们通过Person构造函数创建出来的所有对象[[prototype]]属性都指向Person.prototype

JavaScript
/*
  这个操作相当于:
    1. p1 = {}
    2. p1.__proto__ = Person.prototype
*/
function Person() {}

const p1 = new Person();

console.log(Person.prototype === p1.__proto__);

8.4. constructor属性

    事实上,原型对象上面是有一个属性的:constructor默认情况下,原型上都会添加一个属性叫做constructor,这个constructor指向当前的函数对象

javascript
function Person() {}

const p1 = new Person();

console.log(Person.prototype.constructor); /* [Function: Person] */
console.log(p1.__proto__.constructor); /* [Function: Person] */
console.log(p1.__proto__.constructor.name); /* Person */
console.log(p1.__proto__.constrcutror === Person); /* true */

8.5. 原型链总结

根据前面的知识,我们将画一幅图来直观的查看下对象与构造函数的关系:

  • 通过__proto__.__proto这条链条,我们称之为原型链。

8.6. 重写原型对象

如果我们需要在原型添加过多的属性,通常我们会重写整个原型对象:

javascript
function Person() {}

Person.prototype = {
  name: "Mio",
  age: 16,
  eating: function () {
    console.log(this.name + "在吃东西~");
  },
};

/*
  前面我们说过,每创建一个函数,就会同时创建它的prototype对象,整个对象也会自动获取constructor属性。但是上面的代码中,我们相当于给prototype重写赋值了一个新的对象,那么这个新对象的constructor属性会指向Object构造函数,而不是Person构造函数了。
*/

Object.defineProperty(Person.prototype, "constructor", {
  enumerable: false,
  value: Person,
});

8.7. 面向对象的特性 - 继承

    继承可以帮助我们将重复的代码逻辑抽取到父类中,子类只需要直接继承过来使用即可;在很多编程语言中,继承也是多态的前提。

    我们来观察前面的那一幅图,可以发现,继承我们需要创建一个对象,让子类的原型链指向父类的原型链。

Object是所有类的父类:从上面的图中,我们可以看出,原型链的最顶层的原型对象就是Object的原型对象

ES5最终写法(兼容IE6/7/8)
/* 创建对象的过程 */
function createObject(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

/* 将父类与子类通过寄生式函数连接 */
function inherit(Subtype, Supertype) {
  Subtype.prototype = createObject(Supertype.prototype);

  Object.defineProperty(Subtype.prototype, "constructor", {
    enumerable: false,
    configurable: true,
    writable: true,
    value: Subtype,
  });
}

function Person(name, age, height) {
  this.name = name;
  this.age = age;
  this.height = height;
}

Person.prototype.running = function () {
  console.log("running~");
};

function Son(name, age, height, sno, score) {
  Person.call(this, name, age, height);
  this.sno = sno;
  this.score = score;
}

inherit(Son, Person);

Son.prototype.studying = function () {
  console.log("studying~");
};

const son1 = new Son("Mio", 16, 1.58, 111, 100);
console.log(son1);
son1.running();
son1.studying();
javascript
/* 在ES6中,出了一些方法,可以代替一些操作比如: */.

/* 有兼容性问题 */
/* 弃用。非标准,语义化差 */
Subtype.prototype.__proto__ = Supertype.prototype

/* 有兼容性问题 */
/* 现代但低效。会触发引擎的去优化(Deoptimization),导致访问变慢 */
Object.setPrototypeOf(Subtype.prototype, Supertype.prototype)

/* 最推荐的写法:ES5就有的 */
/*
  1. 本质:创建一个全新的对象,并将该对象的`[[Prototype]]`指向参数
  2. 现状:这是ES5时代就引入的标准方法(IE9+都支持)
  3. 评价:最推荐(王道)。它在创建对象的同时就定好了原型,引擎可以一并优化,性能最稳
*/
Son.prototype = Object.create(Person.prototype)

8.8. 函数本质也是一个对象

    函数本身也是一个对象,它是Function构造函数的实例。所有函数本身也有一个隐式原型(__proto__)。里面的东西和上面的概念也是一样的

javascript
/*
  1. Object是一个函数(构造函数),所以它的__proto__必须指向Function.prototype
  2. Function本身也是一个函数,所以它的__proto__也指向Function.prototype
  3. 终点:Function.prototype也是一个对象,所以它的__proto__最终指向Object.prototype。而Object.protype.__proto__是null
*/

/* 所有的构造函数(包括 Object, Array, Date...)都是 Function 的实例 */
console.log(Object.__proto__.constructor === Function);
/* true (惊人的结论:Function 竟然是自己的实例!) */
/* “这是 JavaScript 万物皆对象模型中最具哲学意味的一环,它自己创造了自己。” */
console.log(Function.__proto__ === Function.prototype);

    由于function本身也是一个对象,所以我们可以直接给他添加属性,添加的属性称之为类属性类方法(在现代开发中,被称为静态成员Static Members

javascript
/*
  为什么叫类属性:
    * 因为它们直接挂载在构造函数对象上,不需要实例化(不需要new Foo())就能访问
    * ES5写法:直接Foo.xxx = ...
    * ES6 Class写法:使用 static关键字
*/

function Foo() {}
Foo.bar = "bar";
Foo.baz = function () {
  console.log("baz");
};
console.log(Foo.bar);
Foo.baz();

    一个容易被忽略的细节:继承静态属性

javascript
/* 在ES6的class继承是可以继承静态属性的,但ES5的手动继承通常会漏掉这一步 */
class Parent {
  static power = 100;
}
class Child extends Parent {}

console.log(Child.power); /* 100 (ES6自动处理了Child.__proto__ = Parent) */

/* ES5手动模拟时,除了修原型链,还得修这个 */
/* Child.__proto__ = Parent */

8.9. 对象方法补充

javascript
/*
  1. hasOwnProperty:对象是否有某一个属于自己的属性(不是在原型上的属性)
*/

/*
  2. in/for...in操作符:判断某个属性是否在某个对象或者对象的原型上
*/

/*
  3. instanceof:用于检测构造函数(Person、Student类)的prototype,是否出现在某个实例对象的原型链上
*/

/*
  4. isPrototypeOf:用于检测某个对象,是否出现在某个实例对象的原型链上
*/

const obj = { name: "YQZ" };

const info = Object.create(obj);

console.log(obj.isPrototypeOf(info)); /* true */

/* 如果给instanceof右边传了非构造函数,JavaScript引擎会毫不留情地甩给你一个TypeError */
/* 这是因为instanceof的底层逻辑非常"死板":它必须要去右边那个东西的身上找prototype */
console.log(obj instanceof info); /* TypeError */

instanceof的伪代码

    关于 instanceof 的手写代码实现非常优雅。可以加一个防御性注释:在现代环境检测中,如果对象使用了 Symbol.hasInstance,原生的 instanceof 会优先触发该 Symbol 方法,这也是改变 instanceof 默认行为的唯一后门

instanceof的伪代码
function myInstanceof(left, right) {
  // 1. 安全检查:如果右边不是函数(或没有 prototype),直接报错
  if (typeof right !== "function" || !right.prototype) {
    throw new TypeError("Right-hand side of 'instanceof' is not a constructor");
  }

  // 2. 获取右边的原型对象(那个我们要找的目标)
  let targetPrototype = right.prototype;

  // 3. 开始在左边的原型链上“爬楼梯”
  let proto = Object.getPrototypeOf(left); // 等同于 left.__proto__

  while (true) {
    if (proto === null) return false; // 爬到顶了,没找到
    if (proto === targetPrototype) return true; // 找到了!
    proto = Object.getPrototypeOf(proto); // 继续往上爬
  }
}

8.10. ES6 class关键字

ES6的class并不是一种全新的继承模型,而是原型链继承的语法糖。它让对象原型的写法更加清晰、更像面向对象编程。

本质:是一个特殊的函数。 特点:

  • 不可提升:必须点定义再使用(存在暂时性死区)。
  • 严格模式:类体内的代码默认运行在strict mode。
  • 不可枚举:类中定义的方法默认是不可枚举(这点比ES5手动改原型更好)。
javascript
/* 基础用法 */
/* 一个完整的类通常包含:构造函数、实例方法、静态方法和属性访问器 */
class Person {
  // 1. 构造函数:new 的时候自动调用
  constructor(name) {
    this.name = name; // 实例属性
  }

  // 2. 实例方法:定义在 Person.prototype 上
  sayHi() {
    console.log(`你好,我是 ${this.name}`);
  }

  // 3. 静态方法:定义在 Person 函数本身上 (类方法)
  static isHuman(obj) {
    return obj instanceof Person;
  }

  // 4. Getter/Setter:拦截属性操作
  get info() {
    return `姓名: ${this.name}`;
  }
}

const p = new Person("Gemini");
p.sayHi(); // 实例调用
Person.isHuman(p); // 类直接调用

继承(Inheritance)

javascript
/* 这是class最强大的地方,它一次性解决了ES5中繁琐的"双链条"焊接 */

/* 心语法:extends & super */

class Student extends Person {
  constructor(name, score) {
    // 【关键】必须先调用 super(),创建父类实例 this
    super(name);
    this.score = score;
  }

  // 重写父类方法
  sayHi() {
    super.sayHi(); // 也可以调用父类的逻辑
    console.log(`我的成绩是 ${this.score}`);
  }
}
  • 继承的底层双链条逻辑:
    • 当你写下class Student extends Person,引擎自动完成了:
      1. 实例链:Student.prototype.proto === Person.prototype(继承方法);
      2. 静态链:Student.proto === Person(继承静态属性)

ES6继承 VS. ES继承

  • 创建顺序反向:
    • ES5:先创建子类实例this,在通过Parent.call(this)去增强它;
    • ES6:先由父类构造函数完成实例创建(super),再由子类修饰。这就是为什么ES6才能完美继承原生Array或Error的原因。
  • 静态成员继承:
    • ES5:默认不继承静态属性,除非手动设置proto
    • ES6:原生支持静态属性/方法继承;

进阶:私有属性(Private Fields)

javascript
/* 现代浏览器(ES2022+)已经支持真正的私有属性,使用`#`前缀 */
class Counter {
  #count = 0; /* 私有属性,外部无法通过 counter.#count访问 */

  increment() {
    this.#count++;
  }
}

避坑:

  • 忘记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很难完美继承原生内置类。

javascript
function Child(name, age) {
  /*
    1. 此时,this已经创建,指向Child实例
    2. 借用父类构造函数来增强这个this
  */
  Parent.call(this, name);
  this.age = age;
}

ES6的class机制完全相反。它要求必须先调用super(),这本质上是先让父类创建出实例,然后再由子类的构造函数来加工这个实例。

顺序:先由父类创建this -> 子类接收加工

强制要求:在constructor中,必须先写super()才能使用this,否则报错。

优势:由于this是从父类"降生"的,子类可以完美继承父类的所有特性,包括内置类(如Array)的私有属性。


维度ES5 继承 (寄生组合)ES6 Class 继承
实例创建顺序先子后父先父后子
this 的产生子类构造函数产生 this,再调用父类父类构造函数产生 this,子类进行修饰
super 的位置无 super 概念,手动调用 call必须先执行 super() 才能用 this
静态方法继承需要手动设置 Child.proto = Parent自动实现(class 语法内部处理)

为什么ES5无法完美继承Array,而ES6可以呢?因为Array的length等内部属性是定义在父类实例上的。ES5是先创建子类this,此时这个this只是个普通对象,不是真正的数组实例。而ES6是先由Array构造函数创建出真正的数组this,子类在继承,所以能获得数组的所有原生魔法。

总结:ES5继承是"子类对自己进行整容,使其长得像父类";而ES6继承是"父亲生了个孩子,孩子在父亲的基础上进行二次发育"。


8.12. 多态

  1. 核心定义:多态的本质是解耦。它允许你编写通用的代码,而不需要关心对象具体的类型,只要对象拥有约定的方法即可。

  2. JavaScript多态的三种表现形式:

  • 基于继承的多态(最经典):子类重写(Override)父类的方法。当调用相同名字的方法时,不同子类表现出不同的行为;
javascript
class Animal {
  makeSound() {
    console.log("某些声音...");
  }
}

class Dog extends Animal {
  makeSound() {
    console.log("汪汪汪!");
  }
}

class Cat extends Animal {
  makeSound() {
    console.log("喵喵喵~");
  }
}

// 通用函数:不关心你是哪种动物
function playSound(animal) {
  animal.makeSound();
}

playSound(new Dog()); // 汪汪汪!
playSound(new Cat()); // 喵喵喵~
  • 鸭子类型(Duck Typing - 动态语言的精髓):这是JS多态最强大的地方。"如果它走起来像鸭子,叫起来也像鸭子,那它就是鸭子"。两个对象之间不需要有继承关系,只要它们拥有相同名称的方法,就可以被同样对待;
javascript
const pilot = {
  say: () => console.log("准备起飞"),
};

const teacher = {
  say: () => console.log("准备上课"),
};

// 只要对象有 say 方法,这个函数就能工作
function action(person) {
  person.say();
}

action(pilot); // 准备起飞
action(teacher); // 准备上课
  • 运算符/方法的多态(内建多态):同一个方法在不同的内置对象上有不同的实现;
    • 例如:toString()方法在Array、Object、Number上返回的结果完全不同。
    • 例如:+运算符在数字间是加法,在字符串间是拼接。

  1. 为什么JavaScript不需要"接口"?在Java中,你必须声明implements Shape才能确保有draw()方法。在JavaScript中:
  • 动态检查:引擎在运行时直接去找方法,找到了就执行,找不到才报错;
  • 灵活性:我们可以随时给对象增加方法来满足多态的需求;

  1. 多态在实战的应用:取代冗长的if-else
javascript
/* 反例:硬编码 */
function getArea(shape) {
  if (shape.type === 'circle') {
    return Math.PI * shape.radius ** 2
  } else if (shape.type === 'rect') {
    return shape.width * shape.height
  }
}

/* 正例:多态 */
/* 这种写法使得你的系统是对扩展开发,对修改封闭的(开闭原则) */
function getArea(shape) {
  /* 无论形状是什么,只要你自己知道怎么算面积就行 */
  return shape.calculateArea();
}"

鸭子类型与强类型语言的对比

在 Java 或 TypeScript 这样强调类型安全的语言中,多态通常需要通过明确声明的接口(Interface)来作为约束契约(比如 class Dog implements Animal),系统在编译阶段就会检查类是否具备对应的方法。

而 JavaScript 彻头彻尾的“鸭子类型”意味着它的多态是隐式的。对象之间不需要建立严格的继承树,只要在运行时具备相应的行为签名,契约就自然成立。这让系统设计彻底摆脱了冗长的类型声明,是动态语言高扩展性的灵魂所在。


JS多态的灵魂总结:

  • 表现:子类重写父类方法,或不同对象拥有同名方法。
  • 前提:JS是动态类型,不需要显示的接口声明,天然支持"鸭子类型"。
  • 价值:消除复杂的条件分支(if/switch),提高代码的可维护性和扩展性。