手记

我如何成功地在一个网页上展示一千万张小图片

我在构建10MPage.com,目标是捕捉2025年的互联网的状态。每位用户都可以上传一个64x64像素的小图,为这个档案库贡献一份力量。

正如名字所暗示的,它需要能够处理一千万张小图片。当我第一次提出这个概念时,我担心如何高效渲染这些图片。在这篇文章里,我会讲讲我最初的尝试和最终的解决方案。

在你继续之前, ,先看一下10MPage.com ,看看你能否弄清楚它是怎么做到的。如果你已经到了10MPage,为什么不为自己申请一个呢?:)

图像标签 VS Canvas

我首先需要做选择,是否使用HTML元素或全屏的画布。

分离图像标签

我最初为每个方块分别用了单独的 <img 标签进行测试,生成了1024张图片,用以组成一个32x32的网格,然后使用Blade将这些图片放在页面上。

    <div class="grid" id="grid">  
        @for($y = 0; $y < 32; $y++)  
            <div class="row">  
                @for($x = 0; $x < 32; $x++)  
                    <div class="tile"><img src="http://10mpage.test/tiles/{{$y}}x{{$x}}.png" alt="第 {{$y}}行第 {{$x}}列的瓷砖"></div>  
                @endfor  
            </div>  
        @endfor  
    </div>

使用以下CSS:

    body {  
        margin: 0;  
        padding: 0;  
        overflow: auto; /* 启用滚动条 */  
    }  

    .grid {  
        display: block;  
        position: relative;  
        width: 100%; /* 网格宽度为100% */  
    }  

    .row {  
        display: flex; /* 每一行都是一个弹性容器 */  
    }  

    .tile {  
        width: 64px;  
        height: 64px;  
        box-sizing: border-box;  
        border: 1px solid #ccc; /* 方块之间有视觉分隔线 */  
    }  

    .tile img {  
        width: 64px;  
        height: 64px;  
    }

看起来就是这样的:

这倒是没问题,但是有几个方面可能会有点麻烦。

  • 浏览器滚动条
  • 大型DOM结构
  • 按需加载
  • 许多小图片
画布

接下来的做法是直接在画布上进行,为了简单起见,我决定只是画一个棋盘。添加滚动也很简单,它看起来像这样。

    <body>  
    <canvas id="canvas"></canvas>  

    <script>  
        const canvas = document.getElementById('canvas');  
        const ctx = canvas.getContext('2d');  
        canvas.width = window.innerWidth;  
        canvas.height = window.innerHeight;  

        const tileSize = 64;  
        let scale = 1;  
        let translateX = 0;  
        let translateY = 0;  
        let isPanning = false, startX, startY;  

        // 绘制棋盘网格  
        function drawGrid() {  
            ctx.clearRect(0, 0, canvas.width, canvas.height);  
            ctx.save();  
            ctx.translate(translateX, translateY);  
            ctx.scale(scale, scale);  

            // 根据视口大小计算所需绘制的列数和行数  
            const cols = Math.ceil(canvas.width / (tileSize * scale)) + 2; // 额外的格子用于溢出  
            const rows = Math.ceil(canvas.height / (tileSize * scale)) + 2; // 额外的格子用于溢出  

            // 根据当前平移和缩放计算起始列和行索引  
            const startCol = Math.floor(-translateX / (tileSize * scale));  
            const startRow = Math.floor(-translateY / (tileSize * scale));  

            for (let x = startCol; x < startCol + cols; x++) {  
                for (let y = startRow; y < startRow + rows; y++) {  
                    // 交替填充黑白格子  
                    ctx.fillStyle = (x + y) % 2 === 0 ? 'black' : 'white';  
                    ctx.fillRect(x * tileSize, y * tileSize, tileSize, tileSize);  
                }  
            }  

            ctx.restore();  
        }  

        // 以鼠标位置为中心的平滑缩放  
        function zoom(event) {  
            event.preventDefault();  
            const zoomIntensity = 0.05; // 更小的值可以获得更平滑的缩放效果  
            const mouseX = event.clientX;  
            const mouseY = event.clientY;  

            const wheelDelta = event.deltaY > 0 ? -1 : 1; // 滚动方向  

            // 计算缩放因子(更小的值可以获得更平滑的过渡效果)  
            const zoomFactor = Math.exp(wheelDelta * zoomIntensity);  

            // 调整平移以使缩放朝向鼠标位置  
            translateX -= (mouseX - translateX) * (zoomFactor - 1);  
            translateY -= (mouseY - translateY) * (zoomFactor - 1);  

            // 应用缩放因子  
            scale *= zoomFactor;  

            drawGrid();  
        }  

        // 开始平移  
        function startPan(event) {  
            isPanning = true;  
            startX = event.clientX - translateX;  
            startY = event.clientY - translateY;  
        }  
        // 平移  
        function pan(event) {  
            if (!isPanning) return;  
            translateX = event.clientX - startX;  
            translateY = event.clientY - startY;  
            drawGrid();  
        }  
        // 结束平移  
        function endPan() {  
            isPanning = false;  
        }  

        // 事件监听器  
        window.addEventListener('wheel', zoom); // 窗口添加滚动事件监听器  
        canvas.addEventListener('mousedown', startPan);  
        canvas.addEventListener('mousemove', pan);  
        canvas.addEventListener('mouseup', endPan);  
        window.addEventListener('resize', () => {  
            canvas.width = window.innerWidth;  
            canvas.height = window.innerHeight;  
            drawGrid(); // 调整屏幕大小时重绘网格  
        });  

        // 初始绘制网格  
        drawGrid(); // 调整屏幕大小时重绘网格  
    </script>  
    </body>

这种方法挺不错,因为它允许我通过代码来展示一切,这会让更复杂的特性更容易实现。

选择使用图像标签还是画布(canvas)

最后我选择了 canvas,因为它的灵活性比 div 更强,能够实现加载动画、平滑滚动、懒加载这些特性,并且完全通过代码渲染,提供更大的控制范围。

但加载很多小图片会带来很多负担,因此我想把这些小图片打包成更大的组来减少这种负担。

房瓦送货优化

单独加载每张图片会增加很多网络请求。我们快速计算一下1080p屏幕的情况。宽度是1920像素,等于1920 / 64 = 30个图块。高度是1080像素,等于1080 / 64 ≈ 17个图块。所以在全高清显示器上,渲染一整屏瓦片需要渲染30 * 17 = 510个小图片。

但我们必须能够滚动!并且在滚动时,我不希望在渲染之前显示很多加载图标。这意味着我们需要预先加载周围的图片。如果我们想要预加载周围的图片,我们就需要加载周边八个图块。想象这个黑色矩形是我们正在查看的显示区域。

*那就会变成 510 8 = 4080 张图!**

这么快渲染这么多图像是不现实的。解决办法是把各个小图块组合成更大的块状。

使用 PHP,我编写了一个控制器,生成一个包含 1616 个方块的图片。每个块(每块)的宽度和高度都是 64 16 = 1024 像素。你可以访问 10MPage 并查看浏览器的网络标签来看到这一点。

脚本会在未填的空白处加上问号。

所以,从原本需要4080张图片(1920 3 = 5760像素,1080 3 = 3240像素),现在只需要24张图片,计算是这样的:5760像素除以1024约等于6(四舍五入到最接近的整数),3240像素除以1024约等于4(四舍五入到最接近的整数),6乘以4等于24。这完全做得到!

隐藏积木

我已经做了一些事情来隐藏,让瓷砖看起来是以更大的块来加载的,而不是真正的块状加载。

加载画面通常有 64x64 的图块

网格布局总是被渲染成正方形

为了隐藏网格底部或右侧的大空白区域,网格将永远不会加载非正方形的块,而是始终加载正方形的块。你不会看到底部有一个块,而其左边或右边是空白的。

感谢你阅读这篇文章,希望你有所收获。
如果你有所收获,为什么不把你最喜欢的编程语言、加密货币或者宠物添加到10MPage呢?而且这是完全免费的!

原发布于https://dev.to/vincentbean/how-i-managed-to-render-10-million-small-images-on-a-webpage-1f1l 在2025年1月12日。

0人推荐
随时随地看视频
慕课网APP