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

显示模式

登录
ARCHIVE DOCUMENTJS

【JS 进阶】你真的掌握变量和类型了吗

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/66-【JS 进阶】你真的掌握变量和类型了吗
本文目录12 个章节
  1. 导读
  2. 一、JavaScript 数据类型
  3. 二、原始值和对象的区别
  4. 三、函数参数是按值传递
  5. 四、null 和 undefined
  6. 五、Symbol
  7. 六、Number、二进制浮点数和精度
  8. 七、包装对象、装箱和拆箱
  9. 八、ToPrimitive 和类型转换
  10. 九、常见隐式类型转换
  11. 十、判断 JavaScript 数据类型
  12. 十一、总结

【JS 进阶】你真的掌握变量和类型了吗

本文保留原文关于变量复制、原始值和对象、null/undefined、Symbol、浮点数、类型转换以及类型判断的完整路线,并按当前 ECMAScript 语义修正“只有 7 种类型”“所有值固定在栈/堆”“Symbol 可以防 XSS”“toString(2) 就是内存位”等过时或错误说法。

导读

学习 JavaScript 时,经常会遇到这些问题:

  • JavaScript 的变量到底保存了什么?
  • 0.1 + 0.2 为什么不等于 0.3
  • Symbol 和 BigInt 解决什么问题?
  • [] == ![][undefined] == false 为什么为 true
  • 如何区分 ArrayDate、跨窗口对象和普通对象?
  • 函数参数到底是值传递还是引用传递?

这些问题不能只靠“栈里放基本类型、堆里放引用类型”解释。更稳妥的学习方式是先掌握 ECMAScript 的值、对象身份、抽象操作和比较规则,再把引擎的内存实现作为性能背景。

一、JavaScript 数据类型

当前 ECMAScript 有 8 种语言类型,分为 7 种原始类型和 1 种对象类型。

1.1 原始类型

  • Undefined:值为 undefined
  • Null:值为 null
  • Booleantruefalse
  • Number:双精度二进制浮点数,包含 NaNInfinity-Infinity-0
  • BigInt:任意精度整数;
  • String:字符串值;
  • Symbol:唯一的符号值,可作为属性键。

1.2 对象类型

对象是属性集合,也可以拥有内部槽和行为。普通对象、数组、函数、日期、正则、Map、Set、Promise、TypedArray 等都属于对象。

早期文章把 BigInt 写成“ES10 中加入的第七种类型”。更准确地说,BigInt 在 ES2020(ECMAScript 2020)中标准化;因此今天不能再说 JavaScript 只有 7 种类型。

二、原始值和对象的区别

2.1 原始值不可变,但变量可以重新绑定

字符串的方法会返回新字符串,不会修改原字符串:

let str = 'ConardLi'

str.slice(1)
str.substring(1)
str.trim()
str.toLowerCase()

// 非严格模式下给字符串索引赋值通常无效;严格模式下可能抛错
// str[0] = 'X'

console.log(str) // ConardLi

变量重新赋值并不等于修改了原始值:

str += '6'
console.log(str) // ConardLi6

这里是计算了新的字符串值,再让 str 绑定到它。规范关注的是字符串不可变和变量绑定结果,不规定字符串一定位于某个栈槽中。

变量与原始值示意图

图片来源:原文配图。已下载为本地图片;原图来源站点的版权和许可信息仍然适用。

2.2 对象具有可变属性和身份

对象的属性通常可以修改,但“对象可变”不是所有对象的绝对性质:被冻结的对象、某些宿主对象和封装的类实例可能限制修改。

const obj1 = { name: 'ConardLi' }
const obj2 = { age: 18 }
const obj3 = function () {}
const obj4 = [1, 2, 3]

obj1.name = 'ConardLi6'
obj2.age = 19
obj4.length = 0

console.log(obj1) // { name: 'ConardLi6' }
console.log(obj2) // { age: 19 }
console.log(obj4) // []

数组的 poppushshiftunshiftreversesortsplice 会改变数组;mapfilterslicetoSorted 等通常返回新数组,但回调仍可能修改数组元素对象。

对象与引用示意图

图片来源:原文配图

2.3 不要把栈堆模型当作规范

“原始值在栈、对象在堆、对象变量保存指针”是便于入门的图示。ECMAScript 没有规定这些物理位置;引擎可以使用寄存器、指针压缩、内联小整数、逃逸分析和优化后的环境表示。

真正需要掌握的是:

const a = { value: 1 }
const b = a

console.log(a === b) // true,共享同一个对象身份
b.value = 2
console.log(a.value) // 2

如果需要新对象,要显式复制:

const shallow = { ...a }
shallow.value = 3
console.log(a.value) // 2
console.log(shallow === a) // false

对象复制示意图

图片来源:原文配图

2.4 复制和比较

原始值比较的是值:

const name = 'ConardLi'
const name2 = 'ConardLi'
console.log(name === name2) // true

对象比较的是身份,而不是结构:

const first = { name: 'ConardLi' }
const second = { name: 'ConardLi' }
const third = first

console.log(first === second) // false
console.log(first === third) // true

原始值复制示意图

图片来源:原文配图

对象复制示意图

图片来源:原文配图

三、函数参数是按值传递

ECMAScript 中函数参数按值传递。传入对象时,这个值是对象引用的副本,因此可以修改同一个对象的属性,但重新绑定参数不会影响调用者:

let name = 'ConardLi'

function changeValue(value) {
  value = 'code秘密花园'
}

changeValue(name)
console.log(name) // ConardLi
const user = { name: 'ConardLi' }

function changeUser(value) {
  value.name = 'code秘密花园'
  value = { name: 'another user' }
}

changeUser(user)
console.log(user.name) // code秘密花园

因此“对象是引用传递”是容易造成误解的口语。更准确的说法是:所有参数都按值传递;对象参数的值是一个引用对象的值

const obj = {}
function change(obj) {
  obj.name = 'ConardLi'
  obj = { name: 'code秘密花园' }
}

change(obj)
console.log(obj.name) // ConardLi

四、nullundefined

nullundefined 都是原始值,但表达的意图通常不同:

  • undefined 常表示没有提供值、属性不存在或函数没有显式返回;
  • null 常表示程序明确设置了“没有对象/空值”。

这只是编码约定,不是强制规则。对象属性显式为 undefined 也可能是合法数据,不能简单地说“这是不正常的,必须 delete”。判断属性是否存在应使用 inObject.hasOwn()

const a = {}
const b = { value: undefined }

console.log('value' in a) // false
console.log('value' in b) // true
console.log(Object.hasOwn(b, 'value')) // true

数值转换:

console.log(Number(null)) // 0
console.log(Number(undefined)) // NaN
console.log(null == undefined) // true
console.log(null === undefined) // false

typeof null === 'object' 是早期 JavaScript 的历史兼容结果,不是说 null 真的是对象。使用 value === null 可以准确判断 null。

null 与 undefined 示意图

图片来源:原文配图

五、Symbol

5.1 独一无二和全局注册表

每次调用 Symbol() 都创建一个不同的 Symbol;描述只是调试信息,不参与相等比较:

const sym1 = Symbol('ConardLi')
const sym2 = Symbol('ConardLi')
console.log(sym1 === sym2) // false
console.log(sym1.description) // ConardLi

Symbol.for(key) 使用全局 Symbol 注册表,同一个注册表键可以取回同一个 Symbol:

const globalA = Symbol.for('ConardLi')
const globalB = Symbol.for('ConardLi')
console.log(globalA === globalB) // true
console.log(Symbol.keyFor(globalA)) // ConardLi

“全局”指当前 JavaScript agent 的注册表,不等于跨进程、跨页面或跨 Realm 的万能共享存储。

5.2 Symbol 不能用 new

console.log(typeof Symbol('x')) // symbol
// new Symbol('x') // TypeError: Symbol is not a constructor

5.3 Symbol 属性的枚举

Symbol 属性不会出现在 for...inObject.keys()Object.getOwnPropertyNames() 中,但它们不是私密属性;可以用 Object.getOwnPropertySymbols()Reflect.ownKeys() 读取:

const key = Symbol('internal')
const object = {
  name: 'Ada',
  [key]: 42
}

console.log(Object.keys(object)) // ['name']
console.log(Object.getOwnPropertyNames(object)) // ['name']
console.log(Object.getOwnPropertySymbols(object)) // [Symbol(internal)]
console.log(Reflect.ownKeys(object)) // ['name', Symbol(internal)]

5.4 Symbol 的应用

防止属性名冲突

const internal = Symbol('internal')
const state = {
  [internal]: { loading: false }
}

console.log(state[internal]) // { loading: false }

这可以降低属性名冲突,但不能提供真正的安全隔离;拿到 Symbol 的代码仍然可以访问该属性。

Well-known Symbols

Symbol.iteratorSymbol.asyncIteratorSymbol.toPrimitiveSymbol.toStringTag 等 well-known symbols 可以定制语言协议:

const range = {
  start: 1,
  end: 3,
  *[Symbol.iterator]() {
    for (let value = this.start; value <= this.end; value += 1) {
      yield value
    }
  }
}

console.log([...range]) // [1, 2, 3]

React 示例的边界

早期文章把 React 元素的 $$typeof = Symbol.for('react.element') 解释成防止 XSS,这是错误的安全结论。它可以帮助 React 区分特定的数据结构,且 Symbol 不会被普通 JSON 直接表示,但它不能清洗 HTML、阻止恶意脚本或替代服务端/客户端的输出编码。

如果应用使用 dangerouslySetInnerHTML,仍然必须对 HTML 做可信来源控制和适当消毒。原文的 React 属性访问写法是无效语法;历史代码应写成普通属性名 object.$$typeof,并理解它只是库内部约定:

const REACT_ELEMENT_TYPE = Symbol.for('react.element')

function isReactElement(value) {
  return Boolean(
    value &&
    typeof value === 'object' &&
    value.$$typeof === REACT_ELEMENT_TYPE
  )
}

六、Number、二进制浮点数和精度

6.1 为什么 0.1 + 0.2 !== 0.3

JavaScript 的普通 Number 使用 IEEE 754 binary64 双精度格式。许多十进制小数不能用有限位二进制精确表示,输入会先舍入到最接近的可表示值,运算结果再进行舍入:

console.log(0.1 + 0.2) // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3) // false

这不是简单的“JavaScript 只保留小数点后几位”。binary64 由以下部分组成:

  • 1 位符号位;
  • 11 位指数位;
  • 52 位显式 fraction 位;
  • 对普通数值而言,隐藏的最高有效位使有效精度通常为 53 个二进制位。

二进制浮点数示意图

图片来源:原文配图

6.2 toString(2) 不是原始内存位

console.log((0.1).toString(2))
console.log((0.2).toString(2))

Number.prototype.toString(2) 返回一个能代表该 Number 值的二进制字符串,通常采用足以往返恢复该值的最短表示;它不是把内存中的符号位、指数位和 fraction 位原样打印出来。原文把这两者等同起来是不准确的。

下面的示例可以查看某个 Number 的 binary64 位模式,但这只是观察工具:

function numberBits(value) {
  const buffer = new ArrayBuffer(8)
  new DataView(buffer).setFloat64(0, value, false)
  return [...new Uint8Array(buffer)]
    .map(byte => byte.toString(2).padStart(8, '0'))
    .join('')
}

console.log(numberBits(0.1)) // 64 位编码字符串

IEEE 754 位布局示意图

图片来源:原文配图

IEEE 754 指数示意图

图片来源:原文配图

IEEE 754 尾数示意图

图片来源:原文配图

6.3 如何比较带误差的结果

Number.EPSILON 只适合某些接近 1 的相对误差判断,不是所有数量级的通用阈值。常用的相对/绝对误差函数如下:

function nearlyEqual(a, b, relative = 1e-12, absolute = Number.EPSILON) {
  const difference = Math.abs(a - b)
  if (difference <= absolute) return true
  return difference <= relative * Math.max(Math.abs(a), Math.abs(b))
}

console.log(nearlyEqual(0.1 + 0.2, 0.3)) // true

金额、计量和需要十进制定点语义的场景,应使用整数最小单位、十进制定点库或经过验证的 decimal 库;不要只用 toFixed() 就认为计算已经精确,因为 toFixed() 主要是格式化。

6.4 最大值和安全整数

console.log(Number.MAX_VALUE) // 1.7976931348623157e+308
console.log(Number.MAX_SAFE_INTEGER) // 9007199254740991
console.log(Number.isSafeInteger(Number.MAX_SAFE_INTEGER)) // true
console.log(Number.isSafeInteger(Number.MAX_SAFE_INTEGER + 1)) // false

MAX_VALUE 是接近可表示上限的最大有限 Number;MAX_SAFE_INTEGER 讨论的是整数精确性,不是所有小数都能在这个范围内精确表示。

6.5 BigInt

const large = 9007199254740993n
console.log(large + 2n) // 9007199254740995n
console.log(large.toString()) // 9007199254740993

BigInt 用于任意精度整数,但不能与 Number 直接混合算术:

// 1n + 1 // TypeError
console.log(5n / 2n) // 2n,整数除法向零截断

BigInt 不能表示小数,不能直接用于 Math.*,JSON 也不能默认序列化它。与外部 API 交互时要明确使用字符串、数字或 BigInt 的边界。

七、包装对象、装箱和拆箱

原始值不是对象,但语言允许它们调用很多方法。引擎会根据需要执行临时的装箱行为:

const name = 'ConardLi'
const upper = name.toUpperCase()
console.log(upper) // CONARDLI

显式包装对象与原始值不同:

console.log(true === new Boolean(true)) // false
console.log(123 === new Number(123)) // false
console.log('x' === new String('x')) // false
console.log(typeof new String('x')) // object

通常不要用 new String()new Number()new Boolean() 保存业务值,因为它们会带来对象身份和真值陷阱:

if (new Boolean(false)) {
  console.log('对象本身是真值')
}

八、ToPrimitive 和类型转换

对象参与运算时,通常会通过 ToPrimitive 转换成原始值。现代规范中,流程会优先检查 Symbol.toPrimitive;若没有,再根据 hint 调用 valueOf()toString()。具体顺序不是简单地“所有数字都先 valueOf,所有字符串都先 toString”,还要考虑 hint 和 Date 等特殊对象。

const object = {
  valueOf() {
    console.log('valueOf')
    return 123
  },
  toString() {
    console.log('toString')
    return 'ConardLi'
  }
}

console.log(object - 1) // 通常先 valueOf,再得到 122
console.log(`${object}`) // 字符串 hint,通常先 toString

Symbol.toPrimitive 可以明确控制转换:

const price = {
  value: 10,
  [Symbol.toPrimitive](hint) {
    if (hint === 'string') return `$${this.value}`
    return this.value
  }
}

console.log(price + 5) // 15
console.log(String(price)) // $10

如果转换方法都不返回原始值,会抛出 TypeError

const invalid = {
  valueOf: () => ({}),
  toString: () => ({})
}

// invalid - 1 // TypeError

九、常见隐式类型转换

9.1 真值判断

以下值在 Boolean 上下文中是假值:

console.log(Boolean(false)) // false
console.log(Boolean(0)) // false
console.log(Boolean(-0)) // false
console.log(Boolean(NaN)) // false
console.log(Boolean('')) // false
console.log(Boolean(null)) // false
console.log(Boolean(undefined)) // false
console.log(Boolean(0n)) // false

空数组和空对象是真值:

console.log(Boolean([])) // true
console.log(Boolean({})) // true

浏览器中的 document.all 是历史兼容的特殊对象,具有非常规 typeof 和真值行为,不应推广到普通对象。

类型转换关系示意图

图片来源:原文配图

9.2 数值运算和 +

减、乘、除通常会把操作数转换为 Number;BigInt 需要两侧都是 BigInt:

console.log(1 - true) // 0
console.log(1 - null) // 1
console.log(1 * undefined) // NaN
console.log(2 * ['5']) // 10

+ 会先把操作数转换为原始值;如果任一侧是字符串,最终进行字符串拼接,否则进行数值加法:

console.log(123 + '123') // '123123'
console.log(123 + null) // 123
console.log(123 + true) // 124
console.log(123 + {}) // '123[object Object]'
console.log('value: ' + [1, 2]) // 'value: 1,2'

9.3 =====

严格相等 === 不做类型转换;抽象相等 == 会按照规范执行一组转换规则。工程代码通常优先使用 ===,但理解 == 有助于分析旧代码。

  • NaN 与任何值都不相等,包括自己;
  • null == undefinedtrue,它们与其它普通值比较通常为 false
  • Boolean 会先转换为 Number;
  • String 与 Number 比较时,字符串尝试转换为 Number;
  • 对象会先通过 ToPrimitive 转为原始值。
console.log(NaN == NaN) // false
console.log(true == 1) // true
console.log(true == '1') // true
console.log('' == 0) // true
console.log(null == undefined) // true
console.log(null == 0) // false
console.log(undefined == false) // false

[] == ![] 的过程可以拆开:

console.log(![]) // false,因为空数组是真值
console.log([] == false) // true
console.log([] == ![]) // true

右侧 false 转为 0,左侧空数组通过 ToPrimitive 得到空字符串,再转为 0,因此相等。数组中的 nullundefined 在数组转字符串时会形成空字符串:

console.log([null] == false) // true
console.log([undefined] == false) // true
console.log([1, 2].toString()) // '1,2'

这些规则可用于解释结果,但不建议用它们构建难以维护的业务判断。

9.4 一个会产生副作用的 == 示例

const a = {
  values: [3, 2, 1],
  valueOf() {
    return this.values.pop()
  }
}

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

这个例子依赖 valueOf() 的副作用和数组出栈顺序,说明了为什么不应把对象转换参与业务相等判断;它不是推荐技巧。

十、判断 JavaScript 数据类型

10.1 typeof

typeof 适合判断原始类型和函数:

console.log(typeof 'ConardLi') // string
console.log(typeof 123) // number
console.log(typeof true) // boolean
console.log(typeof 1n) // bigint
console.log(typeof Symbol()) // symbol
console.log(typeof undefined) // undefined
console.log(typeof function () {}) // function

局限包括:

console.log(typeof null) // object,历史兼容行为
console.log(typeof []) // object
console.log(typeof {}) // object
console.log(typeof new Date()) // object

对可能未声明的变量,typeof undeclaredName 不会抛错,但直接访问它会抛 ReferenceError。这只是兼容性特例,不应拿来代替模块导入或变量初始化检查。

10.2 instanceof

instanceof 检查构造函数的 prototype 是否位于对象原型链上:

console.log([] instanceof Array) // true
console.log(new Date() instanceof Date) // true
console.log([] instanceof Object) // true

它不适合作为所有类型的通用检测:

  • 跨 iframe/Realm 的数组可能不是当前 Realm 的 Array 实例;
  • 类可以自定义 Symbol.hasInstance
  • 原型链可以被修改;
  • 原始值不适合使用 instanceof

数组使用 Array.isArray() 更可靠:

console.log(Array.isArray([])) // true
console.log(Array.isArray(new Array())) // true

10.3 Object.prototype.toString

const toString = Object.prototype.toString

console.log(toString.call([])) // [object Array]
console.log(toString.call(new Date())) // [object Date]
console.log(toString.call(/x/)) // [object RegExp]
console.log(toString.call(null)) // [object Null]

这比直接调用 value.toString() 更适合许多内置类型,但 Symbol.toStringTag 可以改变显示标签,代理和跨 Realm 仍需结合业务判断:

const value = {
  get [Symbol.toStringTag]() {
    return 'Array'
  }
}

console.log(toString.call(value)) // [object Array],标签可以被伪造
console.log(Array.isArray(value)) // false

Object.prototype.toString 类型判断示意图

图片来源:原文配图

10.4 推荐的类型判断策略

function isPlainObject(value) {
  if (Object.prototype.toString.call(value) !== '[object Object]') {
    return false
  }
  const prototype = Object.getPrototypeOf(value)
  return prototype === Object.prototype || prototype === null
}

function getKind(value) {
  if (value === null) return 'null'
  if (Array.isArray(value)) return 'array'
  if (typeof value === 'object') return 'object'
  return typeof value
}

console.log(getKind(null)) // null
console.log(getKind([])) // array
console.log(getKind(new Map())) // object

jQuery 早期的 jQuery.type() 通过 Object.prototype.toString 和映射表兼容旧浏览器,这段代码有历史价值;现代项目通常使用 Array.isArraytypeofinstanceofObject.getPrototypeOf 和领域校验组合判断,不必复制整套旧实现。

十一、总结

  • 当前 JavaScript 有 8 种语言类型:7 种原始类型和 1 种对象类型;
  • 原始值不可变,对象有身份,变量复制和函数参数都遵循按值传递;
  • 栈/堆是实现和教学模型,不是 ECMAScript 对变量物理位置的承诺;
  • nullundefined、Symbol、BigInt 和 Number 都有明确的规范边界;
  • binary64 的精度问题来自二进制表示和舍入,toString(2) 不是原始位布局;
  • ToPrimitive==+ 的结果来自规范化转换,不应靠直觉猜测;
  • typeofArray.isArrayinstanceofObject.prototype.toString 各有适用范围;
  • 生产代码优先使用严格相等、明确的输入校验和领域类型,而不是依赖隐式转换。

参考资料:

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS