Все что отображает браузер кроме картинок и видео это строки, поэтому грамотная работа с ними может значительно увеличить скорость работы веб-приложений как на стороне клиента так и на стороне сервера.

Что нужно знать о строках с позиции эффективности их использования? Во первых, строки относятся к примитивным типам данных. Во вторых, значения примитивных (простых) типов данных, в отличии от составных, таких как массивы и структуры не изменяемы. Это значит, что если вы присвоили значение переменной строкового типа один раз, то в дальнейшем эту строку изменить невозможно. Однако такое утверждение может удивить. Что это значит на практике? Если, например, выполнить этот код:

let hello = "Hello";
hello += " world";
console.log(hello);

то в консоли однозначно появится Hello world, то есть строковая переменная hello изменила свое значение. Как строковая переменная может быть неизменяемой и измениться одновременно?

Дело в том, что интерпретатор языка в случае со строками не добавляет одну строку к другой напрямую. Вместо этого, он создает третью строку в памяти, затем копирует обе строки "Hello" и " world" в эту новую строку и перенаправляет переменную "hello" чтобы она указывала на эту новую строку. Соответственно, значение новой строки устанавливается один раз, а значения первых двух не изменяются и таким образом правило неизменяемости строк выполняется.

Вот как процесс объединения строк выглядит полностью:

Как вы думаете, что в этом процессе плохого? Это крайне не эффективный алгоритм. Он выполняет больше действий чем необходимо и использует в два раза больше памяти чтобы хранить одну и ту же строку. Конечно это не является проблемой если нужно просто объединить две строки. Проблемы могут появиться при необходимости строить большие строки. Например, если нужно динамически создать HTML-текст страницы из массива данных, поступающих из внешнего источника используя цикл по этому массиву. В этом случае, вся создаваемая строка будет полностью копироваться в новую на каждой итерации цикла. Рассмотрим простой пример такого цикла:

let str = "Hello";

console.log("START",new Date().toUTCString());

for (let index=0;index<100000000;index++) {
    str += "!";
}

console.log("END",new Date().toUTCString());
console.log(str.length);

Этот код создает строку "Hello" и затем добавляет к ней строку "!" сто миллионов раз. В реальных приложениях вместо "!" могут быть реальные данные из внешнего массива. Также, этот код выводит текущее время до начала цикла и после него. Таким образом можно узнать сколько времени требуется на выполнение этого кода. В завершении он выводит длину итоговой строки. Когда я запустил это в Google Chrome, то получил следующий вывод в консоли:

Данная операции выполнилась за 1 минуту 26 секунд и выдала корректную длину строки. Однако, когда я запустил это на другом компьютере, этот код убил текущую вкладку в браузере и вывел вот такое:

После рассмотрения того как работает объединение строк несложно понять почему браузер мог рухнуть. Этот алгоритм совершенно не эффективен. Этот цикл создает новые строки размером от одного символа до ста миллионов символов на каждой итерации цикла сто миллионов раз. В этой ситуации даже сложно сразу представить, сколько для этого может потребоваться памяти. В одном случае операция может завершиться успешно, в другом случае нет. Это зависит от количества доступной памяти и от того как работает сборщик мусора в конкретной реализации движка JavaScript, на котором этот код запускается, то есть насколько быстро он успевает очищать временно созданные строки по ходу цикла.

Увидев все это возникает желание исправить ситуацию и добавлять строку к строке напрямую. В других языках программирования, в таких как Java или Go существуют вспомогательные объекты StringBuilder или StringBuffer, которые именно это и делают. Они позволяют конструировать строки через изменяемые типы данных, такие как массивы. Однако в JavaScript этого нет, но идею несложно реализовать самостоятельно, что и будет сделано далее.

Вернемся к началу и запишем строку "hello" следующим образом:

let hello = ["Hello"];

Переменная hello это не строка, а массив со строкой. Массивы изменяемы и если выполнить:

hello.push(" world");

то произойдет именно то что написано и больше ничего: строка " world" добавится в массив после строки "Hello" и массив будет содержать следующее:

["Hello"," world"]

Таким образом можно добавлять любое количество строк и Javascript будет выполнять лишь одну операцию для каждого добавления. Однако в конце, чтобы получить строку, придется объединить массив с помощью операции join:

hello = hello.join("");
console.log(hello);

Этот код объединил массив в строку и вывел "Hello world" в консоль. Конечно в момент операции "join" происходит то же самое что и при объединении строк: создается новая строка, в нее копируются все элементы массива и затем эта строка присваивается переменной "hello". Однако это происходит всего один раз, а не каждый раз при добавлении новой строки.

Такой способ позволяет значительно ускорить конструирования строк в цикле. Перепишем пример с циклом через массив:

let str = ["Hello"];

console.log("START",new Date().toUTCString());

for (let index=0;index<100000000;index++) {
    str.push("!");
}

str = str.join("");

console.log("END",new Date().toUTCString());
console.log(str.length);

Получилось на одну строчку больше. Когда я запустил этот код на том же компьютере, где рухнул браузер, то получил следующий вывод:

Результат был достигнут за 8 секунд, что в 10 раз быстрее чем при обычном объединении строк.

Это является примером того, что иногда изменив три строки кода можно значительно увеличить производительность обработки данных. В реальной жизни, я столкнулся с ситуацией когда владелец web-сайта из-за медленной загрузки строки сначала кэшировал данные на CloudFlare, а потом на полном серьезе планировал переходить на AWS для увеличения пропускной способности и балансировки нагрузки. Однако нужно было просто провести code review для фронтенда.

Вы можете использовать этот метод для конструирования строк из массивов внешних данных, размер которых не известен. Просто добавляйте строки в массив, а в самом конце объединяйте этот массив в строку.

То что было сделано можно рассматривать как базовую реализацию StringBuilder для Javascript с одной лишь функцией - добавление подстроки. В качестве домашней работы можете оформить это в виде класса с разными функциями для работы с подстроками, такими как "добавить", "изменить", "удалить" и "преобразовать в строку".

При добавлении элементов в массив важно помнить о существующих ограничениях на количество элементов массива, так как если их не учитывать, то можно столкнуться с ошибкой "RangeError: invalid array range". Подробнее об ограничениях можно узнать здесь: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Invalid_array_length . Поэтому если количество строк, которые нужно добавлять в цикле превышает эти ограничения, то придется периодически сбрасывать массив во временные строковые буферы и потом эти буферы объединять.

Приведенный в начале алгоритм объединения строк в Javascript не претендует на академическую точность по некоторым причинам. Разные реализации движков Javascript могут использовать различные оптимизации по работе со строками и механизмы работы с памятью могут отличаться. Однако не стоит расчитывать на то что ваш код всегда будет запускаться в таких движках. Например, в последней версии Google Chrome на момент написания этого текста объединение строк работало так, как показано на скриншотах выше. Поэтому целью данной статьи является побудить вас работать со строками эффективнее независимо от того, как это реализовано по умолчанию и показать реальный эффект от этого.

Также существуют и более эффективные алгоритмы работы со строками, основанные не только на массивах. Наиболее быстрый из них построен на структуре данных Rope. Она используется для ускорения вставки и удаления подстрок в огромных строках. Подробнее о самой структуре можно прочитать в Википедии: https://en.wikipedia.org/wiki/Rope_(data_structure) . Также думаю не сложно будет найти описания на русском языке. Это несколько сложнее для понимания и использования чем метод описанный в этой статье, однако можно воспользоваться одной из готовых библиотек, которые реализуют Rope на JavaScript:

https://github.com/component/rope
https://github.com/josephg/jumprope

Спасибо, надеюсь это поможет вам в работе. Если есть вопросы или дополнения, пишите в комментариях.

Комментарии (15)


  1. ermouth
    02.12.2022 22:48
    +4

    Вместо этого, он создает третью строку в памяти, затем копирует обе строки "Hello" и " world"

    Копирования не происходит, происходит создание так называемой cons string, по сути – пара указателей. Если бы происходило копирование, вы бы наблюдали четырёхкратный рост времени при увеличении к-ва итераций вдвое.

    В целом да, .join('') быстрее, если кусочков строки много. Если составных частей в пределах ~100К, конкатенация будет быстрей из-за экономии на манипуляциях с массивом, причём тем быстрее, чем меньше частей.


    1. germanov_dev Автор
      02.12.2022 23:08

      Спасибо за уточнение о cons string. Я конкретные движки, такие как V8 здесь не рассматривал, только упомянул что они бывают.


      1. ermouth
        02.12.2022 23:15

        В FF практически то же самое, так что это не только про V8.


      1. faiwer
        03.12.2022 02:13
        -2

        Я конкретные движки, такие как V8 здесь не рассматривал, только упомянул что они бывают.

        А какой % мирового JS запускается нынче не на V8?


        1. Lazytech
          03.12.2022 14:51

          Если я правильно понял, что-то около 25%.

          Safari + Firefox + Samsung Internet

          https://gs.statcounter.com/browser-market-share

          Chrome
          65.84%

          Safari
          18.7%

          Edge
          4.44%

          Firefox
          3.04%

          Samsung Internet
          2.68%

          Opera
          2.28%


          1. faiwer
            03.12.2022 15:48

            Samsung Internet

            Он же вроде на Chromium-е построен. Как и Edge, Opera, Vivaldi и пр.
            На деле может и больше 25%, если учесть что все браузеры на iOS вынуждены использовать один webkit движок.


            1. Lazytech
              03.12.2022 15:54

              Он же вроде на Chromium-е построен.

              Да, точно. Не знал.

              Больше всего меня удивила столь малая — возможно, продолжающая уменьшаться? — доля Firefox. Я-то думал, что это до сих пор основной конкурент браузеров Chrome/Chromium.


  1. Zibx
    02.12.2022 22:53
    +8

    То есть вот у нас есть задача добавить к строке Hello 100 миллионов восклицательных знаков? И делаем мы это на JS?

    Я чуть-чуть расширил логирование чтоб оно показывало и миллисекунды.

    var str = "Hello";
    var count = 100000000;
    
    console.log("START",new Date().toUTCString()+ '.' +new Date().getUTCMilliseconds());
    str += new Array(count+1).join('!');
    
    console.log("END",new Date().toUTCString()+ '.' +new Date().getUTCMilliseconds());
    console.log(str.length);

    Результат исполнения:

    START Fri, 02 Dec 2022 19:46:50 GMT.885
    END Fri, 02 Dec 2022 19:46:50 GMT.886
    100000005

    Только тут такое дело, что это абсолютно бессмысленное занятие оптимизации непонятно чего. Должна быть реальная задача, её профилирование. Всю статью можно заменить фразой «А вы знали что в JS строки иммутабельны?»


    1. germanov_dev Автор
      02.12.2022 23:11

      Да, вы правы. Эта статья и есть немного расширенная версия фразы "А вы знали что в JS строки иммутабельны?" для тех кто теоретически это знает, но на практике не задумывается когда складывает строки в циклах, а потом не может найти что тормозит.


  1. DmitryKoterov
    03.12.2022 08:44

    На StackOverflow есть как минимум несколько бенчмарков про += vs. join, там все очень однозначно в пользу += для сколько-нибудь реальных размеров строк (не для мегакосмических).


  1. RegIon
    03.12.2022 10:18
    +2

    Если мне не изменяет память, то в V8 уже Rope структура при определенных условиях.

    Вообще там много вариации стрингов:)

    https://chromium.googlesource.com/v8/v8.git/+/13d38e4a87153ab3b0a8d0612ea1c7ee3f664d77/src/objects/string.h


  1. markelov69
    03.12.2022 11:23

    Да всё просто же, нужно использование в реальном мире:

    function realWorldConcat() {
        const arr = [];
        arr.push('real');
        arr.push(' world');
        arr.push(' concat');
        return arr.join('');
    }
    
    function realWorldConcat2() {
        let str = '';
        str += 'real';
        str += ' world';
        str += ' concat';
        return str;
    }
    
    const result1 = [];
    const result2 = [];
    
    const start1 = new Date().getTime();
    for (let i = 0; i < 1000000; i++) {
        result1.push(realWorldConcat());
    }
    const end1 = new Date().getTime();
    
    console.log(result1.length);
    console.log('[arr] time took', end1 - start1, 'ms');
    
    
    const start2 = new Date().getTime();
    for (let i = 0; i < 1000000; i++) {
        result2.push(realWorldConcat2());
    }
    const end2 = new Date().getTime();
    
    console.log(result2.length);
    console.log('[str +=] time took', end2 - start2, 'ms');

    В Node.js:

    1000000
    [arr] time took 231 ms
    1000000
    [str +=] time took 24 ms

    В браузере:

    1000000
    [arr] time took 174 ms
    1000000
    [str +=] time took 16 ms


    Итого += в реальной жизни рвет по скорости в 10 раз массивы


    1. RAX7
      03.12.2022 12:21
      +1

      А теперь замерьте еще такой вариант:

      function realWorldConcat3() {
          return 'real world concat';
      }
      

      и сравните результат с вашим realWorldConcat2.
      Разница будет в пределах погрешности, потому что под капотом jit соптимизирует вашу функцию до такого вида, а затем может и вовсе заинлайнит её.


      1. markelov69
        03.12.2022 12:26

        Хорошо, согласен, добавим переменные:

        function realWorldConcat(word) {
            const arr = [];
            arr.push('real');
            arr.push(word);
            arr.push(' concat');
            return arr.join('');
        }
        
        function realWorldConcat2(word) {
            let str = '';
            str += 'real';
            str += word;
            str += ' concat';
            return str;
        }
        
        function realWorldConcat3(word) {
            return `real${word} concat`;
        }
        
        const result1 = [];
        const result2 = [];
        const result3 = [];
        
        const start1 = new Date().getTime();
        for (let i = 0; i < 1000000; i++) {
            result1.push(realWorldConcat(i));
        }
        const end1 = new Date().getTime();
        
        console.log(result1.length);
        console.log('[arr] time took', end1 - start1, 'ms');
        
        
        const start2 = new Date().getTime();
        for (let i = 0; i < 1000000; i++) {
            result2.push(realWorldConcat2(i));
        }
        const end2 = new Date().getTime();
        
        console.log(result2.length);
        console.log('[str +=] time took', end2 - start2, 'ms');
        
        
        const start3 = new Date().getTime();
        for (let i = 0; i < 1000000; i++) {
            result3.push(realWorldConcat3(i));
        }
        const end3 = new Date().getTime();
        
        console.log(result3.length);
        console.log('[`{template}`] time took', end3 - start3, 'ms');

        Результаты в Node.js:

        1000000
        [arr] time took 250 ms
        1000000
        [str +=] time took 121 ms
        1000000
        [`{template}`] time took 172 ms

        Всё равно str += побеждает, но уже в 2 раза


        1. markelov69
          03.12.2022 12:40
          +3

          Вот когда переменных побольше склеивается другой расклад:

          function realWorldConcat(word) {
              const arr = [];
              arr.push('real');
              arr.push(word);
              arr.push(word);
              arr.push(word);
              arr.push(word);
              arr.push(word);
              arr.push(word);
              arr.push(' concat');
              return arr.join('');
          }
          
          const arr_cheat = new Array(8);
          function realWorldConcat_cheat(word) {
              arr_cheat[0] = 'real';
              arr_cheat[1] = word;
              arr_cheat[2] = word;
              arr_cheat[3] = word;
              arr_cheat[4] = word;
              arr_cheat[5] = word;
              arr_cheat[6] = word;
              arr_cheat[7] = ' concat';
          
              return arr_cheat.join('');
          }
          
          function realWorldConcat2(word) {
              let str = '';
              str += 'real';
              str += word;
              str += word;
              str += word;
              str += word;
              str += word;
              str += word;
              str += ' concat';
              return str;
          }
          
          function realWorldConcat3(word) {
              return `real${word}${word}${word}${word}${word}${word} concat`;
          }
          
          function realWorldConcat4(word) {
              return 'real' + word + word + word + word + word + word + ' concat';
          }
          
          const result1 = [];
          const result2 = [];
          const result3 = [];
          const result4 = [];
          const result5 = [];
          
          const start1 = new Date().getTime();
          for (let i = 0; i < 1000000; i++) {
              result1.push(realWorldConcat(i));
          }
          const end1 = new Date().getTime();
          
          console.log(result1.length);
          console.log('[arr] time took', end1 - start1, 'ms');
          
          
          const start2 = new Date().getTime();
          for (let i = 0; i < 1000000; i++) {
              result2.push(realWorldConcat2(i));
          }
          const end2 = new Date().getTime();
          
          console.log(result2.length);
          console.log('[str +=] time took', end2 - start2, 'ms');
          
          
          const start3 = new Date().getTime();
          for (let i = 0; i < 1000000; i++) {
              result3.push(realWorldConcat3(i));
          }
          const end3 = new Date().getTime();
          
          console.log(result3.length);
          console.log('[`{template}`] time took', end3 - start3, 'ms');
          
          
          const start4 = new Date().getTime();
          for (let i = 0; i < 1000000; i++) {
              result4.push(realWorldConcat4(i));
          }
          const end4 = new Date().getTime();
          
          console.log(result4.length);
          console.log('[+ + +] time took', end4 - start4, 'ms');
          
          
          
          const start5 = new Date().getTime();
          for (let i = 0; i < 1000000; i++) {
              result5.push(realWorldConcat_cheat(i));
          }
          const end5 = new Date().getTime();
          
          console.log(result5.length);
          console.log('[arr cheat] time took', end5 - start5, 'ms');

          Результаты в Node.js:

          1000000
          [arr] time took 344 ms
          1000000
          [str +=] time took 307 ms
          1000000
          [`{template}`] time took 229 ms
          1000000
          [+ + +] time took 320 ms
          1000000
          [arr cheat] time took 269 ms