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

打造图像编辑器(一)——基础架构与图像滤镜打造图像编辑器 一 基础架构与图像滤镜 前言 公众号 可乐前端 期待关注交流 分享一些有意思的前端知识 这是一个针对于图像编辑的系列 我会陆陆续续完成包括但不限于 图像滤镜 高级滤镜 图像卷积 图像压缩 水印 Gif 操作 图像格式转换等功能

大家好,我是讯享网,很高兴认识大家。

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

前言

公众号:【可乐前端】,期待关注交流,分享一些有意思的前端知识

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

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

体验地址

Kapture 2024-01-28 at 21.33.02.gif
讯享网

基础架构

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

页面设计

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

image.png

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

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

image.png

代码设计

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

image.png

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

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

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

image.png

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

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

image.png

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

骨架搭建

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

const Home = () => { 
    return ( <Layout> <div className={ 
   styles.container}> <Tools /> <Content /> <FileList /> </div> </Layout> ); }; export default Home; 

讯享网

左侧操作区

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

image.png

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

讯享网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就是一个合适的选择。那么这里就可以实现一个预览组件如下:

 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的所有图像渲染出来做一个预览,对于每一个图像来说还有删除跟下载的逻辑。具体的代码实现如下:

讯享网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,宽高与原图像保持一致

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

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表单,具体代码如下:

讯享网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); 

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

image.png

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

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

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

讯享网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; 

Kapture 2024-01-28 at 23.59.12.gif

保存变更

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

image.png

当触发保存之后:

  • 创建一个离屏canvas(在内存中的canvas
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
讯享网 const { 
    canvas, image } = await loadMemoryCanvas(file); if (action) { 
    if (action.type === ACTION_TYPE.FILTER) { 
    doFilter(canvas, action, image); } } 
  • canvas转成一个File对象
canvas.toBlob((blob) => { 
    const newFile = new File([blob], file.name, { 
    type: file.type, }); newFile.uid = file.uid; resolve(newFile); }); 
  • 替换掉Mobx里面的信息
讯享网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标签进行下载,这里需要注意的是下载完之后要把这个链接销毁,不然会造成内存泄漏。

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; 

最后

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

小讯
上一篇 2025-03-20 22:53
下一篇 2025-03-14 14:33

相关推荐

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容,请联系我们,一经查实,本站将立刻删除。
如需转载请保留出处:https://51itzy.com/kjqy/120409.html