探索 JavaScript 中的随机性

原文信息: 查看原文查看原文

Exploring Randomness In JavaScript

- Ben Nadel

在昨天的文章中,我谈到了在 Alpine.js 中构建色彩面板工具,随机性在其中扮演了重要角色:每个色样都是由随机选择的色调 Hue (0..360)、饱和度 Saturation (0..100) 和亮度 Lightness (0..100) 值复合生成的。在制作那个演示时,我偶然发现了 Web Crypto API。通常,在生成随机值时,我会使用 Math.random() 方法;但是,MDN 文档提到 Crypto.getRandomValues() 更安全。因此,我最终尝试了 Crypto(必要时回退到 Math 模块)。但这让我想知道 "更安全" 是否真的意味着在我的特定用例中 "更随机"。

**在 GitHub 上的 *JavaScript Demos 项目中运行此演示*

**在 GitHub 上的 *JavaScript Demos 项目中查看此代码*

从安全的角度来看,随机性有一个实际的含义。我不是安全专家;但是,我的理解是,当一个伪随机数生成器(PRNG)被认为是 "安全的",这意味着它将产生的数字序列——或者已经产生的——不能被攻击者推断。

当涉及到 "随机颜色生成器" 时,比如我的色彩面板工具,"随机性" 的概念就模糊得多了。在我的例子中,随机颜色生成的随机性只是对用户 "感觉" 上的随机性。换句话说,随机性的有效性是整体用户体验(UX)的一部分。

为此,我想尝试使用 Math.random()crypto.getRandomValues() 生成一些随机的 视觉 元素,看看其中一种方法是否在 感觉 上有实质性的不同。每次试验将包含一个随机生成的 <canvas> 元素和一组随机生成的整数。然后,我将使用我(严重有缺陷的)人类直觉来看看其中一项试验是否看起来 "更好"。

Math.random() 方法通过返回一个介于 0(包括)和 1(不包括)之间的小数来工作。这可以用来通过将随机结果乘以可能值的范围来生成随机整数。

换句话说,如果 Math.random() 返回 0.25,你会选择最接近给定最小-最大范围 25% 的值。而如果 Math.random() 返回 0.97,你会选择最接近给定最小-最大范围 97% 的值。

crypto.getRandomValues() 方法的工作原理非常不同。与返回单个值不同,你传入一个预分配大小(长度)的 Typed Array。然后 .getRandomValues() 方法用可以由给定类型的最小/最大值存储的随机值填充该数组。

为了使这次探索更简单,我想让这两种方法大致上工作得相同。所以,我将迫使两种算法都依赖于小数生成。这意味着,我必须将 .getRandomValues() 返回的 value 强制转换为小数 (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() 引用,该引用可以用来无缝地在给定范围内使用任一算法交替生成随机值:

/**
* 我在给定的最小和最大值之间(包括两者)生成一个随机整数。
*/
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 中的随机性
    </h1>

    <div class="side-by-side">
        <section x-data="Explore( 'math' )">
            <h2>
                Math 模块
            </h2>

            <!-- 一个非常大数量的随机 {X,Y} 坐标。 -->
            <canvas
                x-ref="canvas"
                width="320"
                height="320">
            </canvas>

            <!-- 一个非常小数量的随机值坐标。 -->
            <p x-ref="list"></p>

            <p>
                持续时间:<span x-text="duration"></span>
            </p>
        </section>

        <section x-data="Explore( 'crypto' )">
            <h2>
                Crypto 模块
            </h2>

            <!-- 一个非常大数量的随机 {X,Y} 坐标。 -->
            <canvas
                x-ref="canvas"
                width="320"
                height="320">
            </canvas>

            <!-- 一个非常小数量的随机值坐标。 -->
            <p x-ref="list"></p>

            <p>
                持续时间:<span x-text="duration"></span>毫秒
            </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-refscanvaslist。当组件初始化时,它将使用 fillCanvas()fillList() 方法分别填充这两个引用的随机值。

这是我的 JavaScript / Alpine.js 组件:

/**
* I return a random float between 0 (inclusive) and 1 (exclusive) using the Math module.
*/
function randFloatWithMath() {

    return Math.random();

}

/**
* I return a random float between 0 (inclusive) and 1 (exclusive) using the Crypto module.
*/
function randFloatWithCrypto() {

    // This method works by filling the given array with random values of the given type.
    // In our case, we only need one random value, so we're going to pass in an array of
    // length 1.
    // --
    // Note: For better performance, we could cache the typed array and just keep passing
    // the same reference in (cuts performance in half). But, we're exploring the
    // randomness, not the performance.
    var [ randomInt ] = crypto.getRandomValues( new Uint32Array( 1 ) );
    var maxInt = 4294967295;

    // Unlike Math.random(), crypto is giving us an integer. To feed this back into the
    // same kind of math equation, we have to convert the integer into decimal so that we
    // can figure out where in our range our randomness leads us.
    return ( randomInt / ( maxInt + 1 ) );

}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

function Explore( algorithm ) {

    // Each instance of this Alpine.js component is assigned a different randomization
    // strategy for floats (0..1). Other than that, the component instances behave exactly
    // the same.
    var randFloat = ( algorithm === "math" )
        ? randFloatWithMath
        : randFloatWithCrypto
    ;

    return {
        duration: 0,
        // Public methods.
        init: init,
        // Private methods.
        fillCanvas: fillCanvas,
        fillList: fillList,
        randRange: randRange
    }

    // ---
    // PUBLIC METHODS.
    // ---

    /**
    * I initialize the Alpine.js component.
    */
    function init() {

        var startedAt = Date.now();

        this.fillCanvas();
        this.fillList();

        this.duration = ( Date.now() - startedAt );

    }

    // ---
    // PRIVATE METHODS.
    // ---

    /**
    * I populate the canvas with random {X,Y} pixels.
    */
    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 );

            // As we add more pixels, let's make the pixel colors increasingly opaque. I
            // was hoping that this might help show potential clustering of values.
            context.globalAlpha = ( i / pixelCount );
            context.fillRect( x, y, 1, 1 );

        }

    }

    /**
    * I populate the list with random 0-9 values.
    */
    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( " " );

    }

    /**
    * I generate a random integer in between the given min and max, inclusive.
    */
    function randRange( min, max ) {

        return ( min + Math.floor( randFloat() * ( max - min + 1 ) ) );

    }

}

从左侧的 Math.random() 和右侧的 crypto.getRandomValues() 的视觉探索,没有明显的区别显示随机性的感觉。

如上所述,从人类的角度来看,随机性是非常模糊的。这更多是关于 "感觉" 而不是数学概率。例如,连续生成两个 相同 的值的概率与生成 任何两个 值的概率是相同的。但对人类来说,这很显眼 —— 它 感觉 不同。

话虽如此,从分布的角度来看,这些并排随机生成的可视化看起来没有实质性的不同。当然,Crypto 模块明显慢得多(其中一半是 Typed Array 分配的成本)。但从 "感觉" 的角度来看,一个并不明显优于另一个。

总之,当生成一个随机颜色面板时,我可能没有必要使用 Crypto 模块 —— 我本应该继续使用 Math。它更快,并且 感觉 同样随机。我会把 Crypto 的东西留给任何客户端加密工作(我从未做过)。

分享于 2024-07-03

访问量 50

预览图片