普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月3日技术

ruoyi集成camunda-前端篇

2025年12月3日 11:12

RuoYi-Vue 是一个 Java EE 企业级快速开发平台,基于经典技术组合(Spring Boot、Spring Security、MyBatis、Jwt、Vue),内置模块如:部门管理、角色用户、菜单及按钮授权、数据权限、系统参数、日志管理、通知公告、代码生成等。在线定时任务配置,支持集群,支持多数据源,支持分布式事务等。

本文将讲解ruoyi分离版的前端如何集成camunda在线设计器,实现流程的建模。
我们也有网站提供一站式解决方案, 请直接跳转若依工作流

ScreenShot_2025-12-03_110311_216.png 本文主要聚焦在RuoYi-Vue2如何集成camunda 实现bpmn在线建模。

环境要求:

  • JDK >= 1.8
  • MySQL >= 5.7
  • Maven >= 3.0
  • Node >= 23( node版本低将导致bpmn-js相关依赖安装失败!!!)
  • Redis >= 3

安装步骤

  • 通过vscode打开前端代码,在控制台执行下面的命令
npm install \  bpmn-js@18.6.2 \  bpmn-js-properties-panel@2.0.0 \  camunda-bpmn-moddle@7.0.1 \  bpmn-moddle@7.0.2 \  @camunda/feel-builtins \  lezer-feel --save --registry=https://registry.npmmirror.comnpm install --save @bpmn-io/feel-lint @bpmn-io/lezer-feel feelers   --registry=https://registry.npmmirror.comnpm install --save feelin --registry=https://registry.npmmirror.com// 建议不要直接使用 cnpm 安装依赖,会有各种诡异的 bug。可以通过如下操作解决 npm 下载速度慢的问题npm install --registry=https://registry.npmmirror.com
  • 编辑 vue.config.js 将包加入 Babel 转译(解决 node_modules 内部现代语法如可选链):
transpileDependencies: [    'quill''bpmn-js''diagram-js','bpmn-js-properties-panel','@bpmn-io/properties-panel','@bpmn-io/feel-editor','@bpmn-io/feel-lint''@bpmn-io/lezer-feel''feelers''lezer-feel'],

增加 alias(解决 Webpack 4 对 package.json exports/ESM 解析不完善的问题)


resolve: {  alias: {        
'@': resolve('src'),        // webpack4 不识别 package.json exports,手动指向入口  
'lezer-feel$': resolve('node_modules/lezer-feel/dist/index.js'),        
'@camunda/feel-builtins$': 
resolve('node_modules/@camunda/feel-builtins/dist/index.js'), // 将 FEEL 相关库固定到
'feelers$': resolve('node_modules/feelers/dist/index.js'),        
'feelin$': resolve('node_modules/feelin/dist/index.cjs'),        
'@bpmn-io/feel-lint$': resolve('node_modules/@bpmn-io/feel-lint/dist/index.js'),
'@bpmn-io/lezer-feel$': resolve('node_modules/@bpmn-io/lezer-feel/dist/index.js')   
}}

  • 构建测试页面 需要您配置好菜单
<template>
  <div class="bpmn-modeler-page">
    <div class="toolbar">
      <el-button size="middle" @click="handleOpen">导入</el-button>
      <el-button size="middle" @click="downloadXML">导出XML</el-button>
      <el-button size="middle" @click="downloadSVG">导出SVG</el-button>
      <el-button type="primary" size="middle" @click="validateDiagram">流程检查</el-button>
      <el-button type="success" size="middle" @click="deployDiagram">部署</el-button>
      <input ref="fileInputRef" type="file" accept=".bpmn,.xml" style="display:none" @change="onFileChange" />
    </div>
    <div class="modeler-wrap">
      <div class="canvas" ref="canvasRef"></div>
      <div class="properties-panel" ref="propertiesRef"></div>
    </div>
  </div>
</template>

<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BpmnModeler from 'bpmn-js/lib/Modeler'
import camundaBpmnModdle from 'camunda-bpmn-moddle/resources/camunda.json'
import { BpmnPropertiesPanelModule, BpmnPropertiesProviderModule, CamundaPlatformPropertiesProviderModule } from 'bpmn-js-properties-panel'
import 'bpmn-js-properties-panel/dist/assets/properties-panel.css'
import 'bpmn-js/dist/assets/diagram-js.css'
import 'bpmn-js/dist/assets/bpmn-js.css'
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css'

const canvasRef = ref(null)
const propertiesRef = ref(null)
const fileInputRef = ref(null)
let modeler = null

function generateProcessId() {
  const rand = Math.random().toString(36).slice(2, 8)
  return `Process_${rand}`
}

const processId = ref(generateProcessId())

function buildDefaultXml(pid) {
  return `<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" id="Definitions_1" targetNamespace="http://bpmn.io/schema/bpmn">
  <bpmn:process id="${pid}" isExecutable="true">
    <bpmn:startEvent id="StartEvent_1" />
  </bpmn:process>
  <bpmndi:BPMNDiagram id="BPMNDiagram_1">
    <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="${pid}">
      <bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
        <dc:Bounds x="180" y="180" width="36" height="36" />
      </bpmndi:BPMNShape>
    </bpmndi:BPMNPlane>
  </bpmndi:BPMNDiagram>
</bpmn:definitions>`
}

function initModeler() {
  if (modeler) return
  modeler = new BpmnModeler({
    container: canvasRef.value,
    propertiesPanel: {
      parent: propertiesRef.value
    },
    additionalModules: [
      BpmnPropertiesPanelModule,
      BpmnPropertiesProviderModule,
      CamundaPlatformPropertiesProviderModule
    ],
    moddleExtensions: {
      camunda: camundaBpmnModdle
    },
    keyboard: {
      bindTo: document
    }
  })
}

async function createNewDiagram() {
  try {
    await modeler.importXML(buildDefaultXml(processId.value))
    const canvas = modeler.get('canvas')
    canvas.zoom('fit-viewport')
    
    // 自动选中开始事件以显示属性面板
    const elementRegistry = modeler.get('elementRegistry')
    const startEvent = elementRegistry.get('StartEvent_1')
    if (startEvent) {
      const selection = modeler.get('selection')
      selection.select(startEvent)
    }
  } catch (error) {
    console.error('创建新图表失败:', error)
  }
}

function handleNew() {
  createNewDiagram()
}

function handleOpen() {
  fileInputRef.value && fileInputRef.value.click()
}

function onFileChange(e) {
  const file = e.target.files && e.target.files[0]
  if (!file) return
  const reader = new FileReader()
  reader.onload = async () => {
    try {
      await modeler.importXML(reader.result)
      modeler.get('canvas').zoom('fit-viewport')
    } catch (err) {
      console.error('导入失败', err)
    } finally {
      e.target.value = ''
    }
  }
  reader.readAsText(file)
}

async function downloadXML() {
  try {
    const { xml } = await modeler.saveXML({ format: true })
    const blob = new Blob([xml], { type: 'application/xml' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = 'diagram.bpmn'
    a.click()
    URL.revokeObjectURL(url)
  } catch (err) {
    console.error('导出XML失败', err)
  }
}

async function downloadSVG() {
  try {
    const { svg } = await modeler.saveSVG()
    const blob = new Blob([svg], { type: 'image/svg+xml' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = 'diagram.svg'
    a.click()
    URL.revokeObjectURL(url)
  } catch (err) {
    console.error('导出SVG失败', err)
  }
}

function validateDiagram() {
  try {
    const elementRegistry = modeler.get('elementRegistry')
    const processes = elementRegistry.filter(e => e.type === 'bpmn:Process')
    if (!processes.length) {
      ElMessage.error('未找到流程定义 (bpmn:Process)')
      return
    }
    const startEvents = elementRegistry.filter(e => e.type === 'bpmn:StartEvent')
    if (!startEvents.length) {
      ElMessage.error('流程缺少开始事件')
      return
    }
    ElMessage.success('流程检查通过')
  } catch (e) {
    console.error(e)
    ElMessage.error('流程检查失败')
  }
}

async function deployDiagram() {
  try {
    const { xml } = await modeler.saveXML({ format: true })
    const blob = new Blob([xml], { type: 'application/xml' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = `${processId.value}.bpmn`
    a.click()
    URL.revokeObjectURL(url)
    ElMessage.success('已打包流程XML(请接入后端部署接口)')
  } catch (e) {
    console.error(e)
    ElMessage.error('部署打包失败')
  }
}

onMounted(async () => {
  try {
    initModeler()
    await createNewDiagram()
  } catch (error) {
    console.error('初始化模型器失败:', error)
  }
})

onBeforeUnmount(() => {
  if (modeler) {
    modeler.destroy()
    modeler = null
  }
})
</script>

<style scoped>
.bpmn-modeler-page {
  display: flex;
  flex-direction: column;
  height: 100%;
}
.toolbar {
  padding: 8px;
  border-bottom: 1px solid var(--el-border-color);
}
.modeler-wrap {
  display: flex;
  flex: 1;
  min-height: 0;
}
.canvas {
  flex: 1;
  height: calc(100vh - 120px);
}
.properties-panel {
  width: 360px;
  border-left: 1px solid var(--el-border-color);
  height: calc(100vh - 120px);
  overflow: auto;
}
/* bpmn-js core styles (containers) */
:deep(.djs-container) {
  width: 100%;
  height: 100%;
}

/* 属性面板样式调整 */
:deep(.bio-properties-panel) {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

:deep(.bio-properties-panel .bio-properties-panel-group-header) {
  background: #f5f5f5;
  border-bottom: 1px solid #e0e0e0;
}

:deep(.bio-properties-panel .bio-properties-panel-entry) {
  border-bottom: 1px solid #f0f0f0;
}

</style>
  • 最终结果

1.png

如果您想在自己的ruoyi项目集成工作流, 我们提供一站式解决方案,ruoyiflow

zustand 从原理到实践 - 原理篇(1)

作者 鲨叔
2025年12月3日 10:59

初识 zustand

什么是 zustand ?

没错,zustand 又是一个基于 React 的状态管理库。Zustand 的发音为 /ˈzuːstænd/。该词源自德语,本义就是“状态”(condition, state),与库的核心功能“状态管理”正好同义,算是一种“直给式”命名。

也许你会问,从初生代的 redux,mobx 和 xstate,再到中生代的其他的状态管理库,如 redux + reduxToolkit,context base, recoil,jotai等,可供选择的 react 的第三方状态管理库已经有了很多,那么我为什么还要选择 zustand 呢?

答案是,zustand 足够简单、快速、高性能,特别适合于中小型项目(大部分人做的就是这种规模的项目),或者需要以特别低的成本快速实现状态管理的场景。

zustand 特点

简单

说它简单,是因为核心的 API 数量只有三个:create()useBoundStore()setState()。这使得它非常易于学习和使用,即使是 React 新手也能快速上手。

所谓的「useBoundStore」就是指通过通过闭包来「绑定」了你通过create()来创建的 zustand store 实例的自定义 hook 函数。这里的“Bound”,在理解上要替换为相应的业务领域的 scope 名,比如 useUserStore

而这些核心的 API设计时候的函数签名使用的都是已经被社区普遍接受的心智模型:

  • create():用于创建一个状态的 store。使用之初,先创建一个 store,这对于使用过 redux 的人群来说,是一个比较熟知的概念。
  • useBoundStore():用于在组件中订阅状态变化并获取状态值。在 react 的 hook 时代,要想使用 store,先 useXxx 一下,这几乎成为了 react 开发者的思维惯性了。而且这与使用 redux 中的 useSelector() 是非常相似的。
  • setState():用于更新状态值。

说它简单的另一个原因是从 redux 之前的原始范式的视角来讲的。在 redux 的原始范式里面,状态管理的概念和代码是拆分得很细的。这里面有 store, state,reducer,action,dispatch 等。由于 redux 比较推崇单一 store 模式,往往你还要 combine 一下 所有的 reducer。如果再加上它的三层洋葱模型的中间件机制,状态管理的代码就会变得比较复杂了。当然,这么做好处是,你可以非常清晰地看到一个单向的数据流,且状态管理的每一个环节都可以被审视,这对于调试和维护都是非常有帮助的。所以,当前社区的普遍的认知是, redux 的状态管理范式是更适用于大规模的,长期维护的前端项目。

而 zustand 则是基于这个原始范式的一个简化版本。 zustand 只保留了 store 的概念。一切都是围绕 store 展开的。无非就是三部曲:

  1. 定义 store(包含了 state 和 action) - StateCreator
  2. 创建 store - create
  3. 在组件中使用 store - useBoundStore

灵活

zustand 是很灵活的,因为你可以在任何地方(react 环境和非 react 环境)去调用 setState() 来触发 react 界面更新。它实现灵活的秘诀在于充分利用了 react 原生 API useSyncExternalStore 对非 react 环境触发 state 更新的的支持和 js 中函数也是对象的语言特性。

本文中的「非 react 环境」指的就是「非组件函数作用域」。换句话,就是指那些不能调用 react hook 函数的地方。

一句话,主要你能拿到 zustand store 的 setState() 函数,就可以在任何地方触发 react 界面更新。假设我们有一个下面代码的 store:

import create from 'zustand'

const useBoundStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}))

那么,怎样拿到 setState() handler呢?方法有二。

方法一

zustand 会往 StateCreator 注入 setState() 函数引用。你可以在 StateCreator 中通过直接使用 setState() 函数来定义 action。然后,在组件中使用 useStore() 来获取 action 函数。

import { useBoundStore } from './store'

const MyComponent = () => {
  const count = useBoundStore((state) => state.count)
  const increment = useBoundStore((state) => state.increment)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  )
}
方法二

zustand 已经利用「函数即对象」的语言特性,把 store 实例的 API (这里面就包含了 setState()方法)挂载到返回的 useStore() 函数上面了。换句话说,你可以直接在组件中使用 useBoundStore.setState() 来访问 setState() 函数。

import { useStore } from './store'

const MyComponent = () => {
  const count = useStore((state) => state.count)
  const setState = useStore.setState

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setState({ count: count + 1 })}>Increment</button>
    </div>
  )
}

因为 useBoundStore 既是一个 hook, 又是一个普通的函数对象,所以你可以在任何地方调用它,包括非 react 环境。这进一步提高了 zustand 在使用上的灵活度。比如,你完全可以这么用:

import { useBoundStore } from './store'

const increment = () =>
  useBoundStore.setState((state) => ({ count: state.count + 1 }))

const MyComponent = () => {
  const count = useBoundStore((state) => state.count)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  )
}

高性能

这里的高性能指的是,zustand 实现了真正的「按需渲染」。什么是按需渲染?简单来说,就是只有当组件中使用到的状态值发生变化时,才会触发组件的重新渲染。相比于 redux+ react-redux 的早期版本或者 context provider + useContext 的这种「全量刷新」的渲染方式,zustand 谈得上「高性能」了。

当然,要想实现这个「按需渲染」的功能,还需要作为开发者的你配合 - 使用 selector 来选择你需要的状态。selector 是一个函数,它的参数是 store 的全量 state,返回值是你需要的状态。比如,你只需要 count 状态,就可以这么写:

const count = useStore((state) => state.count)

假如你是这么写的话:

const MyComponent = () => {
  const countStore = useStore()

  return (
    <div>
      <p>Count: {countStore.count}</p>
    </div>
  )
}

那么就会发生性能回退 - 只要 store 的任意一个 state 发生了变化,哪怕不是 count 的值改变了,组件也会 MyComponent 也会重新渲染。

另外一个注意点是,如果你所订阅的值是一个引用类型,比如对象或者数组,如果还是像上面这种写法的话,那么就很容易发生性能回退(具体的例子可以看官网给出的例子)。

对于这种情况,你就需要再用 useShallow 来包一层。比如:

function BearNames() {
  const names = useBearFamilyMealsStore(
    useShallow((state) => Object.keys(state)),
  )

  return <div>{names.join(', ')}</div>
}

它相比于 redux 有什么区别?

从源码实现角度来说,zustand 就是 redux 的一个继任者。这一点可以从 vanilla create API 的实现可以看出。vanilla create API 的实现几乎是“抄”了 redux 的核心实现。zustand 同样实现了 store, middleware 等两个概念, 连 devtool 都是复用 redux 的 chrome devtool。不同的是,zustand 没有坚守 redux 的严格的单向数据流的设计中其他该概念,比如说:reducer, action, dispatch 等。然而,正如官网所指出的那样,你仍然可以把 zustand 写成 redux 的单向数据流的样子

从使用层面来说,zustand 比 redux 范式更加的松散和灵活(unopinionated)。它没有那套像 redux 那样稍显得冗余的模板代码。简直可以说,怎么写怎么有。另外一个最大的不同是,zustand 可以在任何地方去调用 setState() 来触发 react 界面更新。这一点是 redux 做不到的。

另外的一个不同点是渲染性能方面的。zustand 从第一版本就实现了组件的按需渲染。而 redux 那边,在 redux toolkit 还没有推出之前,都是「全量刷新」的渲染方式。把 redux toolkit 看作加强版的 redux 的话,那么在「按需渲染」方面,zustand 和 redux 到目前为止,都是支持通过 selector 来实现这个功能了。

快速入门

正如上面「特点」小节所提到的那样,zustand 上手是很简单的 - 就是三部曲:

  1. 创建 store
  2. 在组件中使用 useBoundStore() 来获取状态和 action 函数
  3. 在组件中调用 action 函数来触发状态更新

下面直接把官网的 demo 搬过来,整个三部曲就一步了然了:

  1. 创建 store
import { create } from 'zustand'

const useBearStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
  updateBears: (newBears) => set({ bears: newBears }),
}))
  1. 在组件中使用 useBoundStore() 来获取状态和 action 函数
function BearCounter() {
  const bears = useBearStore((state) => state.bears)
  return <h1>{bears} bears around here...</h1>
}

function Controls() {
  const increasePopulation = useBearStore((state) => state.increasePopulation)
  //......
}
  1. 在组件中调用 action 函数来触发状态更新
function Controls() {
  const increasePopulation = useBear((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

好了,到这就入门成功了。

原理揭秘

上面的简介只是一个热身活动,目的是让你对 zustand 的基本使用方法和特性有一个印象。下面我们来揭秘一下它的原理,以便于你更好地理解和使用 zustand。

源码框架

首先,我们看看源码的目录结构

zustand/
├── src/
│   ├── index.ts                    # 默认入口:导出 create(React 版)
│   ├── vanilla.ts                  # 框架无关核心:createStore
│   ├── react.ts                    # React 绑定:useSyncExternalStore 封装
│   ├── traditional.ts              # React 16/17 兼容入口(自动垫片)
│   ├── middleware/
│   │   ├── combine.ts
│   │   ├── devtools.ts
│   │   ├── immer.ts
│   │   ├── persist/
│   │   │   ├── index.ts
│   │   │   └── storage.ts
│   │   ├── redux.ts
│   │   └── subscribeWithSelector.ts
│   ├── vanilla/
│   │   ├── shallow.ts              # 浅比较函数(供 vanilla 用户)
│   ├── react/
│   │   ├── shallow.ts              # useShallow Hook
│   └── types.d.ts                    # 全局 TypeScript 类型定义

zustand 的核心代码采用了分层架构,目的是为了做到框架无关性。zustand 的核心代码分两层:

  • vanilla 层 - 也就是原生层,是纯 javascript 实现,它不依赖任何的视图层框架。
  • binding 层 - 也就是桥接特定的视图层框架。当前,这个视图层框架就是 react。

这两层之间的关联可以从下面的的鸟瞰图中看出:

image.png

简单来说,zustand 通过 react 原生 API useSyncExternalStore 把 react 内部的 listener(react core 在内部实现会创建一个 listenr) 注册到 zustand 原生 store 实例的 listener 数组中。如此一来,react 组件就成为了 zustand 原生 store 实例的众多订阅者中的一员。

当用户代码调用 store action 的时候,间接就调用了 store 实例的 setState 方法。而在 setState 方法的源码实现中,zustand 会遍历调用所有的 listener 函数。而这些 listener 函数中,就包括了 react 组件。这就是在通知 react 组件着手去判断组件是否真的需要更新。

在执行 react 注册到 store 实例的 listener 的时候,react 会对比前后两次的 snapshot 的值,如果值发生了变化, react 就会发起一个请强制更新的请求(forceStoreRerender(fiber))。那么,该 react 组件就会在下一轮的 render 阶段的时候得到 rerender。

限于篇幅,更多精彩内容请查看下篇《zustand 从原理到实践 - 原理篇(2)》。

从零实现2D绘图引擎:6.动画系统的实现

作者 irises
2025年12月3日 10:44

MiniRender仓库地址参考

动画系统 (Animation System)

这是让静态图表变成“活”图表的关键。我们的目标不是写死 requestAnimationFrame,而是构建一个声明式的动画库,让开发者只需要告诉引擎“我想去哪里”,引擎自动负责“怎么去”。


1. 核心模块设计

我们需要三个新文件:

  1. Easing.ts: 缓动函数库(提供 linear, cubicOut 等数学公式)。
  2. Animator.ts: 动画执行者(负责单个对象的属性插值计算)。
  3. Animation.ts: 动画管理器(负责全局的时间循环 Loop,调度所有 Animator)。

同时,我们需要修改 ElementMiniRender 类来集成这个系统。


2. 缓动函数 (src/animation/Easing.ts)

缓动函数输入一个 01 的时间进度 t,输出一个变换后的进度 p

// src/animation/Easing.ts

type EasingFunc = (t: number) => number;

export const Easing = {
    linear: (t: number) => t,
    
    // 二次缓动
    quadraticIn: (t: number) => t * t,
    quadraticOut: (t: number) => t * (2 - t),
    
    // 三次缓动 (常用,自然)
    cubicIn: (t: number) => t * t * t,
    cubicOut: (t: number) => --t * t * t + 1,
    
    // 弹性缓动
    elasticOut: (t: number) => {
        const c4 = (2 * Math.PI) / 3;
        return t === 0 ? 0 : t === 1 ? 1 :
            Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
    }
};

export type EasingType = keyof typeof Easing;

3. 动画执行者 (src/animation/Animator.ts)

这是最复杂的部分。它需要能够深度遍历对象,找出数值属性,并在起始值和目标值之间进行插值。

// src/animation/Animator.ts
import { Easing, EasingType } from './Easing';

export class Animator {
    target: any;
    
    private _startState: any = {};
    private _endState: any;
    
    private _duration: number;
    private _easing: EasingType;
    
    private _startTime: number = 0;
    private _delay: number = 0;
    
    private _onUpdate?: () => void;
    private _onDone?: () => void;
    
    // 标记动画是否已结束
    isFinished: boolean = false;

    constructor(target: any, endState: any, duration: number, easing: EasingType = 'linear') {
        this.target = target;
        this._endState = endState;
        this._duration = duration;
        this._easing = easing;
    }

    start(time: number) {
        this._startTime = time + this._delay;
        // 核心:在开始瞬间,克隆当前状态作为起始状态
        this._startState = this._cloneState(this._endState, this.target);
    }

    /**
     * 每一帧调用此方法
     * @param globalTime 全局时间戳
     * @return boolean 是否有变化
     */
    step(globalTime: number): boolean {
        if (this.isFinished) return false;
        if (globalTime < this._startTime) return false; // 还没到 delay 时间

        // 1. 计算进度 (0 ~ 1)
        let p = (globalTime - this._startTime) / this._duration;
        if (p >= 1) {
            p = 1;
            this.isFinished = true;
        }

        // 2. 应用缓动
        const easingFunc = Easing[this._easing] || Easing.linear;
        const v = easingFunc(p);

        // 3. 执行插值
        this._interpolate(this.target, this._startState, this._endState, v);

        // 4. 回调
        if (this._onUpdate) this._onUpdate();
        if (this.isFinished && this._onDone) this._onDone();

        return true;
    }
    
    // API: 设置延迟
    delay(ms: number) {
        this._delay = ms;
        return this;
    }
    
    // API: 完成回调
    done(cb: () => void) {
        this._onDone = cb;
        return this;
    }
    
    // API: 更新回调
    update(cb: () => void) {
        this._onUpdate = cb;
        return this;
    }

    // --- 内部辅助方法 ---

    // 递归插值:将 start 到 end 的值设置给 target
    private _interpolate(target: any, start: any, end: any, p: number) {
        for (const key in end) {
            const sVal = start[key];
            const eVal = end[key];

            if (typeof eVal === 'number' && typeof sVal === 'number') {
                // 数字:直接计算
                target[key] = sVal + (eVal - sVal) * p;
            } else if (Array.isArray(eVal) && Array.isArray(sVal)) {
                // 数组:递归处理 (如 position: [x, y])
                if (!target[key]) target[key] = [];
                this._interpolate(target[key], sVal, eVal, p);
            } else if (typeof eVal === 'object' && eVal !== null) {
                // 对象:递归处理 (如 style: { ... })
                if (!target[key]) target[key] = {};
                this._interpolate(target[key], sVal, eVal, p);
            }
            // 颜色插值比较复杂(涉及字符串解析),Stage 4 暂不实现,直接跳变
        }
    }

    // 递归克隆状态:只克隆 endState 中有的属性
    private _cloneState(end: any, source: any) {
        const res: any = {};
        for (const key in end) {
            const val = source[key];
            if (typeof end[key] === 'object' && end[key] !== null && !Array.isArray(end[key])) {
                // 递归对象
                res[key] = this._cloneState(end[key], val);
            } else if (Array.isArray(end[key])) {
                // 拷贝数组
                res[key] = Array.from(val || []);
            } else {
                // 基本类型
                res[key] = val;
            }
        }
        return res;
    }
}

4. 动画管理器 (src/animation/Animation.ts)

这是一个单例或者依附于 MiniRender 的管理器,负责驱动 requestAnimationFrame

// src/animation/Animation.ts
import { Animator } from './Animator';

export class Animation {
    private _animators: Animator[] = [];
    private _isRunning: boolean = false;

    // 注入 MiniRender 的刷新方法
    onFrame?: () => void;

    add(animator: Animator) {
        this._animators.push(animator);
        // 自动启动 Animator
        animator.start(Date.now());
        
        if (!this._isRunning) {
            this._startLoop();
        }
    }

    private _startLoop() {
        this._isRunning = true;

        const step = () => {
            const time = Date.now();
            let hasChange = false;

            // 倒序遍历,方便删除
            for (let i = this._animators.length - 1; i >= 0; i--) {
                const anim = this._animators[i];
                const changed = anim.step(time);
                if (changed) hasChange = true;

                // 移除已完成的动画
                if (anim.isFinished) {
                    this._animators.splice(i, 1);
                }
            }

            // 如果有属性变化,触发外部重绘
            if (hasChange && this.onFrame) {
                this.onFrame();
            }

            if (this._animators.length > 0) {
                requestAnimationFrame(step);
            } else {
                this._isRunning = false;
            }
        };

        requestAnimationFrame(step);
    }
}

5. 集成到核心系统

A. 修改 src/core/MiniRender.ts

初始化 Animation 模块,并建立“动画 -> 重绘”的桥梁。

import { Animation } from '../animation/Animation';
// ...

export class MiniRender {
    // ...
    animation: Animation;

    constructor(dom: HTMLElement) {
        // ...
        this.animation = new Animation();
        
        // 当动画产生帧更新时,调用 painter 刷新
        this.animation.onFrame = () => {
            this.painter.refresh();
        };
    }

    addAnimator(animator: any) {
        this.animation.add(animator);
    }
    // ...
}

B. 修改 src/graphic/Element.ts

给所有元素添加便捷 API animateTo

注意:我们需要一种方式让 Element 能访问到 MiniRender 实例或者 Animation 全局实例。为了解耦,MiniRender 在添加 Element 时,可以给 Element 注入一个 miniRender 引用,或者我们简单点,让 animateTo 返回一个 Animator 对象,由用户手动 miniRender.animation.add(),或者实现一个更高级的调度。

我们需要修改 Element.ts、Group.ts 和 MiniRender.ts 三个文件。

1). 修改 src/graphic/Element.ts

给 Element 增加一个 miniRender 属性。并在 animateTo 中检查这个属性。

// src/graphic/Element.ts
import { Animator } from '../animation/Animator';
import { EasingType } from '../animation/Easing';

export abstract class Element extends Eventful {
    // ... 原有属性 ...

    // 新增:持有对 MiniRender 实例的引用
    // 使用 any 是为了避免循环引用类型问题 (Element <-> MiniRender)
    miniRender: any = null; 

    animateTo(targetState: any, duration: number, easing: EasingType = 'linear', delay: number = 0) {
        const animator = new Animator(this, targetState, duration, easing);
        if (delay > 0) animator.delay(delay);
        
        this.animators.push(animator);

        if (this.miniRender) {
            this.miniRender.animation.add(animator);
        }

        return animator; // 依然返回,以便链式调用 .done() 等
    }
}
2). 定义遍历辅助函数

我们需要一个递归函数,当一个 Group 被加入时,把它底下所有的子孙节点的 miniRender 属性都设置好。为了避免复杂的循环依赖,我们可以把这个函数放在 src/utils/zrenderHelper.ts 或者直接作为 Element 的一个简单方法,这里我们修改 Group.ts 和 MiniRender.ts 来配合。

我们采用最简单的方式:在 添加子节点 时进行传递。

3).修改 src/graphic/Group.ts

当向 Group 添加子节点时,如果 Group 已经有了 miniRender,就传给子节点。

// src/graphic/Group.ts
import { Element } from './Element';

export class Group extends Element {
    // ...

    add(child: Element) {
        if (child && child !== this && child.parent !== this) {
            this.children.push(child);
            child.parent = this;

            if (this.miniRender) {
                // 递归设置子树
                this._propagateRender(child, this.miniRender);
            }
        }
    }

    /**
     * 辅助方法:递归向下传递 miniRender 引用
     */
    private _propagateRender(el: Element, miniRender: any) {
        el.miniRender = miniRender;
        if ((el as Group).isGroup) {
            const children = (el as Group).children;
            for (let i = 0; i < children.length; i++) {
                this._propagateRender(children[i], miniRender);
            }
        }
    }
}
4).修改 src/core/MiniRender.ts

这是入口。当用户调用 miniRender.add(el) 时,注入依赖。

// src/core/MiniRender.ts

export class MiniRender {
    // ...

    add(el: Element) {
        // 关键修改:根节点注入
        this._propagateRender(el, this);
        
        this.storage.addRoot(el);
        this.refresh();
    }

    // 复制 Group 中的那个辅助逻辑,或者提取成公共函数
    // 这里简单拷贝一份逻辑确保根节点也能递归
    private _propagateRender(el: Element, miniRender: any) {
        el.miniRender = miniRender;
        if ((el as any).isGroup) {
            const children = (el as any).children;
            for (let i = 0; i < children.length; i++) {
                this._propagateRender(children[i], miniRender);
            }
        }
    }
    
    // addAnimator 方法现在是给内部用的,或者作为高级 API 保留
    addAnimator(animator: any) {
        this.animation.add(animator);
    }
}

6. Demo

我们要修改之前的柱状图代码,让柱子在创建时高度为 0,然后长高。

逻辑分析

  1. 初始状态:
    • height: 0
    • y: chartHeight (即 X 轴的位置)
  2. 目标状态:
    • height: barHeight (真实高度)
    • y: chartHeight - barHeight (真实 Y 坐标)

修改 index.ts:

// ... 前面的代码不变

data.forEach((value, index) => {
    // ... 计算 barHeight, finalY 等 ...

    const bar = new Rect({
        shape: {
            x: finalX,
            y: chartConfig.height,
            width: chartConfig.barWidth,
            height: 0
        },
        style: { fill: chartConfig.barColor }
    });

    // 先 add 到 Group (此时 Group 还没加到 miniRender,所以 bar.miniRender 还是 null)
    chartGroup.add(bar);
    chartGroup.add(label);
    
    // 方式 1: 延迟动画
    // 因为 chartGroup 还没加到 miniRender,所以这里直接调 animateTo 不会立即启动
    // 但是!我们在 chartGroup 加到 miniRender 后,bar.miniRender 会被赋值。
    // 可是 animator 已经在 create 时判断过 miniRender 是 null 了,没有加进去。
    // 【修正逻辑】:
    // 我们的 animateTo 是“创建即启动”。
    // 如果 element 还没加到 miniRender,调用 animateTo 会创建 Animator 但不会加入 Animation Loop。
    // 这会导致动画“丢失”。
    
    // 为了解决这个问题,通常有两种写法:
    // 1. 先把 chartGroup 加到 miniRender,再创建图形和动画 (推荐)。
    // 2. Element 内部做一个 pendingAnimators 队列,当 miniRender 被赋值时自动 add (实现较复杂)。
    
    // 这里我们采用写法 1 (ZRender 的标准用法也是先 add 再 animate)。
});

// 1. 先把组加到引擎中!(此时所有 children 的 .miniRender 都会被赋值)
miniRender.add(chartGroup); 

// 2. 再遍历数据创建动画 (或者在创建 bar 时就 animate,前提是 bar 已经有 miniRender)
// 这里我们需要稍微调整代码顺序,或者在上面的 forEach 里改一下逻辑:

// === 最佳实践代码顺序 ===

// 1. 准备 Group
const chartGroup = new Group({ position: [chartConfig.x, chartConfig.y] });
miniRender.add(chartGroup); // <--- 先把 Group 挂载上去!

data.forEach((value, index) => {
    // ... 坐标计算 ...

    const bar = new Rect({ /*...*/ });
    
    // 2. bar 加入 chartGroup
    // 因为 chartGroup 已经在 miniRender 里了,bar 加入瞬间,Group.add 会把 miniRender 传给 bar
    chartGroup.add(bar); 
    
    // 3. 此时 bar.miniRender 已经有值了,直接开启动画!
    bar.animateTo(
        {
            shape: {
                y: finalY,
                height: finalHeight
            }
        },
        1000,
        'cubicOut',
        index * 100
    );
    // 不需要 miniRender.addAnimator(animator) 了!
});

animation转存失败,建议直接上传图片文件

7.阶段总结

我们已经从零构建了一个具备对象模型、渲染管线、交互系统、动画引擎的 Canvas 2D 引擎微内核(MiniRender)。它已经时一个具备扩展性的图形库雏形了。

我们目前的 MiniRender 已经实现了 ZRender v4/v5 的核心设计思想:

模块 类名 (Class) 核心职责 当前状态
入口 MiniRender 外观模式入口,协调各模块,管理主循环。 ✅ 已实现依赖注入
数据 Storage 场景图 (Scene Graph) 管理,维护显示列表,处理层级排序。 ✅ 支持 Group/Z-index
渲染 Painter 视图 (View),负责 Canvas 上下文管理、重绘循环 (refresh)。 ✅ 基础全量重绘
图形 Element / Displayable 节点 (Node),实现仿射变换矩阵 (transform)、父子级联。 ✅ 矩阵运算/样式封装
形状 Rect / Circle / Text 具体图形几何定义与包含检测 (contain)。 ✅ 基础形状
交互 Handler 控制器 (Controller),实现坐标逆变换,DOM 事件代理与分发。 ✅ Click/Hover/Silent
动画 Animation / Animator 声明式动画系统,支持缓动函数与属性插值。 ✅ 支持递归插值

从零实现2D绘图引擎:5.5.简单图表demo

作者 irises
2025年12月3日 10:41

MiniRender仓库地址参考

这正是检验“轮子”是否圆润的最佳时刻。我们将利用目前 MiniRender 已经具备的图形能力(Rect, Text)层级管理(Group, z-index)交互能力(Hover, Click),构建一个经典的柱状图(Bar Chart)

虽然我们还没有 ECharts 那样的高级配置项,但通过原生绘图指令,我们完全可以“手搓”一个出来。

功能点:

  1. 坐标轴:绘制 X 轴和 Y 轴(使用细长的 Rect 模拟线条)。
  2. 数据可视化:根据数据生成柱子。
  3. 交互反馈:鼠标悬停时,柱子高亮变色。
  4. 数据提示:悬停时,在柱子上方显示具体数值(简单的 Tooltip)。

代码实现 (index.ts)

请将以下代码放入你的入口文件。

import { init } from './core/MiniRender';
import { Group } from './graphic/Group';
import { Rect } from './graphic/shape/Rect';
import { Text } from './graphic/Text';

// 1. 初始化引擎
const dom = document.getElementById('main')!;
const miniRender = init(dom);

// --- 配置数据 ---
const data = [120, 200, 150, 80, 70, 110, 130];
const categories = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];

// --- 图表布局配置 ---
const chartConfig = {
    x: 50,          // 图表左边距
    y: 50,          // 图表上边距
    width: 500,     // 绘图区宽度
    height: 300,    // 绘图区高度
    barWidth: 30,   // 柱子宽度
    barColor: '#5470C6', // 默认颜色
    hoverColor: '#91CC75' // 高亮颜色
};

// 创建一个组来容纳整个图表,方便整体移动
const chartGroup = new Group({
    position: [chartConfig.x, chartConfig.y]
});

// --- 第一步:绘制坐标轴 (使用 Rect 模拟线) ---

// Y轴 (左侧竖线)
const yAxis = new Rect({
    shape: {
        x: 0, 
        y: 0, 
        width: 1, 
        height: chartConfig.height
    },
    style: { fill: '#333' }
});

// X轴 (底部横线)
const xAxis = new Rect({
    shape: {
        x: 0, 
        y: chartConfig.height, 
        width: chartConfig.width, 
        height: 1
    },
    style: { fill: '#333' }
});

chartGroup.add(yAxis);
chartGroup.add(xAxis);

// --- 第二步:准备 Tooltip (浮动提示文字) ---
// 我们创建一个共享的 Text 对象,默认隐藏,悬停时移动位置并显示
const tooltip = new Text({
    style: {
        text: '',
        fill: '#000',
        fontSize: 14,
        fontWeight: 'bold',
        textAlign: 'center',
        textBaseline: 'bottom'
    },
    z: 10,       // 保证在最上层
    invisible: true, // 初始隐藏
    silent: true // 关键:让 Tooltip 不阻挡鼠标,防止闪烁
});
// Tooltip 不加到 chartGroup,而是直接加到 miniRender,防止受 group 变换影响(虽然这里 chartGroup 没旋转)
// 但为了坐标方便,加到 chartGroup 里更容易计算相对坐标
chartGroup.add(tooltip);


// --- 第三步:绘制柱子与标签 ---

// 计算每个柱子的间隔
const step = chartConfig.width / data.length;
const maxVal = Math.max(...data);

data.forEach((value, index) => {
    // 1. 数据映射计算
    // 高度比例:value / maxVal
    const barHeight = (value / maxVal) * (chartConfig.height - 40); // 留40px顶部余量
    
    // 柱子左上角坐标 (相对于 chartGroup)
    // x = 间隔 * 索引 + 居中偏移
    const x = index * step + (step - chartConfig.barWidth) / 2;
    // y = 底部Y - 柱子高度
    const y = chartConfig.height - barHeight;

    // 2. 创建柱子
    const bar = new Rect({
        shape: {
            x: x,
            y: y,
            width: chartConfig.barWidth,
            height: barHeight
        },
        style: {
            fill: chartConfig.barColor
        }
    });

    // 3. 创建 X 轴分类文本
    const label = new Text({
        style: {
            text: categories[index],
            fill: '#666',
            fontSize: 12,
            textAlign: 'center',
            textBaseline: 'top'
        },
        position: [x + chartConfig.barWidth / 2, chartConfig.height + 10],
        silent: true // 文本不响应交互
    });

    // 4. 绑定交互事件
    bar.on('mouseover', () => {
        // 柱子变色
        bar.style.fill = chartConfig.hoverColor;
        
        // 显示 Tooltip
        tooltip.invisible = false;
        tooltip.style.text = `${value}`; // 设置数值
        // 移动 Tooltip 到柱子顶部中间
        tooltip.x = x + chartConfig.barWidth / 2;
        tooltip.y = y - 5; // 往上飘一点

        miniRender.refresh();
    });

    bar.on('mouseout', () => {
        // 颜色复原
        bar.style.fill = chartConfig.barColor;
        
        // 隐藏 Tooltip
        tooltip.invisible = true;

        miniRender.refresh();
    });

    chartGroup.add(bar);
    chartGroup.add(label);
});

// 将整个图表组添加到引擎
miniRender.add(chartGroup);

// 渲染第一帧
miniRender.refresh();

chart.gif

从零实现2D绘图引擎:5.鼠标悬停事件

作者 irises
2025年12月3日 10:39

MiniRender仓库地址参考

好的,我们开始 悬停事件 (Hover Events) 的实现。

这是交互体验中质的飞跃。目前的点击是“瞬间”的,而悬停是“连续”的状态管理。

核心逻辑分析

要实现 mouseover (移入) 和 mouseout (移出),Handler 需要记忆上一帧鼠标在哪一个图形上。

我们定义:

  • target: 当前鼠标下的图形。
  • _hovered: 上一次鼠标所在的图形(缓存状态)。

逻辑如下:

  1. 监听 DOM 的 mousemove
  2. 计算当前鼠标下的 target
  3. 对比 target_hovered
    • 如果 target !== _hovered,说明发生了状态切换:
      • 如果有 _hovered,对它触发 mouseout
      • 如果有 target,对它触发 mouseover
      • 更新 _hovered = target
  4. 为了更好的体验,当有 target 时,我们将鼠标指针设为手型 (pointer),否则设为默认 (default)。

1. 修改 Handler (src/handler/Handler.ts)

我们需要给 Handler 类添加状态属性,并增加 mousemove 的监听逻辑。

为了代码整洁,我提取了一个 _getEventPoint 方法来复用坐标计算。

// src/handler/Handler.ts
import { Storage } from '../storage/Storage';
import { Painter } from '../painter/Painter';
import { Displayable } from '../graphic/Displayable';

export class Handler {
    storage: Storage;
    painter: Painter;
    dom: HTMLElement;

    // 状态缓存:记录当前正悬停的元素
    private _hovered: Displayable | null = null;

    constructor(storage: Storage, painter: Painter, dom: HTMLElement) {
        this.storage = storage;
        this.painter = painter;
        this.dom = dom;
        this._initDomEvents();
    }

    private _initDomEvents() {
        // 绑定 this 上下文
        this.dom.addEventListener('click', this._clickHandler.bind(this));
        this.dom.addEventListener('mousemove', this._mouseMoveHandler.bind(this));
        // 这里还可以加 mousedown, mouseup 等
    }

    /**
     * 辅助方法:获取相对于 Canvas 左上角的坐标
     */
    private _getEventPoint(e: MouseEvent) {
        const rect = this.dom.getBoundingClientRect();
        return {
            x: e.clientX - rect.left,
            y: e.clientY - rect.top
        };
    }

    private _clickHandler(e: MouseEvent) {
        const { x, y } = this._getEventPoint(e);
        const target = this._findHover(x, y);

        if (target) {
            target.trigger('click', { target, event: e });
        }
    }

    /**
     * 核心:处理鼠标移动,计算 Hover 状态
     */
    private _mouseMoveHandler(e: MouseEvent) {
        const { x, y } = this._getEventPoint(e);
        const target = this._findHover(x, y);
        const lastHovered = this._hovered;

        // 如果鼠标下的元素变了
        if (target !== lastHovered) {
            
            // 1. 处理移出 (MouseOut)
            // 如果之前有悬停元素,说明从那个元素出来了
            if (lastHovered) {
                lastHovered.trigger('mouseout', { target: lastHovered, event: e });
            }

            // 2. 处理移入 (MouseOver)
            // 如果当前有元素,说明进入了这个元素
            if (target) {
                target.trigger('mouseover', { target: target, event: e });
            }

            // 3. 更新状态
            this._hovered = target;
        }

        // 4. 处理鼠标移动 (MouseMove)
        // 即使目标没变,也可以触发 move 事件
        if (target) {
            target.trigger('mousemove', { target, event: e });
        }

        // 5. 设置光标样式 (UX 优化)
        if (target) {
            this.dom.style.cursor = 'pointer';
        } else {
            this.dom.style.cursor = 'default';
        }
    }

    private _findHover(x: number, y: number): Displayable | null {
        const list = this.storage.getDisplayList();
        for (let i = list.length - 1; i >= 0; i--) {
            const el = list[i];
            if (el.invisible) continue;
            if (el.contain(x, y)) {
                return el;
            }
        }
        return null;
    }
}

2. 验证

现在我们来写一个复杂的 Demo。我们将创建多个矩形,每个矩形都有独立的悬停变色效果。

index.ts:

import { init } from './core/MiniRender';
import { Rect } from './graphic/shape/Rect';
import { Text } from './graphic/Text';

const miniRender = init(document.getElementById('main')!);

// 创建 5 个卡片
for (let i = 0; i < 5; i++) {
    const x = 50 + i * 110;
    const y = 100;

    // --- 创建矩形 ---
    const rect = new Rect({
        shape: { x: x, y: y, width: 100, height: 100, r: 5 },
        style: {
            fill: '#FFF',
            stroke: '#999',
            lineWidth: 2
        }
    });

    // --- 创建文本 ---
    const text = new Text({
        style: {
            text: `Card ${i + 1}`,
            fill: '#666',
            fontSize: 14,
            textAlign: 'center',
            textBaseline: 'middle'
        },
        position: [x + 50, y + 50],
        z: 1,
    });

    // --- 绑定交互事件 ---
    
    // 1. 移入高亮
    rect.on('mouseover', () => {
        console.log(`Mouse Over Rect ${i}`);
        
        // 变色
        rect.style.fill = '#E6F7FF'; // 浅蓝背景
        rect.style.stroke = '#1890FF'; // 深蓝边框
        
        // 放大动画效果(这里手动改 scale)
        // 稍微放大一点,注意 scale 是以 origin 为中心的
        // 我们还没有实现自动计算中心,所以这里手动设
        rect.origin = [x + 50, y + 50]; 
        rect.scale = [1.1, 1.1];

        miniRender.refresh();
    });

    // 2. 移出恢复
    rect.on('mouseout', () => {
        console.log(`Mouse Out Rect ${i}`);
        
        // 恢复颜色
        rect.style.fill = '#FFF';
        rect.style.stroke = '#999';
        
        // 恢复大小
        rect.scale = [1, 1];

        miniRender.refresh();
    });

    miniRender.add(rect);
    miniRender.add(text);
}

// 简单的提示
const tip = new Text({
    style: {
        text: 'Try Hovering on the cards!',
        fill: '#333',
        fontSize: 18,
    },
    position: [50, 30]
});
miniRender.add(tip);

// 渲染
miniRender.refresh();

预期效果

  1. 光标变化:当鼠标移动到卡片(白色矩形)上时,鼠标指针会变成手型。
  2. 高亮反馈
    • 移入时:卡片变大(1.1倍),背景变浅蓝,边框变深蓝。
    • 移出时:卡片恢复原状。
  3. 日志:控制台会打印出对应的 Mouse OverMouse Out
  4. 状态切换:如果你快速从卡片 1 移到卡片 2(不经过空白区),你会发现卡片 1 立刻恢复,卡片 2 立刻高亮。这就是 target !== lastHovered 逻辑在起作用。

3.缺陷

在上面的代码中,如果鼠标移到了中间的文字 Card N 上:

  1. 因为 Text 也是个 Displayable,而且在 Rect 上面 (z: 1)。
  2. Handler 会认为 target 变成了 Text
  3. 于是 Rect 会触发 mouseout(变回白色)。
  4. 如果你没给 Text 绑定事件,它就没有反应。

结果:鼠标在卡片边缘是高亮的,一移到文字上,卡片就“灭”了。

解决方案思路: 在 ZRender 和 ECharts 中,有一个属性叫 silent (静默)。 如果 el.silent = true,则 Handler_findHover 遍历时会直接跳过它 (continue)。这样检测到的 target 就会是底下的 Rect。

  1. 修改 src/graphic/Displayable.ts
export interface DisplayableProps extends ElementProps {
    // ...
    silent?: boolean; // 新增:是否响应交互
}

export abstract class Displayable extends Element {
    // ...
    silent: boolean = false;

    constructor(opts?: DisplayableProps) {
        super(opts);
        // ...
        if (opts && opts.silent != null) this.silent = opts.silent;
    }
}
  1. 修改 src/handler/Handler.ts_findHover
    private _findHover(x: number, y: number): Displayable | null {
        const list = this.storage.getDisplayList();
        for (let i = list.length - 1; i >= 0; i--) {
            const el = list[i];
            // 增加 !el.silent 判断
            if (el.invisible || el.silent) continue; 
            if (el.contain(x, y)) {
                return el;
            }
        }
        return null;
    }
  1. 修改 index.ts 中的 Text 创建:
    const text = new Text({
        // ... 样式
        silent: true // 关键!让文字不阻挡鼠标
    });

现在,鼠标移到文字上时,Handler 会忽略文字,直接检测到下面的 Rect,Hover 效果就不会中断了。

hover.gif

preconnect、dns-prefetch、prerender、preload、prefetch

作者 ximimimi
2025年12月3日 10:38

1. preconnect:提前建立连接

什么是preconnect?

preconnect 指令告诉浏览器:当前页面很快就会与某个第三方域名建立连接,请提前完成DNS解析、TCP握手和TLS协商。这可以节省100-500毫秒的连接建立时间。

适用场景

  • 关键第三方资源(如CDN字体、样式表)
  • 已知的API端点
  • 社交媒体小部件
  • 分析脚本

在React中使用preconnect

方法一:在HTML模板中静态添加

public/index.html<head>部分:

html

<!DOCTYPE html>
<html>
<head>
  <!-- 提前连接字体服务 -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  
  <!-- 提前连接API服务器 -->
  <link rel="preconnect" href="https://api.example.com">
  
  <!-- 提前连接CDN -->
  <link rel="preconnect" href="https://cdn.example.com">
</head>
<body>
  <div id="root"></div>
</body>
</html>

方法二:使用React Helmet动态管理

bash

npm install react-helmet-async

jsx

import { HelmetProvider, Helmet } from 'react-helmet-async';

function App() {
  return (
    <HelmetProvider>
      <div>
        <Helmet>
          <link rel="preconnect" href="https://fonts.googleapis.com" />
          <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
          <link rel="preconnect" href="https://api.example.com" />
        </Helmet>
        {/* 应用内容 */}
      </div>
    </HelmetProvider>
  );
}

方法三:基于用户交互智能预连接

jsx

import { useEffect, useRef } from 'react';

function ProductCard({ productId }) {
  const cardRef = useRef(null);

  useEffect(() => {
    const card = cardRef.current;
    
    const handleMouseEnter = () => {
      // 当用户悬停在商品卡片上时,预连接详情API
      const link = document.createElement('link');
      link.rel = 'preconnect';
      link.href = `https://api.example.com/products/${productId}`;
      document.head.appendChild(link);
    };

    card.addEventListener('mouseenter', handleMouseEnter);
    
    return () => {
      card.removeEventListener('mouseenter', handleMouseEnter);
    };
  }, [productId]);

  return <div ref={cardRef}>商品卡片</div>;
}

最佳实践

  1. 只对关键第三方资源使用preconnect
  2. 为跨域资源添加crossorigin属性
  3. 最多预连接4-6个域名,避免过度使用
  4. 结合dns-prefetch作为后备

2. dns-prefetch:提前DNS解析

什么是dns-prefetch?

dns-prefetch 告诉浏览器:提前解析指定域名的DNS,但不建立TCP连接。DNS解析通常需要20-120毫秒,提前解析可以显著减少后续请求的延迟。

与preconnect的区别

特性 dns-prefetch preconnect
作用 仅DNS解析 DNS + TCP + TLS
开销 中等
适用场景 非关键第三方资源 关键第三方资源

在React中使用dns-prefetch

jsx

// 在应用的根组件或布局组件中
import { Helmet } from 'react-helmet-async';

function Layout() {
  return (
    <>
      <Helmet>
        {/* 为分析服务提前解析DNS */}
        <link rel="dns-prefetch" href="https://www.google-analytics.com" />
        
        {/* 为社交媒体插件提前解析DNS */}
        <link rel="dns-prefetch" href="https://connect.facebook.net" />
        
        {/* 为CDN资源提前解析DNS */}
        <link rel="dns-prefetch" href="https://cdnjs.cloudflare.com" />
      </Helmet>
      {/* 页面布局 */}
    </>
  );
}

自动化dns-prefetch

jsx

// 自动为页面中所有第三方链接添加dns-prefetch
import { useEffect } from 'react';

function useAutoDnsPrefetch() {
  useEffect(() => {
    // 收集页面中所有的外部链接
    const externalLinks = Array.from(document.querySelectorAll('a[href^="http"]'))
      .map(link => {
        try {
          return new URL(link.href).origin;
        } catch {
          return null;
        }
      })
      .filter(Boolean)
      .filter(origin => origin !== window.location.origin);

    // 去重
    const uniqueOrigins = [...new Set(externalLinks)];

    // 为每个唯一域名添加dns-prefetch
    uniqueOrigins.forEach(origin => {
      if (!document.querySelector(`link[href="${origin}"]`)) {
        const link = document.createElement('link');
        link.rel = 'dns-prefetch';
        link.href = origin;
        document.head.appendChild(link);
      }
    });
  }, []);
}

// 在应用中使用
function App() {
  useAutoDnsPrefetch();
  return <div>应用内容</div>;
}

3. prerender:提前渲染页面(谨慎使用)

什么是prerender?

prerender 是最激进的资源提示,它告诉浏览器:在后台完全加载并渲染整个页面。当用户导航到该页面时,可以立即显示。

风险与注意事项

  1. 高流量消耗:预渲染会加载页面所有资源
  2. 高CPU/内存占用:在后台渲染整个页面
  3. 可能浪费资源:如果用户不访问该页面
  4. 隐私问题:可能预加载需要认证的页面

适用场景

  • 用户极有可能访问的下一页(如购物车→结账)
  • 单页应用的初始路由
  • 登录后的首个页面

在React中谨慎使用prerender

jsx

import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';

function useSmartPrerender() {
  const location = useLocation();
  const [prerenderedPages, setPrerenderedPages] = useState(new Set());

  useEffect(() => {
    // 根据当前页面决定预渲染哪些页面
    const predictions = {
      '/products': ['/product/123', '/cart'],
      '/cart': ['/checkout'],
      '/': ['/login', '/register']
    };

    const predictedPaths = predictions[location.pathname] || [];
    
    predictedPaths.forEach(path => {
      if (!prerenderedPages.has(path)) {
        // 创建prerender链接
        const link = document.createElement('link');
        link.rel = 'prerender';
        link.href = `${window.location.origin}${path}`;
        document.head.appendChild(link);
        
        // 限制预渲染时间,避免长期占用资源
        setTimeout(() => {
          if (link.parentNode) {
            link.parentNode.removeChild(link);
          }
        }, 30000); // 30秒后移除
        
        setPrerenderedPages(prev => new Set([...prev, path]));
      }
    });
  }, [location.pathname, prerenderedPages]);
}

// 在应用中使用
function App() {
  useSmartPrerender();
  return <div>应用内容</div>;
}

替代方案:部分预渲染

jsx

// 只预渲染关键组件,而不是整个页面
function PredictiveLoader() {
  const [preloadedComponents, setPreloadedComponents] = useState({});

  // 预测用户可能需要的组件
  const predictComponents = (currentPage) => {
    const predictions = {
      homepage: ['LoginForm', 'FeaturedProducts'],
      productList: ['ProductFilters', 'Pagination'],
      cart: ['CheckoutButton', 'Recommendations']
    };
    
    return predictions[currentPage] || [];
  };

  const preloadComponent = async (componentName) => {
    if (!preloadedComponents[componentName]) {
      // 动态导入组件(Webpack代码分割)
      const module = await import(`./components/${componentName}`);
      setPreloadedComponents(prev => ({
        ...prev,
        [componentName]: module.default
      }));
    }
  };

  // 根据用户行为预加载组件
  useEffect(() => {
    const components = predictComponents(currentPage);
    components.forEach(componentName => {
      // 在空闲时间预加载
      if ('requestIdleCallback' in window) {
        requestIdleCallback(() => preloadComponent(componentName));
      } else {
        setTimeout(() => preloadComponent(componentName), 1000);
      }
    });
  }, [currentPage]);

  return null;
}

4. preload:提前加载关键资源

什么是preload?

preload 告诉浏览器:当前页面需要这个资源,请立即以高优先级加载。它强制浏览器提前发现并加载资源,避免资源加载过晚导致的渲染阻塞。

关键特性

  • 立即加载,优先级高
  • 必须指定as属性(script、style、font等)
  • 支持onload回调
  • 会触发同源策略

在React中使用preload

预加载关键字体

jsx

import { Helmet } from 'react-helmet-async';

function FontPreloader() {
  return (
    <Helmet>
      <link
        rel="preload"
        href="/fonts/roboto-bold.woff2"
        as="font"
        type="font/woff2"
        crossOrigin="anonymous"
      />
      <link
        rel="preload"
        href="/fonts/roboto-regular.woff2"
        as="font"
        type="font/woff2"
        crossOrigin="anonymous"
      />
    </Helmet>
  );
}

预加载关键图片

jsx

function HeroImage({ imageUrl }) {
  useEffect(() => {
    // 动态预加载英雄图片
    const link = document.createElement('link');
    link.rel = 'preload';
    link.href = imageUrl;
    link.as = 'image';
    link.onload = () => {
      console.log('Hero image preloaded');
      // 可以在这里触发一些动画或状态变化
    };
    document.head.appendChild(link);
    
    return () => {
      if (link.parentNode) {
        link.parentNode.removeChild(link);
      }
    };
  }, [imageUrl]);

  return <img src={imageUrl} alt="Hero" />;
}

预加载关键脚本

jsx

// 预加载延迟加载的组件
import { useEffect } from 'react';

function useComponentPreloader(componentPath) {
  useEffect(() => {
    // 使用Webpack的魔法注释预加载
    import(/* webpackPreload: true */ `./components/${componentPath}`);
  }, [componentPath]);
}

function LazyComponentWrapper() {
  // 当用户悬停在按钮上时,预加载组件
  const handleMouseEnter = () => {
    useComponentPreloader('ExpensiveChart');
  };

  return (
    <button onMouseEnter={handleMouseEnter}>
      悬停我预加载图表组件
    </button>
  );
}

Webpack配置中的preload

javascript

// webpack.config.js
module.exports = {
  // ...
  plugins: [
    new PreloadWebpackPlugin({
      rel: 'preload',
      include: 'initial',
      fileBlacklist: [/.map$/, /hot-update.js$/],
    }),
    new PreloadWebpackPlugin({
      rel: 'prefetch',
      include: 'asyncChunks',
    }),
  ],
};

5. prefetch:空闲时预加载资源

什么是prefetch?

prefetch 告诉浏览器:未来可能需要这个资源,请在空闲时以低优先级加载。它不会阻塞关键资源,而是利用浏览器空闲时间提前准备。

适用场景

  • 用户可能访问的下一页资源
  • 单页应用的路由代码分割
  • 非关键的功能脚本
  • 预测性加载

在React中使用prefetch

路由级预取(React Router)

jsx

import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

function useRoutePrefetch() {
  const location = useLocation();
  const navigate = useNavigate();

  useEffect(() => {
    // 根据当前路径预测下一个可能的路由
    const routePredictions = {
      '/': ['/dashboard', '/login'],
      '/products': ['/product/featured', '/cart'],
      '/cart': ['/checkout', '/payment'],
    };

    const predictedRoutes = routePredictions[location.pathname] || [];
    
    predictedRoutes.forEach(route => {
      // 预取路由对应的JS chunk
      const link = document.createElement('link');
      link.rel = 'prefetch';
      link.href = `/static/js/${route.replace(///g, '_')}.chunk.js`;
      link.as = 'script';
      document.head.appendChild(link);
    });
  }, [location.pathname]);
}

// 在应用中使用
function App() {
  useRoutePrefetch();
  
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/products" element={<Products />} />
      <Route path="/cart" element={<Cart />} />
    </Routes>
  );
}

基于Intersection Observer的智能预取

jsx

import { useEffect, useRef } from 'react';

function LazySection({ componentName, threshold = 0.1 }) {
  const sectionRef = useRef(null);
  const hasPrefetched = useRef(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting && !hasPrefetched.current) {
            // 当组件进入视口时,预取相关资源
            prefetchComponent(componentName);
            hasPrefetched.current = true;
            observer.unobserve(entry.target);
          }
        });
      },
      { threshold }
    );

    if (sectionRef.current) {
      observer.observe(sectionRef.current);
    }

    return () => observer.disconnect();
  }, [componentName, threshold]);

  const prefetchComponent = async (name) => {
    // 预取组件及其依赖
    const prefetchPromises = [
      // 组件本身
      import(`./components/${name}`),
      // 组件可能需要的样式
      fetch(`/css/${name}.css`),
      // 组件可能需要的图片
      name === 'Gallery' && fetch('/api/gallery-images'),
    ].filter(Boolean);

    await Promise.all(prefetchPromises);
  };

  return <div ref={sectionRef}>懒加载区域</div>;
}

用户行为触发的预取

jsx

function ProductRecommendations() {
  const [prefetchedProducts, setPrefetchedProducts] = useState(new Set());

  const handleProductHover = (productId) => {
    if (!prefetchedProducts.has(productId)) {
      // 预取产品详情
      const links = [
        { url: `/api/products/${productId}`, as: 'fetch' },
        { url: `/images/products/${productId}.jpg`, as: 'image' },
        { url: `/js/product-detail.chunk.js`, as: 'script' },
      ];

      links.forEach(({ url, as }) => {
        const link = document.createElement('link');
        link.rel = 'prefetch';
        link.href = url;
        link.as = as;
        document.head.appendChild(link);
      });

      setPrefetchedProducts(prev => new Set([...prev, productId]));
    }
  };

  return (
    <div>
      {products.map(product => (
        <div
          key={product.id}
          onMouseEnter={() => handleProductHover(product.id)}
          className="product-card"
        >
          {product.name}
        </div>
      ))}
    </div>
  );
}

综合最佳实践

1. 优先级策略

jsx

function ResourcePriorityManager() {
  return (
    <Helmet>
      {/* 最高优先级:首屏关键资源 */}
      <link rel="preload" href="/critical.css" as="style" />
      <link rel="preload" href="/critical.js" as="script" />
      
      {/* 高优先级:关键第三方资源 */}
      <link rel="preconnect" href="https://fonts.googleapis.com" />
      <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
      
      {/* 中等优先级:首屏字体 */}
      <link
        rel="preload"
        href="/fonts/primary.woff2"
        as="font"
        type="font/woff2"
        crossorigin
      />
      
      {/* 低优先级:预测性资源 */}
      <link rel="dns-prefetch" href="https://analytics.example.com" />
      <link rel="prefetch" href="/next-page-data.json" as="fetch" />
    </Helmet>
  );
}

2. 性能监控与调整

jsx

function ResourceHintsMonitor() {
  useEffect(() => {
    // 监控资源提示的效果
    const observer = new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        const data = {
          name: entry.name,
          duration: entry.duration,
          initiatorType: entry.initiatorType,
          startTime: entry.startTime,
        };
        
        // 发送到分析服务
        console.log('资源加载性能:', data);
        
        // 根据性能数据动态调整策略
        if (entry.duration > 1000) {
          console.warn(`资源 ${entry.name} 加载过慢,考虑优化`);
        }
      });
    });
    
    observer.observe({ entryTypes: ['resource'] });
    
    return () => observer.disconnect();
  }, []);
  
  return null;
}

总结

浏览器资源提示是优化页面加载性能的强大工具,但需要根据具体场景合理使用:

  1. preconnect:用于关键第三方资源,提前建立连接
  2. dns-prefetch:用于非关键第三方资源,提前解析DNS
  3. prerender:谨慎使用,仅用于高度可预测的页面
  4. preload:用于当前页面的关键资源,立即加载
  5. prefetch:用于未来可能需要的资源,空闲时加载

前端转战后端:JavaScript 与 Java 对照学习指南(第三篇 —— Map 对象)

作者 汤姆Tom
2025年12月3日 10:38

当前端工程师开始向 Java 后端开发迈进时,“Map” 是最常遇到的概念之一。
然而,同样叫 Map,JavaScript 与 Java 的实现却截然不同:

  • JS 的 Map 是一个 轻量的键值对集合
  • Java 的 Map 是一个 接口体系,背后有多种实现(HashMap、LinkedHashMap、TreeMap……),并伴随严格的类型系统和丰富的工程化功能。

为了帮助前端开发者顺利迁移到 Java 后端思维,本篇从基础到进阶、从 API 到底层、从代码到最佳实践,对比解析 JS 与 Java 中的 Map。

Map 是什么?整体概念对比

特性 JavaScript Map Java Map
类型性质 内置对象 接口(有多种实现)
键类型 任意类型 由泛型决定,必须是对象
迭代顺序 保持插入顺序 取决于具体实现类
类型检查 弱类型 强类型
线程安全 可通过 ConcurrentHashMap
是否可扩展 丰富(SortedMap、ConcurrentMap…)

一句话总结:
JavaScript 的 Map 是灵活的小工具;Java 的 Map 是完整的集合框架体系。


Map 的创建 —— 灵活 vs 强类型

JavaScript

const map = new Map();

const map2 = new Map([
  ["name", "Alice"],
  ["age", 20]
]);

特点:

  • 自动决定类型
  • 可用二元数组初始化

Java

Map<String, Object> map = new HashMap<>();

Map<String, Object> map2 = Map.of(
    "name", "Alice",
    "age", 20
);

特点:

  • 必须指定泛型类型 Map<K, V>
  • Map.of() 是不可变 Map
  • 常用实现:HashMap

强类型是后端稳定性的重要保证。


常用 API 全面对照表

操作 JavaScript Java
新增 set(key, value) put(key, value)
查询 get(key) get(key)
判断是否存在 has(key) containsKey(key)
删除 delete(key) remove(key)
清空 clear() clear()
大小 map.size map.size()
遍历 for-of for-each + entrySet()

增删改查全对照(含正确与错误示例)

1. 新增元素

JavaScript

map.set("name", "Alice");

Java

map.put("name", "Alice");

错误示例(Java)

map["name"] = "Alice";   // ❌ JS 写法

2. 修改元素

JS 和 Java 都是覆盖:

JavaScript

map.set("name", "Bob");

Java

map.put("name", "Bob");

3. 查询元素

JavaScript

map.get("name"); // Bob

Java

map.get("name");  // Bob

但 Java 有 Optional 风格建议

String name = Optional.ofNullable(map.get("name"))
                      .orElse("default");

4. 删除

JavaScript

map.delete("name");

Java

map.remove("name");

5. 大小

JavaScript

map.size;

Java

map.size();

遍历方式对比(Java 共有 6 种)

JavaScript(天生可迭代)

for (const [key, value] of map) {}
for (const key of map.keys()) {}
for (const val of map.values()) {}
map.forEach((v, k) => console.log(k, v));

Java(复杂但工程化)

1. entrySet() —— 最常用、最高效

for (Map.Entry<String, Object> e : map.entrySet()) {
    System.out.println(e.getKey() + "=" + e.getValue());
}

2. keySet()

for (String key : map.keySet()) {}

3. values()

for (Object v : map.values()) {}

4. forEach(Java 8)

map.forEach((k, v) -> System.out.println(k + "=" + v));

5. Stream API

map.entrySet()
   .stream()
   .filter(e -> e.getKey().startsWith("a"))
   .forEach(System.out::println);

6. Iterator(旧版,不推荐但面试爱问)

Iterator<Map.Entry<String, Object>> it = map.entrySet().iterator();
while (it.hasNext()) {
    Map.Entry e = it.next();
}

键类型的本质区别(非常重要)

JavaScript 键类型:无限制

map.set({}, "obj");
map.set(function(){}, "fn");
map.set(1, "num");
map.set("1", "string");

注意:map.set(1, ...)map.set("1", ...) 是不同键!


Java 键类型:必须是对象,且由泛型决定

Map<Object, String> map = new HashMap<>();
map.put(1, "num");      // 自动装箱为 Integer
map.put("1", "string");

Java 中相同类型的对象依赖 equals()hashCode()

例如:

class Person {
    String name;
}

// 无 equals / hashCode
Map<Person, String> map = new HashMap<>();
map.put(new Person("A"), "data");
map.get(new Person("A")); // ❌ null

因为 Java 使用 hashCode 判断键是否相等。
JS 没有这个问题。


底层原理对照:HashMap 内部结构

JavaScript Map(简单)

  • 内部是一个哈希结构
  • 自动处理扩容
  • 保持插入顺序
  • 无视键的类型

Java HashMap(复杂但优秀)

Java HashMap 底层结构:

数组 + 链表 + 红黑树

流程:

  1. key 通过 hashCode() → 计算哈希值
  2. 映射到数组 index
  3. 如果冲突,使用链表
  4. 链表长度 > 8 时转换为红黑树(提高性能)

时期:

  • Java 8 之前:只用链表
  • Java 8 之后:链表 + 树并存

这是 Java Map 能支持大规模数据高性能访问的关键。


Java 的 Map 实现全家桶

实现类 特点 应用场景
HashMap 无序,最快 最常用
LinkedHashMap 有序(按插入顺序) 需要排序输出
TreeMap 按 key 自动排序(红黑树) 按字典序排序
Hashtable 线程安全,过时 不推荐
ConcurrentHashMap 高并发环境下的线程安全 Map Web 服务场景
WeakHashMap 弱引用键,会自动回收 缓存

JS 只有一个简单的 Map,远不如 Java 丰富。


典型应用场景对比

1. 统计词频(前端刷题常用)

JavaScript

const map = new Map();
for (let ch of str) {
  map.set(ch, (map.get(ch) || 0) + 1);
}

Java

Map<Character, Integer> map = new HashMap<>();
for (char ch : str.toCharArray()) {
    map.put(ch, map.getOrDefault(ch, 0) + 1);
}

2. 缓存数据

JavaScript

const cache = new Map();
cache.set(key, result);

Java

Map<String, Object> cache = new ConcurrentHashMap<>();
cache.put(key, result);

Java 需要考虑多线程场景,因此有线程安全版本。


最佳实践与常见坑

JavaScript Map

✔ 最佳实践

  • 用 Map 替代对象 {} 做键值存储
  • 键不是字符串的时候(对象、函数)

❌ 常见坑

  • 对象作为 key 时必须是同一个引用

Java Map

✔ 最佳实践

  1. 覆盖类的 equals()hashCode() 使之可作为 key
  2. 大多数场景默认用 HashMap
  3. 并发场景用 ConcurrentHashMap
  4. 需要顺序则用 LinkedHashMap
  5. 大数据量避免使用嵌套 Map(过于复杂)

❌ 常见坑

  • 使用 Arrays.asList() 初始化导致不可变
  • 忘记重写 equals/hashCode 导致取不到值
  • 并发环境用 HashMap 导致死循环(旧版本问题)

总结

对比维度 JavaScript Map Java Map
灵活性 ⭐⭐⭐⭐⭐ ⭐⭐⭐
类型安全 ⭐⭐⭐⭐⭐
性能(大规模) ⭐⭐⭐ ⭐⭐⭐⭐⭐
可扩展性 ⭐⭐ ⭐⭐⭐⭐⭐
学习曲线 ⭐⭐ ⭐⭐⭐⭐

从零实现2D绘图引擎:4.矩形与文本的实现

作者 irises
2025年12月3日 10:37

MiniRender仓库地址参考

  • 矩形 (Rect):这是所有 UI 组件(按钮、卡片、背景)的基础。
  • 文本 (Text):这是信息展示的核心。

相较于圆,文本的难点在于“包围盒计算”(为了支持点击检测),因为 Canvas 只有画图命令,没有直接告诉我们字有多高。

我们将分两步实现。

1. 实现矩形 (src/graphic/shape/Rect.ts)

矩形的逻辑比较标准,重点是实现 buildPathcontainLocal

// src/graphic/shape/Rect.ts
import { Displayable, DisplayableProps } from '../Displayable';

export interface RectShape {
    x?: number;
    y?: number;
    width?: number;
    height?: number;
    r?: number; // 圆角半径 (简单起见,暂只支持统一圆角)
}

interface RectProps extends DisplayableProps {
    shape?: RectShape;
}

export class Rect extends Displayable {
    shape: Required<RectShape>; // 确保内部使用时都有值

    constructor(opts?: RectProps) {
        super(opts);
        this.shape = {
            x: 0, y: 0, width: 0, height: 0, r: 0,
            ...opts?.shape
        };
    }

    buildPath(ctx: CanvasRenderingContext2D) {
        const shape = this.shape;
        const x = shape.x;
        const y = shape.y;
        const width = shape.width;
        const height = shape.height;
        const r = shape.r;

        if (!r) {
            // 普通矩形
            ctx.rect(x, y, width, height);
        } else {
            // 圆角矩形 (使用 arcTo 或者 roundRect)
            // 这里使用通用的 arcTo 模拟
            ctx.moveTo(x + r, y);
            ctx.lineTo(x + width - r, y);
            ctx.arcTo(x + width, y, x + width, y + r, r);
            ctx.lineTo(x + width, y + height - r);
            ctx.arcTo(x + width, y + height, x + width - r, y + height, r);
            ctx.lineTo(x + r, y + height);
            ctx.arcTo(x, y + height, x, y + height - r, r);
            ctx.lineTo(x, y + r);
            ctx.arcTo(x, y, x + r, y, r);
            ctx.closePath();
        }
    }

    /**
     * 矩形的包含检测
     */
    containLocal(x: number, y: number): boolean {
        const shape = this.shape;
        // 简单矩形检测
        // 如果要做圆角检测比较复杂,通常这里简化为矩形包围盒
        return x >= shape.x && x <= shape.x + shape.width &&
               y >= shape.y && y <= shape.y + shape.height;
    }
}

2. 实现文本 (src/graphic/Text.ts)

文本比较特殊。

  1. 绘制方式不同:它不用 beginPath/fill 流程,而是直接 fillText
  2. 样式属性多:字号、字体、对齐方式。
  3. 碰撞检测难:需要用 measureText 算宽度,用字号估算高度。

我们需要先在 DisplayableStyle 中补充文本相关的样式定义。

更新 src/graphic/Style.ts:

export interface CommonStyle {
    // ... 原有属性 ...
    
    // 文本相关
    text?: string;
    fontSize?: number;
    fontFamily?: string;
    fontWeight?: string; // 'bold', 'normal'
    
    // 对齐
    textAlign?: CanvasTextAlign; // 'left' | 'right' | 'center' | 'start' | 'end'
    textBaseline?: CanvasTextBaseline; // 'top' | 'middle' | 'bottom' ...
}

创建 src/graphic/Text.ts:

注意:为了能在 brush 中使用特殊的绘制逻辑,我们这里覆盖 brush 方法,或者复用基类逻辑但重写 buildPath 实际上不太合适(因为 fillText 不是 path)。

ZRender 的做法是 Text 也是 Displayable,但绘制逻辑独立。为了 MiniRender 架构简单,我们重写 brush

// src/graphic/Text.ts
import { Displayable, DisplayableProps } from './Displayable';

// 默认字体
const DEFAULT_FONT_FAMILY = 'sans-serif';

export class Text extends Displayable {
    
    constructor(opts?: DisplayableProps) {
        super(opts);
    }

    /**
     * 重写 brush,因为文本不是 Path
     */
    brush(ctx: CanvasRenderingContext2D) {
        const style = this.style;
        if (!style.text) return;

        ctx.save();
        
        // 1. 设置常规样式
        if (style.fill) ctx.fillStyle = style.fill;
        if (style.stroke) ctx.strokeStyle = style.stroke;
        if (style.opacity != null) ctx.globalAlpha = style.opacity;

        // 2. 设置字体样式
        const fontSize = style.fontSize || 12;
        const fontFamily = style.fontFamily || DEFAULT_FONT_FAMILY;
        const fontWeight = style.fontWeight || '';
        ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`.trim();

        ctx.textAlign = style.textAlign || 'left';
        ctx.textBaseline = style.textBaseline || 'alphabetic';

        // 3. 应用变换
        const m = this.globalTransform;
        ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);

        // 4. 绘制文本
        // 这里的 0, 0 是相对于 Text 元素自身的原点
        if (style.stroke) ctx.strokeText(style.text, 0, 0);
        if (style.fill) ctx.fillText(style.text, 0, 0);

        ctx.restore();
    }

    // 文本不需要 buildPath,因为我们在 brush 里直接画了
    buildPath(ctx: CanvasRenderingContext2D) {}

    /**
     * 文本的碰撞检测
     * 难点:计算文本的包围盒
     */
    containLocal(x: number, y: number): boolean {
        const style = this.style;
        if (!style.text) return false;

        // 借用一个辅助 canvas 来测量文本宽度(或者用全局单一实例)
        // 在真实项目中,应该缓存 measureText 的结果
        const ctx = document.createElement('canvas').getContext('2d')!;
        const fontSize = style.fontSize || 12;
        const fontFamily = style.fontFamily || DEFAULT_FONT_FAMILY;
        const fontWeight = style.fontWeight || '';
        ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`.trim();
        
        // 1. 计算宽
        const width = ctx.measureText(style.text).width;
        // 2. 估算高 (Canvas API 不直接提供高度,通常用 fontSize 估算)
        const height = fontSize;

        // 3. 根据对齐方式计算左上角 (Bounding Box 的 x, y)
        // 默认原点在 (0,0)
        let bx = 0;
        let by = 0;

        // 水平对齐修正
        const align = style.textAlign || 'left';
        if (align === 'center') {
            bx -= width / 2;
        } else if (align === 'right' || align === 'end') {
            bx -= width;
        }

        // 垂直对齐修正
        const baseline = style.textBaseline || 'alphabetic';
        if (baseline === 'top') {
            by = 0;
        } else if (baseline === 'middle') {
            by -= height / 2;
        } else if (baseline === 'bottom') {
            by -= height;
        } else {
            // alphabetic (基线) 大概在 bottom 偏上一点,这里简单按 bottom 处理或忽略
            by -= height; 
        }

        // 4. 判断点是否在矩形内
        return x >= bx && x <= bx + width &&
               y >= by && y <= by + height;
    }
}

3. 验证

现在我们可以在 index.ts 中同时使用圆形、矩形和文本,构建一个简单的 UI 按钮。

index.ts (测试代码)

import { init } from './core/MiniRender';
import { Group } from './graphic/Group';
import { Circle } from './graphic/shape/Circle';
import { Rect } from './graphic/shape/Rect'; // 新增
import { Text } from './graphic/Text';       // 新增

const miniRender = init(document.getElementById('main')!);

// --- 示例 1: 创建一个简单的按钮 (Group + Rect + Text) ---

const button = new Group({
    position: [100, 100], // 按钮整体位置
    // scale: [1.5, 1.5]     // 测试父级缩放对文本点击是否有效
});

// 1. 按钮背景
const bg = new Rect({
    shape: {
        x: 0, 
        y: 0, 
        width: 120, 
        height: 40, 
        r: 10 // 圆角
    },
    style: {
        fill: '#409EFF',
        stroke: '#000',
        lineWidth: 1
    }
});

// 2. 按钮文字
const label = new Text({
    style: {
        text: 'Hello World',
        fill: '#fff',
        fontSize: 16,
        textAlign: 'center',       // 水平居中
        textBaseline: 'middle'     // 垂直居中
    },
    // 将文字放到按钮中心
    position: [60, 20], // 120/2, 40/2
    z: 1 // 确保文字在背景上面
});

button.add(bg);
button.add(label);
miniRender.add(button);

// --- 交互测试 ---

// 点击背景变色
bg.on('click', () => {
    console.log('Background clicked');
    bg.style.fill = bg.style.fill === '#409EFF' ? '#67C23A' : '#409EFF';
    miniRender.refresh();
});

// 点击文字变色
label.on('click', () => {
    console.log('Text clicked');
    label.style.fill = label.style.fill === '#fff' ? '#000' : '#fff';
    miniRender.refresh();
});

// --- 动画测试 ---
// 让按钮慢慢旋转,测试 Rect 和 Text 的点击区域是否跟着旋转
let angle = 0;
function loop() {
    angle += 0.01;
    button.rotation = angle;
    
    // 如果想要看旋转效果,取消下面注释
    miniRender.refresh(); 
    requestAnimationFrame(loop);
}
loop();

text.gif

Vue 2 vs React 18 深度对比指南

2025年12月3日 10:36

Vue 2 vs React 18 深度对比指南

本文档面向熟练使用 Vue 2 的开发者,帮助快速理解 React 18 的核心概念与差异。


目录

  1. 核心设计哲学
  2. 组件定义
  3. 响应式原理
  4. 模板 vs JSX
  5. 生命周期对比
  6. 计算属性 vs useMemo
  7. 侦听器 vs useEffect
  8. 父子通信
  9. 跨层级通信
  10. 插槽 vs children / render props
  11. 虚拟 DOM 与 Diff 算法
  12. React 18 新特性
  13. 性能优化对比
  14. 总结对照表
  15. 迁移建议

1. 核心设计哲学

维度 Vue 2 React 18
定位 渐进式框架(框架帮你做更多) UI 库(你自己组合生态)
模板 模板语法 + 指令 JSX(JS 的语法扩展)
响应式 自动依赖追踪(getter/setter) 手动声明更新(setState)
心智模型 "数据变了,视图自动变" "调用更新函数,触发重新渲染"

2. 组件定义

Vue 2:选项式 API

<template>
  <div>{{ count }}</div>
</template>

<script>
export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}
</script>

React 18:函数组件 + Hooks

import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)
  
  const increment = () => setCount(c => c + 1)
  
  return <div>{count}</div>
}

原理差异

  • Vue 2:组件是一个"配置对象",Vue 内部实例化并管理生命周期。this 指向组件实例,data 会被 Vue 用 Object.defineProperty 转成响应式。
  • React 18:组件就是一个"纯函数",每次渲染都会重新执行。状态通过 Hooks 保存在 React 内部的 Fiber 节点上,而不是组件实例上。

3. 响应式原理

Vue 2:基于 Object.defineProperty 的依赖追踪

数据变化流程:
data → defineProperty(getter/setter)
       ↓
   getter 收集依赖(Watcher)
       ↓
   setter 触发依赖更新
       ↓
   Watcher 通知组件重新渲染
核心代码逻辑(简化)
// Vue 2 响应式核心
function defineReactive(obj, key, val) {
  const dep = new Dep() // 依赖收集器
  
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) {
        dep.depend() // 收集当前 Watcher
      }
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      dep.notify() // 通知所有 Watcher 更新
    }
  })
}

局限性:

  • 无法检测属性的添加/删除(需要 Vue.set
  • 无法检测数组索引赋值(需要用 splice 等变异方法)

React 18:不可变数据 + 调度更新

状态变化流程:
setState(newValue)
    ↓
React 调度器标记组件需要更新
    ↓
批量处理更新(Batching)
    ↓
重新执行函数组件
    ↓
Diff 虚拟 DOM → 更新真实 DOM

核心机制:

// React 状态更新(简化)
function useState(initialValue) {
  // 状态存储在 Fiber 节点的 memoizedState 链表上
  const hook = mountWorkInProgressHook()
  hook.memoizedState = initialValue
  
  const dispatch = (action) => {
    // 创建更新对象,加入更新队列
    const update = { action, next: null }
    enqueueUpdate(hook.queue, update)
    // 调度更新
    scheduleUpdateOnFiber(fiber)
  }
  
  return [hook.memoizedState, dispatch]
}

React 18 新特性:自动批处理(Automatic Batching)

// React 17:只有事件处理函数内会批处理
// React 18:所有更新都会自动批处理

// 以下三次 setState 只会触发一次重渲染
setTimeout(() => {
  setCount(c => c + 1)
  setFlag(f => !f)
  setName('new')
}, 1000)

4. 模板 vs JSX

Vue 2:模板 + 指令

<template>
  <div>
    <!-- 条件渲染 -->
    <span v-if="show">显示</span>
    <span v-else>隐藏</span>
    
    <!-- 列表渲染 -->
    <ul>
      <li v-for="item in list" :key="item.id">
        {{ item.name }}
      </li>
    </ul>
    
    <!-- 双向绑定 -->
    <input v-model="text" />
    
    <!-- 事件 -->
    <button @click="handleClick">点击</button>
  </div>
</template>

原理:

  • 模板在编译阶段被转换为渲染函数(render function
  • 指令(v-ifv-for)是编译时的语法糖
  • 编译器可以做静态分析优化(标记静态节点)

React 18:JSX

function MyComponent({ show, list, text, setText }) {
  return (
    <div>
      {/* 条件渲染 */}
      {show ? <span>显示</span> : <span>隐藏</span>}
      
      {/* 列表渲染 */}
      <ul>
        {list.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
      
      {/* 受控组件(双向绑定) */}
      <input value={text} onChange={e => setText(e.target.value)} />
      
      {/* 事件 */}
      <button onClick={handleClick}>点击</button>
    </div>
  )
}

原理:

  • JSX 是 React.createElement() 的语法糖
  • 编译后:<div>hello</div>React.createElement('div', null, 'hello')
  • 没有指令,一切都是 JS 表达式

语法对照表

特性 Vue 2 模板 React JSX
条件 v-if / v-else {condition && ...} 或三元
循环 v-for array.map()
双向绑定 v-model 受控组件(value + onChange)
事件 @click onClick
样式 :class / :style className / style={{}}

5. 生命周期对比

Vue 2 生命周期

beforeCreate → created → beforeMount → mounted
                              ↓
                        beforeUpdate → updated
                              ↓
                        beforeDestroy → destroyed
export default {
  created() {
    // 实例创建完成,data/methods 可用,DOM 未挂载
    // 常用于:初始化数据、发起请求
  },
  mounted() {
    // DOM 已挂载
    // 常用于:操作 DOM、初始化第三方库
  },
  updated() {
    // 数据变化导致 DOM 更新后
  },
  beforeDestroy() {
    // 销毁前,清理定时器、事件监听等
  }
}

React 18:useEffect 统一处理

import { useEffect, useLayoutEffect } from 'react'

function MyComponent() {
  // 相当于 mounted + updated
  useEffect(() => {
    console.log('组件挂载或更新后')
    
    // 相当于 beforeDestroy
    return () => {
      console.log('清理:组件卸载前 或 下次 effect 执行前')
    }
  }) // 无依赖数组:每次渲染后都执行
  
  // 相当于 mounted(只执行一次)
  useEffect(() => {
    console.log('只在挂载时执行')
    return () => console.log('只在卸载时执行')
  }, []) // 空依赖数组
  
  // 相当于 watch
  useEffect(() => {
    console.log('count 变化了')
  }, [count]) // 依赖 count
  
  return <div>...</div>
}

原理:

  • useEffect 的回调在 DOM 更新后异步执行(不阻塞渲染)
  • useLayoutEffectDOM 更新后同步执行(阻塞渲染,用于测量 DOM)
  • 依赖数组决定何时重新执行 effect

生命周期对照表

Vue 2 React 18
created 函数体顶部(但要注意 SSR)
mounted useEffect(() => {}, [])
updated useEffect(() => {})useEffect(() => {}, [deps])
beforeDestroy useEffect 返回的清理函数
watch useEffect(() => {}, [watchedValue])

6. 计算属性 vs useMemo

Vue 2:computed

export default {
  data() {
    return { firstName: 'John', lastName: 'Doe' }
  },
  computed: {
    fullName() {
      return `${this.firstName} ${this.lastName}`
    }
  }
}

原理:

  • computed 是一个惰性求值的 Watcher
  • 只有依赖变化时才重新计算
  • 有缓存:多次访问不会重复计算

React 18:useMemo

function MyComponent({ firstName, lastName }) {
  const fullName = useMemo(() => {
    return `${firstName} ${lastName}`
  }, [firstName, lastName])
  
  return <div>{fullName}</div>
}

原理:

  • useMemo 在依赖数组不变时返回缓存值
  • 依赖数组变化时重新执行计算函数
  • 必须手动声明依赖(Vue 是自动追踪)

关键区别

特性 Vue 2 computed React useMemo
依赖追踪 自动 手动声明
缓存
用途 派生状态 派生状态 + 避免重复计算

7. 侦听器 vs useEffect

Vue 2:watch

export default {
  data() {
    return { query: '' }
  },
  watch: {
    query: {
      handler(newVal, oldVal) {
        this.search(newVal)
      },
      immediate: true, // 立即执行
      deep: true       // 深度监听
    }
  }
}

React 18:useEffect

function SearchComponent() {
  const [query, setQuery] = useState('')
  
  useEffect(() => {
    // 没有 oldVal,需要自己用 useRef 保存
    search(query)
  }, [query])
  
  return <input value={query} onChange={e => setQuery(e.target.value)} />
}

获取旧值的方式:

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>()
  useEffect(() => {
    ref.current = value
  })
  return ref.current
}

// 使用
const prevQuery = usePrevious(query)

8. 父子通信

Vue 2

<!-- 父组件 -->
<template>
  <Child :msg="message" @update="handleUpdate" />
</template>

<!-- 子组件 -->
<template>
  <div @click="$emit('update', newValue)">{{ msg }}</div>
</template>

<script>
export default {
  props: ['msg']
}
</script>

React 18

// 父组件
function Parent() {
  const [message, setMessage] = useState('')
  
  return (
    <Child 
      msg={message} 
      onUpdate={(newValue) => setMessage(newValue)} 
    />
  )
}

// 子组件
interface ChildProps {
  msg: string
  onUpdate: (value: string) => void
}

function Child({ msg, onUpdate }: ChildProps) {
  return <div onClick={() => onUpdate('new')}>{msg}</div>
}

通信方式对照

特性 Vue 2 React 18
父→子 props props
子→父 $emit 回调函数 props
双向绑定 v-model / .sync 受控组件模式

9. 跨层级通信

Vue 2:provide / inject

// 祖先组件
export default {
  provide() {
    return {
      theme: this.theme
    }
  }
}

// 后代组件
export default {
  inject: ['theme']
}

注意: Vue 2 的 provide/inject 不是响应式的(除非 provide 一个响应式对象)。


React 18:Context

// 创建 Context
const ThemeContext = createContext<string>('light')

// 祖先组件
function App() {
  const [theme, setTheme] = useState('dark')
  
  return (
    <ThemeContext.Provider value={theme}>
      <Child />
    </ThemeContext.Provider>
  )
}

// 后代组件
function DeepChild() {
  const theme = useContext(ThemeContext)
  return <div>当前主题:{theme}</div>
}
原理
  • Provider 的 value 变化时,所有消费该 Context 的组件都会重新渲染
  • 这是 React 的一个性能陷阱:Context 变化会导致所有消费者重渲染,即使它们只用了 Context 的一部分

优化方式:

  • 拆分 Context(读写分离)
  • 使用 useMemo 包裹 value
  • 或使用状态管理库(Redux/Zustand)

10. 插槽 vs children / render props

Vue 2:插槽

<!-- 父组件 -->
<Card>
  <template #header>标题</template>
  <template #default>内容</template>
  <template #footer="{ data }">{{ data }}</template>
</Card>

<!-- Card 组件 -->
<template>
  <div>
    <header><slot name="header" /></header>
    <main><slot /></main>
    <footer><slot name="footer" :data="footerData" /></footer>
  </div>
</template>

React 18:children + render props

// 父组件
<Card
  header={<span>标题</span>}
  footer={(data) => <span>{data}</span>}
>
  内容
</Card>

// Card 组件
interface CardProps {
  header?: ReactNode
  footer?: (data: string) => ReactNode
  children: ReactNode
}

function Card({ header, footer, children }: CardProps) {
  const footerData = 'some data'
  
  return (
    <div>
      <header>{header}</header>
      <main>{children}</main>
      <footer>{footer?.(footerData)}</footer>
    </div>
  )
}

插槽对照

Vue 2 React 18
默认插槽 <slot /> children
具名插槽 <slot name="x" /> 具名 props(如 header
作用域插槽 render props(函数作为 props)

11. 虚拟 DOM 与 Diff 算法

Vue 2 Diff

  • 双端比较算法:同时从新旧节点列表的两端向中间比较
  • 优化:静态节点标记,跳过不变的节点
旧: [A, B, C, D]
新: [D, A, B, C]

双端比较:
1. 旧头(A) vs 新头(D) ❌
2. 旧尾(D) vs 新尾(C) ❌
3. 旧头(A) vs 新尾(C) ❌
4. 旧尾(D) vs 新头(D) ✅ → 移动 D 到最前

React 18 Diff

  • 单向遍历 + key 映射
  • 只从左到右遍历,通过 key 建立映射
旧: [A, B, C, D]
新: [D, A, B, C]

1. 遍历新列表,D 在旧列表中找到,但位置不对
2. 标记需要移动的节点
3. 最小化 DOM 操作

关键:key 的作用

// ❌ 错误:用 index 作为 key
{list.map((item, index) => <Item key={index} />)}

// ✅ 正确:用唯一标识作为 key
{list.map(item => <Item key={item.id} />)}

12. React 18 新特性

12.1 并发渲染(Concurrent Rendering)

React 18 最大的变化:渲染可以被中断。

import { useTransition, useDeferredValue } from 'react'

function SearchResults() {
  const [query, setQuery] = useState('')
  const [isPending, startTransition] = useTransition()
  
  const handleChange = (e) => {
    // 紧急更新:输入框立即响应
    setQuery(e.target.value)
    
    // 非紧急更新:搜索结果可以延迟
    startTransition(() => {
      setSearchResults(search(e.target.value))
    })
  }
  
  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending ? <Spinner /> : <Results />}
    </>
  )
}

原理:

  • React 18 引入了优先级调度
  • startTransition 标记的更新是低优先级的,可以被高优先级更新打断
  • 用户输入等交互是高优先级,数据渲染是低优先级

12.2 Suspense 数据获取

// 配合 React Query / SWR / Relay 等
function ProfilePage() {
  return (
    <Suspense fallback={<Spinner />}>
      <ProfileDetails />
      <Suspense fallback={<PostsSpinner />}>
        <ProfilePosts />
      </Suspense>
    </Suspense>
  )
}

12.3 自动批处理

// React 18:所有更新自动批处理
setTimeout(() => {
  setCount(c => c + 1)  // 不会立即渲染
  setFlag(f => !f)      // 不会立即渲染
  // 只触发一次渲染
}, 1000)

13. 性能优化对比

Vue 2 性能优化

// 1. v-once:只渲染一次
<span v-once>{{ staticContent }}</span>

// 2. v-memo(Vue 3.2+,Vue 2 没有)

// 3. computed 自带缓存

// 4. keep-alive 缓存组件
<keep-alive>
  <component :is="currentComponent" />
</keep-alive>

React 18 性能优化

// 1. React.memo:组件级别缓存
const MemoizedComponent = React.memo(function MyComponent(props) {
  return <div>{props.value}</div>
})

// 2. useMemo:值缓存
const expensiveValue = useMemo(() => compute(a, b), [a, b])

// 3. useCallback:函数缓存
const handleClick = useCallback(() => {
  doSomething(a, b)
}, [a, b])

// 4. 懒加载
const LazyComponent = React.lazy(() => import('./HeavyComponent'))

<Suspense fallback={<Loading />}>
  <LazyComponent />
</Suspense>

优化方式对照

优化点 Vue 2 React 18
组件缓存 自动(响应式追踪) 手动(React.memo
计算缓存 computed(自动依赖) useMemo(手动依赖)
函数缓存 不需要(方法绑定在实例上) useCallback(避免子组件重渲染)
组件保活 <keep-alive> 无内置,需第三方库

14. 总结对照表

特性 Vue 2 React 18
组件定义 选项式对象 函数 + Hooks
响应式 自动(defineProperty) 手动(setState)
模板 模板 + 指令 JSX
状态 data() useState
计算属性 computed useMemo
侦听 watch useEffect
生命周期 多个钩子函数 useEffect 统一
父子通信 props + $emit props + 回调
跨层级 provide/inject Context
插槽 slot children / render props
性能优化 框架自动优化多 开发者手动优化多
并发 Concurrent Mode
学习曲线 平缓 陡峭(Hooks 心智模型)

15. 迁移建议

从 Vue 2 转 React 18,重点转变思维:

15.1 从"响应式"到"不可变"

// Vue:直接修改
this.list.push(item)

// React:创建新数组
setList([...list, item])

15.2 从"自动依赖"到"手动声明"

// Vue:computed 自动追踪依赖
computed: {
  fullName() {
    return this.firstName + this.lastName
  }
}

// React:useMemo 必须手动写依赖数组
const fullName = useMemo(() => {
  return firstName + lastName
}, [firstName, lastName])

15.3 从"实例方法"到"闭包函数"

// Vue:this.handleClick 始终是同一个函数
methods: {
  handleClick() { ... }
}

// React:每次渲染 handleClick 都是新函数(需要 useCallback 优化)
const handleClick = useCallback(() => {
  doSomething(a, b)
}, [a, b])

15.4 从"模板指令"到"JS 表达式"

Vue 2 React 18
v-if="show" {show && <Component />}
v-for="item in list" {list.map(item => ...)}
v-model="value" value={value} onChange={...}

附录:常用 Hooks 速查

Hook 用途 Vue 2 对应
useState 状态管理 data
useEffect 副作用处理 mounted / watch / beforeDestroy
useMemo 计算缓存 computed
useCallback 函数缓存 无(Vue 不需要)
useRef 引用 DOM / 保存可变值 $refs / 实例属性
useContext 跨层级状态 inject
useReducer 复杂状态逻辑 Vuex mutations
useLayoutEffect 同步副作用 mounted(同步部分)
useTransition 并发更新
useDeferredValue 延迟更新

从零实现2D绘图引擎:3.交互系统(Handle)的实现

作者 irises
2025年12月3日 10:36

MiniRender仓库地址参考

要实现交互,我们需要解决三个层面的问题:

  1. 数学层:矩阵求逆(为了把鼠标点转换到图形内部坐标系)。
  2. 基类层:事件订阅机制(On/Off)和包含判断接口(Contain)。
  3. 控制层:监听 DOM 事件并分发给图形。

1.数学工具升级 (矩阵求逆)

我们在 src/utils/matrix.ts 中只实现了乘法和合成。为了做碰撞检测,我们需要逆矩阵。 图形被平移、旋转、缩放了。判断点 (100, 100) 是否在被旋转了 30 度的矩形里很难;但把点逆向旋转 30 度,判断它是否在未旋转的矩形里很简单。

src/utils/matrix.ts 中追加:

// src/utils/matrix.ts

/**
 * 求逆矩阵
 * out = invert(a)
 */
export function invert(out: MatrixArray, a: MatrixArray): MatrixArray {
    const aa = a[0], ac = a[2], atx = a[4];
    const ab = a[1], ad = a[3], aty = a[5];

    // 计算行列式
    let det = aa * ad - ab * ac;
    if (!det) {
        // 行列式为0,无法求逆,返回 null 或 设为单位矩阵
        return [1, 0, 0, 1, 0, 0] as any; // 简单处理
    }
    det = 1.0 / det;

    out[0] = ad * det;
    out[1] = -ab * det;
    out[2] = -ac * det;
    out[3] = aa * det;
    out[4] = (ac * aty - ad * atx) * det;
    out[5] = (ab * atx - aa * aty) * det;
    return out;
}

2.实现事件中心 (Eventful)

我们需要一个类来管理 .on, .trigger

创建 src/core/Eventful.ts

// src/core/Eventful.ts

type EventHandler = (...args: any[]) => void;

export class Eventful {
    private _handlers: { [event: string]: EventHandler[] } = {};

    on(event: string, handler: EventHandler): this {
        if (!this._handlers[event]) {
            this._handlers[event] = [];
        }
        this._handlers[event].push(handler);
        return this;
    }

    off(event?: string, handler?: EventHandler): this {
        // 简化实现:清空指定事件或全部
        if (event && !handler) {
            this._handlers[event] = [];
        } else if (!event) {
            this._handlers = {};
        }
        // 完整实现还需要处理移除特定 handler,这里略过
        return this;
    }

    trigger(event: string, ...args: any[]): this {
        const handlers = this._handlers[event];
        if (handlers) {
            handlers.forEach(h => h.apply(this, args));
        }
        return this;
    }
}

Element 继承 Eventful。 修改 src/graphic/Element.ts

import { Eventful } from '../core/Eventful';
// ...
export abstract class Element extends Eventful { 
    // ... 
}

3.图形拾取逻辑 (Element & Shape)

我们需要在 Displayable 中定义标准,并在 Circle 中实现具体算法。

1. 修改 src/graphic/Element.ts 增加坐标转换方法。这是交互的核心。

// src/graphic/Element.ts

export abstract class Element extends Eventful {
    // ... 原有代码 ...

    // 辅助矩阵,避免重复创建对象 (GC优化)
    private static _invertMat: MatrixArray = matrix.create();

    /**
     * 将全局坐标转换到当前元素的局部坐标系
     * @param x 全局 x
     * @param y 全局 y
     * @return [localX, localY]
     */
    globalToLocal(x: number, y: number): Point {
        const m = this.globalTransform;
        // 计算逆矩阵
        // 注意:这里用简单的静态变量缓存逆矩阵,非线程安全但JS是单线程所以OK
        const inv = Element._invertMat;
        matrix.invert(inv, m);

        // 应用逆变换: x' = a*x + c*y + tx
        const lx = inv[0] * x + inv[2] * y + inv[4];
        const ly = inv[1] * x + inv[3] * y + inv[5];
        
        return [lx, ly];
    }
}

2. 修改 src/graphic/Displayable.ts 增加抽象方法 contain

// src/graphic/Displayable.ts

export abstract class Displayable extends Element {
    // ... 原有代码 ...

    /**
     * 判断点是否在图形内
     * @param x 全局 x
     * @param y 全局 y
     */
    contain(x: number, y: number): boolean {
        // 1. 转换为局部坐标
        const local = this.globalToLocal(x, y);
        // 2. 调用具体形状的几何判断
        return this.containLocal(local[0], local[1]);
    }

    /**
     * 具体形状实现这个方法,判断局部坐标是否在路径内
     */
    abstract containLocal(x: number, y: number): boolean;
}

3. 修改 src/graphic/shape/Circle.ts 实现圆形的几何判断。

// src/graphic/shape/Circle.ts

export class Circle extends Displayable {
    // ... 原有代码 ...

    containLocal(x: number, y: number): boolean {
        // 圆形判断很简单:点到圆心的距离 < 半径
        // 注意:这里的 x,y 已经是经过逆变换的,所以是在圆没有被旋转缩放的坐标系下
        // 而 this.shape.cx/cy 也是在这个坐标系下
        const d2 = Math.pow(x - this.shape.cx, 2) + Math.pow(y - this.shape.cy, 2);
        return d2 <= this.shape.r * this.shape.r;
    }
}

4.实现 Handler 控制器

这是最后一块拼图。它监听 DOM 事件,找到图形,然后由图形触发事件。

创建 src/handler/Handler.ts

// src/handler/Handler.ts
import { Storage } from '../storage/Storage';
import { Painter } from '../painter/Painter';
import { Displayable } from '../graphic/Displayable';

export class Handler {
    storage: Storage;
    painter: Painter;
    dom: HTMLElement;

    constructor(storage: Storage, painter: Painter, dom: HTMLElement) {
        this.storage = storage;
        this.painter = painter;
        this.dom = dom;

        // 初始化 DOM 监听
        this._initDomEvents();
    }

    private _initDomEvents() {
        // 简单的监听 click 事件作为示例
        this.dom.addEventListener('click', (e) => {
            this._clickHandler(e);
        });
        
        // 实际还有 mousedown, mouseup, mousemove 等复杂逻辑
    }

    private _clickHandler(e: MouseEvent) {
        // 1. 获取相对于 Canvas 的坐标
        // getBoundingClientRect 包含了页面滚动和边框
        const rect = this.dom.getBoundingClientRect();
        // Canvas 的实际像素尺寸是 CSS 尺寸的 dpr 倍
        const x = (e.clientX - rect.left - this.dom.clientLeft) * window.devicePixelRatio;
        const y = (e.clientY - rect.top - this.dom.clientTop) * window.devicePixelRatio;

        // 2. 寻找被点击的图形
        const target = this._findHover(x, y);

        if (target) {
            // 3. 触发图形事件
            console.log('Clicked shape:', target.id);
            target.trigger('click', { target: target, event: e });
        } else {
            console.log('Clicked empty space');
        }
    }

    private _findHover(x: number, y: number): Displayable | null {
        const list = this.storage.getDisplayList();
        
        // 核心:逆序遍历!
        // 因为 displayList 是按渲染顺序排的(后面的盖在前面),
        // 所以我们检测点击时,要从最上面(数组末尾)开始查。
        for (let i = list.length - 1; i >= 0; i--) {
            const el = list[i];
            
            // 忽略不可见或不响应鼠标的元素
            if (el.invisible) continue; // 可以再加 ignoreMouse 等标志

            // 碰撞检测
            if (el.contain(x, y)) {
                return el;
            }
        }
        return null;
    }
}

5.集成到 MiniRender

修改 src/core/MiniRender.ts,初始化 Handler。

// src/core/MiniRender.ts
import { Handler } from '../handler/Handler';

export class MiniRender {
    storage: Storage;
    painter: Painter;
    handler: Handler; // 新增

    constructor(dom: HTMLElement) {
        this.storage = new Storage();
        this.painter = new Painter(dom, this.storage);
        // 初始化交互系统
        this.handler = new Handler(this.storage, this.painter, dom);
    }
    // ...
}

6.测试

现在我们修改 index.ts 来测试点击事件。我们利用 Eventful 的能力。

// index.ts
import { init } from './core/MiniRender';
import { Group } from './graphic/Group';
import { Circle } from './graphic/shape/Circle';

const miniRender = init(document.getElementById('main')!);

const group = new Group({ position: [200, 200] });

// 一个红色的圆
const circle = new Circle({
    shape: { r: 50 },
    style: { fill: '#F00' }
});

// 绑定点击事件!
circle.on('click', () => {
    console.log('Circle Clicked!');
    // 点击变色
    if (circle.style.fill === '#F00') {
        circle.style.fill = '#00F'; // 变蓝
    } else {
        circle.style.fill = '#F00'; // 变红
    }
    miniRender.refresh(); // 记得手动刷新
});

group.add(circle);
miniRender.add(group);

// 动画:让它旋转,测试旋转后的点击检测是否准确
let angle = 0;
function loop() {
    angle += 0.01;
    group.rotation = angle;
    // 手动更新 Group 属性,Painter 会在 refresh 时计算矩阵
    
    // 如果想要点击生效,不需要一直 refresh,但为了看动画:
    miniRender.refresh();
    requestAnimationFrame(loop);
}
loop();
  1. 屏幕上有一个旋转的圆。
  2. 当你点击圆的内部时,控制台输出 "Circle Clicked!",并且圆颜色在红蓝之间切换。
  3. 关键点:即使圆旋转到了奇怪的角度,或者被 Group 缩放了,只要你的鼠标点在视觉上的圆内,事件就应该触发。这就是 invert 逆矩阵的作用。

handler.gif

SpreadJS 自定义函数实战指南:从入门到避坑

2025年12月3日 10:34

SpreadJS 自定义函数实战指南:从入门到避坑

在企业级表格应用开发中,内置函数往往难以满足复杂的业务逻辑需求。SpreadJS 作为一款功能强大的类 Excel 表格控件,提供了灵活的自定义函数(Custom Function) 能力,让开发者可以像使用 SUM、VLOOKUP 那样,在单元格公式中直接调用自己编写的逻辑。本文将围绕“什么是自定义函数”、“如何实现”、“异步场景处理”以及“新手避坑指南”四个维度,带你全面掌握 SpreadJS 自定义函数的开发技巧。

一、为什么需要自定义函数?

函数的本质是“封装好的代码片段”。用户通过简单调用(如 =SUM(A1:A10)),即可完成复杂操作,无需重复编写逻辑。SpreadJS 的内置函数虽覆盖数学、逻辑、查找等常见场景,但在面对以下情况时仍显不足:

  • 业务规则特殊:如固定资产折旧计算、行业特定指标;
  • 依赖外部数据:如实时汇率、商品库存、用户信息;
  • 多人协作需统一逻辑:避免因手动计算导致结果不一致。

自定义函数的价值在于:

  • 提升效率:一键完成复杂计算;
  • 动态响应:数据变化自动更新结果;
  • 标准化逻辑:确保团队内计算规则统一。

二、从零实现一个自定义函数

在 SpreadJS 中,创建自定义函数只需三步:定义逻辑 → 注册函数 → 应用调用

1.定义函数逻辑

你需要创建一个函数类,继承自 GC.Spread.CalcEngine.Functions.Function,并实现 evaluate 方法。该方法负责接收参数、执行计算并返回结果。

function FactorialFunction() {
  this.name = "FACTORIAL";
  this.maxArgs = 1;
  this.minArgs = 1;
}
FactorialFunction.prototype = new GC.Spread.CalcEngine.Functions.Function();
FactorialFunction.prototype.evaluate = function (arg) {
  let result = 1;
  if (arguments.length === 1 && !isNaN(parseInt(arg))) {
    for (let i = 1; i <= arg; i++) {
      result = i * result;
    }
    return result;
  }
  return "#VALUE!";
};
FactorialFunction.prototype.description = function () {
  return {
    name: "FACTORIAL",
    description:
      "这是一个计算从 1 开始的阶乘并在单元格中显示的函数",
    parameters: [{ name: "number" }],
  };
};

常见的实战案例包括:

  • 阶乘函数:处理单个数值的递归计算;
  • 阶乘数组函数:支持对区域(如 A1:A5)批量计算;
  • 固定资产折旧函数:根据年限、残值率等参数计算月折旧额。

2.注册函数

通过 workbook.addCustomFunction() 将函数注册到工作簿(或工作表、全局),使其可在公式中被识别。

let factorial = new FactorialFunction();
// 工作簿级别注册
spread.addCustomFunction(factorial);
// 工作表级别注册
sheet.addCustomFunction(factorial);

3.在单元格中使用

注册成功后,即可像内置函数一样使用,例如:=FACTORIAL(5)=DEPRECIATION(B2, C2, D2)

三、异步函数:处理外部数据依赖

普通自定义函数是同步执行的,一旦涉及网络请求(如调用 API 获取汇率)、数据库查询或耗时计算,就会阻塞 UI 线程,导致表格卡顿。此时,应使用 异步自定义函数

异步函数的核心特点:

  • 不立即返回结果,而是发起异步操作;
  • 表格暂时显示默认值(如 "Loading...");
  • 异步完成后,通过 context.setAsyncResult(result) 自动更新单元格。

典型应用场景:

  • 根据商品 ID 实时获取价格;
  • 查询用户历史订单并统计;
  • 调用第三方服务(如天气、汇率)。
function AsyncRateFunction() {
  this.name = "ASYNC_GET_RATE";
  this.minArgs = 1;
  this.maxArgs = 1;
}
AsyncRateFunction.prototype =
  new GC.Spread.CalcEngine.Functions.AsyncFunction("ASYNC_GET_RATE");
AsyncRateFunction.prototype.defaultValue = function () {
  return "加载中...";
};
AsyncRateFunction.prototype.evaluate = function (context) {
  let currency = arguments[1]?.toUpperCase();

  if (!["USD", "EUR"].includes(currency)) {
    return "#INVALID! 仅支持USD/EUR";
  }

  fetch(
    `https://api.apilayer.com/exchangerates_data/latest?base=${currency}&symbols=CNY`,
    {
      headers: { apikey: "your_api_key" },
    }
  )
    .then((res) => res.json())
    .then((data) => {
      let rate = data.rates?.CNY;
      rate
        ? context.setAsyncResult(parseFloat(rate.toFixed(4)))
        : context.setAsyncResult("#NO_DATA! 未获取到汇率");
    })
    .catch((err) => {
      context.setAsyncResult(`#API_ERROR: ${err.message}`);
    });
};
AsyncRateFunction.prototype.evaluateMode = function () {
  return 1;
};
AsyncRateFunction.prototype.description = function () {
  return {
    name: "ASYNC_GET_RATE",
    description: "异步获取指定货币的汇率",
    parameters: [{ name: "currency", description: "货币类型(USD/EUR)" }],
  };
};

💡 注意:异步函数必须在 evaluate 中调用 context.setAsyncResult(),而不能使用 return

同步 vs 异步对比

维度 同步函数 异步函数
执行方式 立即执行,阻塞表格 异步执行,不阻塞
适用场景 本地计算(阶乘、加减) 外部数据依赖(API/DB)
返回方式 return result context.setAsyncResult(result)
未完成状态 显示 defaultValue

四、新手开发避坑指南

在实际开发中,以下问题高频出现,务必提前规避:

1.导入文件后出现 #NAME?

原因:自定义函数在导入模板之后才注册,导致公式无法识别。 ✅ 解决方案:先导入文件 → 再注册函数 → 最后调用 workbook.calcEngine().calculate("rebuild")

2.函数作用域混乱

函数可注册到 workbookworksheet 或全局 GC 对象。

  • 一般推荐注册到 workbook
  • 若需跨多个工作簿复用,可注册到 GC.Spread.CalcEngine.Functions.

3.返回值类型错误

  • 同步函数:return value
  • 数组函数:返回 CalcArray 对象
  • 异步函数:必须用 context.setAsyncResult()

4.需访问 workbook 对象?

若函数需读取选区、样式等上下文信息(如实现 =SELECTION()),需设置 isContextSensitive: true

5.参数合法性校验缺失

用户可能传入字符串、空值、错误范围。务必在 evaluate 中做类型判断与容错处理。

6.忽略单元格数据类型

看似数字的单元格可能是文本格式(如从 CSV 导入)。建议使用 parseFloat 或类型转换确保计算正确。

7.与内置函数重名

避免命名如 SUMAVERAGE 等,否则会覆盖原生函数。

8.大数据量性能问题

遍历大范围单元格时,使用 getArray() 替代多次 getValue(),并配合 suspendPaint() 提升性能。

9.数组公式未开启动态数组

使用数组公式(如返回多单元格结果)时,需设置 workbook.options.allowDynamicArray = true

开发建议

  • 先易后难:从阶乘、求和等简单函数入手,再挑战异步或数组场景;
  • 多打日志:在 evaluateconsole.log 参数与中间结果,快速定位问题;
  • 勤查文档SpreadJS 官方 API 文档是最佳参考

从零实现2D绘图引擎:2.Storage和Painter的实现

作者 irises
2025年12月3日 10:33

MiniRender仓库地址参考

好的,我们开始 仓库 (Storage) 与 渲染器 (Painter) 的实现。

这一步的目标是把“手动挡”变成“自动挡”。我们将不再手动调用 circle.brush(ctx),而是构建一个自动化系统:Storage 负责管理所有图形,Painter 负责把 Storage 里的东西画出来。

为了让 Storage 的逻辑完整,我们需要先补充一个简单的 Group 类(容器),因为 Storage 本质上是在遍历一棵树。

1. 容器类实现 (src/graphic/Group.ts)

Group 继承自 Element。它不画任何东西(没有 brush 方法),它的作用是把子元素“打包”,并且传递变换矩阵。

// src/graphic/Group.ts
import { Element } from './Element';

export class Group extends Element {
    readonly isGroup = true;
    
    // 子节点列表
    children: Element[] = [];

    /**
     * 添加子节点
     */
    add(child: Element) {
        if (child && child !== this && child.parent !== this) {
            this.children.push(child);
            child.parent = this; // 建立父子链接
        }
    }

    /**
     * 移除子节点
     */
    remove(child: Element) {
        const idx = this.children.indexOf(child);
        if (idx >= 0) {
            this.children.splice(idx, 1);
            child.parent = null;
        }
    }
}

2. 仓库模块 (src/storage/Storage.ts)

Storage 是内存中的数据库。它的核心职责是:将场景图(树状结构)扁平化为一个渲染列表(数组),并按层级排序。

// src/storage/Storage.ts
import { Element } from '../graphic/Element';
import { Displayable } from '../graphic/Displayable';
import { Group } from '../graphic/Group';

// 类型守卫:判断是否为 Group
function isGroup(el: Element): el is Group {
    return (el as Group).isGroup;
}

// 类型守卫:判断是否为 Displayable
function isDisplayable(el: Element): el is Displayable {
    return el instanceof Displayable;
}

export class Storage {
    // 根节点列表 (Scene Graph 的入口)
    private _roots: Element[] = [];
    
    // 扁平化的渲染列表 (缓存结果)
    private _displayList: Displayable[] = [];

    // 标记列表是否脏了(需要重新遍历和排序)
    private _displayListDirty: boolean = true;

    addRoot(el: Element) {
        this._roots.push(el);
        this._displayListDirty = true;
    }

    /**
     * 核心方法:获取排序后的渲染列表
     * 逻辑:
     * 1. 深度优先遍历所有根节点
     * 2. 收集所有的 Displayable
     * 3. 按 zLevel 和 z 排序
     */
    getDisplayList(): Displayable[] {
        if (this._displayListDirty) {
            this._updateDisplayList();
            this._displayListDirty = false;
        }
        return this._displayList;
    }

    private _updateDisplayList() {
        const list: Displayable[] = [];
        
        // 1. 递归遍历 (DFS)
        const traverse = (el: Element) => {
            if (isDisplayable(el)) {
                list.push(el);
            }
            if (isGroup(el)) {
                for (let i = 0; i < el.children.length; i++) {
                    traverse(el.children[i]);
                }
            }
        };

        for (let i = 0; i < this._roots.length; i++) {
            traverse(this._roots[i]);
        }

        // 2. 排序
        // 优先级:zLevel (Canvas层) > z (同层叠加顺序) > 插入顺序
        list.sort((a, b) => {
            if (a.zLevel === b.zLevel) {
                return a.z - b.z;
            }
            return a.zLevel - b.zLevel;
        });

        this._displayList = list;
    }
}

3. 渲染器模块 (src/painter/Painter.ts)

Painter 是也是最“脏”的地方,因为它要直接操作 DOM。为了保持简单,我们暂时只实现单层 Canvas(假设所有图形 zLevel 都是 0)。

核心逻辑

  1. 初始化时创建 <canvas> 并插入 DOM。
  2. refresh 方法负责清空画布、获取列表、更新矩阵、绘制。
// src/painter/Painter.ts
import { Storage } from '../storage/Storage';

export class Painter {
    private _dom: HTMLElement;
    private _storage: Storage;
    
    private _canvas: HTMLCanvasElement;
    private _ctx: CanvasRenderingContext2D;
    
    private _width: number = 0;
    private _height: number = 0;

    constructor(dom: HTMLElement, storage: Storage) {
        this._dom = dom;
        this._storage = storage;

        // 1. 创建 Canvas
        this._canvas = document.createElement('canvas');
        // 简单的样式设置
        this._canvas.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%';
        dom.appendChild(this._canvas);
        
        this._ctx = this._canvas.getContext('2d')!;

        // 初始化大小
        this.resize();
        
        // 监听窗口大小变化(简单版)
        window.addEventListener('resize', () => this.resize());
    }

    resize() {
        // 获取容器宽高
        const width = this._dom.clientWidth;
        const height = this._dom.clientHeight;
        
        // 处理高清屏 (Retina)
        const dpr = window.devicePixelRatio || 1;
        
        this._canvas.width = width * dpr;
        this._canvas.height = height * dpr;
        
        // 缩放 Context,这样绘图时直接用逻辑坐标,不用管 dpr
        this._ctx.scale(dpr, dpr);

        this._width = width;
        this._height = height;
        
        // 大小变了,必须重绘
        this.refresh();
    }

    /**
     * 渲染入口
     */
    refresh() {
        const list = this._storage.getDisplayList();
        const ctx = this._ctx;

        // 1. 清空画布
        ctx.clearRect(0, 0, this._width, this._height);

        // 2. 遍历绘制
        for (let i = 0; i < list.length; i++) {
            const el = list[i];
            
            // 优化:看不见的直接跳过
            // if (el.invisible) continue;

            // 3. 关键步骤:更新变换矩阵
            // 注意:必须从根节点开始 update,这里为了简化,
            // 假设 Storage 里的顺序已经保证了父级在子级之前,或者 el.updateTransform 内部会自动回溯父级。
            // 在真正的 MiniRender 中,会在 refresh 前统一更新一遍所有节点的 globalTransform。
            el.updateTransform(); 

            // 4. 绘制
            el.brush(ctx);
        }
    }
}

4. 入口类 (src/core/MiniRender.ts)

这是给开发者用的“门面”(Facade)。它把 StoragePainter 组装起来。

// src/core/MiniRender.ts
import { Storage } from '../storage/Storage';
import { Painter } from '../painter/Painter';
import { Element } from '../graphic/Element';

export class MiniRender {
    storage: Storage;
    painter: Painter;

    constructor(dom: HTMLElement) {
        this.storage = new Storage();
        this.painter = new Painter(dom, this.storage);
    }

    /**
     * 添加图形元素
     */
    add(el: Element) {
        this.storage.addRoot(el);
        this.refresh(); // 暂时:每次添加都立即刷新
    }

    /**
     * 触发重绘
     */
    refresh() {
        // 在真实 MiniRender 中,这里会使用 requestAnimationFrame 进行防抖
        this.painter.refresh();
    }
}

/**
 * 工厂函数
 */
export function init(dom: HTMLElement) {
    return new MiniRender(dom);
}

5. 测试

现在我们拥有了一个完整的静态渲染引擎。我们可以创建一个带有层级关系的场景。

index.ts (测试代码):

import { init } from './core/MiniRender';
import { Group } from './graphic/Group';
import { Circle } from './graphic/shape/Circle';

// 1. 初始化
const container = document.getElementById('main')!;
const miniRender = init(container);

// 2. 创建一个 Group (当作太阳系中心)
const sunGroup = new Group();
sunGroup.x = 300;
sunGroup.y = 300;

// 3. 创建一个红色的太阳 (加入 Group)
const sun = new Circle({
    shape: { r: 50 },
    style: { fill: '#F00' }
});
sunGroup.add(sun);

// 4. 创建一个蓝色的地球 (加入 Group,相对太阳偏移)
const earth = new Circle({
    shape: { r: 20 },
    style: { fill: '#00F' },
    position: [100, 0] // 距离太阳中心 100px
});
sunGroup.add(earth);

// 5. 创建一个月亮 (加入地球 Group? 这里为了简单,我们让月亮单独在地球旁边)
// 演示层级:我们把月亮直接加到 Group 里,但是 Z 设低一点
const moon = new Circle({
    shape: { r: 10 },
    style: { fill: '#CCC' },
    position: [120, 0], // 在地球右边
    z: -1 // 放在最下面 (测试排序)
});
sunGroup.add(moon);

// 6. 添加到 miniRender
miniRender.add(sunGroup);

// --- 动起来!(简单的动画循环) ---
// 这验证了 Painter 的 refresh 和 Storage 的 updateTransform
let angle = 0;
function loop() {
    angle += 0.02;
    
    // 旋转整个太阳系
    sunGroup.rotation = angle;
    
    // 自转地球 (修改属性,标记 dirty)
    // 注意:MiniRender 还没实现 dirty 标记,我们需要手动调 refresh
    
    miniRender.refresh();
    requestAnimationFrame(loop);
}

loop();

此时会发现一个问题,各图形位置不正确

6.图形位置问题

当前存在两个问题:

  • group内各图形相对位置错误
  • 整个group位置错误

A.解决group内的相对位置错误问题

回顾我们使用的代码:

错误原因:我们在 Circle 的构造函数中只处理了 shape 和 style,完全忽略了继承自 Element 的通用属性(position, scale, rotation)

我们需要在基类中处理这些通用属性的初始化。

1. 修改 src/graphic/Element.ts

增加一个初始化方法,用于解析 opts。

// src/graphic/Element.ts
import * as matrix from '../utils/matrix';
import { MatrixArray, Point } from '../utils/types';

export interface ElementProps {
    position?: Point; // [x, y]
    rotation?: number;
    scale?: Point;    // [sx, sy]
    origin?: Point;   // [ox, oy]
}

export abstract class Element {
    // ... 之前的属性定义 ...

    constructor(opts?: ElementProps) {
        this.id = `el_${idBase++}`;
        if (opts) {
            this.attr(opts);
        }
    }

    /**
     * 仿照 MiniRender 的 attr 方法,用于更新属性
     */
    attr(opts: ElementProps) {
        if (opts.position) {
            this.x = opts.position[0];
            this.y = opts.position[1];
        }
        if (opts.rotation != null) {
            this.rotation = opts.rotation;
        }
        if (opts.scale) {
            this.scaleX = opts.scale[0];
            this.scaleY = opts.scale[1];
        }
        if (opts.origin) {
            this.originX = opts.origin[0];
            this.originY = opts.origin[1];
        }
    }

    // ... updateTransform 等方法保持不变 ...
}
2. 修改 src/graphic/Displayable.ts

让子类将 opts 传递给 super。

// src/graphic/Displayable.ts
import { Element, ElementProps } from './Element';

// 组合类型
export interface DisplayableProps extends ElementProps {
    style?: any;
    z?: number;
    zLevel?: number;
    invisible?: boolean;
}

export abstract class Displayable extends Element {
    // ... 属性定义 ...

    constructor(opts?: DisplayableProps) {
        super(opts); // 关键!把 opts 传给 Element 处理 position/rotation
        
        if (opts) {
            if (opts.style) this.style = opts.style;
            if (opts.z != null) this.z = opts.z;
            if (opts.zLevel != null) this.zLevel = opts.zLevel;
            if (opts.invisible != null) this.invisible = opts.invisible;
        }
    }
    // ... brush 等方法 ...
}
3.修改 src/graphic/shape/Circle.ts
// src/graphic/shape/Circle.ts
import { Displayable, DisplayableProps } from '../Displayable';

interface CircleProps extends DisplayableProps {
    shape?: { cx?: number, cy?: number, r?: number };
}

export class Circle extends Displayable {
    shape: { cx: number, cy: number, r: number };

    constructor(opts?: CircleProps) {
        // 传递 opts 给父类
        super(opts); 
        
        // 处理自己特有的 shape
        this.shape = { cx: 0, cy: 0, r: 0, ...opts?.shape };
    }
    
    // ... buildPath ...
}

B.解决group的位置问题

在之前的 Painter.ts 代码中,我们在 refresh 循环渲染列表时调用 updateTransform。但存在一个问题,我们获取到的是displayable类型的图形,没有计算group这种根元素的矩阵。

解决方案: 将“计算矩阵”和“渲染绘制”分成了两个独立的遍历过程。

  1. Update 阶段:从根节点开始,递归(DFS)遍历整棵树,计算所有节点的 globalTransform。保证父级一定比子级先计算。
  2. Render 阶段:获取扁平化的 displayList(已排序),直接使用计算好的矩阵进行绘制。

我们需要修改 Painter.ts 或 Storage.ts 来体现这个逻辑。最简单的方法是在 Painter.refresh 中先更树,再画表。

修改 src/painter/Painter.ts

// src/painter/Painter.ts

export class Painter {
    // ... 

    refresh() {
        const list = this._storage.getDisplayList();
        const roots = this._storage.getRoots(); // 假设 Storage 暴露了 _roots
        const ctx = this._ctx;

        // 步骤 0: 确保 Canvas 尺寸正确 (防止 resize 没触发导致 width=0)
        if (this._width === 0) this.resize();

        // 步骤 1: 优先更新全场景图的变换矩阵 (MiniRender 核心逻辑)
        // 必须从根节点开始递归,确保父级矩阵先于子级生成
        roots.forEach(el => this._updateElementTransform(el));

        // 步骤 2: 清空画布
        ctx.clearRect(0, 0, this._width, this._height);

        // 步骤 3: 绘制扁平列表
        for (let i = 0; i < list.length; i++) {
            const el = list[i];
            // 此时 el.globalTransform 已经是正确的了,直接画
            el.brush(ctx);
        }
    }

    // 递归更新帮助函数
    private _updateElementTransform(el: Element) {
        el.updateTransform();
        // 如果是 Group,递归更新子节点
        if ((el as any).isGroup) {
            const children = (el as any).children;
            for (let i = 0; i < children.length; i++) {
                this._updateElementTransform(children[i]);
            }
        }
    }
}

注意:你需要在 Storage.ts 中增加一个 getRoots() 方法来返回 _roots 数组。

// src/storage/Storage.ts
public getRoots(): Element[] {
    return this._roots;
}

动画.gif

此时各图形位置将正确展示在画布中。

!

从零实现2D绘图引擎:1.实现数学工具库与基础图形类

作者 irises
2025年12月3日 10:26

MiniRender仓库地址参考

好的,我们开始 数学工具与基础图形类 的实现。

这是整个引擎的基石。如果这里的矩阵运算或者父子坐标变换写错了,后续所有的交互判定和渲染位置都会错乱。

1. 基础类型定义 (src/utils/types.ts)

为了代码清晰,我们先统一定义一些类型。

// src/utils/types.ts

// 向量/点: [x, y]
export type Point = [number, number];

// 3x2 仿射变换矩阵
// index: [0, 1, 2, 3, 4, 5] -> [a, b, c, d, tx, ty]
// 数学表示:
// | a c tx |
// | b d ty |
// | 0 0 1  |
export type MatrixArray = Float32Array | number[];

export interface BoundingRect {
    x: number;
    y: number;
    width: number;
    height: number;
}

2. 矩阵运算库 (src/utils/matrix.ts)

这是处理复杂层级和动画的核心。我们只需要实现最关键的几个方法:创建、乘法(级联)、合成(属性转矩阵)。

// src/utils/matrix.ts
import { MatrixArray } from './types';

// 创建单位矩阵
export function create(): MatrixArray {
    return [1, 0, 0, 1, 0, 0];
}

// 矩阵乘法: out = m1 * m2
// 用于计算 父级矩阵 * 子级局部矩阵 = 子级全局矩阵
export function mul(out: MatrixArray, m1: MatrixArray, m2: MatrixArray): MatrixArray {
    const out0 = m1[0] * m2[0] + m1[2] * m2[1];
    const out1 = m1[1] * m2[0] + m1[3] * m2[1];
    const out2 = m1[0] * m2[2] + m1[2] * m2[3];
    const out3 = m1[1] * m2[2] + m1[3] * m2[3];
    const out4 = m1[0] * m2[4] + m1[2] * m2[5] + m1[4];
    const out5 = m1[1] * m2[4] + m1[3] * m2[5] + m1[5];
    
    out[0] = out0; out[1] = out1;
    out[2] = out2; out[3] = out3;
    out[4] = out4; out[5] = out5;
    return out;
}

// 核心:将平移、缩放、旋转属性合成为一个矩阵
// 变换顺序:Translate -> Rotate -> Scale (标准顺序)
export function compose(
    out: MatrixArray,
    x: number, y: number,
    scaleX: number, scaleY: number,
    rotation: number
): MatrixArray {
    const sr = Math.sin(rotation);
    const cr = Math.cos(rotation);

    // 矩阵公式推导结果
    out[0] = cr * scaleX;
    out[1] = sr * scaleX;
    out[2] = -sr * scaleY;
    out[3] = cr * scaleY;
    out[4] = x;
    out[5] = y;
    
    return out;
}

// 克隆矩阵
export function clone(m: MatrixArray): MatrixArray {
    return Array.from(m); // 简易版
}

3. 元素基类 (src/graphic/Element.ts)

Element 不负责画画,只负责“我在哪”和“我的父级是谁”。它是场景图(Scene Graph)的节点。

核心逻辑updateTransform 方法。它负责先算自己的局部矩阵,然后看有没有爸爸。如果有,乘上爸爸的矩阵。

// src/graphic/Element.ts
import * as matrix from '../utils/matrix';
import { MatrixArray } from '../utils/types';

// 用一个简单的 GUID 生成器
let idBase = 0;

export abstract class Element {
    id: string;
    
    // --- 变换属性 (Transform Props) ---
    x: number = 0;
    y: number = 0;
    scaleX: number = 1;
    scaleY: number = 1;
    rotation: number = 0; // 弧度制

    // --- 矩阵状态 (Matrix State) ---
    // 局部变换矩阵 (相对于父级)
    localTransform: MatrixArray = matrix.create();
    // 全局变换矩阵 (相对于 Canvas 左上角)
    globalTransform: MatrixArray = matrix.create();

    // --- 层级关系 ---
    parent: Element | null = null;

    constructor() {
        this.id = `el_${idBase++}`;
    }

    /**
     * 核心方法:更新变换矩阵
     * 递归更新:通常由渲染器从根节点开始调用
     */
    updateTransform() {
        // 1. 根据属性计算局部矩阵
        // 优化:如果没有任何变换,保持单位矩阵 (此处省略优化,直接计算)
        matrix.compose(
            this.localTransform,
            this.x, this.y,
            this.scaleX, this.scaleY,
            this.rotation
        );

        // 2. 计算全局矩阵
        const parentTransform = this.parent && this.parent.globalTransform;
        
        if (parentTransform) {
            // 有父级:全局 = 父级全局 * 自身局部
            matrix.mul(this.globalTransform, parentTransform, this.localTransform);
        } else {
            // 无父级:全局 = 自身局部
            // 注意:这里需要拷贝,防止引用错乱
            for(let i = 0; i < 6; i++) {
                this.globalTransform[i] = this.localTransform[i];
            }
        }
    }
}

4. 样式接口 (src/graphic/Style.ts)

定义简单的 Canvas 样式。

// src/graphic/Style.ts
export interface CommonStyle {
    fill?: string;       // 填充颜色
    stroke?: string;     // 描边颜色
    lineWidth?: number;  // 线宽
    opacity?: number;    // 透明度 0-1
    shadowBlur?: number;
    shadowColor?: string;
    // ... 其他 Canvas 样式
}

5. 可绘制对象基类 (src/graphic/Displayable.ts)

Displayable 继承自 Element,负责将对象真正“画”到 Context 上。

核心逻辑brush 方法。它是渲染管线的核心步骤。

// src/graphic/Displayable.ts
import { Element } from './Element';
import { CommonStyle } from './Style';

export abstract class Displayable extends Element {
    
    style: CommonStyle = {};
    
    // 绘制顺序,类似于 CSS z-index
    z: number = 0;
    
    // 层级,不同的 zLevel 会被绘制在不同的 Canvas 实例上 (Layer)
    zLevel: number = 0;

    /**
     * 绘制入口
     * @param ctx 原生 CanvasContext
     */
    brush(ctx: CanvasRenderingContext2D) {
        const style = this.style;
        
        // 1. 保存当前 Context 状态
        ctx.save();

        // 2. 应用样式
        if (style.fill) ctx.fillStyle = style.fill;
        if (style.stroke) ctx.strokeStyle = style.stroke;
        if (style.lineWidth) ctx.lineWidth = style.lineWidth;
        // ... 其他样式应用

        // 3. 应用变换 (关键!)
        // setTransform(a, b, c, d, e, f)
        // 使用 globalTransform,这样 Canvas 原点就变到了图形的坐标系下
        const m = this.globalTransform;
        ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);

        // 4. 开始路径
        ctx.beginPath();
        
        // 5. 调用具体形状的路径构建逻辑
        this.buildPath(ctx);

        // 6. 绘制
        ctx.closePath(); // 可选
        if (style.fill) ctx.fill();
        if (style.stroke) ctx.stroke();

        // 7. 恢复 Context 状态 (弹出 save 的状态)
        ctx.restore();
    }

    /**
     * 抽象方法:由子类实现具体的路径
     * 例如 Circle 会调用 ctx.arc
     */
    abstract buildPath(ctx: CanvasRenderingContext2D): void;
}

6. 具体图形实现:圆形 (src/graphic/shape/Circle.ts)

最后,我们实现一个具体的图形来验证这一套逻辑。

// src/graphic/shape/Circle.ts
import { Displayable } from '../Displayable';

interface CircleShape {
    cx?: number;
    cy?: number;
    r?: number;
}

export class Circle extends Displayable {
    
    // 图形特有的几何属性
    shape: CircleShape;

    constructor(opts?: { shape?: CircleShape, style?: any, z?: number }) {
        super();
        this.shape = { cx: 0, cy: 0, r: 0, ...opts?.shape };
        if (opts?.style) this.style = opts.style;
        if (opts?.z) this.z = opts.z;
    }

    buildPath(ctx: CanvasRenderingContext2D) {
        const shape = this.shape;
        // 直接调用 Canvas API
        // 注意:因为我们在 Displayable.brush 中已经做了 setTransform
        // 这里的 cx, cy 是相对于图形自身坐标系的位置
        ctx.arc(shape.cx!, shape.cy!, shape.r!, 0, Math.PI * 2);
    }
}

7.验证

虽然我们还没有 StoragePainter,但我们可以写一段模拟代码来验证 变换矩阵 是否生效。

// test.ts (模拟运行)
import { Circle } from './graphic/shape/Circle';

// 1. 模拟一个 Canvas
const canvas = document.createElement('canvas');
canvas.width = 500;
canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d')!;

// 2. 创建一个圆
const circle = new Circle({
    shape: { cx: 0, cy: 0, r: 50 }, // 圆心在 0,0
    style: { fill: 'red' }
});

// 3. 设置变换属性
circle.x = 200;      // 移到 x=200
circle.y = 200;      // 移到 y=200
circle.scaleX = 2;   // 宽度放大 2 倍
circle.rotation = Math.PI / 4; // 旋转 45 度

// 4. 手动更新矩阵 (正常这是由 MiniRender 系统做的)
circle.updateTransform();

console.log('Global Matrix:', circle.globalTransform);
// 预期:tx=200, ty=200, 且 a,b,c,d 有值(因为有缩放和旋转)

// 5. 手动绘制 (正常这是由 Painter 做的)
// 清空画布
ctx.clearRect(0, 0, 500, 500);
// 绘制
circle.brush(ctx);

image-20251203005357167.png

AI取名大师 | 使得 uni-app 兼容 vue3 同名简写语法糖的 vite 插件

作者 集成显卡
2025年12月3日 10:18

关于 AI 取名大师

借助豆包通义千问DeepSeek 等 AI 大模型,为您的宝宝、宠物、店铺、网名、笔名、项目、产品、服务、文章等取一个专业、有意义的名字😄。


开源地址:👉GitCode(国内友好)👈、👉GitHub👈 技术组合:Bun.jsElysia.jsuni-app 体验地址:AI取名大师(H5版)、小程序搜索取名大师


特别注明:本系列文章仅为实战经验分享,并记录开发过程中碰到的问题😄,如有不足之处欢迎随时留言提出。


📣 同名简写语法

这是 vue 3.4+ 后更新的语法糖,如果属性名变量名完全一致,Vue 允许你简写,详见官方文档

写法 等价
v-bind:text="text" 完整写法
:text="text" v-bind 简写
:text 同名简写(implicit value)

示例:

<script setup>
const message = "Hello";
const active = true;
</script>

<template>
  <Comp :message :active />
<!--甚至可以这样写-->
<Comp v-bind="{ message, active }" />
</template>

😔 在小程序中不支持

同名简写语法,用过就觉得很香,习惯后怎么舍得改回来。可惜小程序(尤其是微信小程序)不支持该语法糖,使得编译不通过,只能一个个把同名简写改成传统模式😔。

🔧 搞一个插件

功能

专门为 uni-app 修补 Vue3 同名简写,就是 Vue SFC 进入 uni-app 小程序编译之前,用一个自定义 Vite 插件把 :text 自动转换为 :text="text"

原理简介

  • Vue 编译器对 :text 是合法的,因为它是 Vue3 的同名简写;
  • uni-app 的小程序编译器(特别是微信)不支持,会报错;
  • 我们可以在 Vite 阶段 预处理 .vue 文件的 template 源码:
    • 找到所有 :<attr> 且没有 = 的情况;
    • 转换成 :<attr>="<attr>"

设计原则

  • ✔ 只处理

AI取名大师 | uni-app 微信小程序打包 v-bind、component 动态组件问题

作者 集成显卡
2025年12月3日 10:18

关于 AI 取名大师

借助豆包通义千问DeepSeek 等 AI 大模型,为您的宝宝、宠物、店铺、网名、笔名、项目、产品、服务、文章等取一个专业、有意义的名字😄。


开源地址:👉GitCode(国内友好)👈、👉GitHub👈 技术组合:Bun.jsElysia.jsuni-app 体验地址:AI取名大师(H5版)、小程序搜索取名大师


特别注明:本系列文章仅为实战经验分享,并记录开发过程中碰到的问题😄,如有不足之处欢迎随时留言提出。


v-bind / 属性绑定

直接上代码:

<template>
<view :title />
</template>

<script setup>
let title = "标题"
</script>

这是一个再简单不过的 Vue3 示例,使用了绑定属性缩写,非常简洁清爽!编译为 h5 完全没问题,但是编译成微信小程序就会报错:

正在编译中...
✗ Build failed in 1.02s
[vite:vue] v-bind is missing expression.

2  |      <view @click="create" class="inline">
3  |          <slot>
4  |              <wd-button :icon="icon" :size="size" :type>创建积分券</wd-button>
   |                                         ^^^^^
5  |          </slot>
6  |      </view>

简单说就是微信小程序环境下不支持绑定缩写😔,只能一个个修给出:title="title"的形式,心累。如果需要多端支持,写代码时就得注意。

不支持 dblclick

打包为小程序时,如果使用了@dblclick会报错[vite:vue] v-bind is missing expression

这不是 @dblclick 自身的问题,因为打包为 H5 是完成没问题的,而是 小程序不支持 dblclick 事件,导致 Vue 在编译阶段将 @dblclick 解析为未知指令,间接触发 v-bind 报错。

我们可以通过 click 事件模拟双击,双击本质是两次 点击 之间时间间隔足够短

<template>
<wd-text @click="onClick" size="12px" />
</template>

<script setup>
let lastTime = 0
const onClick = ()=>{
    const now = Date.now()
    // 250ms 是常见阈值,可以按需求调整
    if(now - lastTime < 250){
        //处理双击
    }
    lastTime = now
}
</script>

component is not supported

原始代码:

<template>
<component :is='buildSVG(bean)' />
</template>

<script setup>
const buildSVG = item=>{
        let { color, svg, fill } = item.icon || {}
        if(svg && svg.startsWith("<svg "))
            return h('view', {class:"icon", innerHTML: svg })

        return h(
            item.id == 'baobao'? BabySVG:
            item.id == 'dianpu'? ShopSVG:
            item.id == 'chongwu'? DogSVG:
            item.id == 'wangming'? VestSVG:
            item.id == 'biming'? EditSVG:
            item.id == 'zuopin'? CreationSVG:
            item.id == 'wenzhang'? TitleSVG:
            null,
            { clazz:'icon', fill: fill || color, size: props.iconSize }
        )
    }
</script>

由于·微信小程序·的自定义组件系统不支持 <component is="">,也不支持 Vue 的动态组件渲染。

所以只能改成条件判断。

<template>
    <template v-if="inited">
        <view v-if="custom" class="icon" :innerHTML="bean.icon.svg" />
        <BabySVG v-if="bean.id=='baobao'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <ShopSVG v-else-if="bean.id=='dianpu'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <DogSVG v-else-if="bean.id=='chongwu'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <VestSVG v-else-if="bean.id=='wangming'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <EditSVG v-else-if="bean.id=='biming'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <CreationSVG v-else-if="bean.id=='zuopin'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <TitleSVG v-else-if="bean.id=='wenzhang'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <view v-else />
    </template>
</template>

<script setup>
    import BabySVG from '@SVG/baby.vue'
    import ShopSVG from '@SVG/shop.vue'
    import DogSVG from '@SVG/dog.vue'
    import EditSVG from '@SVG/edit.vue'
    import CreationSVG from '@SVG/creation.vue'
    import VestSVG from '@SVG/vest.vue'
    import TitleSVG from '@SVG/title.vue'

    const props = defineProps({
        bean:{type:Object, default:{}},
        size:{type:Number, default:48}
    })

    let inited = ref(false)
    let fill
    let color
    let custom = false

    onMounted(() => {
        let { svg } = props.bean.icon || {}
        if(svg && svg.startsWith("<svg "))
            custom = true

        fill = props.bean.icon?.fill
        color = props.bean.icon?.color

        inited.value = true
    })
</script>

Invalid pattern

Invalid pattern "../node-modules/wot-design-uni/components/wd-navbar/wd-navbar.js" for "output.chunkFileNames", patterns can be neither absolute nor relative paths. If you want your files to be stored in a subdirectory, write its name without a leading slash like this: subdirectory/pattern.

原因不明,删除 node_modules后,重新bun i就能正常打包😔。

页面出现莫名其妙的滚动条

作者 魂祈梦
2025年12月3日 10:16

场景

这种情况多半是出现在使用了vh或者vw这种单位的情况。

原因

假如我搞了一个上下布局,上面导航栏,下面内容区域。

比如导航栏固定高度60px。
内容区域使用下面的计算高度。

height: calc(100vh - 60px);

由于产生了横向滚动条,元素的实际高度发生变化。
在edge上,打开开发者工具,鼠标悬停在calc上,可以看到100vh对应924px。

image.png

如果在页面上选取元素,会发现滚动条不包含在元素区域。

image.png

$0.offsetHeight
// 输出 909
$0.scrollHeight
// 输出 921

这个差异就是因为滚动条。
100vh包含滚动条,100%不包含滚动条。

类似于scrollHeight和offsetHeight的区别

image.png

解决

使用100%

这种方法使用场景比较少,大家都不是傻子,既然能用100%为什么还用vh呢?

vh的场景一般是,外部容器没有限定大小,比如没有设置高度100%,或者只设置了min-height: 100%,这会导致内部的height百分比无效。
如果是min-height的问题,可以通过下面的方法解决。
juejin.cn/post/740913…

使用js自定义css变量计算正确的vh

function setViewportHeight() {
  let vh = window.innerHeight * 0.01;
  document.documentElement.style.setProperty('--vh', `${vh}px`);
}

// 初始化
setViewportHeight();

// 响应窗口大小变化(包括旋转、展开开发者工具等)
window.addEventListener('resize', setViewportHeight);

下面是加了防抖的写法

function debounce(func, wait) {
  let timeout;
  return function (...args) {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, args), wait);
  };
}

function setViewportHeight() {
  let vh = document.body.offsetHeight * 0.01;
  document.documentElement.style.setProperty('--vh', `${vh}px`);
}

// 初始化
setViewportHeight();

// 使用防抖包装 resize 回调,例如延迟 100ms
const debouncedSetViewportHeight = debounce(setViewportHeight, 100);

window.addEventListener('resize', debouncedSetViewportHeight);
.my-element {
  height: calc(var(--vh, 1vh) * 100 - 60px);
}

你还在为画各种流程图头疼吗?

作者 海边的云
2025年12月3日 10:14

1.官方文档

vueflow.dev/

Vue Flow 简介

Vue Flow 是一个专为 Vue 3 设计的可视化流程编辑库,灵感来自 React Flow。它提供了拖拽、缩放、连接、高亮等一系列交互能力,适合用于流程图、业务流程编排、数据流、低代码画布等场景。核心特点:

  1. 组件化节点:节点、边都可用 Vue 组件自定义,易于扩展。
  2. 强交互性:内置拖拽、选择框、多选、键盘快捷键等交互。
  3. 可视化状态管理:通过 useVueFlow 暴露节点/边/视图状态与增删改查 API。
  4. 插件生态:MiniMap、Background、Controls 等增强组件可直接引入。
  5. 性能优化:虚拟化渲染、事件去抖等,适合中大型画布。

文档入口:vueflow.dev/guide/intro… | Vue Flow”)


最小示例 Demo

以下示例展示两节点一条边的基本用法,可直接放在 Vue SFC 中运行(例如 Vue Flow Playground 或 Vite 项目)。

<template>
  <div style="width: 100%; height: 500px">
    <VueFlow
      v-model:nodes="nodes"
      v-model:edges="edges"
      fit-view
    >
      <Background />
      <Controls />
    </VueFlow>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { VueFlow, Background, Controls } from '@vue-flow/core'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'

const nodes = ref([
  {
    id: '1',
    position: { x: 0, y: 50 },
    data: { label: '开始' },
  },
  {
    id: '2',
    position: { x: 200, y: 150 },
    data: { label: '结束' },
  },
])

const edges = ref([
  { id: 'e1-2', source: '1', target: '2', animated: true },
])
</script>

关键点说明:

  1. VueFlow 组件接收 nodesedges 数组,可通过 v-model 实时同步状态。
  2. 节点 data.label 默认会在内置 Node 中渲染;也可通过 vueFlow.registerNode 或插槽自定义。
  3. fit-view 自动将视图缩放居中到所有节点。
  4. BackgroundControls 是官方提供的 UI 插件,分别渲染网格背景和缩放/定位控件。
  5. 若需要事件处理(如拖拽回调、连线校验),可通过 useVueFlow() 获取 onEdgeUpdate, addEdges 等 API。

需要更复杂的例子(自定义节点、交互、动态加载等),可以在文档中的 “Examples” 或 “Cookbook” 章节查阅。

word解析从入门到出门

作者 王大宇_
2025年12月3日 09:59

word解析

在word对比一文中,我们用到了mammoth库,在这篇文章,我们将实现一个word解析工具

word对比详细请见:word对比

🌰demo

思路

word的本质是XML格式的文件,.docx文件是ZIP压缩包,里面包含多个XML文件和其他资源文件

  1. 解压docx
  2. 解析XML为DOM对象
  3. 解析段落元素
  4. 解析表格元素
  5. 解析其余
  6. 统一配置

实现

解压word

拿到数据并将其解析为ZIP对象

从ZIP中获取文档主要内容

从ZIP中获取文档内资源关系映射

从ZIP中获取文档样式定义

遍历word/media文件夹中所有文件,将它们转化为base64编码

import JSZip from 'jszip';

export async function unzipDocx(arrayBuffer: ArrayBuffer) {
  const zip = await JSZip.loadAsync(arrayBuffer);

  const documentXml = await zip.file('word/document.xml')?.async('string');
  const relsXml = await zip
    .file('word/_rels/document.xml.rels')
    ?.async('string');
  const stylesXml = await zip.file('word/styles.xml')?.async('string');

  const media: Record<string, Promise<string>> = {};

  zip.folder('word/media')?.forEach((path, file) => {
    media[path] = file.async('base64');
  });

  return {
    documentXml,
    relsXml,
    stylesXml,
    media,
  };
}

解析XML

我们需要将XML转化为可操作的DOM对象

<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
  <w:body>
    <w:p>
      <w:r>
        <w:t>Hello World</w:t>
      </w:r>
    </w:p>
  </w:body>
</w:document>
export function parseXml(xml: string) {
  return new DOMParser().parseFromString(xml, 'application/xml');
}

解析文本与样式

上面我们已经将XML解析为了DOM对象

我们需要将其文本内容与一些样式解析出来

export function parseRun(rNode: Element) {
  const t = rNode.getElementsByTagName('w:t')[0];
  const text = t?.textContent ?? '';

  const rPr = rNode.getElementsByTagName('w:rPr')[0];

  return {
    text,
    bold: !!rPr?.getElementsByTagName('w:b').length,
    italic: !!rPr?.getElementsByTagName('w:i').length,
    underline: !!rPr?.getElementsByTagName('w:u').length,
  };
}

解析段落

将段落内容提取

<w:p>
  <w:r>
    <w:t>Hello </w:t>
  </w:r>
  <w:r>
    <w:rPr><w:b/></w:rPr>
    <w:t>World</w:t>
  </w:r>
</w:p>

解析为:

{
  type: 'paragraph',
  id: 'p1',
  runs: [
    {
      text: 'Hello ',
      bold: false,
      italic: false,
      underline: false
    },
    {
      text: 'World',
      bold: true,
      italic: false,
      underline: false
    }
  ]
}
import { parseRun } from './parseRun';

let pid = 0;

export function parseParagraph(pNode: Element) {
  const runs = [...pNode.getElementsByTagName('w:r')].map(parseRun);

  return {
    type: 'paragraph',
    id: `p${++pid}`,
    runs,
  };
}

解析表格

将嵌套结构解析并展开,word中还有很多嵌套结构(列表、文本框等),此处以表格为例

<w:tbl>
  <w:tr>
    <w:tc>
      <w:p><w:r><w:t>Cell 1</w:t></w:r></w:p>
    </w:tc>
    <w:tc>
      <w:p><w:r><w:t>Cell 2</w:t></w:r></w:p>
    </w:tc>
  </w:tr>
  <w:tr>
    <w:tc>
      <w:p><w:r><w:t>Cell 3</w:t></w:r></w:p>
    </w:tc>
    <w:tc>
      <w:p><w:r><w:t>Cell 4</w:t></w:r></w:p>
    </w:tc>
  </w:tr>
</w:tbl>
{
  type: 'table',
  id: 'tbl1',
  rows: [
    [  // 第一行
      [  // 第一个单元格
        {  // 段落对象
          type: 'paragraph',
          id: 'p1',
          runs: [{ text: 'Cell 1', bold: false, italic: false, underline: false }]
        }
      ],
      [  // 第二个单元格
        {  // 段落对象
          type: 'paragraph',
          id: 'p2',
          runs: [{ text: 'Cell 2', bold: false, italic: false, underline: false }]
        }
      ]
    ],
    [  // 第二行
      [  // 第一个单元格
        {  // 段落对象
          type: 'paragraph',
          id: 'p3',
          runs: [{ text: 'Cell 3', bold: false, italic: false, underline: false }]
        }
      ],
      [  // 第二个单元格
        {  // 段落对象
          type: 'paragraph',
          id: 'p4',
          runs: [{ text: 'Cell 4', bold: false, italic: false, underline: false }]
        }
      ]
    ]
  ]
}

入口

  1. 解压docx

  2. 解析XML为DOM对象

  3. 解析段落元素

  4. 解析表格元素

  5. 解析其余

import { unzipDocx } from './unzip';
import { parseXml } from './parseXml';
import { parseParagraph } from './parseParagraph';
import { parseTable } from './parseTable';

export async function parseDocx(arrayBuffer: ArrayBuffer) {
  const { documentXml } = await unzipDocx(arrayBuffer);
  const doc = parseXml(documentXml!);

  const body = doc.getElementsByTagName('w:body')[0];
  const children = [...(body.childNodes as unknown as Element[])];

  const result = [];

  for (const node of children) {
    if (node.nodeName === 'w:p') {
      result.push(parseParagraph(node));
    }
    if (node.nodeName === 'w:tbl') {
      result.push(parseTable(node));
    }
  }

  return result;
}

转为html

export function toHtml(ast) {
  return ast
    .map(block => {
      if (block.type === 'paragraph') {
        const runs = block.runs
          .map(run => {
            let html = run.text;
            if (run.bold) html = `<strong>${html}</strong>`;
            if (run.italic) html = `<em>${html}</em>`;
            return html;
          })
          .join('');

        return `<p data-id="${block.id}">${runs}</p>`;
      }

      if (block.type === 'table') {
        const rows = block.rows
          .map(row => {
            const cells = row
              .map(cell => {
                const html = cell.map(p => toHtml([p])).join('');

                return `<td>${html}</td>`;
              })
              .join('');
            return `<tr>${cells}</tr>`;
          })
          .join('');

        return `<table data-id="${block.id}">${rows}</table>`;
      }
    })
    .join('\n');
}

使用

将word对比中的handleUpload进行调整

  async function handleUpload(file: File, type: 'old' | 'new') {
    const arrayBuffer = await file.arrayBuffer();

    const ast = await parseDocx(arrayBuffer);
    const html = toHtml(ast);


    const tempDiv = document.createElement('div');
    tempDiv.innerHTML = html;
    const text = tempDiv.textContent || tempDiv.innerText || '';

    if (type === 'old') {
      setOldText(text);
    } else {
      setNewText(text);
    }
  }

从零实现一个「就地编辑」组件:深入理解 OOP 封装与复用的艺术

2025年12月3日 09:25

在现代 Web 应用中,「就地编辑(Edit in Place)」是一种非常常见的交互模式 —— 用户点击一段文本,它立刻变成可编辑的输入框;编辑完成后,点击“保存”即可提交更新,无需跳转页面或弹出模态框。这种体验既简洁又高效。

今天,我们就基于一个真实的代码片段,从零开始剖析并实现一个 EditInPlace,深入探讨面向对象编程(OOP)如何帮助我们写出高内聚、低耦合、可复用的前端组件。


🌟 为什么需要「就地编辑」?

传统的表单提交方式往往需要用户进入专门的编辑页面,填写后再提交。而「就地编辑」将编辑操作直接嵌入到内容展示区域,减少上下文切换,提升用户体验。例如:

  • GitHub 的 Issue 标题双击编辑
  • Notion 中的段落点击即改
  • 后台管理系统中的配置项快速修改

这些场景背后,其实都可以抽象为同一个组件逻辑 —— 这正是我们封装 EditInPlace 的价值所在。


🔧 初识 EditInPlace:结构与职责

我们来看核心代码:

function EditInPlace(id, value, parentElement) {
  this.id = id;
  this.value = value || '这个家伙很懒,什么都没有留下';
  this.parentElement = parentElement;
  // ... 初始化 DOM 引用
  this.createElement();
  this.attachEvent();
}

这是一个典型的 构造函数 + 原型方法 的 OOP 写法(ES5 风格)。虽然现在我们更习惯用 class,但这种写法更能清晰展现 OOP 的本质:数据 + 行为

✅ 组件的核心职责:

  1. 渲染两种状态:只读文本(<span>)和可编辑输入框(<input>
  2. 状态切换:点击文本 → 显示输入框;点击保存/取消 → 回到文本
  3. 事件绑定:处理点击、保存、取消等交互
  4. 值管理:维护当前内容,并支持后续扩展(如发送到后端)

🏗️ 拆解实现:模块化思维是关键

1. createElement():构建 UI 结构

createElement: function() {
  this.containerElement = document.createElement('div');
  this.staticElement = document.createElement('span');
  this.fieldElement = document.createElement('input');
  // ... 创建按钮、设置初始值
  this.parentElement.appendChild(this.containerElement);
  this.convertToText(); // 默认显示文本
}

这里体现了 关注点分离:UI 构建独立于逻辑控制。即使未来要换成 React/Vue,这部分也只需重写渲染层,核心状态机不变。

2. convertToText() / convertToField():状态切换

通过 display: none/inline 控制元素显隐,实现“视图切换”。虽然简单,但足够有效。更高级的做法可能是用 CSS 类或虚拟 DOM diff,但对轻量组件而言,简单即优雅

3. attachEvent():事件委托的雏形

this.staticElement.addEventListener('click', () => this.convertToField());
this.saveButton.addEventListener('click', () => this.save());

注意这里使用了箭头函数,确保 this 指向实例本身 —— 这是 ES5 时代容易踩的坑,也说明作者有良好的实践意识。


💡 OOP 的真正价值:封装与复用

正如 readme.md 中所说:

“类的编写者和使用者可以是两拨人,封装可以隐藏实现细节”

这意味着,使用者只需关心接口,无需了解内部实现

<!-- index.html -->
<div id="app"></div>
<script>
  new EditInPlace('slogan-editor', 'Hello World', document.getElementById('app'));
</script>

一行代码,即可获得完整交互能力。如果未来要支持:

  • 自动保存(onBlur 触发)
  • 输入校验
  • 调用 API 同步数据(替换 save() 中的 fetch

都无需改动调用方代码,只需增强类内部逻辑 —— 这就是封装的力量。


🚀 扩展思考:如何让它更“现代”?

虽然当前实现是 ES5 风格,但我们完全可以将其升级:

✅ 改写为 ES6 Class

class EditInPlace {
  constructor(id, value, parentElement) { /* ... */ }
  createElement() { /* ... */ }
  // ...
}

✅ 支持 Promise / async 的 save

async save() {
  try {
    await fetch('/api/update', { method: 'POST', body: JSON.stringify({ value: this.value }) });
    // 成功后更新 UI
  } catch (err) {
    alert('保存失败');
  }
}

✅ 增加配置选项

new EditInPlace({
  id: 'xxx',
  value: 'xxx',
  parent: el,
  placeholder: '点击编辑...',
  onSave: (val) => console.log('自定义保存逻辑')
});

📌 总结:小组件,大智慧

EditInPlace 看似简单,却完整体现了前端工程化的几个核心思想:

  • 组件化:独立功能单元
  • 封装性:隐藏实现,暴露接口
  • 可复用:一处编写,多处使用
  • 可维护:逻辑清晰,易于扩展

好的代码不是写出来的,而是设计出来的。

❌
❌