В моем посте о создании утилиты цветовой палитры в Alpine.js случайность играла большую роль: каждый образец генерировался как композиция случайно выбранных значений Hue (0..360), Saturation (0..100) и Lightness (0..100). Когда я создавал эту демонстрацию, я наткнулся на Web Crypto API. Обычно при генерации случайных значений я использую метод Math.random(), но в документации MDN упоминается, что Crypto.getRandomValues() более безопасен. В итоге я решил попробовать Crypto (с фоллбэком на модуль Math по мере необходимости). Но это заставило меня задуматься, действительно ли "более безопасный" означает "более случайный" для моего варианта использования.
Посмотреть пример в моем проекте JavaScript Demos на GitHub.
Посмотреть код в моем проекте JavaScript Demos на GitHub.
Случайность, с точки зрения безопасности, имеет значение. Я не спе��иалист по безопасности, но, насколько я понимаю, генератор псевдослучайных чисел (ГПСЧ) считается "безопасным" в том случае, когда последовательность чисел, которую он произведет или уже произвел, не может быть вычислена злоумышленником.
Когда речь идет о "генераторах случайных цветов", таких, как моя утилита для создания цветовой палитры, понятие "случайности" гораздо более расплывчато. В моем случае генерация цвета настолько случайна, насколько это «ощущается» пользователем. Другими словами, эффективность случайности является частью пользовательского опыта (UX).
С этой целью я хочу попробовать сгенерировать несколько случайных визуальных элементов, используя как Math.random(), так и crypto.getRandomValues(), чтобы посмотреть, будет ли один из методов существенно отличаться по ощущениям. Каждая попытка будет содержать случайно сгенерированный элемент <canvas> и случайно сгенерированный набор целых чисел. Затем я воспользуюсь своей (глубоко ошибочной) человеческой интуицией, чтобы понять, выглядит ли один из методов "лучше" другого.
Метод Math.random() работает, возвращая десятичное значение от 0 (включительно) до 1 (исключительно). Это можно использовать для генерации случайных целых чисел, взяв результат случайности и умножив его на диапазон возможных значений.
Другими словами, если Math.random() вернет 0.25, вы выберете значение, которое ближе всего к 25% в заданном диапазоне минимума-максимума. А если Math.random() вернет 0.97, вы выберете значение, которое ближе всего к 97% в заданном диапазоне минимума-максимума.
Метод crypto.getRandomValues() работает совсем по-другому. Вместо того чтобы вернуть вам единственное значение, он ожидает принять TypedArray с заранее выделенным размером (длиной). Затем метод .getRandomValues() заполняет этот массив случайными значениями, ограниченными минимумом/максимумом, которые может хранить данный тип.
Чтобы облегчить это исследование, я хочу, чтобы оба подхода работали примерно одинаково. Поэтому вместо того, чтобы иметь дело с десятичными числами в одном алгоритме и целыми числами в другом, я приведу результаты алгоритмов к десятичным числам. Это означает, что я должен превратить value, возвращаемое .getRandomValues(), в десятичное число (0..1):
value / ( maxValue + 1 )
Я инкапсулирую эту разницу в два метода, randFloatWithMath() и randFloatWithCrypto():
/** * С помощью модуля Math я возвращаю случайное число с плавающей запятой в диапазоне от 0 (включительно) до 1 (исключительно). */ function randFloatWithMath() { return Math.random(); } /** * С помощью модуля Crypto я возвращаю случайное число с плавающей запятой в диапазоне от 0 (включительно) до 1 (исключительно). */ function randFloatWithCrypto() { var [ randomInt ] = crypto.getRandomValues( new Uint32Array( 1 ) ); var maxInt = 4294967295; return ( randomInt / ( maxInt + 1 ) ); }
Имея эти два метода, я могу присвоить один из них переменной randFloat(), которая может быть использована для генерации случайных значений в заданном диапазоне, используя любой из алгоритмов:
/** * Я генерирую случайное целое число между заданными min и max, включ��тельно. */ function randRange( min, max ) { return ( min + Math.floor( randFloat() * ( max - min + 1 ) ) ); }
Теперь перейдем к созданию экспериментов. Пользовательский интерфейс небольшой и работает на Alpine.js. В каждом эксперименте используется один и тот же компонент Alpine.js, но его конструктор получает аргумент, который определяет, какая реализация randFloat() будет использоваться:
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="stylesheet" type="text/css" href="./main.css" /> </head> <body> <h1> <!-- Изучение случайности в JavaScript --> Exploring Randomness In JavaScript </h1> <div class="side-by-side"> <section x-data="Explore( 'math' )"> <h2> <!-- Модуль Math --> Math Module </h2> <!-- Очень большое количество случайных координат {X,Y}. --> <canvas x-ref="canvas" width="320" height="320"> </canvas> <!-- Небольшое количество случайных значений координат. --> <p x-ref="list"></p> <p> <!-- Длительность --> Duration: <span x-text="duration"></span> </p> </section> <section x-data="Explore( 'crypto' )"> <h2> <!-- Модуль Crypto --> Crypto Module </h2> <!-- Очень большое количество случайных координат {X,Y}. --> <canvas x-ref="canvas" width="320" height="320"> </canvas> <!-- Небольшое количество случайных значений координат. --> <p x-ref="list"></p> <p> <!-- Длительность --> Duration: <span x-text="duration"></span>ms </p> </section> </div> <script type="text/javascript" src="./main.js" defer></script> <script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.min.js" defer></script> </body> </html>
Как видите, каждый компонент x-data="Explore" содержит два x-ref: canvas и list. Когда компонент инициализируется, он заполнит эти два x-ref случайными значениями с помощью методов fillCanvas() и fillList() соответственно.
Вот мой компонент JavaScript / Alpine.js:
/** * С помощью модуля Math я возвращаю случайное число с плавающей запятой в диапазоне от 0 (включительно) до 1 (исключительно). */ function randFloatWithMath() { return Math.random(); } /** * С помощью модуля Crypto я возвращаю случайное число с плавающей запятой в диапазоне от 0 (включительно) до 1 (исключительно). */ function randFloatWithCrypto() { // Этот метод работает, заполняя массив случайными значениями заданного типа. // В нашем случае нам нужно только одно случайное значение, поэтому мы передадим массив // длиной 1. // -- // Примечание: Для повышения производительности мы можем кэшировать типизированный массив и просто передавать // одну и ту же ссылку (это улучшает производительность вдвое). Но мы исследуем // случайность, а не производительность. var [ randomInt ] = crypto.getRandomValues( new Uint32Array( 1 ) ); var maxInt = 4294967295; // В отличие от Math.random(), crypto генерирует нам целое число. Чтобы подставить его // в то же математическое уравнение, мы должны преобразовать целое число в десятичное, // чтобы получить такое же случайное значение. return ( randomInt / ( maxInt + 1 ) ); } // ----------------------------------------------------------------------------------- // // ----------------------------------------------------------------------------------- // function Explore( algorithm ) { // Каждому компоненту Alpine.js назначается своя стратегия генерации случайных // чисел с плавающей запятой (0..1). В остальном компоненты ведут ��ебя // одинаково. var randFloat = ( algorithm === "math" ) ? randFloatWithMath : randFloatWithCrypto ; return { duration: 0, // Публичные методы. init: init, // Приватные методы. fillCanvas: fillCanvas, fillList: fillList, randRange: randRange } // --- // ПУБЛИЧНЫЕ МЕТОДЫ. // --- /** * Я инициализирую компонент Alpine.js. */ function init() { var startedAt = Date.now(); this.fillCanvas(); this.fillList(); this.duration = ( Date.now() - startedAt ); } // --- // ПРИВАТНЫЕ МЕТОДЫ. // --- /** * Я заполняю canvas случайными пикселями {X,Y}. */ function fillCanvas() { var pixelCount = 200000; var canvas = this.$refs.canvas; var width = canvas.width; var height = canvas.height; var context = canvas.getContext( "2d" ); context.fillStyle = "deeppink"; for ( var i = 0 ; i < pixelCount ; i++ ) { var x = this.randRange( 0, width ); var y = this.randRange( 0, height ); // По мере добавления новых пикселей изменяем их непрозрачность. // Я надеялся, что это поможет показать потенциальную кластеризацию значений. context.globalAlpha = ( i / pixelCount ); context.fillRect( x, y, 1, 1 ); } } /** * Я заполняю список случайными значениями от 0 до 9. */ function fillList() { var list = this.$refs.list; var valueCount = 105; var values = []; for ( var i = 0 ; i < valueCount ; i++ ) { values.push( this.randRange( 0, 9 ) ); } list.textContent = values.join( " " ); } /** * Я генерирую случайное целое число между заданными min и max, включительно. */ function randRange( min, max ) { return ( min + Math.floor( randFloat() * ( max - min + 1 ) ) ); } }
Когда мы запускаем этот пример, мы получаем следующий результат:

Как я уже говорил выше, случайность с человеческой точки зрения очень размыта. Она больше связана с ощущениями, чем с математическими вероятностями. Например, вероятность того, что в одном ряду появятся два одинаковых значения подряд, равна вероятности того, что в одном ряду появятся два разных значения подряд. Но для человека это ощущается по-другому.
Тем не менее, если сравнить эти визуализации случайной генерации, ни одна из них не кажется существенно отличающейся с точки зрения распределения. Конечно, модуль Crypto значительно медленнее (половина из этого - затраты на выделение ресурсов под TypedArray). Но с точки зрения "ощущений" ни один из них не является лучше другого.
Скажу лишь, что при использовании генерации в утилите цветовой палитры мне, вероятно, не было необходимости использовать модуль Crypto - возможно, стоило остановиться на Math. Это гораз��о быстрее и ощущается таким же случайным. Я буду использовать модуль Crypto для работы с криптографией на стороне клиента (чего мне пока не приходилось делать).
