技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

原生 JS 灵魂之问,请问你能接得住几个?(上)

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/88-原生JS灵魂之问, 请问你能接得住几个?(上)
本文目录7 个章节
  1. 第一篇:JS 数据类型之问——概念篇
  2. 第二篇:JS 数据类型之问——检测篇
  3. 第三篇:JS 数据类型之问——转换篇
  4. 第四篇:谈谈你对闭包的理解
  5. 第五篇:谈谈你对原型链的理解
  6. 第六篇:JS 如何实现继承?
  7. 小结

原生 JS 灵魂之问,请问你能接得住几个?(上)

Category(分类): JavaScript Status: 已整理

本文保留原文按“数据类型 → 类型检测 → 类型转换 → 闭包 → 原型链 → 继承”提问的学习路线。示例中的输出按当前 ECMAScript 语义修正;手写实现主要用于理解机制,不应替代标准内置方法。

第一篇:JS 数据类型之问——概念篇

1. JS 原始数据类型有哪些?引用数据类型有哪些?

当前 ECMAScript 有 7 种原始类型:

  • undefined
  • null
  • boolean
  • number
  • bigint
  • string
  • symbol

除此之外的是对象类型,包括普通对象、数组、函数、正则表达式、日期、Map、Set、Promise、DOM 对象等。函数也是对象的一种,但 typeof function () {} 会返回特殊的字符串 "function"

“基本类型放栈、引用类型放堆”是便于入门的实现想象,不是 ECMAScript 对引擎内存布局的规定。引擎可以根据逃逸分析、优化和垃圾回收策略决定实际存储方式。真正稳定的语言层结论是:原始值不可变,变量保存的是值;对象变量保存对对象的引用值。

2. 说出下面运行的结果,解释原因

function test(person) {
  person.age = 26
  person = {
    name: 'hzj',
    age: 18,
  }
  return person
}

const p1 = {
  name: 'fyq',
  age: 19,
}

const p2 = test(p1)
console.log(p1) // { name: 'fyq', age: 26 }
console.log(p2) // { name: 'hzj', age: 18 }

JavaScript 的参数传递始终是按值传递。对象参数的值是一个引用值:修改 person.age 会通过这个引用修改原对象;但给局部参数 person 重新赋值,不会改变调用者变量 p1 保存的引用。

3. null 是对象吗?为什么?

结论:null 是一种原始值,不是对象。

console.log(typeof null) // 'object'
console.log(null === null) // true

typeof null === 'object' 是 JavaScript 早期实现留下的历史行为。不要根据 typeof 的结果把 null 当作对象;判断对象通常需要先排除 null

function isObject(value) {
  return value !== null && typeof value === 'object'
}

4. '1'.toString() 为什么可以调用?

对原始字符串调用属性或方法时,规范会在需要时执行临时的包装/装箱行为,使它能够访问 String.prototype 上的方法;这不意味着原始字符串永久变成了对象。

const value = '1'
console.log(value.toString()) // '1'
console.log(typeof value) // 'string'

类似地,数字和布尔值也可以访问相应包装原型的方法。SymbolBigInt 不能使用 new Symbol()new BigInt() 创建实例,但它们仍可以在需要时进行临时包装:

console.log((1n).toString())
console.log(Object(1n)) // BigInt 包装对象

“包装对象”与原始值不是同一种值:

console.log(new String('a') === 'a') // false
console.log(Object('a') === 'a') // false

5. 0.1 + 0.2 为什么不等于 0.3

JavaScript 的 number 使用 IEEE 754 双精度二进制浮点数。很多十进制小数无法用有限长度的二进制小数精确表示,因此运算会在表示范围内进行舍入:

console.log(0.1 + 0.2) // 0.30000000000000004

金额等对精度敏感的场景,应使用最小货币单位的整数、定点/十进制定点库,或在支持的环境中谨慎使用 decimal 相关方案,而不是直接用二进制浮点数比较相等。

6. 如何理解 BigInt?

BigInt 用于表示任意精度的整数,适合超过 Number.MAX_SAFE_INTEGER 的整数计算,但它不是浮点数,也不能直接与 number 混合进行大多数算术运算。

console.log(Number.MAX_SAFE_INTEGER) // 9007199254740991
console.log(9007199254740993) // 9007199254740992(精度已丢失)
console.log(9007199254740993n) // 9007199254740993n
console.log(9007199254740992 === 9007199254740993) // true

创建 BigInt 可以使用字面量后缀 nBigInt()

const a = 9007199254740995n
const b = BigInt('9007199254740995')

console.log(a + b)
console.log(20n / 3n) // 6n,向零截断

需要注意:

  • 10n + 10 会抛出 TypeError,应显式转换后再运算。
  • 10n / 3n 不能得到小数结果。
  • Math.max(2n, 4n) 会抛出 TypeError,很多 Web API 也要求传入 number
  • BigInt 不支持一元加号 +10n;可以使用 BigInt() 或其他明确转换方式。
  • BigInt 可以参与布尔判断,0n 是假值,其他 BigInt 是真值。
  • BigInt 支持整数的比较和位运算,但不能与 Number 直接做位运算。
  • JSON 没有原生 BigInt 表示;JSON.stringify({ value: 1n }) 默认会抛错,需要自行转换为字符串或其他协议格式。
console.log(10n < 11) // true,比较运算允许适当的数值比较
console.log(Boolean(0n)) // false
console.log(Boolean(3n)) // true

try {
  console.log(10n + 10)
} catch (error) {
  console.log(error.name) // TypeError
}

BigInt 兼容性历史截图

原文图片反映的是 BigInt 刚推出时的兼容性状态;现在主流现代浏览器和 Node.js 已支持 BigInt,但发布到旧环境仍应检查目标运行时。图片已下载到本地,保留作历史资料。

第二篇:JS 数据类型之问——检测篇

1. typeof 是否能正确判断类型?

对原始值来说,typeof 大多能给出有用结果,但 null 是历史例外;对对象来说,除了函数、可调用代理等情况,通常都返回 "object"

console.log(typeof 1) // 'number'
console.log(typeof '1') // 'string'
console.log(typeof undefined) // 'undefined'
console.log(typeof true) // 'boolean'
console.log(typeof Symbol()) // 'symbol'
console.log(typeof 1n) // 'bigint'
console.log(typeof null) // 'object'
console.log(typeof []) // 'object'
console.log(typeof {}) // 'object'
console.log(typeof console.log) // 'function'

因此 typeof 适合做第一层判断,但不能单独区分数组、日期、Map 或普通对象。

2. instanceof 能否判断基本数据类型?

原生 instanceof 主要用于检查对象的原型链,不能直接把数字原始值当成 Number 实例:

console.log(111 instanceof Number) // false
console.log(new Number(111) instanceof Number) // true

不过,构造函数可以自定义 Symbol.hasInstance,因此可以定义自己的判断规则:

class PrimitiveNumber {
  static [Symbol.hasInstance](value) {
    return typeof value === 'number'
  }
}

console.log(111 instanceof PrimitiveNumber) // true

这里改变的是 PrimitiveNumberinstanceof 行为,不是改变原生 Number 的内部语义。跨 iframe 判断数组、日期等对象时,也要注意每个 realm 有自己的构造函数,优先使用对应的品牌检测 API。

3. 能不能手动实现 instanceof

下面是一个用于教学的简化实现。它没有覆盖所有代理、异常构造器和 Symbol.hasInstance 语义,生产代码应直接使用原生 instanceof

function myInstanceof(value, Constructor) {
  if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
    return false
  }

  if (typeof Constructor !== 'function') {
    throw new TypeError('Right-hand side of instanceof is not callable')
  }

  const prototype = Constructor.prototype
  if (typeof prototype !== 'object' || prototype === null) {
    throw new TypeError('Constructor.prototype is not an object')
  }

  let current = Object.getPrototypeOf(value)
  while (current !== null) {
    if (current === prototype) return true
    current = Object.getPrototypeOf(current)
  }
  return false
}

console.log(myInstanceof(new String('111'), String)) // true
console.log(myInstanceof('111', String)) // false

4. Object.is=== 的区别?

Object.is 使用 SameValue 语义;严格相等使用 Strict Equality Comparison。两者最常见的差异是 NaN+0/-0

console.log(NaN === NaN) // false
console.log(Object.is(NaN, NaN)) // true

console.log(+0 === -0) // true
console.log(Object.is(+0, -0)) // false

Object.is 不是“更严格的深比较”,它仍然只比较两个值本身,不会递归比较对象内容:

console.log(Object.is({}, {})) // false
const object = {}
console.log(Object.is(object, object)) // true

第三篇:JS 数据类型之问——转换篇

1. [] == ![] 结果是什么?为什么?

console.log([] == ![]) // true

简化过程如下:

  1. [] 是对象,转换为布尔值时是真值,所以 ![]false
  2. == 比较对象与布尔值时,会继续执行规范规定的类型转换。
  3. false 转换为数字 0
  4. 空数组经过 ToPrimitive 得到空字符串,再转换为数字 0
  5. 最终比较 0 == 0,结果为 true

这也是为什么不建议在业务代码中依赖复杂的隐式类型转换。

2. JS 中类型转换有哪些?

“类型转换只有数字、布尔值、字符串三种”是便于入门的概括,不是规范中的完整分类。ECMAScript 还定义了 ToPrimitiveToBooleanToNumberToNumericToStringToObject 等抽象操作。SymbolBigInt 的转换边界尤其需要单独注意。

类型转换规则历史示意图

3. ===== 有什么区别?

=== 不进行一般的隐式类型转换;== 会按照 Abstract Equality Comparison 规则转换。常见规则包括:

  • 类型相同,按严格相等继续比较。
  • null == undefinedtrue,但它们不等于其他普通值。
  • 字符串与数字比较时,字符串可能转换为数字。
  • 布尔值比较时,会先转换为数字。
  • 对象与字符串、数字、BigInt 等比较时,会先执行 ToPrimitive
  • Symbol 参与某些隐式转换时会抛出异常,不能把它当作普通字符串处理。
console.log(1 == '1') // true
console.log(1 === '1') // false
console.log(null == undefined) // true
console.log(null === undefined) // false
console.log({ valueOf: () => 1 } == 1) // true

现代业务代码通常优先使用 ===,但理解 == 有助于读懂旧代码和分析面试题。

4. 对象转原始类型依据什么流程?

对象转换为原始值时,会先调用 Symbol.toPrimitive(如果存在);否则根据 hint 选择 valueOftoString 的顺序:

  • number/default hint:通常先 valueOf,再 toString
  • string hint:通常先 toString,再 valueOf
  • 如果最后仍未得到原始值,就抛出 TypeError
const object = {
  name: 'John',
  [Symbol.toPrimitive](hint) {
    if (hint === 'string') return this.name
    return 42
  },
}

console.log(String(object)) // John
console.log(+object) // 42
const object = {
  valueOf() {
    return 10
  },
  toString() {
    return 'Hello'
  },
}

console.log(+object) // 10
console.log(String(object)) // Hello

普通对象默认的 toString 会返回 "[object Object]",所以 +{} 的结果通常是 NaN,并不会因为转换失败而自动抛错:

console.log(`${{}}`) // [object Object]
console.log(+{}) // NaN

5. 如何让 if (a == 1 && a == 2) 成立?

可以利用对象参与 == 时会反复执行 ToPrimitive 的事实:

let value = 0
const a = {
  valueOf() {
    value++
    return value
  },
}

console.log(a == 1 && a == 2) // true

这是语言机制演示,不是推荐的业务写法;对象的转换副作用会让代码非常难以维护。

第四篇:谈谈你对闭包的理解

什么是闭包?

闭包可以理解为:函数与它能够访问的词法环境一起被保存,使函数在定义它的外层执行结束后仍能访问相关变量。函数不一定要“返回出来”才产生闭包,只要存在对外层变量的引用和可观察的延续使用即可。

function createCounter() {
  let count = 0
  return () => {
    count++
    return count
  }
}

const next = createCounter()
console.log(next()) // 1
console.log(next()) // 2

闭包保存的是必要的词法环境,不应简单理解成“复制了整个父函数作用域”或“所有变量都永远放在堆中”。引擎可以进行优化,但只要闭包仍然可达,被闭包使用的状态就不能被回收。

闭包产生的原因

作用域链是词法结构:访问变量时,先查当前环境,再沿外层环境查找。下面的 f2 可以访问定义位置的 a

function f1() {
  const a = 2

  function f2() {
    console.log(a)
  }

  return f2
}

const x = f1()
x() // 2

闭包也可以通过外部变量保存回调形成:

let callback

function setup() {
  const value = 2
  callback = () => console.log(value)
}

setup()
callback() // 2

闭包的常见表现形式

  1. 返回函数。
  2. 把函数作为参数传给另一个函数。
  3. 定时器、事件监听器、网络回调和消息回调引用外层变量。
  4. IIFE 或模块通过私有词法环境隐藏状态。
function callLater() {
  const message = 'done'
  setTimeout(() => console.log(message), 0)
}

callLater()

并不是“只要是回调函数就必然保存了有用的外层状态”;关键在于它是否引用了定义位置的自由变量。

如何解决循环输出问题?

for (var i = 1; i <= 5; i++) {
  setTimeout(() => console.log(i), 0)
}

常见输出是五个 6:计时器回调运行时,循环已经结束,所有回调读取的是同一个 var 绑定。

方法一:IIFE 创建新的参数绑定

for (var i = 1; i <= 5; i++) {
  ((value) => {
    setTimeout(() => console.log(value), 0)
  })(i)
}

方法二:使用计时器的额外参数

for (var i = 1; i <= 5; i++) {
  setTimeout((value) => console.log(value), 0, i)
}

方法三:使用 let 的每次迭代绑定

for (let i = 1; i <= 5; i++) {
  setTimeout(() => console.log(i), 0)
}

let 不会让作用域链“消失”;规范为 for 循环的每次迭代创建了新的词法绑定,因此每个回调捕获不同的 i

闭包与循环示意图

第五篇:谈谈你对原型链的理解

1. 原型对象和构造函数有什么关系?

可作为普通构造器使用的函数和类通常具有可用的 prototype 属性;箭头函数、async 函数、Generator 函数和 bound function 等并不都能直接作为构造器。通过 new 调用时,实例的 [[Prototype]] 通常被设置为构造函数的 prototype。类也有原型对象,但类构造器不能像普通函数一样不使用 new 直接调用。

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

Person.prototype.say = function () {
  return this.name
}

const person = new Person('Ada')
console.log(Object.getPrototypeOf(person) === Person.prototype) // true
console.log(person.say()) // Ada

现代代码应使用 Object.getPrototypeOfObject.setPrototypeOf 观察/修改原型,避免依赖非标准的 Object.prototype.__proto__ 访问器。

2. 什么是原型链?

对象读取属性时,如果自身没有该属性,JavaScript 会沿 [[Prototype]] 链继续查找,直到找到属性或到达 null

const parent = { kind: 'parent' }
const child = Object.create(parent)
child.name = 'child'

console.log(child.kind) // parent
console.log(Object.hasOwn(child, 'kind')) // false
console.log('kind' in child) // true

原型链历史示意图

  • Object.hasOwn(object, key) 只检查对象自身属性。
  • key in object 会检查自身和原型链。
  • 原型方法中的 this 取决于调用它的实例,不会固定为原型对象。

第六篇:JS 如何实现继承?

第一种:借助 call

function Parent1() {
  this.name = 'parent1'
}

function Child1() {
  Parent1.call(this)
  this.type = 'child1'
}

console.log(new Child1())

子实例可以得到父构造函数写入的自有属性,但不能自动访问 Parent1.prototype 上的方法。这种技术也称借用构造函数,只是复用初始化逻辑,不是完整的原型继承。

第二种:直接借助原型链

function Parent2() {
  this.name = 'parent2'
  this.play = [1, 2, 3]
}

Parent2.prototype.say = function () {
  return this.name
}

function Child2() {}
Child2.prototype = new Parent2()

const s1 = new Child2()
const s2 = new Child2()
s1.play.push(4)

console.log(s1.play) // [1, 2, 3, 4]
console.log(s2.play) // [1, 2, 3, 4]:共享了原型上的数组

此外,定义原型时执行了一次父构造函数,Child2.prototype.constructor 也没有自动恢复为 Child2。这是历史模式,现代代码不应直接使用。

原型继承共享状态示意图

第三种:组合继承

function Parent3() {
  this.name = 'parent3'
  this.play = [1, 2, 3]
}

Parent3.prototype.say = function () {
  return this.name
}

function Child3() {
  Parent3.call(this)
  this.type = 'child3'
}

Child3.prototype = new Parent3()
Child3.prototype.constructor = Child3

const s3 = new Child3()
const s4 = new Child3()
s3.play.push(4)

console.log(s3.play) // [1, 2, 3, 4]
console.log(s4.play) // [1, 2, 3]

组合继承解决了实例之间共享可变自有属性的问题,但 Parent3 在设置 Child3.prototype 和创建每个实例时各执行一次。

第四种:共享原型(不推荐)

function Parent4() {}
function Child4() {}

Child4.prototype = Parent4.prototype

这种方式虽然避免了额外构造,但父子构造器共享同一个 prototype。给 Child4.prototype 增加方法会影响 Parent4constructor 和类型关系也无法表达清楚。它不是正常的继承实现。

第五种:寄生组合式继承

function Parent5(name) {
  this.name = name
  this.play = [1, 2, 3]
}

Parent5.prototype.say = function () {
  return this.name
}

function Child5(name, type) {
  Parent5.call(this, name)
  this.type = type
}

Child5.prototype = Object.create(Parent5.prototype, {
  constructor: {
    configurable: true,
    value: Child5,
    writable: true,
  },
})

const child = new Child5('parent5', 'child5')
console.log(child.say()) // parent5
console.log(child instanceof Parent5) // true
console.log(child.constructor === Child5) // true

这就是传统 ES5 中更合理的寄生组合式写法:父构造函数负责实例状态,Object.create 负责建立原型关系,避免 new Parent5() 的额外调用。

ES6 的 extends

现代代码通常直接使用 class extends

组合继承历史示意图

寄生组合继承历史示意图

class Parent {
  constructor(name) {
    this.name = name
    this.play = [1, 2, 3]
  }

  say() {
    return this.name
  }
}

class Child extends Parent {
  constructor(name, type) {
    super(name)
    this.type = type
  }
}

const child = new Child('Ada', 'student')
console.log(child.say())
console.log(child instanceof Parent) // true

class 仍然建立在原型机制之上,但还提供派生构造器、静态继承、私有元素和严格模式等语言语义。Babel 等转译器可能使用 Object.createObject.setPrototypeOf 和辅助函数模拟一部分行为,但转译代码不能简单等同于原生 class 的所有内部语义。

面向对象一定是好的设计吗?

不一定。继承适合稳定的 is-a 关系,但它会带来强耦合:子类通常得到父类暴露的整套接口,父类变化可能影响多个子类。原文用“汽车与新能源汽车”说明这一点,这个例子仍然有价值,但不应得出“继承永远不好”的结论。

组合可以按需拼装能力:

function drive() {
  return 'wuwuwu'
}

function music() {
  return 'lalala'
}

function addOil() {
  return 'add oil'
}

function compose(...behaviors) {
  return Object.assign({}, ...behaviors.map((behavior) => ({
    [behavior.name]: behavior,
  })))
}

const car = compose(drive, music, addOil)
const newEnergyCar = compose(drive, music)

console.log(Object.keys(car))
console.log(Object.keys(newEnergyCar))

实际项目中还可以使用函数、模块、对象组合、依赖注入和接口约束。稳定的类型层次可以使用 class extends;横向能力复用通常更适合组合或明确的 mixin。

小结

  • 原始值、对象引用值和引擎的实际内存布局要区分。
  • Array.isArrayObject.is 等标准 API 通常比手写原型判断更可靠。
  • 闭包保存的是可访问的词法环境,不等于“返回函数才有闭包”。
  • 原型链是属性查找机制;constructor 不是可靠的类型标签。
  • ES5 继承模式适合理解历史机制,现代项目优先考虑 class、组合和明确的对象能力边界。

参考出处:

配图说明:图示按原文顺序从 article.rivers.pub 的历史文章图片地址下载到本地 images/88-image-*,图片并非规范官方图,公开发布前仍需核实原始授权。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS