JS 底层解析:作用域、原型链、继承、this、事件模型、类型判断

0 阅读10分钟

本文的内容是JS 底层解析,覆盖作用域、原型链、继承、this、事件模型、类型判断等核心问题。每道题附带原理说明 + 代码示例 + 面试要点,建议收藏反复记忆。


一、说说你对 JS 作用域的理解

是什么

作用域就是变量和函数生效的区域,决定了变量在哪些范围内可以被访问。

JS 作用域分为三种:

类型说明创建方式
全局作用域最外层区域,全局变量随处可访问顶层声明的变量
函数作用域函数内部,外部无法访问函数声明
块级作用域{} 内部,let/const 专属let/const + {}

核心规则

// 1. 内层可以访问外层,外层不能访问内层
let global = '全局';

function outer() {
  let outerVar = '外层';
  function inner() {
    console.log(outerVar); // ✅ 内层访问外层
  }
  inner();
  console.log(innerVar); // ❌ ReferenceError
}

// 2. 块级作用域
{
  let a = 1;
  var b = 2;        // var 没有块级作用域
}
console.log(a); // ❌ ReferenceError(let 受块级作用域限制)
console.log(b); // ✅ 2(var 泄漏到外层)

// 3. 经典面试题:var vs let
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);  // 3 3 3
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0);  // 0 1 2
}

词法作用域

JS 采用词法作用域(静态作用域):函数的作用域在定义时就确定了,而不是调用时。

let name = 'global';

function foo() {
  console.log(name);  // 'global',定义时的环境域
}

function bar() {
  let name = 'bar';
  foo();  // 调用时 name 仍是 'global'
}

bar();  // 输出 'global'

作用域查找规则

1. 先在当前作用域查找
2. 当前没有 → 去父作用域查找
3. 层层往上,直到全局作用域
4. 全局也没有 → ReferenceError

面试要点

  • var 没有块级作用域let/const 有——这是 ES6 引入块级作用域的原因
  • 词法作用域:函数的作用域在定义时确定,与调用位置无关
  • 作用域链:内层访问外层变量的查找路径,层层往外直到全局
  • let 经典题for 循环中 var 打印全是 3,let 正常打印 0 1 2,因为 let 每轮循环创建新的块级作用域

二、说说 JS 中的原型和原型链

原型是什么

JS 中每个函数天生自带一个 prototype 属性(显式原型),每个对象天生自带一个 __proto__ 属性(隐式原型)。

function Person(name) {
  this.name = name;
}

Person.prototype.sayHi = function() {
  console.log('Hi, I am ' + this.name);
};

const p = new Person('Tom');

console.log(p.__proto__ === Person.prototype);  // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__);  // null(终点)

为什么要有原型

JS 中的对象由 new 构造函数 创建。为了让所有实例共享属性和方法,就把公共方法放在 prototype 上,实例通过 __proto__ 就能访问到。

原型链

V8 查找对象属性时,沿着 __proto__ 一直往上找,直到 null 为止。这条链就是原型链

实例 p
  └── __proto__ → Person.prototype
        └── __proto__ → Object.prototype
              └── __proto__ → null  (终点)
p.sayHi();     // 在 Person.prototype 上找到
p.toString();  // 在 Object.prototype 上找到
p.xxx;         // 一路找到 null 都没有 → undefined

完整的原型链关系图

function Person() {}

// 实例 ←→ 构造函数
const p = new Person();
p.__proto__ === Person.prototype;         // true

// 构造函数 ←→ Function
Person.__proto__ === Function.prototype; // true

// Function ←→ Object
Function.prototype.__proto__ === Object.prototype; // true

// Object 的终点
Object.prototype.__proto__ === null;      // true

面试要点

  • prototype 是函数的属性__proto__ 是对象的属性
  • 原型链终点Object.prototype.__proto__ === null
  • instanceof 原理:沿着 __proto__ 查找是否有 === 构造函数.prototype 的节点
  • 不要用 obj.__proto__,推荐用 Object.getPrototypeOf(obj) / Object.setPrototypeOf()

三、说一说 JS 中的继承

是什么

子类继承父类的属性和方法,避免重复定义。JS 有 5 种主要继承方式,从低到高演进。

1. 原型链继承

function Parent() {
  this.colors = ['red', 'blue'];
}
Parent.prototype.say = function() { console.log('hi'); };

function Child() {}
Child.prototype = new Parent();

const c1 = new Child();
c1.colors.push('green');
const c2 = new Child();
console.log(c2.colors); // ['red', 'blue', 'green'] ← 引用类型被共享!

缺点:引用类型的属性被所有实例共享,修改一个影响其他。

2. 构造函数继承(借用调用)

function Parent() {
  this.colors = ['red', 'blue'];
}
Parent.prototype.say = function() { console.log('hi'); };

function Child() {
  Parent.call(this);  // 借用 Parent 的 this
}

const c1 = new Child();
c1.colors.push('green');
const c2 = new Child();
console.log(c2.colors); // ['red', 'blue'] ✅ 不共享了
console.log(c1.say);    // undefined ❌ 拿不到原型上的方法

缺点:只能继承实例属性,不能继承原型上的方法。

3. 组合继承(原型链 + 构造函数)

function Parent(name) {
  this.name = name;
  this.colors = ['red'];
}
Parent.prototype.say = function() { console.log(this.name); };

function Child(name, age) {
  Parent.call(this, name);  // 第1次调用 Parent(继承实例属性)
  this.age = age;
}
Child.prototype = new Parent();  // 第2次调用 Parent(继承原型)
Child.prototype.constructor = Child;

const c = new Child('Tom', 18);
c.say(); // 'Tom'

缺点Parent 被调用了两次,浪费性能。

4. 寄生组合继承(最优解)

function Parent(name) {
  this.name = name;
  this.colors = ['red'];
}
Parent.prototype.say = function() { console.log(this.name); };

function Child(name, age) {
  Parent.call(this, name);  // 只调1次 Parent
  this.age = age;
}

// 关键:用 Object.create 继承原型,不调 Parent
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

const c = new Child('Tom', 18);
c.say(); // 'Tom'

优点Parent 只调用一次,实例属性不共享,原型方法也能继承。面试推荐写法

5. class 继承(ES6 语法糖)

class Parent {
  constructor(name) {
    this.name = name;
    this.colors = ['red'];
  }
  say() { console.log(this.name); }
}

class Child extends Parent {
  constructor(name, age) {
    super(name);  // 等价于 Parent.call(this, name)
    this.age = age;
  }
}

const c = new Child('Tom', 18);
c.say(); // 'Tom'

优点:语法清晰,extends + super 本质就是寄生组合继承。

五种方式对比

方式实例属性共享?原型方法继承?Parent 调用次数推荐度
原型链继承共享(有问题)0
构造函数继承不共享 ✅1
组合继承不共享 ✅2⚠️
寄生组合继承不共享 ✅1
class 继承不共享 ✅1✅✅

面试要点

  • 面试首选写寄生组合继承,核心是 Object.create(Parent.prototype) 避免第二次调用 Parent
  • Object.create 的作用:创建一个新对象,其 __proto__ 指向参数
  • class 继承是 ES6 语法糖,底层仍是寄生组合继承
  • 别忘了修复 constructorChild.prototype.constructor = Child

四、说说 JS 中的 this

是什么

this 是 JS 中的一个关键字,指向谁取决于函数的调用方式,而不是定义方式。

四大绑定规则 + 箭头函数

// 1. 默认绑定:独立调用,this 指向 window(严格模式 undefined)
function foo() {
  console.log(this);  // window
}
foo();

// 2. 隐式绑定:作为对象方法调用,this 指向该对象
const obj = {
  name: 'Tom',
  say() { console.log(this.name); }
};
obj.say();  // 'Tom'

// ⚠️ 隐式丢失:把方法赋值给变量后独立调用
const fn = obj.say;
fn();  // undefined(this 变成 window)

// 3. 显式绑定:call / apply / bind
function greet(greeting) {
  console.log(greeting + ', ' + this.name);
}
const person = { name: 'Tom' };
greet.call(person, 'Hello');    // 'Hello, Tom'
greet.apply(person, ['Hello']); // 'Hello, Tom'
const bound = greet.bind(person, 'Hello');
bound();  // 'Hello, Tom'
// call/apply 立即执行,bind 返回新函数

// 4. new 绑定:this 指向新创建的实例
function Person(name) {
  this.name = name;  // this 指向新对象
}
const p = new Person('Tom');
console.log(p.name); // 'Tom'

call / apply / bind 的区别

方法执行时机参数返回值
call立即执行参数列表 (thisArg, arg1, arg2)函数返回值
apply立即执行参数数组 (thisArg, [arg1, arg2])函数返回值
bind返回新函数参数列表绑定后的新函数

箭头函数的 this

箭头函数没有自己的 this,它继承外层(定义时)的 this,且不可被 call/apply/bind 改变。

const obj = {
  name: 'Tom',
  // 普通函数:this 指向 obj
  sayHi() {
    setTimeout(function() {
      console.log(this.name);  // ❌ this 是 window
    }, 100);
  },
  // 箭头函数:this 继承外层 sayHi 的 this(obj)
  sayHiArrow() {
    setTimeout(() => {
      console.log(this.name);  // ✅ 'Tom'
    }, 100);
  }
};
obj.sayHiArrow();

绑定优先级

new 绑定 > 显式绑定 > 隐式绑定 > 默认绑定

面试要点

  • this 取决于调用方式,不是定义方式
  • 箭头函数没有 this,用外层的——这是 setTimeout 回调常用箭头函数的原因
  • 隐式丢失:对象方法赋值给变量后独立调用,this 丢失指向 window
  • call/apply/bind 区别:call/apply 立即执行,bind 返回新函数

五、说一说 JS 中的事件模型(事件流)

是什么

事件流是事件在浏览器中传播的路径,分为三个阶段

1. 捕获阶段(Capture):从 window 往目标元素传播
2. 目标阶段(Target):到达目标元素,触发事件
3. 冒泡阶段(Bubble):从目标元素往 window 传播
<div id="outer">
  <div id="inner">点击我</div>
</div>

<script>
// addEventListener 第3个参数:true=捕获阶段触发,false/默认=冒泡阶段触发
outer.addEventListener('click', () => {
  console.log('outer 冒泡');
}, false);

outer.addEventListener('click', () => {
  console.log('outer 捕获');
}, true);

inner.addEventListener('click', () => {
  console.log('inner 目标');
}, false);

// 点击 inner,输出顺序:
// outer 捕获 → inner 目标 → outer 冒泡
</script>

阻止事件传播

element.addEventListener('click', (e) => {
  e.stopPropagation();  // 阻止冒泡/捕获,后续阶段不触发
});

element.addEventListener('click', (e) => {
  e.stopImmediatePropagation();  // 阻止传播 + 阻止同元素上的其他监听器
});
方法作用
e.stopPropagation()阻止事件继续传播
e.stopImmediatePropagation()阻止传播 + 阻止同元素其他监听器

事件委托(事件代理)

借助冒泡机制,把子元素的事件统一绑定在父元素上,减少事件绑定数量。

<ul id="list">
  <li>item 1</li>
  <li>item 2</li>
  <li>item 3</li>
</ul>

<script>
// ❌ 不好的写法:每个 li 都绑定
document.querySelectorAll('li').forEach(li => {
  li.addEventListener('click', () => console.log(li.textContent));
});

// ✅ 事件委托:只绑父元素
document.getElementById('list').addEventListener('click', (e) => {
  if (e.target.tagName === 'LI') {
    console.log(e.target.textContent);
  }
});

// 后续动态添加的 li 也能响应(无需重新绑定)
const newLi = document.createElement('li');
newLi.textContent = 'item 4';
list.appendChild(newLi);  // 点击也能触发
</script>

面试要点

  • 三个阶段:捕获(window → target)→ 目标 → 冒泡(target → window)
  • 默认在冒泡阶段触发addEventListener 第三参数传 true 才在捕获阶段触发
  • 事件委托:利用冒泡,父元素统一处理子元素事件,动态添加的元素也能响应
  • stopPropagation vs stopImmediatePropagation:后者额外阻止同元素其他监听器

六、说说 JS 怎么做类型判断

五种方式总览

方法能判断的类型缺点
typeof原始类型(除 null)+ functionnull 返回 'object',引用类型都是 'object'
instanceof引用类型不能判断原始类型,跨 iframe 失效
Object.prototype.toString.call()所有类型写法长
Array.isArray()只判断数组只能判断数组
constructor引用类型可被修改,不靠谱

1. typeof

typeof 'abc';     // 'string'
typeof 123;       // 'number'
typeof true;      // 'boolean'
typeof undefined; // 'undefined'
typeof Symbol();  // 'symbol'
typeof 123n;      // 'bigint'

typeof null;      // 'object'  ⚠️ 历史遗留 bug
typeof [];        // 'object'  ⚠️ 分不清数组和对象
typeof {};        // 'object'
typeof function(){}; // 'function'

原理:将值转换为二进制,判断前三位是否为 000。null 的二进制全是 0,所以前三位也是 000,被误判为 object。

2. instanceof

[] instanceof Array;    // true
[] instanceof Object;  // true(数组也是对象)
{} instanceof Object;   // true
new Date() instanceof Date;     // true
/'abc' instanceof RegExp;      // true

'abc' instanceof String;  // false(不能判断原始类型)

原理:沿着实例的 __proto__(隐式原型链)查找,是否有 === 构造函数.prototype 的节点,找到返回 true,找到 null 返回 false

// 手写 instanceof
function myInstanceof(left, right) {
  let proto = Object.getPrototypeOf(left); // left.__proto__
  while (proto !== null) {
    if (proto === right.prototype) return true;
    proto = Object.getPrototypeOf(proto);
  }
  return false;
}

3. Object.prototype.toString.call()(最准确)

Object.prototype.toString.call('abc');    // '[object String]'
Object.prototype.toString.call(123);      // '[object Number]'
Object.prototype.toString.call(null);      // '[object Null]'
Object.prototype.toString.call(undefined); // '[object Undefined]'
Object.prototype.toString.call([]);        // '[object Array]'
Object.prototype.toString.call({});        // '[object Object]'
Object.prototype.toString.call(new Date());// '[object Date]'
Object.prototype.toString.call(/abc/);     // '[object RegExp]'

// 封装一个通用类型判断函数
function getType(val) {
  return Object.prototype.toString.call(val).slice(8, -1).toLowerCase();
}
getType(null);  // 'null'
getType([]);    // 'array'
getType({});    // 'object'

原理Object.prototype.toString 内部访问值的 [[Class]] 内部属性,返回 '[object ' + [[Class]] + ']'

4. Array.isArray()

Array.isArray([]);       // true
Array.isArray({});       // false
Array.isArray('abc');    // false

ES5 引入,专门用于准确判断数组,推荐判断数组时优先使用。

5. constructor

[].constructor === Array;    // true
({}).constructor === Object;  // true
new Date().constructor === Date; // true

// ⚠️ 不可靠:constructor 可以被修改
function Foo() {}
Foo.prototype.constructor = Object;  // 被篡改
new Foo().constructor === Foo;  // false

面试要点

  • typeof 的 bugtypeof null === 'object'(历史遗留,前三位 000)
  • 判断数组:优先 Array.isArray(),次选 Object.prototype.toString.call()
  • 通用类型判断Object.prototype.toString.call(val).slice(8, -1) 最准确
  • instanceof 原理:沿原型链查找 __proto__ === 构造函数.prototype
  • constructor 不可靠:可以被手动修改,面试时作为"了解"即可

总结

以上 6 道题是 JS 面试的第二批核心高频考点,记忆建议:

  1. 作用域 → 记住三种类型 + 词法作用域(定义时确定)+ let 块级作用域
  2. 原型链prototype 是函数的,__proto__ 是对象的,终点是 null
  3. 继承 → 面试手写寄生组合继承,Object.create(Parent.prototype) 是核心
  4. this → 四大规则(默认/隐式/显式/new)+ 箭头函数继承外层 this
  5. 事件模型 → 捕获→目标→冒泡三阶段 + 事件委托利用冒泡
  6. 类型判断typeof 判原始类型(除 null),toString.call() 万能判断

配合上篇(数组/字符串方法、类型转换、== vs ===、拷贝、闭包)共 12 题,基本覆盖 JS 面试核心范围 👋