I. Задача



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

II. Варианты решения



Предположим, у нас есть переменная с числом.

    var i = 100000;


Превратить её вывод в 100,000 (или в 100 000, или в 100.000) можно следующими способами.

1. Заменой по регулярному выражению



Существует несколько вариантов, этот мне показался наиболее компактным:

    i.toString().replace( /\B(?=(?:\d{3})+$)/g, ',' );


2. При помощи объекта Intl



А именно метода format() конструктора NumberFormat. Возможны два варианта.

а. С умолчанием:



    var fn_undef = new Intl.NumberFormat();
    fn_undef.format(i);


б. С принудительным заданием локали:



    var fn_en_US = new Intl.NumberFormat('en-US');
    fn_en_US.format(i);


3. При помощи метода Number.toLocaleString()



У этого способа много общего с предыдущим, как можно понять из описаний. Тоже рассмотрим два варианта.

а. С умолчанием:



    i.toLocaleString();


б. С принудительным заданием локали:



    i.toLocaleString('en-US');


Этот способ кажется самым кратким и удобным, но на деле оказывается самым коварным.

III. Тесты



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

1. Node.js 4.1.0



К сожалению, локаль ru-RU в этой версии Node.js не поддерживается (или я не знаю, как добавить её поддержку), поэтому для единообразия пришлось везде использовать локаль en-US.

Сначала скрипт определяет переменные и для иллюстрации выводит форматирование всеми способами (пять идентичных результатов). Затем следуют пять тестовых циклов с отображением прошедшего времени после каждого.

Код для Node.js
'use strict';

var i = 100000;
const fn_undef = new Intl.NumberFormat();
const fn_en_US = new Intl.NumberFormat('en-US');

console.log( i.toString().replace( /\B(?=(?:\d{3})+$)/g, ',' ) );
console.log( fn_undef.format(i)                                );
console.log( fn_en_US.format(i)                                );
console.log( i.toLocaleString()                                );
console.log( i.toLocaleString('en-US')                         );

var time = process.hrtime();
while (i-- > 0) {
	i.toString().replace( /\B(?=(?:\d{3})+$)/g, ',' );
}
console.log(process.hrtime(time));

i = 100000;
time = process.hrtime();
while (i-- > 0) {
	fn_undef.format(i);
}
console.log(process.hrtime(time));

i = 100000;
time = process.hrtime();
while (i-- > 0) {
	fn_en_US.format(i);
}
console.log(process.hrtime(time));

i = 100000;
time = process.hrtime();
while (i-- > 0) {
	i.toLocaleString();
}
console.log(process.hrtime(time));

i = 100000;
time = process.hrtime();
while (i-- > 0) {
	i.toLocaleString('en-US');
}
console.log(process.hrtime(time));


Функция для профайлинга hrtime выдаёт разницу во времени как кортеж из двух чисел в массиве: количество секунд и наносекунд.

Пример вывода (исключая начальные иллюстрации):

[  0,  64840650 ]
[  0, 473762595 ]
[  0, 470775460 ]
[  0, 514655925 ]
[ 14, 120328524 ]


Как мы видим, первый вариант самый быстрый. Следующие два почти не отличаются друг от друга, но медленнее первого на порядок. Четвёртый способ ещё чуть медленнее. Но последний оказывается аномально медленным.

Тут и проявляется существенная разница между методами Intl.NumberFormat.format() и Number.toLocaleString(): в первом мы один раз задаём локаль в конструкторе, во втором мы задаём её в каждом вызове. При определении локали интерпретатор производит довольно ресурсоёмкие операции, описанные в справке. В первом случае он проивзодит их раз и на всё время работы форматера, во втором случае он производит их заново сто тысяч раз. Малозаметная разница в коде, но очень существенная для времени выполнения.

Можно сделать предварительный вывод: если вы знаете нужную локаль заранее, лучше воспользоваться заменой по регулярному выражению. Если локаль непредсказуема, лучше пользоваться методом Intl.NumberFormat.format(), не задавая локаль принудительно.

Если протестировать данный код (заменив функцию профайлинга) в браузерах, мы убедимся, что и для них этот выводы правильны.

2. Браузеры



Запустим этот код в консолях.

Код для браузеров
var i = 100000;
const fn_undef = new Intl.NumberFormat();
const fn_en_US = new Intl.NumberFormat('en-US');

console.log( i.toString().replace( /\B(?=(?:\d{3})+$)/g, ',' ) );
console.log( fn_undef.format(i)                                );
console.log( fn_en_US.format(i)                                );
console.log( i.toLocaleString()                                );
console.log( i.toLocaleString('en-US')                         );

var time = Date.now();
while (i-- > 0) {
	i.toString().replace( /\B(?=(?:\d{3})+$)/g, ',' );
}
console.log(Date.now() - time);

i = 100000;
time = Date.now();
while (i-- > 0) {
	fn_undef.format(i);
}
console.log(Date.now() - time);

i = 100000;
time = Date.now();
while (i-- > 0) {
	fn_en_US.format(i);
}
console.log(Date.now() - time);

i = 100000;
time = Date.now();
while (i-- > 0) {
	i.toLocaleString();
}
console.log(Date.now() - time);

i = 100000;
time = Date.now();
while (i-- > 0) {
	i.toLocaleString('en-US');
}
console.log(Date.now() - time);


Теперь сравнивать придётся миллисекунды, но и это будет достаточно наглядным.

а. Chrome 47.0.2515.0



   80
  543
  528
  604
18699


б. Firefox 44.0a1



 218
 724
 730
 439
7177


в. IE 11.0.14



  215
  328
  355
32628
37384


Видим, что Chrome в последнем способе отстал от Node.js, Firefox оказался в этом же проблемном месте в два раза быстрее, а в IE 11 предпоследний способ по скорости значительно приблизился к последнему (т. е. опущение локали мало чем спасает этот вариант в IE).

Наконец, для большей объективности и для удобства желающих проверить, добавил страничку на jsperf.com. У меня последняя редакция тестов выдала следующее:

Скриншоты







Код там упрощённый, потому что основную работу по прогону циклов сайт берёт на себя. Можете поэкспериментировать, редактируя код и добавляя свои варианты тестов.

P.S. В комментариях добавили ещё два способа. Они, хоть и существенно объёмнее по коду, во многих тестовых случаях ещё быстрее замены по регулярному выражению (тесты на Node и в консолях браузеров: раз, два). Добавил тестовую страничку со всеми семью способами. У меня она выдаёт:

Скриншоты








P.S. 2 Появились ещё две функции, сделал новые тесты (раз, два) и добавил их на jsperf.com. Заодно чуть поправил код с регулярным выражением, вынеся компиляцию из цикла: хоть на MDN и говорится, что в циклах литеральные регулярные выражения не перекомпилируются, я не уверен, имеется ли в виду — когда они определяются вне цикла или даже когда внутри (в Perl ест дополнительный флаг, запрещающий перекомпилирование не изменяющегося в цикле регулярного выражения, не знаю, как себя ведёт в этих случаях JS). Во всяком случае, тесты в Node.js и браузерах показали небольшой прирост скорости при вынесении регулярки из цикла. По итогам новых тестов из девяти способов однозначно выигрывают новые четыре, «математические», но при этом в каждом браузере выигрывают разные «математические» способы. Мои новые результаты:

Скриншоты







P.S. 3 Ещё +1 функция: новая таблица (уже десять вариантов), мои показатели.

P.S. 4 Решил добавить самый линейный вариант — перебор всех возможных длин целого числа в безопасном диапазоне Number.MAX_SAFE_INTEGER c конкатенацией строки посимвольно и вставкой в нужных местах разделителя. Это уже одиннадцатый вариант (функция exhaustion() ), и он оказался довольно быстрым, а в тестах на Firefox даже занял первое место.

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


  1. Yahweh
    22.09.2015 08:50

    А где вы взяли firefox 44? FF43 только сегодня должен стать Developer Edition. Или я чего то не понимаю?


    1. vmb
      22.09.2015 08:57

      Это ночная сборка, только сегодня версия сменилась.


  1. mwizard
    22.09.2015 09:20
    +2

    Наивная сборка строки через slice работает быстрее регулярок:

    var s = i.toString();
    var o = [];
    var l = s.length;
    var off = l % 3;
    var groups = Math.floor(l / 3);
    var x;
    if (off) {
      o.push(s.slice(0, off));
    }
    for (x = 0; x < groups; x++) {
      o.push(s.slice(off, off += 3));
    }
    var result = o.join("'");
    console.log(result)
    


    1. NeXTs_od
      22.09.2015 12:12

      Неа, в хроме 45.0.2454.93 m на win7 не быстрее
      jsfiddle.net/rf7co2gj

      результаты у меня

      regex: 472.494ms
      nativeSlice: 598.145ms


      1. vmb
        22.09.2015 14:58

        Чудеса)

        У меня:
        Хром:
        
        regex:        985.489ms
        nativeSlice:  393.228ms
        regex:       1030.108ms
        nativeSlice:  398.542ms
        regex:       1016.810ms
        nativeSlice:  412.375ms
        
        Firefox:
        
        regex:       608.23ms
        nativeSlice: 999.5ms
        regex:       573.7ms
        nativeSlice: 898.71ms
        regex:       613.18ms
        nativeSlice: 933.68ms
        
        IE:
        
        regex:       1 766,432 мс
        nativeSlice: 1 702,35 мс
        regex:       1 651,024 мс
        nativeSlice: 1 684,765 мс
        regex:       1 760,125 мс
        nativeSlice: 1 690,707 мс
        
        Node.js:
        
        regex:       667ms
        nativeSlice: 374ms
        regex:       748ms
        nativeSlice: 392ms
        regex:       764ms
        nativeSlice: 380ms
        


        1. dom1n1k
          22.09.2015 19:36
          +1

          В Хроме и особенно в IE скорость выполнения может сильно меняться в зависимости от того, открыты ли инструменты разработчика. Вероятно, какие-то оптимизации отключаются. FF в этом вроде бы не замечен (ну, во всяком случае, чтобы сильно).


      1. vmb
        22.09.2015 15:53

        Добавил в конец поста данные по вашему варианту.


    1. andruekonst
      22.09.2015 22:16
      +2

      Наивная сборка строки через числа работает быстрее наивной сборки через slice:

      function mathPower (num){
          if (num < 999)
              return num;
          return mathPower(~~(num / 1000)) + "'"
                 + ("00"+(~~(num % 1000))).substr(-3,3);
      }
      

      jsfiddle.net/5nzL3yLo


      1. vmb
        22.09.2015 23:20

        Спасибо. Попробовал в Node.js и консолях:

        результат регулярки плюс по три результата вашего варианта
        Node.Js
        
        [ 0, 64840650 ]
        
        [ 0, 16904683 ]
        [ 0, 14002223 ]
        [ 0, 15718417 ]
        
        Chrome
        
         80
        
        102
        101
         98
        
        Firefox
        
        218
        
        214
        218
        239
        
        IE
        
        215
        
        193
        204
        186
        


      1. vmb
        23.09.2015 01:58

        Обновил конец статьи, добавил ваш вариант в тесты. У меня он выиграл на IE.


  1. ua9msn
    22.09.2015 10:34
    +3

    … если вы знаете нужную локаль заранее, лучше воспользоваться заменой по регулярному выражению…

    Вы же сами пишете выше про конструктор. Да, как только вам стала известна локаль, то для Intl лучше предварительно создавать эм… форматтер
    new Intl.NumberFormat([locales[, options]])
    

    В таком случае он работает достаточно быстро. А регулярки не обеспечат вам поддержку всех локалей, Китайские, японские цифры или иврит все вам поломают, намучаетесь.


    1. mwizard
      22.09.2015 14:00

      (пардон, не туда)


    1. vmb
      22.09.2015 15:05

      Да, пожалуй, если сложный случай, тем более — если дело не только в разделении на группы, лучше положиться на проверенные стандарты. Но если случай простой, всего лишь разделить запятыми или пробелами, и важна скорость, то можно, наверное, и регуляркой ограничиться.


  1. mwizard
    22.09.2015 14:00
    +1

    Вот этот способ в пять раз быстрее регулярки:

    var s = i.toString();
    var l = s.length;
    var result = '';
    
    if (l > 15) result += s.slice(-18, -15) + "'";
    if (l > 12) result += s.slice(-15, -12) + "'";
    if (l > 9) result += s.slice(-12, -9) + "'";
    if (l > 6) result += s.slice(-9, -6) + "'";
    if (l > 3) result += s.slice(-6, -3) + "'";
    result += s.slice(-3);
    


    1. vmb
      22.09.2015 15:25

      То есть вы полагаетесь на длину Number.MAX_SAFE_INTEGER? Интересное практичное упрощение.

      У меня (небольшой прирост только в Node.js):

      Регулярка vs по три теста вашего способа
      Хром:
      
      80
      
      99
      97
      99
      
      Firefox:
      
      218
      
      599
      633
      656
      
      IE:
      
      215
      
      377
      370
      358
      
      Node.js:
      
      [ 0, 64840650 ]
      
      [ 0, 46075947 ]
      [ 0, 53800444 ]
      [ 0, 43452631 ]
      
      


    1. vmb
      22.09.2015 15:54

      Добавил в конец поста данные по вашему варианту.


  1. 3axap4eHko
    23.09.2015 01:07
    +1

    function fmt(n) {
        n = n.toString();
        const l = n.length;
        var s='',
            i=0;
        while(i<l){
            if((l-i)%3 || !i){
                s+=n[i++];
            }else{
                s+=','+n[i++];
            }
        }
        return s;
    }
    


    1. vmb
      23.09.2015 01:23
      +1

      Спасибо!

      Проверил, вроде бы по сравнению с регуляркой получается быстрее везде, кроме IE. Вот результаты — по три запуска, сначала регулярка, потом ваш вариант.

      Результаты
      Node.Js
      
      [ 0, 72634428 ]
      [ 0, 35303136 ]
      
      [ 0, 73739665 ]
      [ 0, 38605069 ]
      
      [ 0, 85149363 ]
      [ 0, 39749604 ]
      
      Chrome
      
      98
      36
      
      119
      36
      
      91
      34
      
      Firefox
      
      214
      185
      
      224
      186
      
      220
      182
      
      IE
      
      186
      266
      
      175
      277
      
      185
      302
      


      1. 3axap4eHko
        23.09.2015 01:24
        +1

        А Вы на лэптопе тестируете? Посмотрите в настройках Advanced Power Settings-> Internet Explorer -> Javascript Timer Frequency, там должно быть Maximum Perfomance


        1. vmb
          23.09.2015 01:33

          Да, но он работает от сети. Проверил, там при работе от сети стоит «Максимальная производительность».


        1. vmb
          23.09.2015 01:59

          Обновил конец статьи, добавил ваш вариант в тестовую страницу. У меня он выиграл на Firefox.


  1. 3axap4eHko
    23.09.2015 03:45
    +1

    И еще

    function fmt2(n) {
        var s='';
        while(n){
            s=('000'+n%1000).slice(n>=1000 ? -3 : -n.toString().length)+s;
            n=n/1000|0;
            if(n) {
                s=','+s;
            }
        }
        return s;
    }
    


    1. vmb
      23.09.2015 04:33

      Спасибо. Новая табличка. В Хроме более чем в два раза быстрее предыдущего, в IE чуть медленнее, а вот в Firefox почему-то более чем в два раза медленнее по сравнению с fmt1:

      Скриншоты:






  1. vmb
    23.09.2015 06:59

    Плюс самый разжёванный вариант (см. P.S. 4 в конце статьи).


  1. AHDPEu
    24.09.2015 09:44
    +1

    Нет смысла заново инициализировать регулярку. Так же быстрее:

    var pattern = new RegExp('\B(?=(?:\d{3})+$)', 'g');
    
    i.toString().replace( pattern , ',' ); //А это в цикле
    
    


    1. AHDPEu
      24.09.2015 09:47
      +1

      upd: Прошлый комментарий отменяется. Увидел в тестах.


      1. vmb
        24.09.2015 14:11

        Всё равно спасибо)


  1. mwizard
    24.09.2015 15:18
    +1

    и он оказался довольно быстрым, а в тестах на Firefox даже занял первое место.
    Я вижу, что во всех браузерах, кроме IE11, slice_concatenate имеет наибольшую скорость работы. exhaustion на 3-5 местах. Может, я не туда смотрю?


    1. vmb
      24.09.2015 16:10

      У них какой-то баг на сайте. Кажется, ни один из моих тестов на Firefox Nightly 44.0a1 не сохранился и не отображается в сведённых чартах, хотя после каждого теста в текущей табличке всё отображается. Может, при распознавании ночных сборок у них сбой происходит.