阅读视图

发现新文章,点击刷新页面。

React基础框架搭建10-webpack配置:react+router+redux+axios+Tailwind+webpack

webpack配置

npm install --save-dev webpack webpack-cli webpack-dev-server
npm install --save-dev babel-loader @babel/core @babel/preset-env @babel/preset-react
npm install --save-dev html-webpack-plugin clean-webpack-plugin
npm install --save-dev css-loader style-loader
npm install --save-dev file-loader url-loader
npm install --save-dev mini-css-extract-plugin
npm install --save-dev dotenv-webpack

在根目录创建webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const Dotenv = require('dotenv-webpack');

module.exports = {
    mode: 'development', // 开发模式
    entry: './src/index.js', // 入口文件
    output: {
        path: path.resolve(__dirname, 'dist'), // 输出目录
        filename: 'bundle.js', // 输出文件名
        publicPath: '/', // 公共路径
    },
    resolve: {
        extensions: ['.js', '.jsx'], // 解析的文件扩展名
        alias: {
            '@': path.resolve(__dirname, 'src'), // 设置路径别名
        },
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/, // 处理 JavaScript 和 JSX 文件
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env', '@babel/preset-react'], // Babel 配置
                    },
                },
            },
            {
                test: /\.css$/, // 处理 CSS 文件
                use: ['style-loader', 'css-loader'],
            },
            {
                test: /\.(png|jpg|gif|svg)$/, // 处理图片文件
                use: [
                    {
                        loader: 'file-loader',
                        options: {
                            name: '[path][name].[ext]', // 保持原有路径和文件名
                        },
                    },
                ],
            },
        ],
    },
    devServer: {
        static: {
            directory: path.join(__dirname, 'dist'), // 更新为 static
        },
        compress: true, // 启用 gzip 压缩
        port: 3000, // 端口号
        historyApiFallback: true, // 支持 HTML5 History API
    },
    plugins: [
        new CleanWebpackPlugin(), // 清理输出目录
        new HtmlWebpackPlugin({
            template: './public/index.html', // HTML 模板
            filename: 'index.html', // 输出的 HTML 文件名
        }),
    ],
};

在 package.json 中添加 Webpack 的构建和开发脚本:

"scripts": {
    "start": "webpack serve --open", // 启动开发服务器
    "build": "webpack --mode production" // 构建生产版本
}

Vue 3 中 v-for 动态组件 ref 收集失败问题排查与解决

Vue 3 中 v-for 动态组件 ref 收集失败问题排查与解决

问题描述

在开发部门管理页面的搜索栏功能时,遇到了一个奇怪的问题:在 v-for 循环中渲染的动态组件,无法正确收集到 ref 数组中。

image.png

问题现象

// schema-search-bar.vue
const searchComList = ref([]);

const getValue = () => {
  let dtoObj = {};
  console.log("searchComList", searchComList.value); // 输出: Proxy(Array) {}
  searchComList.value.forEach((component) => {
    dtoObj = { ...dtoObj, ...component.getValue() };
  });
  return dtoObj; // 返回: {}
};

现象:

  • searchComList.value 始终是空数组 []
  • 无法获取到任何子组件的实例
  • 导致搜索功能无法正常工作

代码结构

<template>
  <el-form v-if="schema && schema.properties" :inline="true">
    <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
      <!-- 动态组件 -->
      <component 
        :ref="searchComList"  <!-- ❌ 问题所在 -->
        :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
        :schemaKey="key"
        :schema="schemaItem">
      </component>
    </el-form-item>
  </el-form>
</template>

<script setup>
const searchComList = ref([]);
</script>

排查过程

1. 初步怀疑:打印时机问题

最初怀疑是打印时机不对,组件还没有挂载完成。但即使使用 nextTick 或在 onMounted 中打印,searchComList.value 仍然是空数组。

2. 对比其他正常工作的代码

在同一个项目中,发现 schema-view.vue 中类似的代码却能正常工作:

<!-- schema-view.vue - ✅ 正常工作 -->
<component 
  v-for="(item, key) in components" 
  :key="key" 
  :is="ComponentConfig[key]?.component" 
  ref="comListRef"  <!-- ✅ 使用字符串形式 -->
  @command="onComponentCommand">
</component>

<script setup>
const comListRef = ref([]);
// comListRef.value 能正确收集到所有组件实例
</script>

3. 发现关键差异

对比两个文件的代码,发现了关键差异:

文件 ref 写法 结果
schema-view.vue ref="comListRef" (字符串) ✅ 正常工作
schema-search-bar.vue :ref="searchComList" (绑定对象) ❌ 无法收集

根本原因

Vue 3 中 v-for 使用 ref 的机制

在 Vue 3 中,v-for 中使用 ref 时,两种写法的行为完全不同

1. 字符串形式的 ref(自动收集到数组)
<component v-for="item in list" ref="comListRef" />

行为:

  • Vue 会自动将 ref 的值设置为一个数组
  • 数组中的元素按顺序对应 v-for 中的每一项
  • 这是 Vue 3 的特殊处理机制
2. 绑定 ref 对象(不会自动收集)
<component v-for="item in list" :ref="comListRef" />

行为:

  • :ref 绑定的是一个 ref 对象,Vue 会直接赋值
  • v-for 中,不会自动收集到数组
  • 每次循环都会覆盖上一次的值
  • 最终只会保留最后一个组件的引用

官方文档说明

根据 Vue 3 官方文档:

当在 v-for 中使用 ref 时,ref 的值将是一个数组,包含所有循环项对应的组件实例。

关键点: 这个特性只适用于字符串形式的 ref,不适用于 :ref 绑定。

解决方案

方案一:使用字符串形式的 ref(推荐)

<template>
  <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
    <component 
      ref="searchComList"  <!-- ✅ 去掉冒号,使用字符串形式 -->
      :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
      :schemaKey="key"
      :schema="schemaItem">
    </component>
  </el-form-item>
</template>

<script setup>
const searchComList = ref([]);
// 现在 searchComList.value 会自动收集到所有组件实例
</script>

方案二:使用函数形式的 ref(更灵活)

如果需要更精细的控制(比如去重、按 key 索引等),可以使用函数形式:

<template>
  <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
    <component 
      :ref="(el) => handleRef(el, key)"  <!-- ✅ 函数形式 -->
      :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
      :schemaKey="key"
      :schema="schemaItem">
    </component>
  </el-form-item>
</template>

<script setup>
const searchComList = ref([]);
const componentMap = new Map();

const handleRef = (el, key) => {
  if (el) {
    // 如果已经存在,先移除旧的(避免重复)
    if (componentMap.has(key)) {
      const oldIndex = searchComList.value.indexOf(componentMap.get(key));
      if (oldIndex > -1) {
        searchComList.value.splice(oldIndex, 1);
      }
    }
    // 添加新的组件实例
    componentMap.set(key, el);
    searchComList.value.push(el);
  } else {
    // 组件卸载时,从 Map 和数组中移除
    if (componentMap.has(key)) {
      const oldEl = componentMap.get(key);
      const index = searchComList.value.indexOf(oldEl);
      if (index > -1) {
        searchComList.value.splice(index, 1);
      }
      componentMap.delete(key);
    }
  }
};
</script>

技术要点总结

1. Vue 3 ref 在 v-for 中的行为

写法 在 v-for 中的行为 适用场景
ref="xxx" 自动收集到数组 ✅ 推荐,简单场景
:ref="xxx" 不会自动收集,会覆盖 ❌ 不适用于 v-for
:ref="(el) => fn(el)" 手动控制收集逻辑 ✅ 需要精细控制时

2. 最佳实践

  1. 在 v-for 中使用 ref 时,优先使用字符串形式

    <component v-for="item in list" ref="comListRef" />
    
  2. 如果需要按 key 索引或去重,使用函数形式

    <component v-for="(item, key) in list" :ref="(el) => handleRef(el, key)" />
    
  3. 避免在 v-for 中使用 :ref="refObject"

    <!-- ❌ 不推荐 -->
    <component v-for="item in list" :ref="comListRef" />
    

3. 调试技巧

当遇到 ref 收集问题时,可以:

  1. 检查 ref 的写法:确认是字符串还是绑定对象
  2. 使用 nextTick 延迟检查:确保组件已挂载
  3. 对比正常工作的代码:找出差异点
  4. 查看 Vue DevTools:检查组件实例是否正确创建

相关资源

总结

这个问题看似简单,但实际上涉及到 Vue 3 中 refv-for 中的特殊处理机制。关键点在于:

  1. 字符串形式的 ref 在 v-for 中会自动收集到数组
  2. 绑定形式的 :ref 在 v-for 中不会自动收集
  3. 函数形式的 :ref 可以手动控制收集逻辑

记住这个规则,可以避免很多类似的坑。在开发过程中,如果遇到 ref 收集问题,首先检查是否在 v-for 中使用了错误的 ref 写法。

迈向开源第一步,给fabric.js提PR

大家好,我是CC,在这里欢迎大家的到来~

开场

今天这篇文章主要是想分享一下给 fabric.js 从提 issues 到后边合并 PR 的过程。之前在公司业务中有了一些对 fabric.js 源代码的阅读、调试以及修改,就想着看看能不能给开源做些贡献;在测试了 fabric.js 最新版本也没有修复问题的情况下就开始尝试提 issues。

提 issues

新开 issues 按照规范填写发现的 Bug 的相关内容(可以参考别人 issues 的书写),像发现 Bug 的版本、环境、复现步骤、期望的结果和实际的结果等,需如实填写。

后续仓库主理人就会在这个 issues 下进行沟通,在这里也可以表明你正在尝试修复此问题,主理人也会告诉后续合并到哪些分支。

阅读开源库的文档

在开发前需要先阅读代码库文档,了解如何规范提问,如何提交 issues,如何参与问题的修复等等。这一步的需要详细阅读。

像 fabric.js 的贡献流程文档,从内容中可以看到它详细罗列一步步的操作以及鼓励参与,真的非常友好了。

Fork 代码开发

开发前需要 Fork 一份代码到个人的仓库中,如果之前 Fork 过导致代码长时间未更新的话需要点击“Update branch”拉去最新代码。

接下来把代码克隆到本地,基于 master 新建分支(分支名称可以参考原仓库其他人的分支命名)。

跑通测试用例

测试用例之前也没写过,这里仓库主理人也给了一部分帮助和建议。

当时写了个 unit 测试用例,然后需要执行 package.json 中的测试脚本,确保测试用例通过后再提交。

describe('measuring, splitting', () => {
  it('measuring a single char', () => {
    cache.clearFontCache();
    const text = new FabricText('');
    const style = text.getCompleteStyleDeclaration(0, 0);
    const measurement1 = text._measureChar('a', style, '', style);
    const measurement2 = text._measureChar('a', style, '', style);
    expect(measurement1).toEqual(measurement2);
    const cacheKeys = cache.charWidthsCache
      .get(text.fontFamily.toLowerCase())
      ?.get('normal_normal')
      ?.keys();
    expect(cacheKeys?.next().value).not.toBe('undefineda');

提 PR

把功能分支通过 Github 平台的 Pull requests 模块进行提交,从图中可以看到是从个人仓库的 Fork 仓库的功能分支合并到原仓库的 master 分支进行的提交合并。这里也注意填写标题和描述。

等待与回复

在仓库主理人阅读了 Pull requests 后会进行评审,看是否缺少测试用例以及提审代码是否符合规范等等。这里需要多耐心沟通,可能主理人和我们有着时差。

耐心等待

在有主理人 review 代码后只需要耐心等待,在下一次版本更像时就会被合并到 master 以及新版本。

总结

整个流程走下来,我最深的体会是:开源社区远比想象中更友好。刚开始提 issue 时难免忐忑,担心问题太简单或表达不清晰,但实际上,维护者往往非常耐心,甚至主动引导你如何修复。从阅读贡献规范、跑通测试,到接受代码审查,每一步都是宝贵的学习。

不从零开始构建专属 SVG 编辑器的实战指南

在Web开发领域,重复造轮子并非明智之举。当我们需要定制一个 SVG 编辑器时,与其完全从零开始,不如基于成熟的基础进行构建。

@svgedit/svgcanvas 简介

@svgedit/svgcanvas 是开源项目 SVGEdit 的核心组件,提供底层的 SVG 操作能力。将界面和交互逻辑交给开发者自定义,使我们可快速构建自己的编辑器,同时保持高度可定制性

  • 优点: 经过了超过十年的发展和实际项目的考验,提供了一个功能高度完备的起点

实现了从基本的图形绘制(如矩形、圆形、路径)到复杂的编辑操作(如图层管理、历史记录)等一套完整的 SVG 画布操作能力。

这意味着无需从零开始实现这些复杂逻辑,可以专注于定制编辑器的界面、交互和特定业务功能,从而显著提升开发效率

  • 缺点: 缺乏 TypeScript 支持和详细的 API 文档。

没有 ts 就失去了类型检查、代码智能提示、以及编译时错误检测,开发体验会受到影响

缺乏详细 API 文档,有时需要直接阅读源码,虽然一开始可能有难度,但这是理解库的核心工作机制和最可靠的途径。

快速搭建

为了避免 new SvgCanvas 后得到的 canvas 实例在组件间传递的复杂性,通常建议使用状态管理库将其提升为全局状态。

但出于简化示例的目的,本文将暂不引入相关库,而是聚焦于核心逻辑。

import React, { useEffect, useRef, useState } from 'react';
import SvgCanvas from '@svgedit/svgcanvas';

//默认配置
const config = {
  canvas_expansion: 0,
  initFill: {
    color: 'fff',
    opacity: 1,
  },
  initStroke: {
    width: 5,
    color: '000000',
    opacity: 1,
  },
  text: {
    stroke_width: 0,
    font_size: 12,
  },
  gridSnapping: false,
  baseUnit: 'px',
};

const App: React.FC = () => {
  const svgcanvasRef = useRef(null);
  const textRef = useRef(null);

  const [canvas, setCanvas] = useState(null);

  function updateCanvas(canvas: SvgCanvas, editorDom: HTMLDivElement) {
    const workarea = editorDom.parentNode as HTMLElement;
    let { width, height } = workarea.getBoundingClientRect();
    editorDom.style.width = `${width}px`;
    editorDom.style.height = `${height}px`;
    canvas.updateCanvas(width, height);
  }
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    if (editorDom) {
      // 创建 SvgCanvas 实例
      const canvas = new SvgCanvas(editorDom, config);
      setCanvas(canvas);
      updateCanvas(canvas, editorDom);
      if (textRef.current) {
        // 设置文本输入框元素
        canvas.textActions.setInputElem(textRef.current);
      }
    }
  },[]);

  return (
    <>
      <div>
        {['select', 'ellipse', 'rect', 'path', 'line', 'text'].map((item) => (
           canvas?.setMode(item)}
          >
            {item}
          
        ))}
      </div>
      <div>
        <div/>
      </div>
       {
          canvas?.setTextContent((e.target as HTMLInputElement).value);
        }}
        onFocus={() => {
          const selectedElement = canvas?.getSelectedElements()[0];
          if (textRef.current && selectedElement) {
            textRef.current.value = selectedElement.textContent;
          }
        }}
        onBlur={() => {
          if (textRef.current) textRef.current.value = '';
        }}
        style={{ position: 'absolute', left: '-9999px' }}
      />
    
  );
};

export default App;

功能实现

下面提供一些你可能需要的功能实现。

绘制网格背景

源码中的 setBackgroundMethod 方法(位于 elem-get-set.js 文件内)用于设置编辑器背景,但功能较为基础,仅支持配置填充色和背景图片。

为保持结构不变,在生成网格后,将 #canvasBackground元素内的 ``进行替换。

import SvgCanvas from '@svgedit/svgcanvas';

function useGridBg(props: { gridSize: number }) {
  const { gridSize } = props;

  function drawGridBg(canvas: SvgCanvas | null) {
    const editorDom = canvas?.getSvgRoot().parentNode as HTMLElement;

    if (!editorDom || !canvas) return;

    const zoom = canvas.getZoom();
    const scaledGridSize = gridSize * zoom;
    const gridSvg = document.createElementNS(
      'http://www.w3.org/2000/svg',
      'svg',
    );
    gridSvg.setAttribute('width', '100%');
    gridSvg.setAttribute('height', '100%');
    gridSvg.setAttribute('overflow', 'visible');
    gridSvg.setAttribute('pointer-events', 'none');
    // 获取画布的位移
    const viewBox = canvas.getSvgRoot().getAttribute('viewBox')?.split(' ');
    const translateX = viewBox ? parseFloat(viewBox[0]) : 0,
      translateY = viewBox ? parseFloat(viewBox[1]) : 0;
    const bg = document.getElementById('canvasBackground');

    if (bg) {
      const visibleWidth = Number(editorDom.style.width.replace('px', '')),
        visibleHeight = Number(editorDom.style.height.replace('px', ''));
      const bgWidth = Number(bg.getAttribute('width')?.replace('px', '')),
        bgHeight = Number(bg.getAttribute('height')?.replace('px', ''));
      const width = Math.max(visibleWidth, bgWidth),
        height = Math.max(visibleHeight, bgHeight);

      // 计算可视区域的边界
      const redundancy = Math.ceil(width / 2 / scaledGridSize) * scaledGridSize;

      const startX =
        -redundancy + Math.floor(translateX / scaledGridSize) * scaledGridSize;
      const startY =
        -redundancy + Math.floor(translateY / scaledGridSize) * scaledGridSize;
      const endX = width + redundancy + translateX;
      const endY = height + redundancy + translateY;
      // 绘制横线
      for (let y = startY; y < endY; y += scaledGridSize) {
        const line = document.createElementNS(
          'http://www.w3.org/2000/svg',
          'line',
        );
        line.setAttribute('x1', startX.toString());
        line.setAttribute('y1', y.toString());
        line.setAttribute('x2', endX.toString());
        line.setAttribute('y2', y.toString());
        line.setAttribute('stroke', '#e2deded5');
        line.setAttribute('stroke-width', '1');
        gridSvg.appendChild(line);
      }

      // 绘制竖线
      for (let x = startX; x < endX; x += scaledGridSize) {
        const line = document.createElementNS(
          'http://www.w3.org/2000/svg',
          'line',
        );
        line.setAttribute('x1', x.toString());
        line.setAttribute('y1', startY.toString());
        line.setAttribute('x2', x.toString());
        line.setAttribute('y2', endY.toString());
        line.setAttribute('stroke', '#e2deded5');
        line.setAttribute('stroke-width', '1');
        gridSvg.appendChild(line);
      }

      bg.innerHTML = '';
      bg.appendChild(gridSvg);
    }
  }

  return { drawGridBg };
}
export default useGridBg;
:global {
    #canvasBackground {
      overflow: visible;
    }
  }

new SvgCanvas 时,就绘制背景。

const App: React.FC = () => {
  /**......省略部分代码 */
  

  /** 这部分是新增的代码*/
  const { drawGridBg } = useGridBg({ gridSize: 20 });
  /**————————————*/
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    if (editorDom) {
      const canvas = new SvgCanvas(editorDom, config);
      setCanvas(canvas);
      updateCanvas(canvas, editorDom);
      /** 这部分是新增的代码 */
      // 背景网格绘制
      drawGridBg(canvas);
      /**————————————*/
      if (textRef.current) {
        canvas.textActions.setInputElem(textRef.current);
      }
    }
  }, []);
  
  /**......省略部分代码 */
};

按住鼠标滚轮拖拽画布

拖拽功能!这么实用的功能,是的,没有 API。

我是通过更改 viewBox 来实现的。

const App: React.FC = () => {
  /**......省略部分代码 */
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    let isDragging = false;
    // 存储鼠标按下时的坐标和画布初始位置
    let startX: number = 0,
      startY: number = 0,
      initialTranslateX: number = 0,
      initialTranslateY: number = 0;

    function handleMouseDown(e: MouseEvent) {
      // 仅当按下 鼠标滚轮 时开始拖拽
      if (e.button === 1) {
        const selectGroup = document.getElementById('selectorParentGroup');
        if (selectGroup) {
          selectGroup.style.display = 'none';
        }
        document.body.style.cursor = 'grab';
        canvas?.clearSelection();
        e.preventDefault();
        isDragging = true;
        startX = e.clientX;
        startY = e.clientY;

        //获取当前画布移动的距离
        [initialTranslateX, initialTranslateX] = canvas
          ?.getSvgRoot()
          .getAttribute('viewBox')
          ?.split(' ')
          .map(Number) ?? [0, 0, 0, 0];
      }
    }

    function handleMouseMove(e: MouseEvent) {
      if (isDragging) {
        const dx = e.clientX - startX;
        const dy = e.clientY - startY;
        const newTranslateX = initialTranslateX - dx;
        const newTranslateY = initialTranslateY - dy;
        // 设置画布的位置
        const viewBox = canvas?.getSvgRoot(),
          width = viewBox?.getAttribute('width'),
          height = viewBox?.getAttribute('height');

        viewBox?.setAttribute(
          'viewBox',
          `${newTranslateX} ${newTranslateY} ${width} ${height}`,
        );
        document.body.style.cursor = 'grabbing';
        // 重新绘制网格背景
        drawGridBg(canvas);
      }
    }

    function handleMouseUp() {
      isDragging = false;
      document.body.style.cursor = 'default';
      const selectGroup = document.getElementById('selectorParentGroup');
      if (selectGroup) {
        selectGroup.style.display = 'block';
      }
    }

    editorDom?.addEventListener('mousedown', handleMouseDown);
    editorDom?.addEventListener('mousemove', handleMouseMove);
    editorDom?.addEventListener('mouseup', handleMouseUp);
    return () => {
      editorDom?.removeEventListener('mousedown', handleMouseDown);
      editorDom?.removeEventListener('mousemove', handleMouseMove);
      editorDom?.removeEventListener('mouseup', handleMouseUp);
    };
  }, [canvas]);

  /**......省略部分代码 */
};

缩放功能

如果没有使用拖拽功能的话,使用库提供的 API ,这部分代码就够了。

const App: React.FC = () => {
  /**......省略部分代码 */
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    let zoom = 1;

    function handleWheel(e: WheelEvent) {
      if (canvas && editorDom) {
        e.preventDefault();
        canvas.clearSelection();
        // 缩放的比例
        const zoomStep = 0.1;
        const delta = e.deltaY > 0 ? -zoomStep : zoomStep;
        const newZoom = zoom + delta;
        if (newZoom > 0.3 && newZoom < 3) {
          canvas.setZoom(newZoom);
          updateCanvas(canvas, editorDom);
          // 重新绘制网格背景
          drawGridBg(canvas);
          zoom = newZoom;
        }
      }
    }
    editorDom?.addEventListener('wheel', handleWheel);
    return () => {
      editorDom?.removeEventListener('wheel', handleWheel);
    };
  }, [canvas]);
  
  /**......省略部分代码 */ 
};
居中缩放

但如果进行拖拽了,会发现缩放时不是在当前可视区的中心缩放。

因为我们的拖拽是通过 viewBox,所以还需要增加代码,修正下这部分问题。

const App: React.FC = () => {
  /**......省略部分代码 */
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    let zoom = 1;

    function handleWheel(e: WheelEvent) {
      if (canvas && editorDom) {
        e.preventDefault();
        canvas.clearSelection();
        // 缩放的比例
        const zoomStep = 0.1;
        const delta = e.deltaY > 0 ? -zoomStep : zoomStep;
        const newZoom = zoom + delta;
        if (newZoom > 0.3 && newZoom < 3) {
          canvas.setZoom(newZoom);
          updateCanvas(canvas, editorDom);
          drawGridBg(canvas);
          
          /** 这部分是新增的代码*/
          const scale = newZoom / zoom;

          const svg = canvas?.getSvgRoot(),
            width = svg?.getAttribute('width'),
            height = svg?.getAttribute('height'),
            [translateX, translateY] = svg
              .getAttribute('viewBox')
              ?.split(' ')
              .map(Number) ?? [0, 0, 0, 0];

          canvas
            .getSvgRoot()
            .setAttribute(
              'viewBox',
              `${translateX * scale} ${translateY * scale} ${width} ${height}`,
            );
          /**————————————*/

          zoom = newZoom;
        }
      }
    }
    editorDom?.addEventListener('wheel', handleWheel);
    return () => {
      editorDom?.removeEventListener('wheel', handleWheel);
    };
  }, [canvas]);
  
  /**......省略部分代码 */ 
};
鼠标为中心缩放

库中提供的 setZoom 方法是以 idsvgroot 的 svg 的中心点为缩放中心。

所以,要以缩放时要以鼠标为中心的话,我们需要进行修正。

  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    let zoom = 1;

    function handleWheel(e: WheelEvent) {
      if (canvas && editorDom) {
        e.preventDefault();
        canvas.clearSelection();
        // 缩放的比例
        const zoomStep = 0.1;
        const delta = e.deltaY > 0 ? -zoomStep : zoomStep;
        const newZoom = zoom + delta;
        if (newZoom > 0.3 && newZoom < 3) {
          canvas.setZoom(newZoom);
          updateCanvas(canvas, editorDom);
          drawGridBg(canvas);
          
          /** 这部分是新增的代码*/
          const svg = canvas?.getSvgRoot(),
            width = svg?.getAttribute('width'),
            height = svg?.getAttribute('height'),
            [translateX, translateY] = svg
              .getAttribute('viewBox')
              ?.split(' ')
              .map(Number) ?? [0, 0, 0, 0];

          const rect = svgcanvasRef.current!.getBoundingClientRect();
          // svg 中心点的距离
          let offsetCenterX = e.clientX - rect.left - rect.width / 2,
            offsetCenterY = e.clientY - rect.top - rect.height / 2;

          // 缩放中心点的距离
          const offsetSvgCenterX = (offsetCenterX + translateX) / zoom,
            offsetSvgCenterY = (offsetCenterY + translateY) / zoom;

          // 缩放的偏移值
          const offsetX = offsetSvgCenterX * delta,
            offsetY = offsetSvgCenterY * delta;

          canvas
            .getSvgRoot()
            .setAttribute(
              'viewBox',
              `${translateX + offsetX} ${
                translateY + offsetY
              } ${width} ${height}`,
            );
          /**————————————*/
          zoom = newZoom;
        }
      }
    }
    editorDom?.addEventListener('wheel', handleWheel);
    return () => {
      editorDom?.removeEventListener('wheel', handleWheel);
    };
  }, [canvas]);

键盘移动元素

const App: React.FC = () => { 
  /**......省略部分代码 */

 useEffect(() => {
    const uDLRMove = (e: KeyboardEvent) => {
      const moveStep = 1,
        bigMoveStep = 10;

      const selectedElements = canvas?.getSelectedElements() || [];
      if (selectedElements.length === 0) return;

      let dx = 0,
        dy = 0;
      const step = e.shiftKey ? bigMoveStep : moveStep;

      switch (e.key) {
        case 'ArrowUp':
          dy = -step;
          break;
        case 'ArrowDown':
          dy = step;
          break;
        case 'ArrowLeft':
          dx = -step;
          break;
        case 'ArrowRight':
          dx = step;
          break;
        default:
          return;
      }
      e.preventDefault();
      canvas?.moveSelectedElements(dx, dy);
    };

    document.addEventListener('keydown', uDLRMove);
    return () => document.removeEventListener('keydown', uDLRMove);
  }, []);
  
  /**......省略部分代码 */ 
};

按住 ctrl 键框选多选

库已实现按住 Shift + 点击元素进行多选(或取消选中)的功能,但尚未实现按住 Ctrl + 框选多选。

const App: React.FC = () => {
  /**......省略部分代码 */
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;

    if (canvas && editorDom) {
      editorDom.removeEventListener('mousedown', canvas?.mouseDownEvent);
      editorDom.removeEventListener('mousemove', canvas?.mouseMoveEvent);
      
      // 储存之前选中的元素
      let agoSelectedElements: SVGElement[] = [];

      function mousedown(e: MouseEvent) {
        if (e.button === 1) return;

        if (e.ctrlKey || e.metaKey) {
          agoSelectedElements = canvas?.getSelectedElements() ?? [];
        } else {
          agoSelectedElements = [];
        }
        canvas?.mouseDownEvent(e);
        if (agoSelectedElements.length > 0) {
          canvas?.addToSelection(agoSelectedElements);
        }
      }

      function mousemove(e: MouseEvent) {
        if (!canvas?.started || e.button === 1) return;

        canvas?.mouseMoveEvent(e);
        if (agoSelectedElements.length > 0) {
          canvas.addToSelection(agoSelectedElements);
        }
      }
      editorDom.addEventListener('mousedown', mousedown);
      editorDom.addEventListener('mousemove', mousemove);
      
      return () => {
        editorDom.removeEventListener('mousedown', canvas.mouseDownEvent);
        editorDom.removeEventListener('mousemove', canvas.mouseMoveEvent);
      };
    }
  }, [canvas]);
  
  /**......省略部分代码 */ 
};

此处是对 mousedownmousemove 通过重写监听,进行了功能扩展。

键盘操作:复制、粘贴、删除

在执行粘贴操作时,若画布存在缩放或平移,其位置也需要进行修正。

const App: React.FC = () => {
  /**......省略部分代码 */
  
  // 记录鼠标在屏幕上的位置
  const clientXYRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });

  useEffect(() => {
    function handleMouseMove(e: MouseEvent) {
      clientXYRef.current = { x: e.clientX, y: e.clientY };
    }
    svgcanvasRef.current?.addEventListener('mousemove', handleMouseMove);
    return () => {
      svgcanvasRef.current?.removeEventListener('mousemove', handleMouseMove);
    };
  }, []);
  useEffect(() => {
    function onKeyDown(e: KeyboardEvent) {
      //删除功能
      if (
        (e.key === 'Delete' || e.key === 'Backspace') &&
        (e.target as HTMLElement).tagName !== 'INPUT'
      ) {
        e.preventDefault();
        canvas?.deleteSelectedElements();
      }
      //复制功能 ctrl+c
      if ((e.key === 'c' || e.key === 'C') && (e.ctrlKey || e.metaKey)) {
        e.preventDefault();
        try {
          canvas?.copySelectedElements();
        } catch (error) {}
      }
      //粘贴功能 ctrl+v
      if ((e.key === 'v' || e.key === 'V') && (e.ctrlKey || e.metaKey)) {
        e.preventDefault();
        const rect = svgcanvasRef.current!.getBoundingClientRect();

        const svg = canvas!.getSvgRoot(),
          zoom = canvas?.getZoom() ?? 1,
          [translateX, translateY] = svg
            .getAttribute('viewBox')
            ?.split(' ')
            .map(Number) ?? [0, 0];

        let svgX = (clientXYRef.current.x - rect.left + translateX) / zoom,
          svgY = (clientXYRef.current.y - rect.top + translateY) / zoom;

        // 修正偏移量
        const svgcontent = document.getElementById('svgcontent')!;
        svgX -= Number(svgcontent.getAttribute('x')) / zoom;
        svgY -= Number(svgcontent.getAttribute('y')) / zoom;
        
        canvas?.pasteElements('point', svgX, svgY);
      }
    }
    document.addEventListener('keydown', onKeyDown);
    return () => {
      document.removeEventListener('keydown', onKeyDown);
    };
  }, [canvas]);

  /**......省略部分代码 */ 
};

这里为什么要在复制功能那里增加 try/catch 错误捕获呢?

copySelectedElements.png

因为在 copySelectedElements 源码内执行了 document.getElementById('se-cmenu_canvas'),我们这里没有添加这个 div,所以进行报错的拦截。

se-cmenu_canvas 元素主要用在右键菜单功能,当有复制内容被暂存时,该 div 会被赋予一个属性,以便后续判断是否已存在复制内容。因此,你有右键菜单功能,记得在 div 对应位置增加 id=&#34;se-cmenu_canvas&#34; 属性。

水平翻转,垂直翻转

翻转功能的核心是 matrix,通过矩阵来实现的。

简单说下矩阵运算中会用到的 2 个 API:

  • multiplySelf:将当前矩阵右乘另一个矩阵:当前矩阵 = 当前矩阵 × 参数矩阵。累积多个变换,按“自然”顺序(如先缩放,再旋转,后平移)。
  • preMultiplySelf:将当前矩阵左乘另一个矩阵:当前矩阵 = 参数矩阵 × 当前矩阵。累积多个变换,按与 multiplySelf相反的顺序。
const App: React.FC = () => {
  /**......省略部分代码 */

 // 解析transform属性,提取现有的变换矩阵
  function parseTransformToMatrix(element: SVGElement): DOMMatrix {
    const transform = element.getAttribute('transform') || '';
    // 提取矩阵参数
    const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
    if (!matrixMatch) {
      return new DOMMatrix();
    }
    const matrixValues = matrixMatch[1].split(',').map(Number);
    return new DOMMatrix(matrixValues);
  }

  function overturn(type: 'horizontal' | 'vertical') {
    const selectedElements = canvas?.getSelectedElements();
    if (selectedElements?.length === 0) return;

    selectedElements?.forEach((element) => {
      const visualBbox = canvas?.getBBox(element);
      if (!visualBbox) return;

      // 获取位置的中心点
      const centerX = visualBbox.x + visualBbox.width / 2;
      const centerY = visualBbox.y + visualBbox.height / 2;

      // 创建一个新的 DOM 变换矩阵
      const flipMatrix = new DOMMatrix();
      // 将元素平移,使其中点与原点重合
      flipMatrix.translateSelf(centerX, centerY);
      //进行翻转
      switch (type) {
        case 'horizontal':
          flipMatrix.scaleSelf(-1, 1);
          break;
        case 'vertical':
          flipMatrix.scaleSelf(1, -1);
          break;
      }
      // 将元素平移回原位置
      flipMatrix.translateSelf(-centerX, -centerY);

      // 获取现有的变换矩阵
      const existingMatrix = parseTransformToMatrix(element);
      // 合并现有变换
      flipMatrix.preMultiplySelf(existingMatrix);

      const matrixString = `matrix(${flipMatrix.a}, ${flipMatrix.b}, ${flipMatrix.c}, ${flipMatrix.d}, ${flipMatrix.e}, ${flipMatrix.f})`;
      element.setAttribute('transform', matrixString);
    });
  }
  
  /**......省略部分代码 */ 
};

自定义图形

有时候基础图形往往无法满足所有需求,此时便需要实现自定义图形的能力。

存储

保存的逻辑很简单,把 svg 转成 string 进行存储。

function addCustom() {
    const selectedElements = canvas?.getSelectedElements();
    if (selectedElements && selectedElements?.length > 0) {
      const tempSvg = document.createElementNS(
        'http://www.w3.org/2000/svg',
        'svg',
      );

      selectedElements.forEach((elem) => {
        tempSvg.appendChild(elem);
      });
      state.canvas?.svgToString(tempSvg, 0);
    }
  }

为什么svgcanvas 提供了 svgToString 方法,比直接使用 XMLSerializer 优势在哪呢?

XMLSerializer 只是简单地将 DOM 树转换为字符串。而 svgToString 是对 SVG 序列化过程的全面控制:优化输出大小、增强可读性以及支持特殊功能(如图像嵌入),使得生成的 SVG 代码更加精简,输出的文件质量更高。

展示

这里是使用了 React 的 dangerouslySetInnerHTML来实现元素的渲染的。

const App: React.FC = () => {
  /**......省略部分代码 */
  
const svg1 = `


 
 
`;

  // 获取边界框
  function getSVGCoordinateRange(svgString: string): {
    minX: number;
    maxX: number;
    minY: number;
    maxY: number;
    svgElement: SVGSVGElement;
  } {
    const parser = new DOMParser();
    const doc = parser.parseFromString(svgString, 'image/svg+xml');
    const svgElement = doc.documentElement as unknown as SVGSVGElement;

    let minX = Infinity,
      minY = Infinity,
      maxX = -Infinity,
      maxY = -Infinity;

    const updateBounds = (x: number, y: number): void => {
      minX = Math.min(minX, x);
      minY = Math.min(minY, y);
      maxX = Math.max(maxX, x);
      maxY = Math.max(maxY, y);
    };

    const traverse = (element: Element): void => {
      const tagName = element.tagName.toLowerCase();

      switch (tagName) {
        case 'polyline': {
          const points =
            element
              .getAttribute('points')
              ?.split(/[\s,]+/)
              .map(Number) || [];
          for (let i = 0; i < points.length; i += 2) {
            updateBounds(points[i], points[i + 1]);
          }
          break;
        }
        case 'rect': {
          const x = parseFloat(element.getAttribute('x') || '0');
          const y = parseFloat(element.getAttribute('y') || '0');
          const width = parseFloat(element.getAttribute('width') || '0');
          const height = parseFloat(element.getAttribute('height') || '0');
          // 处理矩形四角
          updateBounds(x, y);
          updateBounds(x + width, y);
          updateBounds(x, y + height);
          updateBounds(x + width, y + height);
          break;
        }
        case 'text':
        case 'image':
          updateBounds(
            parseFloat(element.getAttribute('x') || '0'),
            parseFloat(element.getAttribute('y') || '0'),
          );
          break;
        /** ......省略其他元素逻辑  */
      }
      Array.from(element.children).forEach((child) => traverse(child));
    };

    traverse(svgElement);

    return {
      minX: minX === Infinity ? 0 : minX,
      maxX: maxX === -Infinity ? 0 : maxX,
      minY: minY === Infinity ? 0 : minY,
      maxY: maxY === -Infinity ? 0 : maxY,
      svgElement,
    };
  }
  
  function templateSvg(svgString: string) {
    const { minX, minY, maxX, maxY, svgElement } =
      getSVGCoordinateRange(svgString);
      
    svgElement.setAttribute(
      'viewBox',
      `${minX} ${minY} ${maxX - minX} ${maxY - minY}`,
    );
    return { __html: svgElement.outerHTML };
  }
  
  return (
    <div> {
        const svgString = e.currentTarget!.innerHTML;
        e.dataTransfer.setData('text/plain', svgString);
      }}
      dangerouslySetInnerHTML={templateSvg(svg1)}
    />
  );
  
  /**......省略部分代码 */ 
};
拖拽放入

生成元素时,必须为其分配唯一的 id。如果出现重复,将导致元素缺失。

若进行过拖拽和缩放,自然也需要进行修正。

const App: React.FC = () => {
  /**......省略部分代码 */
  
function uuid() {
  const s: Array = [];
  const hexDigits = '0123456789abcdef';
  for (let i = 0; i < 36; i++) {
    s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
  }
  s[14] = '4'; // bits 12-15 of the time_hi_and_version field to 0010
  s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1); // bits 6-7 of the clock_seq_hi_and_reserved to 01
  s[8] = s[13] = s[18] = s[23] = '-';

  const uuid = s.join('');
  return uuid;
}

  useEffect(() => {
    // 监听拖放事件
    const dropHandler = (e: DragEvent) => {
      e.preventDefault();

      let data = e.dataTransfer?.getData('text/plain');
      if (data && canvas) {
        const rect = svgcanvasRef.current!.getBoundingClientRect();

        // 转换为 SVG 坐标(考虑平移和缩放)
        const svg = canvas.getSvgRoot(),
          zoom = canvas.getZoom() ?? 1,
          [translateX, translateY] = svg
            .getAttribute('viewBox')
            ?.split(' ')
            .map(Number) ?? [0, 0];
          
        let svgX = (e.clientX - rect.left + translateX) / zoom,
          svgY = (e.clientY - rect.top + translateY) / zoom;
        // 修正偏移量
        const svgcontent = document.getElementById('svgcontent')!;
        svgX -= Number(svgcontent.getAttribute('x')) / zoom;
        svgY -= Number(svgcontent.getAttribute('y')) / zoom;

        if (data) {
          const { minX, minY, maxX, maxY, svgElement } =
            getSVGCoordinateRange(data);

          const offsetX = svgX - (minX + (maxX - minX) / 2),
            offsetY = svgY - (minY + (maxY - minY) / 2);

          Array.from(svgElement.children).forEach((ele) => {
            switch (ele.tagName) {
              case 'polyline':
                {
                  const points = ele.getAttribute('points');
                  const amendPoints = points!
                    .split(' ')
                    .map((item) => {
                      const [x, y] = item.split(',').map(Number);
                      return `${x + offsetX},${y + offsetY}`;
                    })
                    .join(' ');

                  canvas?.createSVGElement({
                    element: 'polyline',
                    attr: {
                      points: amendPoints,
                      id: 'polyline-' + uuid(),
                      stroke: ele.getAttribute('stroke'),
                      'stroke-width': ele.getAttribute('stroke-width'),
                    },
                  });
                }
                break;
              case 'rect':
                {
                  canvas?.createSVGElement({
                    element: 'rect',
                    attr: {
                      x: Number(ele.getAttribute('x')) + offsetX,
                      y: Number(ele.getAttribute('y')) + offsetY,
                      width: ele.getAttribute('width'),
                      height: ele.getAttribute('height'),
                      stroke: ele.getAttribute('stroke'),
                      fill: ele.getAttribute('fill'),
                      'stroke-width': ele.getAttribute('stroke-width'),
                      id: 'rect-' + uuid(),
                    },
                  });
                }
                break;
              case 'text': {
                const text = canvas?.createSVGElement({
                  element: 'text',
                  attr: {
                    x: Number(ele.getAttribute('x')) + offsetX,
                    y: Number(ele.getAttribute('y')) + offsetY,
                    'font-size': ele.getAttribute('font-size'),
                    fill: ele.getAttribute('fill'),
                    id: 'text-' + uuid(),
                  },
                });
                text.textContent = ele.textContent;
                break;
              }
              case 'image':
                canvas?.createSVGElement({
                  element: 'image',
                  attr: {
                    x: Number(ele.getAttribute('x')) + offsetX,
                    y: Number(ele.getAttribute('y')) + offsetY,
                    width: ele.getAttribute('width'),
                    height: ele.getAttribute('height'),
                    href: ele.getAttribute('href'),
                    id: 'image-' + uuid(),
                  },
                });
                break;
              /** ......省略其他元素逻辑  */
            }
          });
        }
      }
    };

    function preventDefault(e: DragEvent) {
      e.preventDefault();
    }
    svgcanvasRef.current?.addEventListener('dragover', preventDefault);
    svgcanvasRef.current?.addEventListener('drop', dropHandler);

    return () => {
      svgcanvasRef.current?.removeEventListener('drop', dropHandler);
      svgcanvasRef.current?.removeEventListener('dragover', preventDefault);
    };
  }, [canvas]);
  
  /**......省略部分代码 */ 
};

撤销/重做功能

介绍

这部分功能的主要代码是在源码里的 history.js 文件中,且采用了经典的命令模式的设计模式。

支持多种类型的操作命令,都继承自基础的 Command 类:

  • MoveElementCommand:元素移动操作
  • InsertElementCommand:元素插入操作
  • RemoveElementCommand:元素删除操作
  • ChangeElementCommand:元素属性修改操作
  • BatchCommand:批量操作命令组合

UndoManager:负责维护命令历史堆栈,提供撤销与重做功能的核心控制器。

示例

下面以拖拽自定义图形为示例

const App: React.FC = () => {
  /**......省略部分代码 */
  
// 创建批处理命令
const batchCmd = new canvas.history.BatchCommand('添加多个图形');

Array.from(svgElement.children).forEach((ele) => {
  let svgElementNode: SVGElement | null = null;
  
  switch (ele.tagName) {
    case 'polyline':
      {
        const points = ele.getAttribute('points');
        const amendPoints = points!
          .split(' ')
          .map((item) => {
            const [x, y] = item.split(',').map(Number);
            return `${x + offsetX},${y + offsetY}`;
          })
          .join(' ');

        svgElementNode = canvas?.createSVGElement({
          element: 'polyline',
          attr: {
            points: amendPoints,
            id: 'polyline-' + uuid(),
            stroke: ele.getAttribute('stroke'),
            'stroke-width': ele.getAttribute('stroke-width'),
          },
        });
      }
      break;
    /**......省略部分代码 */
  }
  if (svgElementNode) {
    // 添加子命令
    batchCmd.addSubCommand(
      new canvas.history.InsertElementCommand(svgElementNode, `自定义图形元素`),
    );
  }
});

// 将批处理命令添加到历史记录
canvas.addCommandToHistory(batchCmd);

  /**......省略部分代码 */ 
};

BatchCommand的作用是将多个一系列操作视为一个整体,而不是零散的状态变更,使得整个拖拽过程被视为一个完整的步骤。

最后调用 addCommandToHistory 方法,将对应的 BatchCommand 添加到 UndoManager 的历史栈中。

这一步是关键,因为只有被记录到历史栈中的命令,才能在 撤销/重做 中处理。

    canvas?.undoMgr.undo()}>
     后退
   
    canvas?.undoMgr.redo()}>
     前进
   

执行 ChangeElementCommand 命令时,有个顺序细节需要注意,必须是先完成对元素属性修改后,再执行命令的提交。

   // 记录修改前的值
    const oldValue = element.getAttribute(attributeName);
    // 修改属性值
    element.setAttribute(attributeName, newValue);
    // 执行实际的属性修改
    element.setAttribute(attributeName, newValue);
    // 创建 ChangeElementCommand 并添加到历史记录
    canvas.addCommandToHistory(new ChangeElementCommand(element, changes));

Q&A

关于文中的修正偏移

为什么上面会有修正偏移的操作呢?

由于所有图形元素都绘制在 svgroot 下的子容器 svgcontent 内,此操作正是为了校正这个实际容器本身的偏移。

所以在粘贴和拖拽自定义元素放入时,需要进行修正。

关于 matrix 函数

transform=&#34;matrix(a, b, c, d, e, f)&#34;是 SVG 变换的底层数学表示。接受六个参数,它们共同定义了一个 3x3 的变换矩阵(为了便于仿射变换,实际使用齐次坐标):

[ a c e ]
[ b d f ]
[ 0 0 1 ]

通常我们关注左上角的 2x2 线性变换矩阵 (a, b, c, d) 和右侧列的平移向量 (e, f) 来描述元素坐标系的所有基础变换的底层数学表示,功能非常强大但也相对抽象。

对于简单变换,优先使用 translate, scale, rotate, skewX, skewY 等函数以提高代码可读性。

  1. 平移 (Translation)
  • e: 沿 X 轴的平移量。
  • f: 沿 Y 轴的平移量。

示例: 

matrix(1, 0, 0, 1, 100, 50) === translate(100px, 50px)
  1. 缩放 (Scaling)
  • a: X 轴方向的缩放因子。
  • d: Y 轴方向的缩放因子。

示例:

matrix(2, 0, 0, 3, 0, 0) === scale(2, 3)

matrix(0.5, 0, 0, 0.5, 0, 0) === scale(0.5)
  1. 旋转 (Rotation)

旋转需要 a, b, c, d 共同作用。旋转角度 θ(通常以弧度表示) 时:

  • a = cosθ
  • b = sinθ
  • c = -sinθ
  • d = cosθ。

示例: 旋转 30 度 (≈ 0.5236 弧度),cos(30°) ≈ 0.866, sin(30°) = 0.5。

matrix(0.866, 0.5, -0.5, 0.866, 0, 0) === rotate(30deg)
  1. 倾斜/错切 (Skewing/Shearing)
  • 沿 X 轴倾斜 (SkewX):由 c控制。c = tan(α),其中 α是倾斜角度。
  • 沿 Y 轴倾斜 (SkewY):由 b控制。b = tan(β),其中 β是倾斜角度。

示例: tan(26.565deg) ≈ 0.5。

matrix(1, 0, 0.5, 1, 0, 0) === skewX(26.565deg) 

matrix(1, 0.5, 0, 1, 0, 0) === skewY(26.565deg)
  1. 组合变换

matrix的真正威力在于它能用一个函数表示 任意顺序、任意组合 的平移、旋转、缩放、倾斜。这是通过将各个基础变换的矩阵 相乘 得到的。

顺序很重要: 矩阵乘法不满足交换律。旋转 * 平移和 平移 * 旋转的结果通常是不同的。

  • 旋转 * 平移:先围绕原点旋转,然后将旋转后的图形平移。

  • 平移 * 旋转:先将图形平移到新位置,然后围绕当前坐标系的原点(即平移后的点)旋转。

如何组合: 如果你有一系列基础变换,要得到等效的 matrix,你需要将它们对应的矩阵按 从右到左 的顺序相乘(在 SVG 中,transform 列表的书写顺序也是从右到左应用的)。最终得到的 6 个参数就是 matrix(a, b, c, d, e, f)的参数。

关于 createSVGTransform 函数

在阅读源码时,你会看到在对元素进行变换(例如移动、旋转、缩放)操作的函数中, createSVGTransform 被频繁的调用。

createSVGTransform 是 SVG DOM 提供的方法,用于创建 SVG 变换对象(SVGTransform),该对象可描述平移、缩放、旋转等几何变换。

核心作用是:生成临时变换规则,用于动态调整元素在拖拽缩放过程中的位置和尺寸,确保变换围绕正确的锚点(如边角、中心点)进行。

为什么需要通过 createSVGTransform 创建?

  • 临时变换隔离: 直接修改元素的 width/height 可能与现有变换(如旋转)冲突,而通过 SVGTransform 可在变换列表中插入临时规则,避免破坏原始属性。
  • 动态调整支持: 拖拽过程中需要实时更新变换参数(如缩放比例),SVGTransform 对象允许随时修改 setTranslate/setScale 的参数并重新应用。
  • 变换组合能力: 多个 SVGTransform 可按顺序组合(如先平移再缩放),实现复杂的几何调整逻辑(这也是为什么需要创建三个对象的核心原因)。

createSVGTransform 的作用是: 生成可配置的变换规则,通过临时修改元素的变换列表,实现拖拽缩放过程中的精准位置控制。这比直接修改元素属性(如 width/height)更灵活,尤其适用于已有旋转、倾斜等复杂变换的元素。

React基础框架搭建7-测试:react+router+redux+axios+Tailwind+webpack

现在可以对之前弄得一些内容进行测试了

npm下载:

npm install --save-dev @testing-library/react @testing-library/jest-dom

有些可能需要安装:

npm install --save-dev @babel/plugin-proposal-private-property-in-object

单元测试请按如下方式进行:

//src/views/Home/__tests__/Home.test.js

import React from 'react';
import { render, screen } from '@testing-library/react';
import Home from '../Home';

test('renders welcome message', () => {
    render(<Home />);
    const linkElement = screen.getByText(/Hello/i);
    expect(linkElement).toBeInTheDocument();
});

运行:

npm test
❌