普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月9日首页

同样做缩略图,为什么别人又快又稳?踩过无数坑后,我总结出前端缩略图实战指南

作者 李剑一
2026年4月8日 16:02

最近有个项目需要用到上传图片,然后在列表页回显一下图片。

需求这边还想着要不要做一个瀑布图,但是做好以后图片量太多而且图片太大,导致展示效果并不好。

image.png

尤其是放在首屏上,长时间的白屏。

核心问题是:用户上传的图片过大,不仅导致页面加载缓慢、消耗过多带宽,而且还影响了服务器存储。

所以缩略图就成了最优解。

既不影响视觉展示,又能大幅降低资源消耗。

先明确结论:业界主流做法是什么?

很多人会陷入“非此即彼”的误区,纠结到底该前端还是后端生成缩略图。

但实际上,生产环境中最主流、最稳妥的架构是:前端做预览缩略图 + 后端/云存储做正式缩略图

两者分工配合,兼顾用户体验、性能和安全性。这也是目前大厂主流的实现方案。

简单来说:

  • 前端负责"":用户上传图片后,立即生成缩略图用于页面预览,提升交互体验;

  • 后端/云存储负责""和"生成":存储用户上传的原图,同时生成多尺寸正式缩略图,供页面正式展示。

前端缩略图实现(4种方案附代码)

前端生成缩略图的核心目的是"预览"和"减少上传流量",核心技术依赖 Canvas 绘图缩放createImageBitmap API

方案1:Canvas

这是前端生成缩略图的"标准方案",兼容所有浏览器(包括IE10+),零依赖,无需引入任何第三方库,是生产环境中最常用的方案。

核心原理

读取用户上传的图片文件 → 用Image对象加载图片 → 绘制到Canvas并按比例缩小 → 导出为缩略图Blob/Base64。

完整代码

/**
 * 生成图片缩略图
 * @param {File} file - 用户上传的图片文件(input[type="file"]获取)
 * @param {Number} maxWidth - 缩略图最大宽度(默认300px)
 * @param {Number} maxHeight - 缩略图最大高度(默认300px)
 * @param {Number} quality - 图片质量(0~1,1为最高质量,默认0.8)
 * @returns {Promise<Blob>} 缩略图文件(可直接上传或预览)
 */
async function createThumbnail(file, maxWidth = 300, maxHeight = 300, quality = 0.8) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.src = URL.createObjectURL(file);
    img.onload = () => {
      URL.revokeObjectURL(img.src);
      let { width, height } = img;
      // 如果原图尺寸超过设定的最大尺寸,进行等比缩小
      if (width > maxWidth || height > maxHeight) {
        const ratio = Math.min(maxWidth / width, maxHeight / height);
        width *= ratio;
        height *= ratio;
      }
      const canvas = document.createElement('canvas');
      canvas.width = width;
      canvas.height = height;
      const ctx = canvas.getContext('2d');
      ctx.drawImage(img, 0, 0, width, height);
      canvas.toBlob(
        (blob) => resolve(blob), // 成功回调,返回缩略图Blob
        file.type || 'image/jpeg', // 保持原图格式,无格式则默认jpeg
        quality // 图片质量
      );
    };
    img.onerror = () => reject(new Error('图片加载失败,请检查文件格式'));
  });
}

// 使用
document.querySelector('input[type="file"]').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return; // 未选择文件,直接返回

  try {
    // 生成300x300的缩略图(可根据需求调整尺寸和质量)
    const thumbBlob = await createThumbnail(file, 300, 300, 0.7);
    
    // 场景1:预览缩略图(页面展示)
    const thumbUrl = URL.createObjectURL(thumbBlob);
    document.querySelector('#preview').src = thumbUrl;

    // 场景2:将缩略图上传到服务器(搭配FormData)
    const formData = new FormData();
    // 第三个参数是缩略图文件名,可自定义
    formData.append('thumbnail', thumbBlob, `thumbnail_${Date.now()}.jpg`);
    // 发起上传请求(实际项目中替换为自己的接口地址)
    const response = await fetch('/api/upload/thumbnail', {
      method: 'POST',
      body: formData
    });
    const result = await response.json();
    console.log('缩略图上传成功:', result);
  } catch (error) {
    console.error('缩略图生成/上传失败:', error);
  }
});

方案2:createImageBitmap

如果项目不考虑兼容性问题,那么这个方案比Canvas原生方案更高效。

它支持直接解析File/Blob对象,无需创建Image对象,加载速度更快。

而且还能在Web Worker中使用(避免阻塞主线程),适合处理大尺寸图片。

完整代码

/**
 * 高性能缩略图生成(createImageBitmap方案)
 * @param {File} file - 用户上传的图片文件
 * @param {Number} maxWidth - 缩略图最大宽度(默认300px)
 * @param {Number} maxHeight - 缩略图最大高度(默认300px)
 * @returns {Promise<Blob>} 缩略图Blob文件
 */
async function createThumbnailFast(file, maxWidth = 300, maxHeight = 300) {
  try {
    // 直接解析File对象,生成ImageBitmap(比Image对象更快)
    const bitmap = await createImageBitmap(file);
    
    // 计算等比缩放尺寸(和Canvas方案逻辑一致)
    let { width, height } = bitmap;
    if (width > maxWidth || height > maxHeight) {
      const ratio = Math.min(maxWidth / width, maxHeight / height);
      width *= ratio;
      height *= ratio;
    }

    // 创建Canvas并绘制
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(bitmap, 0, 0, width, height);

    // 释放ImageBitmap内存(优化性能)
    bitmap.close();

    // 导出为Blob
    return new Promise((resolve) => {
      canvas.toBlob(resolve, file.type || 'image/jpeg', 0.8);
    });
  } catch (error) {
    console.error('高性能缩略图生成失败:', error);
    throw error;
  }
}

// 使用方式(和Canvas方案一致,直接替换函数名即可)
document.querySelector('input[type="file"]').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const thumbBlob = await createThumbnailFast(file, 300, 300);
  // 预览/上传逻辑和上面一致,此处省略
});

方案3:browser-image-compression插件

如果懒得手写,可以使用browser-image-compression插件。Github地址: github.com/vitaly-z/br…

这是一个轻量级前端图片压缩库,自动处理图片缩放、压缩、格式转换,零配置即可使用,还能解决图片旋转(Exif orientation)等常见问题。

完整代码

// 安装依赖
// npm install browser-image-compression --save

// 导入
import imageCompression from 'browser-image-compression';

/**
 * 基于第三方库的缩略图生成
 * @param {File} file - 用户上传的图片文件
 * @returns {Promise<Blob>} 缩略图文件
 */
async function createThumbnailWithLib(file) {
  // 配置选项(灵活调整,无需手写逻辑)
  const options = {
    maxSizeMB: 0.1, // 缩略图最大体积(100KB,超过会自动压缩)
    maxWidthOrHeight: 300, // 缩略图最大尺寸(宽/高不超过300px)
    useWebWorker: true, // 使用Web Worker,避免阻塞主线程
    useWebp: true, // 导出为WebP格式(比JPG小30%+,质量无损失)
    initialQuality: 0.8 // 初始压缩质量
  };

  try {
    // 直接调用库方法,自动生成缩略图
    const thumbBlob = await imageCompression(file, options);
    console.log('库生成缩略图成功,大小:', thumbBlob.size);
    return thumbBlob;
  } catch (error) {
    console.error('库生成缩略图失败:', error);
    throw error;
  }
}

// 使用方式(和前面一致)
document.querySelector('input[type="file"]').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const thumbBlob = await createThumbnailWithLib(file);
  // 预览/上传逻辑省略
});

当然,还有个偷懒的方法,直接给图片一个最大宽高,让他看起来像缩略图,不过仍然无法解决加载速度的问题。

后端/云存储方案

前面说过,前端生成的缩略图主要用于"预览",而"正式缩略图"(用于页面正式展示、多端适配),必须由后端或云存储生成——这是生产环境的标准做法,也是大厂通用架构。

为什么不能全靠前端生成正式缩略图?

很多兄弟会疑惑,既然前端能生成缩略图,为什么还要麻烦后端?

核心原因主要有4点:

  1. 可靠性不足:不同浏览器、不同设备(手机/PC)的Canvas渲染效果存在差异,可能导致缩略图模糊、变形,甚至生成失败。

  2. 安全性风险:前端传什么后端存什么,无法验证缩略图的真实性和合法性,可能存在恶意文件上传风险,甚至出现脚本。

  3. 多尺寸需求:一个项目通常需要多种尺寸的缩略图(如列表图300x300、头像图100x100、详情图1080x720),前端不可能生成所有尺寸,且维护成本极高。

  4. 性能与成本:云存储(如阿里云OSS、腾讯云COS、七牛云)的图片处理功能几乎免费,且速度极快,比自己写后端压缩代码更省资源、更稳定。

主流实现方式:云存储自动生成

目前大厂最常用的方式,是将用户上传的原图存储到云存储(如阿里云OSS)。

云存储会自动生成多种尺寸的缩略图,前端只需通过URL参数即可获取对应尺寸的缩略图,无需后端额外开发。

以阿里云OSS为例(实战示例)

  1. 用户上传原图到OSS,获得原图URL:https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg

  2. 前端直接通过URL参数,获取不同尺寸的缩略图(无需后端干预):

  • 300x300缩略图:https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg?x-oss-process=image/resize,w_300,h_300

  • 100x100头像图:https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg?x-oss-process=image/resize,w_100,h_100,m_fixed

  • WebP格式缩略图(更小):https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg?x-oss-process=image/resize,w_300/format,wewebp

其中,x-oss-process=image/resize是OSS的图片缩放参数,还支持裁剪、旋转、加水印等功能,详细参数可参考阿里云OSS官方文档。

如果没有使用云存储,也可以通过后端代码生成缩略图(如Node.js、Java),核心逻辑和前端Canvas类似,都是"读取原图→缩放→保存"。

如果是Node的后端推荐尝试一下sharp库:sharp(originalPath).resize(width, height, {fit: 'cover',position: 'center'}).toFile(thumbnailPath);

总结

个人推荐:

前端负责“预览”,后端/云存储负责“正式生成”,用云存储自动生成多尺寸缩略图(生产首选)。

不过生产状况下有些事需要注意(个人踩过坑的):

  • 大图片上传建议提示用户压缩,同时建议仅支持上传jpg、png、webp等常见格式。
  • 推荐用户优先使用webp格式文件(图片小)。
  • 图片上传过程中的异常处理记得给足提示。
  • 多端适配记得覆盖全,尤其是支持移动端的项目。
昨天以前首页

别再用显性水印!前端零宽隐形水印,实现内容溯源级版权保护,已封装复制即用

作者 李剑一
2026年4月3日 14:05

在前端版权、数据防泄露这种场景,我目前一般都是采用显性水印的方案。

包括目前企业微信这种级别的App也是在用显性水印。

image.png

但是显性水印的问题在于:要么遮挡页面内容、破坏视觉体验,要么极易被PS去除。

零宽字符水印凭借肉眼完全不可见、复制粘贴不丢失的核心优势,完全解决了显性水印的痛点。

实现原理

零宽字符属于Unicode标准内的特殊控制字符,这类字符无视觉渲染、不占用页面宽度、不影响文本排版

image.png

日常浏览、复制时完全无法察觉,但会被浏览器、编辑器、各类平台识别并保留。

  • U+200B(零宽空格):用于指代二进制 0

  • U+200C(零宽不连字):用于指代二进制 1

  • U+FEFF(零宽断行符):作为水印前缀标记,提升解析准确率,避免误识别

基于这种特性,加密就是将用户ID进行二进制转换,插入到文本中。

解密就是在文本中识别出零宽字符,再还原为普通字符。

完整代码

简单封装了一个面向对象封装的零宽水印工具类,复制进生产环境可直接使用,支持文本加密、水印解密两大核心功能。

/**
 * 零宽字符隐形水印工具类
 * 核心功能:文本水印加密、隐形水印解密
 * 适用场景:前端版权保护、内容溯源、防搬运追责
 */
class ZeroWidthWatermark {
  static #ZERO_CHAR = '\u200B';   // 零宽空格 = 二进制0
  static #ONE_CHAR = '\u200C';    // 零宽不连字 = 二进制1
  static #WATERMARK_PREFIX = '\uFEFF'; // 水印前缀校验符

  /**
   * 加密:给文本添加隐形水印
   * @param {string} text - 原始文本内容
   * @param {string} watermark - 水印信息(用户ID、溯源标识等)
   * @returns {string} 带隐形水印的文本
   */
  static encrypt(text, watermark) {
    if (!text || typeof text !== 'string' || !watermark) {
      throw new Error('加密失败:文本与水印内容不可为空');
    }

    try {
      const binaryWatermark = this.#textToBinary(watermark);
      const zeroWidthStr = this.#binaryToZeroWidth(binaryWatermark);
      const fullWatermark = this.#WATERMARK_PREFIX + zeroWidthStr;
      return text[0] + fullWatermark + text.slice(1);
    } catch (error) {
      console.error('零宽水印加密异常:', error);
      return text;
    }
  }

  /**
   * 解密:提取文本中的隐形水印
   * @param {string} encryptedText - 带水印的文本
   * @returns {string} 解析后的水印信息/状态提示
   */
  static decrypt(encryptedText) {
    if (!encryptedText || typeof encryptedText !== 'string') {
      return '无水印';
    }

    try {
      const zeroWidthChars = encryptedText.match(/[\u200B\u200C\uFEFF]/g) || [];
      if (zeroWidthChars.length === 0) return '无水印';

      const prefixIndex = zeroWidthChars.indexOf(this.#WATERMARK_PREFIX);
      if (prefixIndex === -1) return '无水印';
      const validChars = zeroWidthChars.slice(prefixIndex + 1);

      const binaryStr = this.#zeroWidthToBinary(validChars);
      if (!binaryStr || binaryStr.length % 8 !== 0) return '水印格式错误';

      return this.#binaryToText(binaryStr);
    } catch (error) {
      console.error('零宽水印解密异常:', error);
      return '解密失败';
    }
  }

  /** 文本转8位二进制字符串 */
  static #textToBinary(text) {
    return Array.from(text)
      .map(char => char.charCodeAt(0).toString(2).padStart(8, '0'))
      .join('');
  }

  /** 二进制转零宽字符 */
  static #binaryToZeroWidth(binary) {
    return binary.split('').map(bit => bit === '0' ? this.#ZERO_CHAR : this.#ONE_CHAR).join('');
  }

  /** 零宽字符转二进制 */
  static #zeroWidthToBinary(chars) {
    return chars.map(char => char === this.#ZERO_CHAR ? '0' : '1').join('');
  }

  /** 二进制转普通文本 */
  static #binaryToText(binary) {
    return Array.from({ length: binary.length / 8 }, (_, i) => {
      const byte = binary.slice(i * 8, (i + 1) * 8);
      return String.fromCharCode(parseInt(byte, 2));
    }).join('');
  }
}

使用示例

// 加密:添加隐形溯源水印
const originalText = "李剑一原创技术文章,禁止未经授权搬运转载";
const watermarkInfo = "userID:10086|publishTime:20260326|from:李剑一";
const watermarkedText = ZeroWidthWatermark.encrypt(originalText, watermarkInfo);

// 解密:提取溯源信息
const result = ZeroWidthWatermark.decrypt(watermarkedText);
console.log('解析出水印信息:', result);

总结

零宽水印能够无侵入式版权保护,而且在前端层面上实现还是比较简单的。

但是需要明确:零宽水印无法直接阻止爬虫爬取内容,因为爬虫会直接抓取页面文本,连带零宽字符一同获取。

如果需要防爬,可将零宽水印与字体加密、接口签名、行为验证、IP限流等方案结合使用,兼顾防护与溯源。

前端必懂!一文搞懂 WebAssembly:Web/Electron/RN 全通用,你天天用的软件,底层都靠它

作者 李剑一
2026年4月2日 16:15

对于前端开发者而言,WebAssembly(简称 Wasm)或许是一个"熟悉又陌生"的名词。

image.png

偶尔能够在技术文章中看到,却很少在日常开发中用到。

但事实上,它在 ElectronReact Native 等主流跨平台框架中,Wasm 现在已经成为了突破前端性能瓶颈的主要手段。

Wasm 到底是啥?

很多时候大家会把 Wasm 当成一种编程语言,其实这是一个常见误区。

Wasm 不是编程语言,而是一种二进制字节码格式,是 W3C 推荐的第四种 Web 核心技术(与 HTML、CSS、JavaScript 并列)。

image.png

用最直白的话来说:

Wasm 是开发者用 C/C++、Rust、Go 等语言编写高性能代码,再通过编译工具将其编译成 .wasm 二进制文件。

前端开发者无需关心底层实现,只需像调用 npm 包一样,通过Js加载并调用其中的功能。

核心优势很直接,就是"接近原生的性能"。

由于是二进制格式,解析速度比Js快 5-10 倍,运行速度可达原生代码的 70%~90%

而且同时具备安全沙箱(运行在隔离环境,不直接访问系统资源)、跨平台(一次编译,多端通用)、体积小(二进制文件比Js体积小得多)的特点。

这里需要注意:Wasm 不是来替代Js的,而是和Js合作的

  • Js 负责 DOM 操作、UI 交互、网络请求等灵活场景。
  • Wasm 负责计算密集型、CPU 高负载任务(如 3D 渲染、图像处理、加密、大数据计算)。

Wasm 在跨平台框架中使用

Wasm 不是只能在浏览器中运行。

无论是桌面端的 Electron,还是移动端的 React Native,都能完美支持 Wasm,甚至比在浏览器中使用更自由、更灵活。

Wasm 的运行是不依赖具体的浏览器环境,只要有对应的运行时(如 V8 引擎、Wasm3 引擎),就能在任何平台运行。

而主流跨平台框架,早已内置或支持集成 Wasm 运行时。

Electron使用

Electron 的架构是"Chromium + Node.js",而 Chromium 内核本身就原生支持 WebAssembly

image.png

因此在 Electron 中使用 Wasm,和在浏览器中几乎没有区别。

// 加载并调用 Wasm 模块(以加法功能为例)
async function loadWasm() {
  // 1. 加载编译好的 .wasm 文件(和前端资源放在同一目录)
  const res = await fetch("/add.wasm");
  const bytes = await res.arrayBuffer();
  
  // 2. 编译 + 实例化 Wasm 模块
  const { instance } = await WebAssembly.instantiate(bytes);
  
  // 3. 直接调用 Wasm 暴露的方法,和调用 npm 包一致
  const result = instance.exports.add(10, 20);
  console.log("Wasm 计算结果:", result); // 输出 30
}

// 执行调用
loadWasm();

实际上 VS Code、Figma、剪映专业版等主流 Electron 桌面应用,都大量使用 Wasm 处理核心计算逻辑。

比如 Figma 的矢量图形引擎、剪映的视频解码,都是通过 C++ 编译成 Wasm 实现的,既保证了性能,又实现了跨平台兼容。

React Native使用

由于 React Native(RN)本身不依赖浏览器环境,无法直接使用浏览器的 Wasm 运行时。

但可以使用 react-native-webassembly(简洁易用)和 wasm3(轻量引擎)插件就能在 RN 中调用 Wasm 模块。

// 安装依赖
// yarn add react-native-webassembly 或 npx expo install react-native-webassembly

// 配置 metro.config.js
module.exports = {
  resolver: {
    extensions: ['.js', '.jsx', '.ts', '.tsx', '.wasm'], // 新增 .wasm 后缀
  },
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineSourceMap: false,
      },
    }),
  },
};

// 调用 Wasm
import React, { useEffect } from 'react';
import { View, Text } from 'react-native';
import WebAssembly from 'react-native-webassembly';
// 导入本地 .wasm 文件(需放在项目可访问目录)
import addWasm from './add.wasm';

const WasmDemo = () => {
  useEffect(() => {
    // 加载并调用 Wasm
    const runWasm = async () => {
      const { instance } = await WebAssembly.instantiate(addWasm);
      const result = instance.exports.add(20, 30);
      console.log("RN 中 Wasm 计算结果:", result); // 输出 50
    };
    runWasm();
  }, []);

  return (
    <View>
      <Text>React Native + WebAssembly 示例</Text>
    </View>
  );
};

export default WasmDemo;

注意:RN 中使用 Wasm 时,需确保项目支持新架构(部分旧版本 RN 可能存在兼容问题)。

总结

其实可以把 Wasm 理解为一个"不挑平台、不挑框架的超级高性能工具包"。

当你在 Web、Electron、RN 等平台开发时,遇到 JS 无法承载的计算密集型任务(如图像处理、3D 渲染、加密、AI 推理),就可以考虑引入Wasm。

❌
❌