不可变的 JS 字符串,操作起来有这么暴力吗

你以为 JS 中的字符串是不可变的,对于 JS 语言的使用者来说确实是这样,每次对字符串操作后,总是会创建一个新的字符串,占用新的内存空间。但是 v8 在底层实现中,对字符串的操作做了优化,字符串的概念发生了一些微妙的变化。

拼接操作

我们先从拼接两个字符串入手,逐步了解 v8 对字符串操作的优化方向,以及分析这种黑箱优化对开发的性能影响。

js 复制代码
const a = "a";
const b = "b";
const ab = a + b;

对我们开发者来说,ab 是一个全新的字符串。然而当 ab 都特别长的时候,如果直接暴力地给这个新字符串分配内存,会有很大的开销(算上原来的两个子字符串,总共占用了双倍的内存),V8 就对这个场景做了优化。参考 v8/src/objects 中对 ConString 的注释

The ConsString class describes string values built by using the addition operator on strings. A ConsString is a pair where the first and second components are pointers to other string values . One or both components of a ConsString can be pointers to other ConsStrings, creating a binary tree of ConsStrings where the leaves are non-ConsString string values.

v8 不管字符串长不长,只要是用 + 运算符拼接的,都用 ConString 来表示新字符串。同时 ConString 不是一个新的字符串,他记录了参与运算的两个字符串的指针。ConString 也可以由小的 ConString 和非 Constring 组合而成,最终的结构是一棵二叉树。

再回过头来看,ab 是最普通的字符串,他们不是 ConString,而 ab 是。这样字符串就出现了两个不同的种类(在底层的结构也不同)

这样是不是省下了不必要的内存开销?当然,拼接字符串也可以通过模板字符串来做,但是 v8 没有对这样的操作做类似的优化。可能未来会有。

切片操作

加法吃到的红利后,减法也想要引用原字符串来优化内存。现在讨论 slice 方法,如果也用引用来处理的话,我们可以保存原字符串,和切片的两端索引。访问子串的时候,只需要给出原字符串在切片范围内的内容就行了。v8 源码中关于 SlicedString 的解释在这里

A Sliced String is described as a pointer to the parent, the offset from the start of the parent string and the length.

看上去好像不错?实际上当我们对一个很长的字符串切片的时候,得到的是一个很小的字符串,我们保存了结果的引用。但是这个小字符串在底层还引用着大字符串,这样 GC 就无法正常回收占用大内存的原字符串。

js 复制代码
const longString = "#".repeat(10_000);
const subString = longString.slice(50, 60);

这个缺点在源码中也有提到

Currently missing features are:

  • truncating sliced string to enable otherwise unneeded parent to be GC'ed.

到这里总结一下,如果底层的操作都基于"可变"的原则来编写,那么结果是有利有弊的,在缩小的操作中,我们需要直接创建一个新的字符串,而不是复用原字符串。

字符串操作的性能优化

我们对创建两种不同字符串的方法做个命名

js 复制代码
const a = "a";
const b = "b";

const NotConString = `${a}${b}`; // Mutation

const ConString = a + b; // Concatenation

Concatenation 就是引用原字符串的方法,Mutation 则是创建一个完全新的字符串。我们希望在拼接的时候使用 Concatenation(当然 v8 默认就是这样实现,只要我们使用 + 来拼接),在切片的时候使用 Mutation(当我们调用 String.slice() 的时候,默认是 Concatenation,我们需要额外处理这个)

小字符串操作无所谓,怎么方便怎么来。在大字符串操作的场景下,对于拼接,尽量使用 +,对于切片,使用了 slice 方法之后,我们需要额外的释放掉原字符串的内存。可以使用其他的 Mutation 方法,比如说 replace

js 复制代码
const longString = "#".repeat(10_000);
let subString = longString.slice(50, 60);

// 替换掉一个完全不存在的子串
subString = subString.replace("*".repeat(subString.length + 1), "")

这样可以强行复制切片后的子串内容,删除掉对原字符串的引用。

参考

相关推荐
水银嘻嘻1 小时前
12 web 自动化之基于关键字+数据驱动-反射自动化框架搭建
运维·前端·自动化
小嘟嚷ovo1 小时前
h5,原生html,echarts关系网实现
前端·html·echarts
十一吖i2 小时前
Vue3项目使用ElDrawer后select方法不生效
前端
只可远观2 小时前
Flutter目录结构介绍、入口、Widget、Center组件、Text组件、MaterialApp组件、Scaffold组件
前端·flutter
周胡杰2 小时前
组件导航 (HMRouter)+flutter项目搭建-混合开发+分栏效果
前端·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
敲代码的小吉米2 小时前
前端上传el-upload、原生input本地文件pdf格式(纯前端预览本地文件不走后端接口)
前端·javascript·pdf·状态模式
是千千千熠啊2 小时前
vue使用Fabric和pdfjs完成合同签章及批注
前端·vue.js
九月TTS3 小时前
TTS-Web-Vue系列:组件逻辑分离与模块化重构
前端·vue.js·重构
我是大头鸟3 小时前
SpringMVC 内容协商处理
前端
Humbunklung3 小时前
Visual Studio 2022 中添加“高级保存选项”及解决编码问题
前端·c++·webview·visual studio