总模型
JavaScript最核心的两套机制:变量查找系统和属性查找系统
变量查找系统是变量、函数名、class名靠作用域链查找。
JS 执行机制
-> 执行上下文
-> 环境记录
-> 变量提升
-> 作用域链
-> 闭包属性查找系统是对象属性、对象方法靠原型链查找。
对象系统
-> 对象属性
-> [[Prototype]]
-> 原型链
-> new
-> 原型继承
-> class extends所以要掌握两条线:
第一条线:变量系统
代码执行 -> 执行上下文 -> 环境记录 -> 作用域链 -> 闭包
第二条线:对象系统
对象创建 -> 属性查找 -> 原型链 -> new -> 继承 -> class
JavaScript 代码执行的整体模型
这一章解决的是:一段 JS 代码进入引擎后,变量、函数、对象、执行环境到底是怎么被创建出来的?
❌代码不是从第一行开始,一行一行执行的。一段JS带面执行前,通常会经历两个阶段:
创建阶段/初始化阶段
执行阶段
例如:
console.log(a);
var a = 1;
foo();
function foo() {
console.log('foo');
}真正执行代码之前,JS 引擎已经先扫描过当前作用域里的声明,并把它们登记到了当前执行环境中。大概会线处理成:
发现 var a
-> 创建变量 a,初始化为 undefined
发现 function foo
-> 创建函数 foo,初始化为函数对象
然后才开始执行代码,这就是变量提升和函数提升背后的真正原因。
执行上下文
JS 每执行一段代码,都需要一个运行环境。这个运行环境叫执行上下文(Execution Context)
执行上下文分为三类:
全局执行上下文
函数执行上下文
模块(块级)执行上下文
执行上下文里有:
词法环境(Lexical Environment)
变量环境(Variable Environment)
this 绑定
环境记录
变量存在于环境记录(Environment Record),可以把环境记录理解成当前作用域里的”变量登记表“
例如:
function foo(x) {
var a = 1;
let b = 2;
const c = 3;
function bar() {}
}当 foo 被调用时,会创建 foo 的函数执行上下文。
这个上下文里会有一个环境记录,大概记录:
x -> 参数值
a -> undefined,执行到赋值后变成 1
b -> 未初始化,执行到 let b = 2 后变成 2
c -> 未初始化,执行到 const c = 3 后变成 3
bar -> 函数对象所以变量查找,本质上是在环境记录里查找。
从 JS 语言语义上看,变量绑定保存在词法环境的环境记录中。对象、数组、函数等引用类型值通常由引擎在堆上管理,变量中保存对它们的引用。
作用域链
作用域链不是抽象概率,而是环境记录的连接,作用域链的本质是:当前词法环境 + 外部词法环境引用 + 更外层词法环境引用
const a = 1;
function outer() {
const b = 2;
function inner() {
const c = 3;
console.log(a, b, c);
}
inner();
}
outer();当 inner 执行时,会创建 inner 的函数执行上下文。inner 自己的环境记录里有:c->3,但是没有a 和 b。
于是 JS 会
先查 inner 自己的环境记录
找不到 b
再查 outer 的环境记录
找到 b
找不到 a
再查 global 的环境记录
找到 a
inner 词法环境
-> outer 词法环境
-> global 词法环境
-> null
变量提升
变量提升不是代码真的被移动到了顶部,而是执行前,JS 引擎先处理声明,把声明登记到当前作用域的环境记录里。
不同声明的处理方式不一样。
1. var:创建并初始化为 undefined
console.log(a);
var a = 1;
console.log(a);创建阶段:
a -> undefined执行阶段:
console.log(a) // undefined
a = 1
console.log(a) // 1所以输出:
undefined
1这就是 var 提升。
2. let / const:创建了,但未初始化
很多人说:let / const 不会提升
这句话不严谨。更准确的是:let / const 也会被提前登记到当前词法环境中,但在执行到声明语句之前,它们处于未初始化状态。这个阶段叫暂时性死区 TDZ。
例如:
console.log(a);
let a = 1;这里会报:
ReferenceError不是因为没有 a,而是因为:a 已经在当前作用域里被登记了,但是还没有初始化,所以不能访问。
看这个例子更明显:
let a = 100;
{
console.log(a);
let a = 1;
}很多人以为会输出 100,但实际是:
ReferenceError因为块级作用域里有自己的 let a,它在执行前已经被登记了。
所以 console.log(a) 会优先命中当前块里的 a,但这个 a 还在 TDZ 中,因此报错。
3. function 声明:创建并初始化为函数对象
foo();
function foo() {
console.log('foo');
}函数声明在创建阶段就会被初始化为函数对象,所以可以提前调用。
输出:
foo4. 函数表达式:本质是变量 + 赋值
foo();
var foo = function () {
console.log('foo');
};创建阶段:
foo -> undefined执行阶段:
foo(); // undefined 不能作为函数调用
foo = function () {};所以报:
TypeError: foo is not a function注意这里不是 ReferenceError。
区别是:
ReferenceError:
变量不能被访问,比如没声明或者处于 TDZ。
TypeError:
变量能访问,但当前值的类型不支持这个操作。闭包
普通函数执行结束后,局部变量通常会被释放。但为什么有些函数执行结束后,它的局部变量还能被另一个函数继续访问?
function createCounter() {
let count = 0;
return function counter() {
count++;
return count;
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3函数的特殊性
在 JavaScript 中,函数不是一段单纯的代码。函数本身也是对象,但它和普通对象不同,它同时具备两种能力。
第一,函数可以被调用执行。
第二,函数在创建时会记录自己定义时所在的词法环境。这一点是闭包能够成立的根本原因。
普通对象只保存属性和方法,例如:
const obj = {
name: 'Alice',
};但是函数不同:
function foo() {
console.log(a);
}函数 foo 被创建时,不只是保存了函数体代码:
console.log(a);它还会保存一个内部引用,指向它定义时所在的词法环境。可以简单理解为:
foo 函数对象
├── 函数代码
└── 定义时的外部词法环境引用这个外部词法环境引用,决定了函数未来执行时,如果在自己作用域里找不到变量,应该继续去哪里找。所以 JavaScript 的函数有一个非常关键的特性:
函数的外层作用域由定义位置决定,不由调用位置决定。
例如:
let name = 'global';
function foo() {
console.log(name);
}
function bar() {
let name = 'bar';
foo();
}
bar();
// 输出 globalfoo 在全局作用域中定义,所以 foo 的 [[Environment]] 指向全局词法环境
foo 在哪里调用不重要,foo 的外部作用域由它在哪里定义决定
函数的作用域链由定义位置决定,不由调用位置决定。
闭包的形成过程
闭包的形成过程,总结出一句话:闭包的形成过程,就是内部函数创建时保存外部词法环境引用,并且这个内部函数在外部函数执行结束后仍然被外部持有,导致外部词法环境继续存活的过程。
看这段代码:
function createCounter() {
let count = 0;
return function counter() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3这段代码里,真正需要解释的是:
createCounter 明明已经执行结束了,
为什么 count 还没有消失?先看第一次执行:
const counter = createCounter();调用 createCounter 时,会创建一个函数执行上下文。这个执行上下文中会有一个词法环境,词法环境里保存当前函数内部声明的变量。
此时可以理解为:
createCounter 的词法环境
└── count = 0然后执行到这里:
return function counter() {
count++;
return count;
};这时内部函数 counter 被创建。
重点来了:counter 函数创建时,会保存它定义时所在的词法环境。也就是说,counter 内部会有一个引用,指向 createCounter 的词法环境。
可以画成这样:
counter 函数对象
├── 函数代码:count++; return count;
└── 外部词法环境引用 -> createCounter 的词法环境
└── count = 0然后 createCounter 把 counter 函数返回出去:
const counter = createCounter();此时外部变量 counter 保存了这个内部函数。
所以整体引用关系变成:
外部变量 counter
↓
counter 函数对象
↓
counter 保存的外部词法环境引用
↓
createCounter 的词法环境
↓
count接下来 createCounter 执行结束,它的函数执行上下文会从调用栈中弹出。
但是这里要区分两个概念:
执行上下文出栈,不等于词法环境一定被销毁。
如果一个函数执行完后,它的词法环境没有再被任何东西引用,那么这个词法环境可以被垃圾回收。
但现在不一样。
counter 函数仍然被外部变量 counter 引用着,而 counter 函数内部又引用着 createCounter 的词法环境。
所以:
createCounter 的执行上下文已经结束;但 createCounter 的词法环境仍然被 counter 引用;因此这个词法环境不能被回收;所以 count 变量仍然存在。这就是闭包形成的核心过程。
当执行:
counter();counter 自己的函数作用域里没有 count,于是它会沿着自己保存的外部词法环境引用继续查找,最终找到 createCounter 词法环境里的 count。
第一次调用:
count 从 0 变成 1第二次调用:
访问的还是同一个 count
count 从 1 变成 2第三次调用:
访问的还是同一个 count
count 从 2 变成 3所以输出:
1
2
3这里必须强调一个关键点:
闭包保存的不是变量值的快照,而是变量所在词法环境的引用。
也就是说,counter 并不是保存了一个固定的 count = 0。
它保存的是对 count 这个变量绑定的访问能力。
所以它每次访问和修改的都是同一个 count。
这也解释了为什么多个内部函数可以共享同一个外部变量。
但是如果多次调用外层函数,就会产生多个互相独立的词法环境。
function createCounter() {
let count = 0;
return function counter() {
count++;
return count;
};
}
const counter1 = createCounter();
const counter2 = createCounter();
console.log(counter1()); // 1
console.log(counter1()); // 2
console.log(counter2()); // 1
console.log(counter2()); // 2
// 每调用一次 createCounter,都会创建一个新的词法环境。
// counter1 引用的是第一次 createCounter 调用产生的词法环境。
// counter2 引用的是第二次 createCounter 调用产生的词法环境。
// 所以 counter1 和 counter2 的 count 互不影响。同一次外层函数调用产生的多个闭包,共享同一个外层词法环境。不同外层函数调用产生的闭包,引用的是不同的词法环境。
为什么说闭包保存的是变量绑定,不是值快照?
function outer() {
let count = 0;
const fn = function () {
console.log(count);
};
count = 100;
return fn;
}
const fn = outer();
fn();
// 输出 100
// 如果闭包保存的是“值快照”,那它应该保存 count = 0,最后输出 0。但实际输出 100。说明闭包保存的不是当时的值,而是对 count 这个变量绑定的引用。var循环闭包
function createFns() {
var arr = [];
for (var i = 0; i < 3; i++) {
arr.push(function () {
console.log(i);
});
}
return arr;
}
const fns = createFns();
fns[0](); // 3
fns[1](); // 3
fns[2](); // 3
//相当于以下函数
function createFns() {
var arr = [];
var i = 0;
for (var i = 0; i < 3; i++) {
arr.push(function () {
console.log(i);
});
}
return arr;
}var i 属于 createFns 的函数作用域。整个循环过程中只有一个 i 变量绑定。三个内部函数都引用同一个 createFns 词法环境。所以三个函数访问的是同一个 i。循环结束后,这个 i 变成 3。因此三个函数执行时,打印的都是 3。
let循环
function createFns() {
const arr = [];
for (let i = 0; i < 3; i++) {
arr.push(function () {
console.log(i);
});
}
return arr;
}
const fns = createFns();
fns[0](); // 0
fns[1](); // 1
fns[2](); // 2for 循环中的 let 每一轮迭代都会创建新的词法环境。每个函数引用的是当前轮次对应的 i。所以三个函数访问的是三个不同的 i 绑定。
遇到 var / let 闭包题,就按这三步分析
函数是不是延后执行?这些函数不是立刻执行,而是后面才执行。只要延后执行,就要小心闭包。
arr.push(function () {
console.log(i);
});
// 或者
setTimeout(function () {
console.log(i);
});函数引用的是哪个变量绑定?
for (var i = 0; i < 3; i++) {}
// 所有函数引用的是同一个 i。
for (let i = 0; i < 3; i++) {}
// 每一轮循环都有新的 i。不同函数引用不同的 i。函数真正执行时,变量的值是多少?
var 情况:
函数执行时,循环已经结束,同一个 i 已经是 3。let 情况:
函数执行时,每个函数找的是自己那一轮的 i。
所以分别是 0、1、2闭包的作用
普通函数每次调用都会创建新的执行上下文,函数执行完之后,局部变量通常会释放。但是闭包可以让某些变量在函数调用结束后继续存在,并且只能通过特定函数访问。这带来了几个重要用途。
实现数据私有化
function createUser(name) {
let age = 18;
return {
getName() {
return name;
},
getAge() {
return age;
},
setAge(value) {
if (value > 0) {
age = value;
}
},
};
}
const user = createUser('Alice');
console.log(user.getName()); // Alice
console.log(user.getAge()); // 18
user.setAge(20);
console.log(user.getAge()); // 20
console.log(user.age); // undefined这里 age 不是对象属性,外部不能直接通过 user.age 访问。它只存在于 createUser 的词法环境中,只能通过 getAge 和 setAge 间接访问。
这就是闭包实现私有状态。
闭包可以让函数记住上一次调用的信息。
防抖就是典型例子:
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}这里 timer 为什么能在多次调用之间保持?因为返回的函数形成了闭包,它一直引用着 debounce 的词法环境。所以每次触发时,访问的都是同一个 timer。这也是防抖能取消上一次定时器的原因。
第三,闭包可以用于缓存。
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}这里 cache 不暴露给外部,但返回的函数可以持续访问它。
这就是闭包在缓存场景中的价值。
第四,闭包和 React Hooks 关系非常密切。
例如:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(timer);
}, []);
return <button => setCount(count + 1)}>+</button>;
}这里定时器回调会形成闭包。
因为 useEffect 只在初始渲染时执行一次,所以定时器回调捕获的是初始渲染那一次的 count。后续组件重新渲染,新的 count 产生了,但原来的定时器回调仍然引用旧的词法环境。这就是 React 里的 stale closure 问题。
所以闭包在工程里的核心作用可以总结为:
闭包让函数能够保存和访问它定义时的状态,
从而实现私有变量、状态记忆、函数封装、缓存、防抖节流等能力。闭包与垃圾回收
闭包会影响变量的生命周期,但闭包本身不是内存泄漏。
普通情况下,函数执行结束后,如果它的局部变量不再被引用,那么相关内存可以被回收。
例如:
function foo() {
let data = new Array(1000000).fill('*');
}
foo();foo 执行结束后,如果 data 没有被外部引用,那么它就可以被回收。
但是闭包会让变量继续被引用。
function foo() {
let data = new Array(1000000).fill('*');
return function () {
console.log(data.length);
};
}
const fn = foo();这里 foo 执行结束后,data 不能被回收。
因为:
fn 引用了内部函数;
内部函数引用了 foo 的词法环境;
foo 的词法环境里有 data;
所以 data 仍然可达。这不是内存泄漏,这是闭包的正常行为。
真正的问题在于:如果这个数据已经不再需要,但因为闭包还在被长期引用,导致它无法释放,就可能形成内存泄漏。
例如:
function bindEvent() {
const largeData = new Array(1000000).fill('*');
document.body.onclick = function () {
console.log(largeData.length);
};
}只要 document.body.onclick 还引用着这个函数,largeData 就不能释放。
如果这个事件处理函数不再需要,应该解除引用:
document.body.onclick = null;或者在组件销毁时清理:
useEffect(() => {
const handler = () => {
// ...
};
window.addEventListener('resize', handler);
return () => {
window.removeEventListener('resize', handler);
};
}, []);所以闭包与垃圾回收的关系可以这样理解:
闭包会延长外部变量的生命周期。
生命周期延长是闭包的正常特性。
只有当闭包长期持有不再需要的对象,才可能造成内存泄漏。对象属性查找、原型链与构造函数
本章要解决的核心问题
这一章要理解的是 JavaScript 的对象系统。
前面讲作用域链时,我们解决的是:变量怎么找
这一章解决的是:对象属性怎么找
例如:
const obj = {
name: 'Alice',
};
console.log(obj.name);这段代码其实分成两步。
第一步,先通过作用域链找到变量 obj。
第二步,在 obj 指向的对象上查找属性 name。
所以要先建立一个边界:
变量查找走作用域链
属性查找走对象自身和原型链。
也就是说:
console.log(obj);这里的 obj 是变量,走作用域链。
obj.name;这里的 name 是属性,走对象属性查找机制。
这一章的完整主线是:
对象是属性集合
-> 对象内部有 [[Prototype]]
-> [[Prototype]] 指向原型对象
-> 多个对象通过 [[Prototype]] 连接成原型链
-> 属性查找会沿着原型链向上找
-> 函数有 prototype 属性
-> new 会让实例的 [[Prototype]] 指向构造函数的 prototype
-> 实例就能访问构造函数 prototype 上的方法对象属性查找:从自身属性到原型链
JavaScript 中的对象可以理解为一组属性的集合。
const user = {
name: 'Alice',
age: 18,
sayName() {
console.log(this.name);
},
};这个对象自身有三个属性:
name
age
sayName当访问:
user.name;JavaScript 会先在 user 自己身上查找 name。因为 user 自己有这个属性,所以直接返回 'Alice'。
但是如果访问:
user.toString;user 自己没有 toString,JavaScript 不会立刻返回 undefined,而是会去 user 的原型对象上继续查找。
普通对象默认的原型对象是 Object.prototype。
例如:
const obj = {};
console.log(Object.getPrototypeOf(obj) === Object.prototype);
console.log(Object.getPrototypeOf(Object.prototype) === null);
// 输出
// true
// true所以普通对象的原型链是:
obj
-> Object.prototype
-> null这就是为什么普通对象可以访问 toString、valueOf、hasOwnProperty 等方法。因为这些方法不是对象自己身上的,而是在 Object.prototype 上。
属性查找过程可以这样理解:
访问 obj.key 时:
先查 obj 自己有没有 key。
如果有,直接返回。
如果没有,就去 obj 的 [[Prototype]] 指向的原型对象上查找。
如果原型对象上还没有,就继续查原型对象的原型。
一直查到 null。
如果最终还没找到,结果就是 undefined。这里的 [[Prototype]] 是对象内部的隐藏原型指向。它不是指向“原型链”,而是指向“当前对象的原型对象”。多个对象通过各自的 [[Prototype]] 一层层连接起来,才形成原型链。
可以通过下面的方法获取对象的原型对象:
Object.getPrototypeOf(obj);例如:
const obj = {};
console.log(Object.getPrototypeOf(obj) === Object.prototype);输出:
true这表示:
obj 的 [[Prototype]] 指向 Object.prototype。Object.prototype 上常见的方法和属性包括:
constructor
hasOwnProperty
isPrototypeOf
propertyIsEnumerable
toString
toLocaleString
valueOf
__proto__其中最重要的是 hasOwnProperty、toString、valueOf、constructor。
hasOwnProperty 用来判断属性是不是对象自身的属性。
const obj = {
name: 'Alice',
};
console.log(obj.hasOwnProperty('name'));
console.log(obj.hasOwnProperty('toString'));输出:
true
false原因是:
name 是 obj 自身属性。
toString 是 Object.prototype 上的属性。而 in 会查对象自身和原型链。
const obj = {
name: 'Alice',
};
console.log('name' in obj);
console.log('toString' in obj);输出:
true
true所以:
hasOwnProperty 只查对象自身。
in 会查对象自身和原型链。现代写法更推荐:
Object.hasOwn(obj, 'name');兼容性更强的写法是:
Object.prototype.hasOwnProperty.call(obj, 'name');因为有些对象可能自己定义了同名的 hasOwnProperty,直接调用可能不安全。
再看属性屏蔽:
const parent = {
name: 'parent',
};
const child = Object.create(parent);
console.log(child.name);
child.name = 'child';
console.log(child.name);
console.log(parent.name);输出:
parent
child
parent一开始,child 自己没有 name,所以沿原型链去 parent 上找。
后来执行:
child.name = 'child';这会在 child 自己身上创建一个 name 属性。
此时结构变成:
child
├── name = 'child'
↓
parent
└── name = 'parent'所以再次访问 child.name 时,会优先命中 child 自己的属性,不会继续查找原型上的 name。
这叫属性屏蔽。
核心结论是:
原型链不是复制属性。
对象自身没有属性时,才会委托原型对象查找。
如果对象自身有同名属性,就会屏蔽原型上的属性。函数、prototype 与构造函数
函数在 JavaScript 中比较特殊。
函数首先是可以被调用的代码,但函数本身也是对象。
例如:
function Func() {}这里的 Func 是一个函数对象。
函数对象上默认会有一个 prototype 属性:
function Func() {}
console.log(Func.prototype);Func.prototype 指向的是一个普通对象。默认情况下,这个对象上有一个 constructor 属性,指回函数本身。
function Func() {}
console.log(Func.prototype.constructor === Func);
// 输出 true可以粗略理解为:
Func.prototype = {
constructor: Func,
};不过真实情况下,默认的 constructor 是不可枚举的。
这里最容易混的是:
Func.prototype 不是 Func 自己的原型对象。
Func.prototype 是将来通过 new Func() 创建出来的实例对象的原型对象。函数对象 Func 自己也有 [[Prototype]],因为函数本身也是对象。
function Func() {}
console.log(Object.getPrototypeOf(Func) === Function.prototype);
// 输出
// true所以函数对象自己的原型链是:
Func
-> Function.prototype
-> Object.prototype
-> null而 Func.prototype 这个对象自己的原型链是:
Func.prototype
-> Object.prototype
-> null所以一定要区分:
Func 的 [[Prototype]] 指向 Function.prototype。
Func.prototype 是 Func 身上的一个属性。
Func.prototype 指向的对象,会成为实例对象的原型对象。这句话非常重要:
函数自己的原型对象是 Function.prototype。
函数的 prototype 属性,是实例对象未来的原型对象。构造函数本身并不是特殊语法。一个普通函数只要可以被 new 调用,就可以作为构造函数使用。
function Person(name) {
this.name = name;
}
const p = new Person('Alice');首字母大写只是命名约定,用来提醒别人这个函数通常应该配合 new 使用。
执行之后:
p 是实例对象。
p 自身有 name 属性。
p 的 [[Prototype]] 指向 Person.prototype。可以验证:
function Person(name) {
this.name = name;
}
const p = new Person('Alice');
console.log(Object.getPrototypeOf(p) === Person.prototype);
// 输出
// true所以实例对象的原型链是:
p
-> Person.prototype
-> Object.prototype
-> null通常情况下,构造函数里面放实例自己的属性,prototype 上放所有实例共享的方法。
function Person(name) {
this.name = name;
}
Person.prototype.sayName = function () {
console.log(this.name);
};
const p = new Person('Alice');
p.sayName();
// 输出
// Alice查找过程是:
p 自己有没有 sayName?
没有。
去 p 的 [[Prototype]] 查找。
p 的 [[Prototype]] 指向 Person.prototype。
Person.prototype 上有 sayName。
找到这个函数。
通过 p.sayName() 调用。
this 指向 p。
函数内部访问 this.name。
也就是访问 p.name。所以:
name 是实例自己的属性。
sayName 是原型对象上的方法。
实例通过原型链访问共享方法。这样做的意义是:
每个实例不需要重复创建同一份方法。
所有实例共享 Person.prototype 上的方法。new、constructor、instanceof 与 Object.create
new 是把构造函数、实例对象和原型链连接起来的关键。
看代码:
function Person(name) {
this.name = name;
}
const p = new Person('Alice');new 做了这些事:
创建一个新对象。
把新对象的 [[Prototype]] 指向构造函数的 prototype。
执行构造函数,并让构造函数内部的 this 指向这个新对象。
如果构造函数显式返回一个对象或函数,则返回这个对象或函数。
否则返回新创建的对象。可以粗略理解为:
const p = {};
Object.setPrototypeOf(p, Person.prototype);
Person.call(p, 'Alice');但真实逻辑还需要处理构造函数返回值。
手写 new:
function myNew(Constructor, ...args) {
const obj = Object.create(Constructor.prototype);
const result = Constructor.apply(obj, args);
const isObject =
result !== null &&
(typeof result === 'object' || typeof result === 'function');
return isObject ? result : obj;
}测试:
function Person(name) {
this.name = name;
}
Person.prototype.sayName = function () {
return this.name;
};
const p = myNew(Person, 'Alice');
console.log(p.name);
console.log(p.sayName());
console.log(Object.getPrototypeOf(p) === Person.prototype);输出:
Alice
Alice
true构造函数返回值规则也要掌握。
如果不写 return,默认返回新创建的实例对象。
function Person(name) {
this.name = name;
}
const p = new Person('Alice');
console.log(p.name);输出:
Alice如果返回基本类型,返回值会被忽略,仍然返回默认创建的新对象。
function Person(name) {
this.name = name;
return 123;
}
const p = new Person('Alice');
console.log(p.name);输出:
Alice如果返回对象,则最终返回这个显式返回的对象,而不是默认创建的实例对象。
function Person(name) {
this.name = name;
return {
name: 'Bob',
age: 20,
};
}
const p = new Person('Alice');
console.log(p.name);
console.log(p.age);输出:
Bob
20如果返回函数,也会覆盖默认实例对象,因为函数也是对象。
function Person(name) {
this.name = name;
return function () {
console.log('returned function');
};
}
const p = new Person('Alice');
console.log(typeof p);输出:
function总结:
new 调用构造函数时:
如果构造函数返回对象或函数,最终返回这个显式返回值。
如果构造函数没有 return,或者 return 的是基本类型,最终返回 new 创建的新对象。constructor 默认存在于构造函数的 prototype 对象上,指回构造函数本身。
function Person() {}
console.log(Person.prototype.constructor === Person);输出:
true实例可以通过原型链访问到 constructor:
function Person() {}
const p = new Person();
console.log(p.constructor === Person);输出:
true但 constructor 不是 p 自己的属性,它是从 Person.prototype 上找到的。
如果重写 prototype,可能会丢失默认的 constructor。
function Person() {}
Person.prototype = {
sayName() {},
};
const p = new Person();
console.log(p.constructor === Person);
console.log(p.constructor === Object);输出:
false
true原因是:
Person.prototype 被替换成了一个新的普通对象。
这个新对象本身没有 constructor: Person。
所以 p.constructor 会沿原型链继续查到 Object.prototype.constructor。
Object.prototype.constructor 指向 Object。修复方式:
function Person() {}
Person.prototype = {
sayName() {},
};
Object.defineProperty(Person.prototype, 'constructor', {
value: Person,
enumerable: false,
writable: true,
configurable: true,
});instanceof 判断的是构造函数的 prototype 是否出现在对象的原型链上。
function Person() {}
const p = new Person();
console.log(p instanceof Person);
console.log(p instanceof Object);输出:
true
true因为:
p
-> Person.prototype
-> Object.prototype
-> nullPerson.prototype 在 p 的原型链上,所以 p instanceof Person 是 true。
Object.prototype 也在 p 的原型链上,所以 p instanceof Object 也是 true。
手写 instanceof:
function myInstanceof(obj, Constructor) {
if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) {
return false;
}
let proto = Object.getPrototypeOf(obj);
while (proto !== null) {
if (proto === Constructor.prototype) {
return true;
}
proto = Object.getPrototypeOf(proto);
}
return false;
}注意基本类型:
console.log(1 instanceof Number);
console.log(new Number(1) instanceof Number);输出:
false
true因为 1 是基本类型,不是对象;new Number(1) 是包装对象,它的原型链上有 Number.prototype。
Object.create 可以直接创建一个以指定对象为原型的新对象。
const parent = {
say() {
console.log('hello');
},
};
const child = Object.create(parent);
child.say();
console.log(Object.getPrototypeOf(child) === parent);
// 输出
// hello
// true此时原型链是:
child
-> parent
-> Object.prototype
-> null手写简化版:
function myObjectCreate(proto) {
function F() {}
F.prototype = proto;
return new F();
}特殊情况:
const obj = Object.create(null);
console.log(Object.getPrototypeOf(obj));
console.log(obj.toString);
// 输出
// null
// undefined这个对象没有继承 Object.prototype,所以没有 toString、hasOwnProperty 等方法。
易混关系与面试表达
这一章最容易混的是三组关系。
第一组是普通对象的原型链:
const obj = {}
obj
-> Object.prototype
-> null第二组是函数对象自己的原型链:
function Func() {}
Func
-> Function.prototype
-> Object.prototype
-> null第三组是构造函数实例的原型链:
const instance = new Func()
instance
-> Func.prototype
-> Object.prototype
-> null只要这三张图能画清楚,prototype、[[Prototype]]、constructor、new、instanceof 基本就通了。
还要注意 this 和原型链的关系。
方法可以沿原型链找到,但 this 由调用方式决定。
function Person(name) {
this.name = name;
}
Person.prototype.sayName = function () {
console.log(this.name);
};
const p = new Person('Alice');
p.sayName();
// 输出
// Alice虽然 sayName 是从 Person.prototype 上找到的,但调用方式是:
p.sayName();所以 this 指向 p。
如果改成:
const fn = p.sayName;
fn();此时是普通函数调用,this 不再自动指向 p。在严格模式下,this 是 undefined。
所以要记住:
方法在哪里找到,不决定 this。
this 由调用方式决定。
p.sayName() 中 this 指向 p。
const fn = p.sayName; fn() 会丢失 this。下面是面试中常见的问法和回答。
面试题:说说对象属性查找过程。
可以这样答:
访问 obj.xxx 时,JavaScript 会先通过作用域链找到变量 obj,然后在 obj 指向的对象上查找 xxx 属性。
属性查找会先查对象自身。如果对象自身存在这个属性,就直接返回。如果自身没有,就会沿着对象内部的 [[Prototype]] 指向的原型对象继续查找。这个过程会一直向上查到 null。如果最终都没有找到,就返回 undefined。
所以对象属性查找和变量查找是两套机制:变量查找走作用域链,属性查找走对象自身和原型链。面试题:什么是原型链?
可以这样答:
每个对象内部都有一个 [[Prototype]],它指向该对象的原型对象。原型对象本身也可能有自己的 [[Prototype]],这样一层层向上连接起来,就形成了原型链。
当访问对象属性时,如果对象自身没有这个属性,就会沿着 [[Prototype]] 去原型对象上查找,直到 null 为止。
例如 const obj = {} 的原型链是 obj -> Object.prototype -> null。这就是为什么普通对象可以访问 toString、hasOwnProperty 等方法,因为这些方法在 Object.prototype 上。面试题:prototype 和 [[Prototype]] 有什么区别?
可以这样答:
[[Prototype]] 是每个对象内部的原型指向,它表示这个对象的原型对象是谁。可以通过 Object.getPrototypeOf(obj) 获取。
prototype 是函数对象上的一个属性。它的作用是:当这个函数被 new 调用时,新创建出来的实例对象的 [[Prototype]] 会指向这个函数的 prototype。
所以 function Person() {} 和 const p = new Person() 之后,Object.getPrototypeOf(p) === Person.prototype。
需要注意,Person.prototype 不是 Person 函数对象自己的原型对象。Person 函数对象自己的 [[Prototype]] 指向 Function.prototype。面试题:__proto__、prototype、constructor 分别是什么?
可以这样答:
__proto__ 是对象访问自身 [[Prototype]] 的历史方式,表示这个对象的原型对象是谁。正式场景更推荐使用 Object.getPrototypeOf。
prototype 是函数对象上的属性,用于 new 调用时指定实例对象的原型。
constructor 默认存在于函数的 prototype 对象上,指回构造函数本身。实例对象可以通过原型链访问到 constructor。
例如 function Person() {} 和 const p = new Person() 之后,Object.getPrototypeOf(p) === Person.prototype,Person.prototype.constructor === Person,p.constructor === Person。面试题:new 做了什么?
可以这样答:
new 做了几件事。
首先,它会创建一个新对象。
然后,它会把新对象的 [[Prototype]] 指向构造函数的 prototype。
接着,它会执行构造函数,并让构造函数内部的 this 指向这个新对象。
最后,如果构造函数显式返回一个对象或函数,就返回这个显式返回值;否则返回新创建的对象。面试题:为什么实例可以访问构造函数 prototype 上的方法?
可以这样答:
因为通过 new 创建实例时,实例对象的 [[Prototype]] 会指向构造函数的 prototype。
当访问实例上的某个方法时,如果实例自身没有这个方法,就会沿着 [[Prototype]] 去构造函数的 prototype 上查找。
例如 Person.prototype.sayName 定义在原型对象上,实例 p 自己没有 sayName,但是 p 的 [[Prototype]] 指向 Person.prototype,所以 p.sayName() 可以找到这个方法。面试题:instanceof 的底层原理是什么?
可以这样答:
instanceof 判断的是构造函数的 prototype 是否出现在对象的原型链上。
例如 p instanceof Person,本质上是判断 Person.prototype 是否在 p 的原型链上。
如果在,就返回 true;如果沿着原型链一直找到 null 都没有找到,就返回 false。面试题:为什么重写 prototype 后 constructor 会丢?
可以这样答:
默认情况下,函数的 prototype 对象上有 constructor 属性,指回构造函数本身。
但是如果直接重写 prototype,例如 Person.prototype = { sayName() {} },这会把原来的 prototype 对象替换成一个新的普通对象。这个新对象本身没有 constructor: Person。
因此实例访问 constructor 时,会沿原型链继续查到 Object.prototype.constructor,结果变成 Object。
所以重写 prototype 后,应该手动修复 constructor,并且最好用 Object.defineProperty 保持 constructor 不可枚举。最后,如果面试官让你系统总结,可以这样回答:
JavaScript 中访问对象属性时,会先查对象自身。如果对象自身没有这个属性,就会沿着对象内部的 [[Prototype]] 指向的原型对象继续查找,这条一层层向上的属性查找路径就是原型链。
普通对象默认的原型链通常是 obj -> Object.prototype -> null,所以普通对象可以访问 Object.prototype 上的 toString、hasOwnProperty 等方法。
函数对象比较特殊。函数对象本身的 [[Prototype]] 指向 Function.prototype,同时函数还有一个 prototype 属性。这个 prototype 属性不是函数自身的原型对象,而是当函数被 new 调用时,实例对象的原型对象。
new 调用构造函数时,会创建一个新对象,把新对象的 [[Prototype]] 指向构造函数的 prototype,然后执行构造函数并让 this 指向新对象。如果构造函数返回对象或函数,就返回这个显式返回值,否则返回新创建的对象。
因此,实例对象可以通过原型链访问构造函数 prototype 上的方法。constructor 默认存在于构造函数的 prototype 上,指回构造函数本身。instanceof 的底层原理也是原型链查找,它判断的是构造函数的 prototype 是否出现在对象的原型链上。

评论
还没有评论
来发表第一条评论吧!