25级开学 JS 考核题解
一、this 指向代码题
1. 讲出几大类函数this指向 - 普通函数,对象函数,构造函数,箭头函数
| 函数类型 | this 指向 | 说明 |
|---|---|---|
普通函数(直接调用 fn()) |
window(非严格模式)/ undefined(严格模式) |
直接调用时 this 落到全局 |
对象函数(obj.fn()) |
调用它的那个对象 | 看 . 前面是谁 |
构造函数(new Fn()) |
新创建出来的实例对象 | new 会把 this 绑定到新对象 |
| 箭头函数 | 定义时外层作用域的 this | 箭头函数自己没有 this,向上找最近一层非箭头函数的 this |
2. 讲出apply,call,bind的区别,以及使用方法
三者都是显式改变 this 的方法,区别在调用方式和参数:
| 方法 | 是否立即执行 | 传参方式 |
|---|---|---|
fn.call(thisArg, a, b, c) |
立即执行 | 一个个传 |
fn.apply(thisArg, [a, b, c]) |
立即执行 | 以数组传 |
fn.bind(thisArg, a, b) |
返回新函数,不立即执行 | 一个个传,参数可部分预置 |
js
var name = 'global'
const obj = {
name: 'obj',
say() {
console.log(this.name)
const fn = function () {
console.log(this.name)
}
fn()
}
}
obj.say()
- 两次
console.log分别输出什么?
第一次 obj,第二次 global。
- 为什么?
- 第一次:
obj.say()是对象方法调用,this =obj,所以this.name=obj。 - 第二次:
fn()是普通函数直接调用,this 落到全局window。而var name = 'global'把name挂到了window上,所以this.name=global。
- 如果把
fn改成箭头函数:
js
const fn = () => {
console.log(this.name)
}
输出又是什么,为什么?
输出 obj。因为箭头函数没有自己的 this,会向上找定义它的作用域,这里定义在 say() 里,say 的 this 是 obj,所以箭头函数里的 this 也是 obj。
- 有没有什么改变this的思路,讲讲实现bind的思路
改变 this 的思路:用 call / apply 临时借 this,或用箭头函数固定 this,或用 bind 永久绑定。
bind 的实现思路(核心):返回一个闭包,闭包里用 apply 把原函数调起来,并把 this 固定为传入的第一个参数:
js
Function.prototype.myBind = function (context, ...args) {
const self = this;
return function (...innerArgs) {
return self.apply(context, [...args, ...innerArgs]);
};
};
二、var / let / const 全局预编译、函数预编译
1. 讲出let,const,var的区别,块级作用域,变量提升,以及后来不使用var的原因
| var | let | const | |
|---|---|---|---|
| 作用域 | 函数作用域 | 块级作用域 | 块级作用域 |
| 变量提升 | 有,且提升时初始化为 undefined |
有提升但进入暂时性死区 | 有提升但进入暂时性死区 |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 声明内容 | 变量 | 变量 | 常量(必须声明时赋值,不可再赋值) |
| 挂到 window | 会 | 不会 | 不会 |
几个关键点:
- 块级作用域:
{}内是一个作用域;函数作用域:一个函数内变量到处可见。 - 变量提升:
var的声明会被提到函数体最前,但赋值仍在原位置,所以提升后值是undefined。 - 暂时性死区:
let/const也有提升,但从作用域开始到let声明那一行之前,访问该变量会直接抛ReferenceError。 - 全局作用域的 var 变量会挂到 window,成为 window 的一个属性。
为什么后来不用 var:
- 可以重复声明,容易命名冲突、覆盖。
- 函数作用域 + 提升,变量会"泄漏"出块、或在声明前就能读到
undefined,容易产生隐蔽 bug。 - 挂到 window,污染全局作用域。
2. 控制台会输出什么?
js
var a = 1;
let b = 2;
function test() {
console.log(a);
console.log(b);
var a = 3;
let b = 4;
console.log(a);
console.log(b);
}
test();
console.log(a);
console.log(b);
输出:
undefined
ReferenceError: Cannot access 'b' before initialization
- 进入
test(),函数内var a提升为undefined,console.log(a)→undefined。 console.log(b):函数内的let b虽然被提升,但在声明之前处于暂时性死区,访问它直接抛ReferenceError,函数在这里中断。
3. 哪一行会报错,为什么会报错?
报错的是 console.log(b)(第五行),原因是暂时性死区:在 let b = 4 初始化之前访问 b。
外层 let b = 2 在全局作用域,函数内 let b = 4 在 test 的函数作用域,两者不是同一作用域,不算重复声明。
4. 如果把函数里面的 let b = 4 改成 var b = 4,输出又会有什么变化?
js
function test() {
console.log(a); // undefined(var a 提升)
console.log(b); // undefined(var b 提升)
var a = 3;
var b = 4;
console.log(a); // 3
console.log(b); // 4
}
test(); // 依次输出:undefined, undefined, 3, 4
console.log(a); // 1
console.log(b); // 2
输出:undefined undefined 3 4 1 2。
三、垃圾回收机制
1. 什么是垃圾回收机制?判断机制是什么?
程序会为变量自动分配内存;当某块内存不再可达时,自动回收,交给后续进程复用。JavaScript 里开发者通常不需要手动释放内存。
判断机制:标记, 清除
- 从根对象(root)出发(全局对象
window、当前调用栈中的局部变量和参数、闭包引用的变量等),一路向下遍历所有引用,凡是能被访问到的都打上"存活"标记。 - 遍历结束后,没有标记的对象就是垃圾,被回收。
2. 什么是内存泄漏?前端常见的内存泄漏场景有哪些?
内存泄漏:一块内存已经不再被使用了,但因为还被某个引用指着,GC 认为它"可达",一直不回收。
常见场景:
- 全局变量:不小心把变量挂到
window,一直存活,window 是浏览器页面的全局对象。 - 定时器没清除:
setInterval一直跑,回调里的引用无法释放。 - 事件监听器没移除:DOM 节点被删了,但监听器还引用着它。
- 闭包滥用:外层变量被内层函数长期持有,导致无法回收。
- 分离的 DOM 节点:把 DOM 元素存到变量里,即使从页面移除,仍被引用。
- 循环引用:两个对象互相引用,引用计数永远不为 0。
3. 你有了解过V8 引擎对垃圾回收机制的优化吗? 比如分代回收式垃圾回收?
V8 把堆分成新生代和老生代:
- 新生代:新分配的对象先放这里,空间小、回收频繁,用复制算法,只保留存活对象。
- 老生代:在新生代里熬过一定次数 GC 仍存活的对象,会晋升到老生代。空间大、回收不频繁,用标记-清除 + 标记-整理(避免碎片)。
四、闭包、作用域链
1. JS 有哪几种作用域?
- 全局作用域(最外层)
- 函数作用域(一个函数内)
- 块级作用域(
{}内)
2. 什么是作用域链?变量查找时按照什么顺序进行?
作用域一层套一层,形成一条由内到外的链。查找变量时,先在本作用域找,找不到就沿着作用域链往外层找,直到全局,还没找到就报 ReferenceError。
3. 什么是闭包?
内层函数访问了外层函数作用域里的变量,即使外层函数执行完毕,这些变量因为仍被内层函数引用而不会被回收,这个"函数 + 被它引用的外部变量"整体就是闭包。
4. 输出什么?为什么?
js
function outer() {
let count = 0;
return function () {
count++;
console.log(count);
};
}
const fn1 = outer();
const fn2 = outer();
fn1(); // 1
fn1(); // 2
fn2(); // 1
fn1(); // 3
fn2(); // 2
输出:1 2 1 3 2。
为什么:每次调用 outer() 都会新创建一个 count,fn1 和 fn2 各自闭包住自己那一份 count,互不影响。所以 fn1 的 count 是 0 → 1 → 2 → 3,fn2 的 count 是 0 → 1 → 2。
五、原型与原型链
js
function Person(name) {
this.name = name;
}
Person.prototype.name = "prototype";
Person.prototype.age = 18;
const p = new Person("Jason");
console.log(p.name);
console.log(p.age);
console.log(p.hasOwnProperty("name"));
console.log(p.hasOwnProperty("age"));
console.log(p.__proto__ === Person.prototype);
console.log(Person.prototype.constructor === Person);
p.age = 20;
console.log(p.age);
console.log(Person.prototype.age);
delete p.age;
console.log(p.age);
1. 九次console.log分别输出什么?
| 输出 | 原因 | |
|---|---|---|
"Jason" |
p 自己的 name 属性(实例属性) |
|
18 |
自己没 age,沿原型链到 Person.prototype.age |
|
true |
name 是实例自有属性 |
|
false |
age 是继承来的,不是自有属性 |
|
true |
实例的原型就是构造函数的 prototype |
|
true |
prototype.constructor 指向构造函数本身 |
|
20 |
给 p.age 赋值,在 p 自己身上新增了 age = 20 |
|
18 |
原型上的 age 没被改动 |
|
18 |
删掉 p 自己的 age,又沿原型链找到 18 |
2. p.name最开始在哪里获取?p.__proto__指向什么?
p.name最先看 p 对象自己身上有没有name,有就直接用(实例属性"Jason"),不会再去原型找。p.__proto__指向Person.prototype。
3. 给 p.age 赋值后,为什么Person.prototype.age没有被修改?删除 p.age 后,为什么仍然能访问到 18?
p.age = 20是给实例 p 自己新增一个age属性(或改自己身上的),它只是遮蔽了原型上的age,并不会去修改Person.prototype.age。delete p.age删掉 p 自己的age后,再次访问p.age就沿__proto__向上,在Person.prototype上找到age = 18。
4. 什么是原型链?试着讲讲实例p的完整原型链以及原型链的末尾是什么。
原型链:对象通过内部指针 __proto__ 一层层向上找属性和方法,直到 null。
实例 p 的完整原型链:
p.__proto__--> Person.prototype.__proto__--> Object.prototype.__proto__--> null
原型链的末尾是 null(Object.prototype.__proto__ === null)。
六、深浅拷贝
1. 讲讲你理解的深浅拷贝,并说出两个拷贝对象的浅拷贝方法
- 浅拷贝:只复制第一层;嵌套的对象/数组仍共享同一个引用,改里层的会互相影响。
- 深拷贝:递归复制所有层级,得到一个完全独立的新对象。
浅拷贝方法:
- 方法一:
Object.assign({}, obj),把 obj 的可枚举自有属性复制到空对象。 - 方法二:展开运算符
{ ...obj }(数组用[...arr]或arr.slice()、arr.concat())。
2. 什么是循环引用?如果你想自己实现深拷贝,讲讲循环引用可能导致的问题以及解决方法
循环引用:对象内部直接或间接引用回自己(如 obj.self = obj,或 A 引用 B、B 又引用 A)。
问题:如果手写深拷贝只是无脑递归,遇到循环引用会无限递归 → 栈溢出(Stack Overflow)。
解决:用一个 Map/WeakMap 记录"已经拷贝过的对象",遇到重复引用时直接返回之前缓存的拷贝:
js
function deepClone(obj, map = new Map()) {
if (obj === null || typeof obj !== 'object') return obj;
if (map.has(obj)) return map.get(obj); // 已拷贝过,直接返回,打破循环
const copy = Array.isArray(obj) ? [] : {};
map.set(obj, copy);
for (const key of Object.keys(obj)) {
copy[key] = deepClone(obj[key], map);
}
return copy;
}
3. 使用 JSON.parse(JSON.stringify(obj)) 实现深拷贝有什么局限?它无法正确处理什么数据(至少两种)?给出一种更适合的深拷贝原生方法。
局限:
undefined、函数、Symbol:作为值会被丢掉(对象的这些属性会被忽略,数组里变成null)。NaN、Infinity→ 变成null。Date→ 变成字符串。RegExp、Map、Set→ 变成{}。- 循环引用 → 直接抛错。
- 丢失原型链、
BigInt直接报错。
更好的原生方法:structuredClone(obj),能处理循环引用、Date、Map/Set、ArrayBuffer 等大多数类型(仍不支持函数、Symbol)。
七、隐式转换
1. 什么时候会出现隐式转换?请说说不同运算符的转换规则是什么。( + - * / ==)
出现时机:参与运算的两个值类型不同,或在布尔/字符串/数字语境下需要类型统一时。
| 运算符 | 转换规则 |
|---|---|
+ |
只要有一边是字符串,就做字符串拼接(另一边转字符串);否则两边都转数字相加 |
- * / |
两边都转成 Number 再运算 |
== |
宽松相等,按类型对齐规则转换 |
补充 == 的常见规则:对象 和 数字/布尔 → 对象先 ToPrimitive 再比较;布尔 和 其他 → 布尔先转数字;null == undefined 为 true。
2. \[\] == false 的输出是什么,并判断过程
输出:true。
过程:
false是布尔,==比较时布尔先转数字:false → 0。[]是对象,转原始值ToPrimitive:先valueOf()得到[](还是对象),再toString()得到""。""转数字 →0。0 == 0→true。
八、数据类型
1. js有几种数据类型?分别是什么?指出哪些是原始类型,哪些是引用类型?Symbol 和 BigInt 的作用?
- 原始类型(7 种):
String、Number、Boolean、null、undefined、Symbol、BigInt。 - 引用类型:
Object。
Symbol 的作用:生成独一无二的值,可用作对象属性名,避免属性名冲突、防止被意外覆盖。
BigInt 的作用:表示 Number 无法精确表示的大整数(超过 2^53 - 1,即 Number.MAX_SAFE_INTEGER)。
2. 获取数据类型方式有哪些?各自的特点
| 方式 | 特点 |
|---|---|
typeof x |
返回 "string"/"number"/"boolean"/"undefined"/"object"/"function"/"symbol"/"bigint";null 错报为 "object",数组也是 "object" |
x instanceof Ctor |
判断某对象是否在某构造函数的原型链上;对原始类型为 false,跨 iframe 会失效 |
Object.prototype.toString.call(x) |
最准确,返回 "[object Array]"、"[object Null]" 等 |
Array.isArray(x) |
专门判断数组,最可靠 |
3. 为什么 typeof null === "object"?
这是历史遗留 bug。早期 JS 用低位二进制表示类型标签,对象的类型标签是 000,而 null 被表示为全 0(机器码 0x00),恰好和对象标签一致,于是 typeof null 就返回了 "object"。这个 bug 一直保留下来以兼容旧代码。
九、任务队列
JS中哪些任务是同步任务 宏任务 微任务?
- 同步任务:主线程直接按顺序执行的代码。
- 宏任务:
script整体、setTimeout、setInterval、I/O、网络请求、事件回调(如 click)、requestAnimationFrame等。 - 微任务:
Promise.then/catch/finally、await之后的代码、queueMicrotask(手动把一个函数放进微任务队列)、MutationObserver(监听 dom 的变化) 等。
每执行完一个宏任务,就要清空整个微任务队列,再执行下一个宏任务。
下列代码的控制台输出是什么?
js
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 1'));
console.log('Listener 1');
});
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 2'));
console.log('Listener 2');
});
输出:
Listener 1
Listener 2
Microtask 1
Microtask 2
原因:一次点击只触发一个 click 事件,两个监听器在同一次事件分发(同一个宏任务)里按顺序同步执行:
- 监听器 1:先把
Microtask 1放进微任务队列,再打印Listener 1。 - 监听器 2:把
Microtask 2放进微任务队列,再打印Listener 2。 - 整个 click 宏任务结束 → 清空微任务队列,依次执行
Microtask 1、Microtask 2。 - 注意一次点击算一个宏任务,而不是一个事件监听,一次点击的宏任务会把所有绑定的事件按顺序执行。
十、异步编程
JS运行特点?为什么需要异步编程?什么是回调地狱?如何解决回调地狱?
JS 运行特点:单线程 + 事件循环。主线程一次只能做一件事,但遇到 I/O、定时器、网络等耗时操作时不会傻等,而是交给浏览器/环境处理,回调在合适时机再回到主线程执行。
为什么需要异步:避免耗时操作阻塞主线程(页面卡死),提高响应速度和吞吐。
回调地狱:多层回调嵌套(fn1(() => fn2(() => fn3(...)))),代码缩进越来越深、难以阅读和维护。
如何解决:
- 用 Promise:把回调变成链式
.then(),避免深层嵌套。 - 用 async / await:本质还是异步,但写起来像同步,代码从上往下读,最直观。
js
async function task() {
const a = await fetchData();
const b = await fetchMore(a);
return b;
}
十一、ES6 新特性
js
const id = Symbol("id");
const user = {
name: "Tom",
age: 18,
[id]: 1001
};
function fn(user) {
const { name, age } = user;
console.log(arguments.length);
console.log(name, age);
console.log(user.id);
console.log(user[id]);
}
fn(user, "extra");
1. 四次 console.log 分别输出什么?
| 输出 | 原因 | |
|---|---|---|
2 |
fn 被调用时传了 2 个实参(user、"extra") |
|
Tom 18 |
解构出来的 name、age |
|
undefined |
user.id 访问的是字符串属性名 "id",对象里没有这个属性 |
|
1001 |
user[id] 用变量 id(Symbol)作键,命中了 [id]: 1001 |
2. 为什么 arguments.length 是 2?
arguments 是函数内的类数组对象,保存实际传入的所有实参。这里 fn(user, "extra") 传了两个参数,所以 length === 2(与形参个数无关,只看调用时传了几个)。
3. const { name, age } = user 是什么语法?
对象解构赋值。把 user 对象里的 name、age 两个属性分别提取到同名变量 name、age 中。
4. user[id] 和 user.id 有什么区别?
user.id:访问字符串键"id"对应的属性,对象里没有 →undefined。user[id]:用变量id(其值是Symbol("id"))作键访问,对象定义时用[id]: 1001存的就是这个 Symbol 键 →1001。
user.id 相当于 user["id"],和 user[Symbol("id")] 是完全不同的两个键。
5. Symbol 作为对象属性名有什么作用?
- 生成独一无二的键,避免和其他属性名冲突。
- 无法被
for...in、Object.keys()枚举到(需要用Object.getOwnPropertySymbols()才能拿到),起到一定的"隐藏/保护"作用。 - 防止外部代码用字符串拼出同名键意外覆盖它。