先记住这个答案
JavaScript 里字符串是原始值,数组是对象,这是差别的根源。原始值创建后内容就固定了,所以字符串上所有方法都只能返回一个新字符串,原字符串保持不变。数组是可变对象,变量里存的是指向它的引用,push、sort、splice、reverse 这些方法会直接修改这块数据,所有持有这个引用的地方都会同时看到变化。这个差别会影响函数传参是否安全、React 状态更新能不能被检测到,以及大量拼接字符串时的写法。
- 字符串方法全部返回新值,原字符串永远不变
s[0] = "x"不会生效,严格模式下直接报错push、sort等会改原数组- 不改原数组可用复制式方法
同样是「排序、转大写」,一个改原值一个不改
把字符串和数组放在一起操作,打印原值,差别很直观。
'use strict'
const s = 'hello'
try {
s[0] = 'H' // 严格模式下给字符串下标赋值会报错
} catch (e) {
console.log(e.name)
}
const upper = s.toUpperCase()
console.log(s, upper) // 原字符串没变,拿到的是新字符串
const arr = [3, 1, 2]
const res = arr.sort()
console.log(arr, res === arr) // 原数组被改了,返回的还是它自己
const sorted = [3, 1, 2].toSorted()
console.log(sorted)查看输出与解释
TypeError
hello HELLO
1,2,3 true
1,2,3字符串的下标是只读的。非严格模式下 s[0] = 'H' 不报错,但也没有任何效果,这比报错更容易坑人。arr.sort() 不仅改了原数组,返回值还是同一个数组,所以 res === arr 是 true,很多人以为拿到的是排好序的副本。最后的 toSorted 是 ES2023 加的,排序结果放在新数组里,原数组不动。
为什么字符串要设计成不可变
字符串用得太多了,对象的键、比较、拼接、当 Map 的键全是它。如果字符串能被原地修改,一个函数改了传进来的字符串,调用方手里的值也跟着变,那用字符串当对象属性名就不安全了,键可能在你不知情的情况下变掉。不可变之后,同样内容的字符串可以放心共享,引擎也可以做缓存和去重,比较两个字符串也不用担心中途被改。
代价是每次「修改」都要生成新字符串。早期在循环里写 str += piece 被认为很慢,因为每次都复制一遍。现在的 V8 会先把拼接结果记成一棵拼接树,等真正读取内容时再压平,所以常规循环拼接已经不是瓶颈。拼接成千上万段时,用数组收集再 join('') 仍然是稳妥写法。
数组可变带来的实际问题
数组可变本身不是坏事,大数组原地排序不用额外开一份内存。麻烦出在「共享引用」上:把数组传给函数,函数内部 sort 了一下,调用方的数据顺序也乱了。
- React 里原地修改导致不重新渲染
setList(list)之前先list.push(item),React 用Object.is比较新旧状态,发现还是同一个数组引用,就认为没变化,界面不会更新。正确写法是生成新数组:setList([...list, item]),删除用filter,修改某一项用map或list.with(index, newItem)。- 哪些数组方法是安全的
- 不改原数组的有
map、filter、slice、concat、reduce,以及 ES2023 新增的toSorted、toReversed、toSpliced、with。会改原数组的主要是push、pop、shift、unshift、splice、sort、reverse、fill、copyWithin。新增的四个里,带to前缀的三个正好对应sort、reverse、splice的副本版,with对应的是arr[i] = x。
容易答错的地方
- 把
const当成不可变 const arr = []之后arr.push(1)完全合法,const只锁住变量指向谁,不锁内容。真要冻结内容用Object.freeze,而且它只冻结一层。- 以为
s += 'a'修改了字符串 - 这行代码是创建了一个新字符串,再让变量
s指向它。原来那个字符串如果还有别的变量引用,内容完全没变。 - 以为
slice和splice差不多 slice返回截取的副本,原数组不变;splice直接在原数组上删除和插入,返回的是被删掉的元素。两个名字只差一个字母,面试和代码评审里都经常被问。
面试官还会怎么问?
字符串是原始值,为什么能调用 s.length 和方法?
访问属性时引擎会临时创建一个 String 包装对象,调用完就丢掉。所以给字符串加属性 s.foo = 1 也不会保留,下次读 s.foo 是 undefined。
怎么反转一个字符串?
字符串没有 reverse,常见写法是 [...s].reverse().join('')。用展开而不是 split(''),是因为展开按码点拆分,emoji 这类由两个 UTF-16 单元组成的字符不会被拆坏。
函数参数传数组,怎么避免被内部修改?
调用方传一份副本,比如 fn([...arr]);或者函数内部约定只用不改原数组的方法。TypeScript 里可以把参数声明成 readonly number[],调用 push、sort 会直接报类型错误。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。