先记住这个答案
字符串是原始值,本身没有属性。但执行 str[0] 时,引擎会临时创建一个 String 包装对象,这个对象上有 0、1、2 这样的索引属性和 length,读到值后包装对象就被丢弃,所以字符串看起来像数组。这些索引属性是只读的,字符串本身也不可变,给下标赋值不会改动原字符串,严格模式下还会抛 TypeError。
- 下标访问靠临时包装对象,读完就丢
- 下标拿到的是长度为 1 的字符串,不是字符类型
- 字符串不可变,改内容只能生成新字符串
- 下标按 UTF-16 码元算,表情会被拆成两半
读得到,改不了
先看一段代码,把读、越界、赋值、表情这几种情况放在一起。
'use strict'
const s = 'hello'
console.log(s[0], s.at(-1), s.charAt(10), s[10])
console.log(typeof s[0], s.length)
try {
s[0] = 'H' // 严格模式下报错
} catch (e) {
console.log(e.constructor.name)
}
console.log(s) // 原字符串没变
console.log('H' + s.slice(1)) // 只能生成新字符串
console.log('😀'[0] === '\uD83D', [...'😀'].length)查看输出与解释
h o undefined
string 5
TypeError
hello
Hello
true 1第一行里 s.charAt(10) 越界返回空字符串,所以输出里 o 后面有两个空格;s[10] 越界返回 undefined。s.at(-1) 支持负数下标,取最后一个字符,而 s[-1] 只会去找名为 '-1' 的属性,得到 undefined。
typeof s[0] 是 'string'。JavaScript 没有单独的字符类型,取出来的是长度为 1 的字符串。
包装对象是怎么回事
'hello'.toUpperCase() 能调用方法,'hello'[0] 能取字符,背后是同一个机制。访问原始值的属性时,引擎相当于做了一次 new String('hello'),在这个临时对象上查属性或调方法,用完立刻丢弃。现代引擎会优化掉真实的对象创建,但语义上就是这样。
包装对象上的索引属性描述符是 writable: false、configurable: false。非严格模式下给它赋值会静默失败;严格模式下对只读属性赋值会抛 TypeError。就算赋值成功了,改的也是那个临时对象,原来的字符串不会受影响。
所以字符串的所有「修改」方法,replace、toUpperCase、slice、padStart,都是返回新字符串,原字符串不变。
表情和组合字符会被拆开
下标的单位是 UTF-16 码元,不是用户看到的一个字。常用汉字一个码元就够,但 😀 这样的表情要两个码元组成代理对,'😀'[0] 取到的只是前一半 \uD83D,单独显示出来是乱码。
需要按「字」处理时,用 [...str] 或 Array.from(str),它们按码点拆分,表情会被当成一个整体。截断用户昵称、统计评论字数这类场景,直接用下标或 slice 可能把表情切成两半,界面上会出现一个问号方块。
容易答错的地方
- 以为字符串是字符数组
- 字符串只是长得像数组,它没有
push、splice这些方法,也没有字符类型。要用数组方法得先split('')或展开成数组,处理完再join('')。 - 以为
str[i] = x能改字符串 - 非严格模式下这行不报错,很容易以为改成功了。实际上字符串一个字符都没变。要替换某个位置,只能拼一个新字符串,比如
s.slice(0, i) + x + s.slice(i + 1)。 - 把下标和
charAt当成完全一样 - 正常范围内两者结果相同,越界时不同:下标返回
undefined,charAt返回空字符串。写if (s[i])和if (s.charAt(i))在越界时都是假值,但比较s[i] === undefined时就只能用下标。
面试官还会怎么问?
那给字符串加一个自定义属性会怎样?
s.foo = 1 在非严格模式下不报错,但属性加在了临时包装对象上,下一行读 s.foo 是 undefined。严格模式下给原始值设置属性会直接抛 TypeError。
for...of 和普通 for 循环遍历字符串有什么区别?
普通 for 配合下标按码元遍历,表情会被拆成两次;for...of 用的是字符串的迭代器,按码点遍历,表情只出现一次。遍历含表情的文本应该用 for...of。
怎么按用户看到的字来拆分,比如带肤色的表情?
[...str] 只能处理到码点。带肤色、家庭组合这类表情由多个码点组成,需要用 Intl.Segmenter 按字素簇拆分,它能把一整个组合表情当成一段。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。