打造图像编辑器(一)——基础架构与图像滤镜

前言

这是一个针对于图像编辑的系列,我会陆陆续续完成包括但不限于:图像滤镜、高级滤镜、图像卷积、图像压缩、水印、Gif操作、图像格式转换等功能。尽量所有的计算都在前端(浏览器)完成,不涉及到服务器计算。

其实很多时候让服务器去操作文件会更简单一些,但我们还是努力不依靠服务器,看看能不能实现一个纯前端的图像编辑器!如果你觉得这样的内容有意思的话,点点关注点点赞吧~

体验地址

基础架构

下面我们先来看整个编辑器的宏观架构,图像编辑器我使用的技术栈是React+Vite+Mobx+antd,这也是自己比较习惯的技术栈,但其实核心并不在这些框架里面,比如今天实现的操作核心是在Canvas,所以这跟你用什么框架关系不大,感兴趣的话可以耐心看下去。

页面设计

整个页面在没有上传图片的时候,只有一个上传框。

在上传完图片之后,会有大致三个核心的区域:

  • 左侧图像操作区域
  • 中间图片预览区域
  • 右侧的缩略图区域

代码设计

依照上面的交互设计,我们就可以来实现页面的组件分层,组件的关系大致如下图:

在划分好组件的职责之后,就要开始更抽象的去划分整一个编辑器的结构。首先整体的交互是:

  • 点击上传图片开始预览
  • 左侧的操作会影响中间区域的图片预览效果
  • 右侧的图片选择区域可以自由选择当前需要编辑和预览的图片
  • 可以下载编辑后的图片

这样看来跨组件的通信会相对来说比较多,所以整个编辑器采用了Mobx作为状态管理工具。

这里的左侧操作区域跟右侧图像列表区域都会对Mobx的数据产生影响,比如说对当前选择的图像应用滤镜效果;更换当前选择的图像等等,在这些数据变更之后,预览区通过监听Mobx的数据变更,来执行相应的UI更新渲染。

那么先来关注一下Mobx里存储了什么东西:

  • FileStore
    • files:上传的图像列表
    • currentFile:当前选中的图像
    • actionMap:图像id对应的操作
      • type:操作类型,比如FILTER滤镜
      • 其他属性

骨架搭建

根据上面的设计,可以先写出如下的架子:

js 复制代码
const Home = () => {
  return (
    <Layout>
      <div className={styles.container}>
        <Tools />
        <Content />
        <FileList />
      </div>
    </Layout>
  );
};

export default Home;

左侧操作区

其中Tools的交互依赖了antdMenu组件,这里我稍微修改了一下菜单组件,把具体的图像操作放在了具体的下拉菜单中:

那Tools就可以分解成一个个具体的操作空间,具体的实现代码如下:

js 复制代码
import { Menu } from "antd";
import styles from "./index.module.less";
import Filter from "./Filter";
import { observer } from "mobx-react-lite";
import useStore from "../../store/RootStore";

const Tools = () => {
  const { fileStore } = useStore();
  const { currentFile } = fileStore;
  const items = [
    {
      key: "0",
      label: "基础滤镜",
      children: [
        {
          key: "0-0",
          label: <Filter />,
        },
      ],
    },
  ];

  if (fileStore.files.length === 0) {
    return;
  }

  return (
    <div className={styles.container}>
      <Menu
        key={currentFile?.uid}
        className={styles.toolMenu}
        mode="inline"
        items={items}
      ></Menu>
    </div>
  );
};

export default observer(Tools);

items数组就是所有的操作集合,具体每一个操作里面的内容则由具体的组件去控制。

中间预览区

中间区域则是图像的预览区域,我们是需要实现各种各样的图像效果,使用img标签来渲染显然是不太合理的,而canvas就是一个合适的选择。那么这里就可以实现一个预览组件如下:

js 复制代码
  const init = (file) => {
    const reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onload = (readerEvent) => {
      const url = readerEvent.target.result;
      const image = new Image();
      image.src = url;
      dataUrl.current = url;
      image.onload = () => {
        const canvas = displayCanvas.current;
        const context = canvas.getContext("2d");
        const originalWidth = image.width;
        const originalHeight = image.height;
        const defaultWidth =
          container.current.getBoundingClientRect().width * 0.8;
        canvas.width = originalWidth;
        canvas.height = originalHeight;
        context.clearRect(0, 0, canvas.width, canvas.height);
        context.drawImage(image, 0, 0, canvas.width, canvas.height);
      };
  };
  useEffect(() => {
    init(file);
  }, [file]);

解释一下上面的代码:

  • file是一个File对象,就是我们通过Upload组件上传文件获取到的内容
  • 读出file的内容,并创建一个Image对象去加载
  • 将加载好的图像绘制到canvas

右侧图像列表

右侧的图像列表就是获取Mobx的所有图像渲染出来做一个预览,对于每一个图像来说还有删除跟下载的逻辑。具体的代码实现如下:

js 复制代码
import { observer } from "mobx-react-lite";
import useStore from "../../store/RootStore";
import styles from "./index.module.less";
import { DownloadOutlined, DeleteOutlined } from "@ant-design/icons";

import React, { useEffect, useRef, useState } from "react";
import Upload from "../Upload";
import { toJS } from "mobx";
import download from "../../actions/download";

const FileList = () => {
  const { fileStore } = useStore();
  const { files, currentFile, actionMap } = fileStore;

  const [imageUrls, setImageUrls] = useState([]);
  const urlRef = useRef([]);
  useEffect(() => {
    urlRef.current.forEach((url) => URL.revokeObjectURL(url));
    const urls = toJS(files).map((file) => URL.createObjectURL(file));
    urlRef.current = urls;
    setImageUrls(urls);
  }, [files]);
  if (files.length === 0) {
    return;
  }

  return (
    <div className={styles.container}>
      <div className={styles.scrollWrapper}>
        {imageUrls.map((url, index) => (
          <div
            key={url}
            onClick={() => fileStore.setCurrentFile(files[index])}
            className={`${styles.imgContainer} ${
              files?.[index]?.uid === currentFile?.uid
                ? styles.imgContainerSelected
                : ""
            }`}
          >
            <img
              className={styles.img}
              key={index}
              src={url}
              alt={`Image ${index}`}
            />
            <div className={styles.actions}>
              <DownloadOutlined
                onClick={() => {
                  const file = files[index];
                  download(files[index], actionMap[file.uid]);
                }}
              />
              <DeleteOutlined
                onClick={() => {
                  const file = files[index];
                  fileStore.deleteFile(file.uid);
                }}
              />
            </div>
          </div>
        ))}
      </div>
      <div className={styles.upload}>
        <Upload inline />
      </div>
    </div>
  );
};

export default observer(FileList);

离屏Canvas

在搭建好上面的架子之后,我们来思考一个问题。如果我有一张2000*2000像素的图片,按照上面的代码来预览,在预览区域中,我们的canvas大小是多少呢?是的,也是2000*2000,因为我们使用了图片的原始宽度跟原始高度作为canvas的宽高。那其实这样是不太合理的,因为整一个预览区域的宽度是有限的,我们必须对画布进行一些缩放。

此时如果我创建一个500*500的画布,然后将这张图片绘制到这个画布上会有什么问题吗?就预览来说,是没有问题的,宽高比也一样,看起来可能会稍微模糊一点,但问题不大。但是当我们重新再把这张图片下载下来的时候,会发现图片的像素变低了,其实我们无意中就做了一个有损的图片压缩操作。

那我们既想预览图片的时候以一个合理的宽高去预览,又不想导出的时候影响图像的质量,这里就需要引入一个离屏canvas

离屏 Canvas 指的是在浏览器中创建一个不直接显示在页面上的 Canvas 元素。这种 Canvas 元素通常用于进行一些图形计算、绘制或处理,而无需在用户界面中显示。离屏 Canvas 提供了一种在不干扰用户界面的情况下进行图形操作的方式。

也就是说我们的预览区域会有两个canvas

  • displayCanvas:显示在界面上的canvas,宽高按一定比例缩放
  • memoryCanvas:在内存的canvas,宽高与原图像保持一致

搞清楚这一点之后,我们可以重新写一下绘制的初始化代码:

js 复制代码
import { useEffect, useRef, useState } from "react";
import styles from "./index.module.less";
import useFilter from "../../hooks/useFilter";
import { observer } from "mobx-react-lite";
import useStore from "../../store/RootStore";

const Preview = ({ file }) => {
  const memoryCanvas = useRef(null);
  const displayCanvas = useRef(null);
  const container = useRef(null);
  const { fileStore } = useStore();
  const currentImg = useRef(null);
  const init = (file) => {
    const reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onload = (readerEvent) => {
      const url = readerEvent.target.result;
      const image = new Image();
      image.src = url;
      image.onload = () => {
        initMemoryCanvas();
        initDisplayCanvas();
        currentImg.current = image;
      };
      const initMemoryCanvas = () => {
        const originalWidth = image.width;
        const originalHeight = image.height;
        const canvas = document.createElement("canvas");
        memoryCanvas.current = canvas;
        const context = canvas.getContext("2d");
        canvas.width = originalWidth;
        canvas.height = originalHeight;
        context.clearRect(0, 0, canvas.width, canvas.height);
        context.drawImage(image, 0, 0, originalWidth, originalHeight);
      };

      const initDisplayCanvas = () => {
        const canvas = displayCanvas.current;
        const context = canvas.getContext("2d");
        const originalWidth = image.width;
        const originalHeight = image.height;
        const defaultWidth =
          container.current.getBoundingClientRect().width * 0.8;
        canvas.width = Math.min(defaultWidth, originalWidth);
        canvas.height = Math.min(
          defaultWidth * Number((originalWidth / originalHeight).toFixed(2)),
          originalHeight
        );
        context.clearRect(0, 0, canvas.width, canvas.height);
        context.drawImage(image, 0, 0, canvas.width, canvas.height);
      };

      initMemoryCanvas();
      initDisplayCanvas();
    };
  };
  useEffect(() => {    
      init(file);
  }, [file]);

  return (
    <div className={styles.container} ref={container}>
      <canvas ref={displayCanvas}></canvas>
    </div>
  );
};

export default observer(Preview);

滤镜

今天我们介绍的canvas滤镜有如下几种:

  • 灰度(grayscale):
    • 描述:将图像转为灰度。
    • 取值范围:0(原始颜色)到 100(完全灰度)。
    • 默认值:0。
  • 模糊(blur):
    • 描述:使图像模糊。
    • 取值范围:0(无模糊)以上的正值,表示模糊程度。
    • 默认值:0。
  • 色相旋转(hue-rotate):
    • 描述:按照一定的角度旋转图像的色相。
    • 取值范围:0deg(原始颜色)到 360deg(完整的颜色轮旋转)。
    • 默认值:0。
  • 对比度(contrast):
    • 描述:调整图像的对比度。
    • 取值范围:0(完全灰度)到 200(最大对比度)。
    • 默认值:100。
  • 反转颜色(invert):
    • 描述:反转图像的颜色。
    • 取值范围:0(原始颜色)到 100(完全反转)。
    • 默认值:0。
  • 饱和度(saturate):
    • 描述:调整图像的饱和度。
    • 取值范围:0%(完全灰度)以上的正值,表示饱和度的倍数。
    • 默认值:100。
  • 亮度(brightness):
    • 描述:调整图像的亮度。
    • 取值范围:0%(完全黑暗)以上的正值,表示亮度的倍数。
    • 默认值:100。

滤镜的UI实现是一个Form表单,具体代码如下:

js 复制代码
import { Button, Form, Slider } from "antd";
import { observer } from "mobx-react-lite";
import useStore from "../../../store/RootStore";
import { isEmpty } from "lodash";
import { ACTION_TYPE } from "../../../utils/constants";
import { toJS } from "mobx";
const DEFAULT_VALUE = {
  grayscale: 0,
  blur: 0,
  "hue-rotate": 0,
  contrast: 100,
  invert: 0,
  saturate: 100,
  brightness: 100,
};
const Filter = () => {
  const [form] = Form.useForm();
  const { fileStore } = useStore();
  const { currentFile, updateActionMap, actionMap, updateFile } = fileStore;
  const handleValueChange = (_, values) => {
    if (currentFile?.uid) {
      updateActionMap(currentFile.uid, { ...values, type: ACTION_TYPE.FILTER });
    }
  };
  const filter = actionMap?.[currentFile?.uid] || {};
  return (
    <div>
      <Form
        initialValues={!isEmpty(toJS(filter)) ? filter : DEFAULT_VALUE}
        onValuesChange={handleValueChange}
        form={form}
      >
        <Form.Item name="grayscale" label="灰度">
          <Slider min={0} max={100} />
        </Form.Item>
        <Form.Item name="blur" label="模糊">
          <Slider min={0} max={100} />
        </Form.Item>
        <Form.Item name="contrast" label="对比度">
          <Slider min={0} max={200} />
        </Form.Item>
        <Form.Item name="hue-rotate" label="色相旋转">
          <Slider min={0} max={360} />
        </Form.Item>
        <Form.Item name="invert" label="反转颜色">
          <Slider min={0} max={100} />
        </Form.Item>
        <Form.Item name="saturate" label="饱和度">
          <Slider min={0} max={200} />
        </Form.Item>
        <Form.Item name="brightness" label="亮度">
          <Slider min={0} max={200} />
        </Form.Item>
      </Form>
    </div>
  );
};

export default observer(Filter);

在调整了各个滤镜参数的时候,预览区的效果应该即时变更,整个流程走向大致可以用下面的图来概括:

Preview组件中使用一个hook来处理数据的变更:

js 复制代码
  useFilter({
    displayCanvas,
    memoryCanvas,
    currentImg: currentImg.current,
    filters: fileStore.actionMap[file.uid] || {},
  });

这边注意任何数据的变更我们都需要同时对两个canvas进行操作,才能保证后续的功能无误。Hook中会调用具体的DoAction操作,这个useFilter对应的就是doFilter,在这个doFilter中就是真正对canvas应用滤镜效果。

js 复制代码
const doFilter = (canvas, filters, img) => {
  const context = canvas.getContext("2d");
  const transfer = [];
  Object.keys(filters).forEach((key) => {
    if (
      ["grayscale", "invert", "saturate", "brightness", "contrast"].includes(
        key
      )
    ) {
      transfer.push(`${key}(${filters[key]}%)`);
    } else if (key === "blur") {
      transfer.push(`${key}(${filters[key]}px)`);
    } else if (key === "hue-rotate") {
      transfer.push(`${key}(${filters[key]}deg)`);
    }
  });
  context.clearRect(0, 0, canvas.width, canvas.height);
  context.filter = transfer.join(" ");
  context.drawImage(img, 0, 0, canvas.width, canvas.height);
};

export default doFilter;

保存变更

调整好自己想要的参数之后就可以把这个变更保存下来,这里的实现逻辑其实就是把上面抽象好的方法拼凑起来。

当触发保存之后:

  • 创建一个离屏canvas(在内存中的canvas
js 复制代码
const loadMemoryCanvas = (file) => {
return new Promise((resolve) => {
  const reader = new FileReader();
  reader.readAsDataURL(file);
  reader.onload = (readerEvent) => {
    const url = readerEvent.target.result;
    const image = new Image();
    image.src = url;
    image.onload = () => {
      const originalWidth = image.width;
      const originalHeight = image.height;
      const canvas = document.createElement("canvas");
      const context = canvas.getContext("2d");
      canvas.width = originalWidth;
      canvas.height = originalHeight;
      context.clearRect(0, 0, canvas.width, canvas.height);
      context.drawImage(image, 0, 0, originalWidth, originalHeight);
      resolve({
        canvas,
        image,
      });
    };
  };
});
};

export default loadMemoryCanvas;
  • 把操作应用到这个canvas
js 复制代码
  const { canvas, image } = await loadMemoryCanvas(file);
  if (action) {
    if (action.type === ACTION_TYPE.FILTER) {
      doFilter(canvas, action, image);
    }
  }
  • canvas转成一个File对象
js 复制代码
canvas.toBlob((blob) => {
   const newFile = new File([blob], file.name, {
     type: file.type,
   });
   newFile.uid = file.uid;
   resolve(newFile);
 });
  • 替换掉Mobx里面的信息
js 复制代码
updateFile = async (uid) => {
  const file = this.files.find((file) => file.uid === uid);
  const newFile = await applyAction(file, this.actionMap[uid]);
  runInAction(() => {
    if (uid === newFile.uid) {
      this.currentFile = newFile;
    }
    const list = toJS(this.files);
    const index = list.findIndex((file) => file.uid === uid);
    list[index] = newFile;
    this.files = list;
    this.actionMap[uid] = {};
  });
};

下载

下载的时候使用URL.createObjectURLFile对象转成一个链接,然后使用a标签进行下载,这里需要注意的是下载完之后要把这个链接销毁,不然会造成内存泄漏。

js 复制代码
import moment from "moment";
const getFileExtension = (fileName) => {
  return fileName.slice(((fileName.lastIndexOf(".") - 1) >>> 0) + 2);
};
const generateName = () => {
  return moment().format("YYYYMMDDHHmmss");
};
const download = async (file) => {
  const downloadLink = document.createElement("a");
  downloadLink.href = URL.createObjectURL(file);
  downloadLink.download = `${generateName()}.${getFileExtension(file.name)}`;
  downloadLink.click();
  URL.revokeObjectURL(downloadLink.href);
};

export default download;

最后

本文到这里就结束了,但是我们的图像编辑器之旅才刚刚开始,后续我会介绍更多对图像的操作,感兴趣的同学可以点点关注点点赞~欢迎评论区或者私信交流~

相关推荐
开心不就得了1 小时前
自定义脚手架
前端·javascript
没事多睡觉6662 小时前
Vue 虚拟列表实现方案详解:三种方法的完整对比与实践
前端·javascript·vue.js
excel3 小时前
Vue3 EffectScope 源码解析与理解
前端·javascript·面试
细节控菜鸡3 小时前
【2025最新】ArcGIS for JS 实现地图卷帘效果
开发语言·javascript·arcgis
ObjectX前端实验室5 小时前
【react18原理探究实践】React Effect List 构建与 Commit 阶段详解
前端·react.js
心.c6 小时前
一套完整的前端“白屏”问题分析与解决方案(性能优化)
前端·javascript·性能优化·html
俺会hello我的6 小时前
舒尔特方格开源
前端·javascript·开源
lbh6 小时前
Chrome DevTools 详解(二):Console 面板
前端·javascript·浏览器
ObjectX前端实验室6 小时前
【react18原理探究实践】更新阶段 Render 与 Diff 算法详解
前端·react.js
wxr06167 小时前
部署Spring Boot项目+mysql并允许前端本地访问的步骤
前端·javascript·vue.js·阿里云·vue3·springboot