如何强制JavaScript深度复制一个字符串?

94

我有一些JavaScript代码,看起来像这样:

var myClass = {
  ids: {}
  myFunc: function(huge_string) {
     var id = huge_string.substr(0,2);
     ids[id] = true;
  }
}

之后该函数被调用时使用了一些大字符串(100 MB+)。我只想保存在每个字符串中找到的一个短id。然而,Google Chrome的substring函数(实际上是我的代码中的正则表达式)仅返回一个“切片字符串”对象,它引用原始字符串。因此,在对myFunc进行一系列调用后,我的Chrome选项卡会耗尽内存,因为临时的huge_string对象不能被垃圾收集。

我应该如何复制字符串id,以便不维护对huge_string 的引用,并且可以回收huge_string?

enter image description here


3
“substring函数(实际上是我的代码中的正则表达式)只返回一个“切片字符串”对象,该对象引用原始字符串。” - 什么? .substr().substring().slice()和相关的正则表达式函数都会返回一个新的字符串。调用myClass.myFunc()的其他代码是否保留了对您巨大字符串的引用?如果您的真实代码更复杂,是否意外地在闭包中保留了这些巨大的字符串? - nnnnnn
3
@nnnnnn 无法确定JavaScript中的“新”字符串data; 实现可以共享底层数据而不违反ECMAScript的任何部分。 Firefox有半打不同的字符串实现(特别是请参见JSDependentString),如果Chrome具有类似的优化,则我不会感到惊讶(这可能在某些边缘情况下表现不佳)。 话虽如此..如果这是一个误导,我也不会感到非常惊讶。 - user2864740
2
读者参考: https://dev59.com/MWIj5IYBdhLWcg3wMiYu - user2864740
4
这个漏洞报告 #2869中包含了一个解决方法:(' ' + src).slice(1)。目前还没有官方的解决方案。 - user2864740
1
在将脚本转换为"use strict;"时,我遇到了这个问题,我们正在写入一个现在只读的字符串文字,并且得到了一个"Cannot assign to read only property '0' of string"的错误。 - Alex Dixon
显示剩余5条评论
11个回答

91

JavaScript的ECMAScript实现在不同的浏览器中可能会有所不同,然而对于Chrome来说,许多字符串操作(substr、slice、regex等)仅保留对原始字符串的引用,而不是复制字符串。这是Chrome中已知的问题(Bug#2869)。为了强制复制字符串,可以使用以下代码:

var string_copy = (' ' + original_string).slice(1);

这段代码的工作原理是在字符串前面添加一个空格。在Chrome的实现中,这种连接导致了一次字符串复制。然后可以引用空格后面的子字符串。

该解决方案的问题已在此处重新创建:http://jsfiddle.net/ouvv4kbs/1/

警告:加载时间很长,请打开Chrome调试控制台查看进度输出。

// We would expect this program to use ~1 MB of memory, however taking
// a Heap Snapshot will show that this program uses ~100 MB of memory.
// If the processed data size is increased to ~1 GB, the Chrome tab
// will crash due to running out of memory.

function randomString(length) {
  var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
  var result = '';
  for (var i = 0; i < length; i++) {
    result +=
        alphabet[Math.round(Math.random() * (alphabet.length - 1))];
  }
  return result;
};

var substrings = [];
var extractSubstring = function(huge_string) {
  var substring = huge_string.substr(0, 100 * 1000 /* 100 KB */);
  // Uncommenting this line will force a copy of the string and allow
  // the unused memory to be garbage collected
  // substring = (' ' + substring).slice(1);
  substrings.push(substring);
};

// Process 100 MB of data, but only keep 1 MB.
for (var i =  0; i < 10; i++) {
  console.log(10 * (i + 1) + 'MB processed');
  var huge_string = randomString(10 * 1000 * 1000 /* 10 MB */);
  extractSubstring(huge_string);
}

// Do something which will keep a reference to substrings around and
// prevent it from being garbage collected.
setInterval(function() {
  var i = Math.round(Math.random() * (substrings.length - 1));
  document.body.innerHTML = substrings[i].substr(0, 10);
}, 2000);

输入图像描述


var string_copy = original_string.slice(0); 变量string_copy等于original_string的副本。 - Wesley Stam
@WesleyStam 我认为 AffluentOwl 的帖子之所以有效是因为他在字符串前面添加了一个字符,这会导致字符串被复制,因为切片运算符实际上并没有像它应该的那样复制字符串。 - Joran Dox
谢谢您。var string_copy = (' ' + original_string).slice(1); 我正在从HTML编辑器中复制文本并将其写在旁边,然后在循环中自动保存它。我想知道为什么复制文本然后更改副本会更改原始文本 - 然后我意识到 - 这是一个引用! - rodmclaughlin
1
我尝试使用你的代码创建一个JS基准测试,以比较这里提出的各种操作。https://jsben.ch/aYDBc 看起来这可能是最好的解决方案。根据基准测试结果,其他在此处提出的解决方案(如插值/重复等)似乎不太可能在所有浏览器中都能正常运行。 - ProdigySim
1
很好的基准测试,但在我的测试中,插值和repeat(1)方法实际上并没有释放保留的内存。 - AffluentOwl

48

不确定如何进行测试,但使用字符串插值创建一个新的字符串变量是否有效?

newString = `${oldString}`

@Qwertiy 为什么你说这个不起作用?在我这里看起来是可以的。在运行了上面的命令之后,我更改了 oldString,但它并没有改变 newString。此外,typeof 返回了两者都是原始字符串类型。 - 425nesp
2
这绝对可行且性能极佳。在4K长度的字符串上进行测试,平均性能约为0.004毫秒。有很多次执行时间约为0.001毫秒。以下是我在控制台中运行的测试代码: !function () { const outputArr = []; const chars = 'ABC'; while(outputArr.length < 4000) { outputArr.push( chars[Math.floor(Math.random() * chars.length)])} const output = outputArr.join(''); console.time('interpolation'); const newVariable = \${output}`; console.timeEnd('interpolation'); }(); ` - Marcus Parsons
1
从我所看到的情况来看,在Chrome中这个程序并没有起作用,因为它实际上没有释放保留的内存。https://imgur.com/a/xAg8ORK - AffluentOwl
需要额外检查 null/undefined。 - Alex Povolotsky
我在Node 19中进行了一些字符串插值的测试,使用pricess.memoryUsage().heapUsed来监视字符串是否被复制或重用原始存储。使用slice、substring或字符串插值没有区别。使用(" " + str).slice(1)可以解决问题。 - some

16

我使用 Object.assign() 方法来处理字符串、对象、数组等:

const newStr = Object.assign("", myStr);
const newObj = Object.assign({}, myObj);
const newArr = Object.assign([], myArr);

请注意,Object.assign仅复制对象内部的键和属性值(仅限一级)。要深度克隆嵌套对象,请参考以下示例:

let obj100 = { a:0, b:{ c:0 } };
let obj200 = JSON.parse(JSON.stringify(obj100));
obj100.a = 99; obj100.b.c = 99; // No effect on obj200

1
它看起来不像期望的结果:https://i.stack.imgur.com/1hsxF.png - Qwertiy
当我执行 Object.assign("", "abc"); 时,我会得到一个空的字符串对象。 - 425nesp
3
const newStr = Object.assign("", myStr); console.log(newStr);这将打印一个数组: [String: ''] {'0': 'H','1': 'e',...}] 不幸的是,它不能用于字符串复制。 - George Mylonas
1
我最喜欢这个解决方案的外观,但不幸的是它在Chrome中对我无效,所以我转而使用了更“hackier”的字符串复制和切片解决方案。 - arhnee
同样的问题,对我也没用。可能是过时了。 - Felipe Centeno

16

2
var a = "hi"; var b = a.repeat(1); 对我来说可行。我尝试更改 a,但 b 保持不变。 - 425nesp
最简单的解决方案 - binarygiant
在我的测试中,Chrome目前实际上不会在repeat上进行复制。 - Patrick Linskey
从我所看到的情况来看,就像插值一样,这在Chrome中不起作用,因为它实际上没有释放保留的内存。https://imgur.com/a/uitL8Dv - AffluentOwl
在 Node 19 中,使用 a.repeat(1) 不会复制字符串。 - some
@425nesp 这不是问题的关键。在Javascript中,字符串是不可变的。Chrome和Node使用的V8引擎利用这一点,在内存中仅保存一个字符串副本。如果您有一个大字符串,并使用slice/substring等方法创建新字符串,则引擎只会创建对内存中位置的引用。即使您创建了一百万个字符串副本,其内容也不会被复制,只有指向字符串所在内存位置的指针。这就是问题所在:如果您加载了一个大字符串,只想保留其中的几个字符,则现在未使用的内存将不会被释放。 - some

7

编辑:这些测试是在2021年9月份使用Google Chrome进行的,并非在NodeJS中运行。

看到这里的一些回复真的很有趣。 如果您不担心旧版浏览器(IE6+)的支持,请跳到插值方法,因为它的性能非常强大。

将字符串按值复制并保持向后兼容性最好的方法之一(向后兼容性可追溯至IE6),而且性能仍然非常高效,是将其拆分为新数组,然后立即将该新数组重新连接为一个字符串:

let str = 'abc';
let copiedStr = str.split('').join('');
console.log('copiedStr', copiedStr);

幕后花絮

以上内容调用JavaScript来使用没有字符的分隔符拆分字符串,这将每个单独字符分割成新创建的数组中的一个元素。 这意味着,对于短暂的时刻,copiedStr变量看起来像这样:

['a', 'b', 'c']

然后,立即使用不插入任何分隔符的方法将 copiedStr 变量重新连接起来,这意味着在每个元素之间没有分隔符,因此新创建数组中的每个元素都被推回到全新的字符串中,有效地复制了该字符串。
执行结束时,copiedStr 是它自己的变量,并输出到控制台:
abc

性能

平均情况下,在我的机器上大约需要0.007毫秒-0.01毫秒,但是在您的机器上可能会有所不同。在一个包含4,000个字符的字符串上进行测试,该方法产生了最大约为0.2毫秒,平均约为0.14毫秒的复制字符串时间,因此它的性能仍然很好。

谁在乎遗留支持?/插值方法

但是,如果您不担心旧浏览器的支持问题,这里提供的插值方法(由 Pirijan 提供)是一种非常高效且易于复制字符串的方法:

let str = 'abc';
let copiedStr = `${str}`;

在同一长度为4,000个字符的字符串上测试插值的性能时,平均时间为0.004毫秒,最大时间为0.1毫秒,最小时间为惊人的0.001毫秒(非常频繁)。


有没有理由相信这种方法比这个问题中标记答案中的.slice(1)方法更高效?或者你只是支持这种方法,因为你喜欢它的语法糖? - AffluentOwl
分割连接方法比.slice(1)方法慢了大约0.05毫秒。我从未说过它比那种方法更高效;我只是提供了另一种方法并为其进行了性能测试。但插值在任何情况下都更胜一筹 =] - Marcus Parsons
我使用Node 19进行了测试。使用字符串插值会被优化掉,没有帮助。使用(" " + str).slice(1)可以解决问题。split/join也可以,但速度慢了90%。(在20 MB的CSV数据上进行了测试,结果产生了许多子字符串) - some
非常有趣!谢谢你,一些。我的测试没有在Node中运行。它们是在浏览器中运行的。我会编辑我的答案来提到这一点。再次感谢! - Marcus Parsons

3

当我向数组添加元素时,遇到了一个问题。每个条目最终都成为相同的字符串,因为它引用了一个随着我通过.next()函数迭代结果而改变的对象上的值。以下是让我复制字符串并获得数组结果中唯一值的方法:

while (results.next()) {
  var locationName = String(results.name);
  myArray.push(locationName);
}

3

在我看来,这是最干净、最自文档化的解决方案:

const strClone = String(strOrigin);

2
使用String.slice()

const str = 'The quick brown fox jumps over the lazy dog.';

// creates a new string without modifying the original string
const new_str = str.slice();

console.log( new_str );


0
我会使用字符串插值并检查是否为 undefined 或空。
`{huge_string || ''}`

请注意,使用此解决方案将会得到以下结果。
'' => ''
undefined => ''
null => ''
'test => 'test'

0

我通常使用strCopy = new String (originalStr);这种方式,但有没有什么原因不建议使用呢?


尝试在该对象上运行 typeof。它将返回一个字符串类型的实例,而不是字符串原始类型,这提供了更多的功能。话虽如此,像 strCopy = String(originalStr); 这样将其作为函数执行也许会起作用。 - Levi Muniz
另外,我刚刚测试了一下,请尝试执行 strCopy = String(originalStr); 然后通过 strCopy[0] = "X" 修改原始字符串。两个副本都将被修改。 - Levi Muniz

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接