普通视图

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

我用 PixiJS 撸了个圆桌会议选座系统,从 0 到 1 踩坑全复盘

作者 悟空瞎说
2026年3月19日 17:34

大家好,我是写了 10 年代码的老前端,最近接了个需求:做一个圆桌会议可视化选座系统


一、需求拆解:圆桌会议到底要什么?

先把需求扒干净,避免做无用功:

  1. 形态:中间是圆桌,座位沿圆周均匀分布,绝对不能重叠
  2. 交互:点击选座 / 取消、拖拽换位、保存布局(刷新不丢)
  3. 性能:座位最多 20 个,要流畅拖拽,不能卡顿
  4. 兼容:PixiJS 版本坑多,要兼容 v6/v7 所有版本

核心难点:

  • 长方桌改圆桌:坐标计算从「上下左右」变成「极坐标 + 角度」
  • 避免重叠:必须用圆周均分算法,不能手动硬编码
  • PixiJS API 差异:getGlobalPosition 在不同版本里写法不一样,很容易踩坑

二、技术选型:为什么选 PixiJS 而不是 Konva?

我做过 Konva 版,也对比过 Fabric.js,最后选 PixiJS 的原因很简单:

  1. 性能更强:PixiJS 是 WebGL 渲染,大量座位时帧率更稳
  2. 分层更灵活:用 Container 做基础层 + 拖拽层,性能损耗极小
  3. 社区成熟:大厂可视化项目都在用,坑都被踩过了
  4. 轻量:比 Fabric.js 小,比原生 Canvas 开发快 10 倍

三、核心实现:从 0 到 1 搭骨架

1. 初始化 Pixi 应用

先搭好画布和分层容器,这是 Pixi 项目的标准起点:

javascript

运行

const app = new PIXI.Application({
  width: 1200,
  height: 700,
  backgroundColor: 0xf5f5f5,
  resolution: window.devicePixelRatio || 1,
  antialias: true,
});
document.body.appendChild(app.view);

// 分层:基础层(桌+座位)+ 拖拽层(临时元素)
const baseLayer = new PIXI.Container();
const dragLayer = new PIXI.Container();
app.stage.addChild(baseLayer, dragLayer);

2. 绘制圆桌:从矩形到圆形

把之前的蓝色长方桌换成灰色圆桌,用 drawCircle 实现:

javascript

运行

const TABLE_RADIUS = 180; // 圆桌半径
const CENTER_X = 600;     // 画布中心X
const CENTER_Y = 350;     // 画布中心Y

const table = new PIXI.Graphics();
table.beginFill(0xCCCCCC);
table.drawCircle(0, 0, TABLE_RADIUS);
table.endFill();
table.x = CENTER_X;
table.y = CENTER_Y;
baseLayer.addChild(table);

3. 环形座位:极坐标计算避免重叠

这是最核心的算法:用极坐标把座位均匀分布在圆周上,彻底解决重叠问题:

javascript

运行

const SEAT_COUNT = 16;    // 总座位数
const SEAT_DISTANCE = TABLE_RADIUS + 40; // 座位到圆心的距离

function createSeat(key, index, isOccupied) {
  const seat = new PIXI.Graphics();
  updateSeatStyle(seat, isOccupied);

  // 极坐标转直角坐标:角度 → x/y
  const angle = (index / SEAT_COUNT) * Math.PI * 2;
  const x = CENTER_X + Math.cos(angle) * SEAT_DISTANCE;
  const y = CENTER_Y + Math.sin(angle) * SEAT_DISTANCE;

  seat.x = x;
  seat.y = y;
  seat.rotation = angle + Math.PI/2; // 让座位朝向圆心,更自然
  // ... 交互逻辑
}

4. 交互实现:点击 + 拖拽 + 保存

点击选座

直接监听 pointertap 事件,切换座位状态:

javascript

运行

seat.on('pointertap', () => {
  seat.isOccupied = !seat.isOccupied;
  updateSeatStyle(seat, seat.isOccupied);
  // 更新数据数组
});

拖拽换位

PixiJS 拖拽的坑:不同版本获取鼠标坐标的 API 不一样,我封装了一个兼容函数:

javascript

运行

// 兼容 PixiJS v6/v7 的坐标获取
function getGlobalPosition(e) {
  if (e.data && typeof e.data.getGlobalPosition === 'function') {
    return e.data.getGlobalPosition();
  } else if (e.data && e.data.global) {
    return e.data.global;
  } else {
    return app.renderer.plugins.interaction.mouse.global;
  }
}

拖拽时在 dragLayer 渲染临时座位,结束后碰撞检测目标座位,交换状态。

保存布局

localStorage 持久化座位数据,刷新页面自动加载:

javascript

运行

const STORAGE_KEY = 'roundTableSeats';
let occupiedSeats = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');

function saveSeatLayout() {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(occupiedSeats));
}

四、踩坑复盘:10 年程序员的血泪教训

坑 1:PixiJS 版本 API 不兼容

  • 问题:e.data.getGlobalPosition is not a function
  • 原因:v6 和 v7 的事件对象结构不一样
  • 解决:封装 getGlobalPosition 兼容函数,同时锁定 CDN 版本为 v6.5.10(最稳定)

坑 2:座位重叠

  • 问题:手动算坐标导致座位挤在一起
  • 解决:用极坐标均分算法,angle = (index / SEAT_COUNT) * Math.PI * 2,保证每个座位间隔一致

坑 3:拖拽卡顿

  • 问题:频繁重绘基础层导致帧率掉帧
  • 解决:用 dragLayer 单独渲染拖拽元素,基础层只在状态变化时重绘

坑 4:CSP 警告

  • 问题:浏览器报 upgrade-insecure-requests 警告
  • 解决:在 <head> 加 CSP 元标签,明确允许 PixiJS CDN 和内联脚本

五、完整代码 & 运行方式

直接复制下面的代码,保存为 .html,双击打开就能跑:(这里放你之前的完整圆桌版代码即可)

运行效果

  • 中间灰色圆桌,16 个红色 / 灰色座位均匀环绕
  • 点击灰色 → 变红(选中),点击红色 → 变灰(取消)
  • 拖动红色座位到空座位 → 自动换位
  • 点击「保存」→ 刷新页面后选中状态不丢失

六、扩展思路:给产品交差的加分项

  1. 座位数量动态调整:加个输入框,修改 SEAT_COUNT 后重新渲染
  2. 座位信息编辑:右键菜单,修改座位名称、备注
  3. 批量操作:框选多个座位,批量移动 / 清空
  4. 后端对接:把 localStorage 换成接口请求,实现多端同步
  5. 权限控制:不同角色只能选指定区域的座位

七、总结

这次重构让我深刻体会到:

  • 可视化项目的核心是坐标计算,圆桌比长方桌难就难在极坐标的理解
  • 分层渲染是性能优化的银弹,把频繁更新的元素单独拎出来
  • 兼容老版本是前端的宿命,封装兼容函数能少踩 90% 的坑

如果你也在做类似的可视化选座需求,直接拿我的代码改,少走半年弯路。


结尾互动

你在做可视化项目时踩过什么坑?评论区聊聊,我帮你一起解决~预览地址


Electron+React必看:electron-router-dom 完整实战指南(含路由守卫/传参/多窗口)

作者 悟空瞎说
2026年3月19日 14:39

做过 Electron + React 桌面端开发的兄弟,大概率都被路由兼容坑惨过:原生 react-router-dom 在开发环境跑得溜,打包生产直接失效;多窗口场景下路由互相污染,关窗还残留历史栈;开发/生产环境加载逻辑不一致,调试到头秃。

今天带来 electron-router-dom 完整版教程,基于官方入门文扩展,不仅保留基础上手流程,还把路由守卫、动态传参、嵌套路由、生产优化、底层逻辑一次性讲透,10年桌面端开发踩坑经验全塞进去,复制代码直接落地。

核心定位:react-router-dom 官方适配器,专为 Electron 多窗口、开发/生产双环境定制

核心解决:环境兼容、多窗口路由隔离、路由污染、生产失效四大痛点

一、先搞懂:为什么原生 react-router-dom 不适配 Electron?

很多人直接把网页路由搬到 Electron,踩坑了都不知道原因。底层逻辑很简单:

  • 网页是单窗口、hash/history 路由模式,Electron 多窗口是独立渲染进程,路由状态无法隔离
  • 开发环境用 localhost 服务,生产环境加载本地 HTML 文件,路由路径解析规则不一致
  • 原生路由没有窗口 ID 绑定,多窗口共用一个路由栈,导致跳转混乱、内存泄漏

electron-router-dom 就是做了一层封装:通过窗口 ID 绑定路由,让每个窗口拥有独立路由栈,自动适配开发/生产环境的路径解析,完美兼容 react-router-dom 原有 API(useNavigate、useParams 等)。


二、完整安装流程(含依赖避坑)

该库依赖 react-router-dom,必须同步安装,别漏装导致启动报错:

# npm 安装
npm i electron-router-dom react-router-dom

# yarn 安装
yarn add electron-router-dom react-router-dom

# pnpm 安装(推荐)
pnpm add electron-router-dom react-router-dom

重点提醒:react-router-dom 必须是 v6 版本(v5 不兼容),当前主流项目都是 v6,直接安装即可。

三、主进程全配置(开发/生产双环境+多窗口)

主进程核心是创建窗口 + 绑定窗口 ID + 区分环境加载路由,这步是路由生效的关键,窗口 ID 必须和渲染进程严格对应,不能错!

import { app, BrowserWindow, BrowserWindowConstructorOptions } from 'electron'
import { createFileRoute, createURLRoute } from 'electron-router-dom'
import { join } from 'path'

// 封装创建窗口函数,id 为路由唯一标识
function createWindow(id: string, options: BrowserWindowConstructorOptions = {}) {
  const window = new BrowserWindow({
    width: 700,
    height: 473,
    ...options,
    // 推荐开启,避免白屏
    show: false,
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      // 关闭跨域限制(桌面端常用)
      webSecurity: false,
      nodeIntegration: false,
      contextIsolation: true
    }
  })

  // 开发环境:加载本地服务路由
  const devURL = createURLRoute(process.env.ELECTRON_RENDERER_URL!, id)
  // 生产环境:加载本地 HTML 文件路由
  const prodRoute = createFileRoute(join(__dirname, '../renderer/index.html'), id)

  // 环境区分加载
  if (process.env.NODE_ENV === 'development') {
    window.loadURL(devURL)
  } else {
    window.loadFile(...prodRoute)
  }

  // 页面加载完毕再显示,避免闪烁
  window.once('ready-to-show', () => window.show())
  return window
}

// 应用就绪后创建窗口
app.whenReady().then(() => {
  // 主窗口,id = main
  createWindow('main')
  // 关于窗口,id = about
  createWindow('about', { width: 450, height: 350 })
})

// 关闭所有窗口退出(mac 除外)
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

四、渲染进程路由配置(基础+嵌套+多窗口)

渲染进程通过 Router 组件按窗口 ID 配置路由,和主进程 ID 一一对应,支持嵌套路由、路由分组。

1. 路由配置文件(routes.tsx)

import { Router, Route } from 'electron-router-dom'
// 引入页面组件
import { MainScreen, SearchScreen, AboutScreen, UserDetailScreen } from './screens'
// 引入路由守卫组件
import { AuthGuard } from './guards/AuthGuard'

export function AppRoutes() {
  return (
    <Router
      // 主窗口路由id=main),支持嵌套/多路由
      main={
        <>
          {/* 基础路由 */}
          <Route path="/" element={<MainScreen />} />
          {/* 带路由守卫的路由(需登录) */}
          <Route path="/search" element={<AuthGuard><SearchScreen /></AuthGuard>} />
          {/* 动态传参路由 */}
          <Route path="/user/:id" element={<UserDetailScreen />} />
        </>
      }
      // 关于窗口路由(id=about),独立路由栈
      about={<Route path="/" element={<AboutScreen />} />}
    />
  )
}

2. 入口文件挂载路由(index.tsx)

import React from 'react'
import ReactDOM from 'react-dom/client'
import { AppRoutes } from './routes'

const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(
  <React.StrictMode>
    <AppRoutes />
  </React.StrictMode>
)

五、核心进阶用法(原文缺失,必看)

1. 路由跳转(useNavigate 用法)

和原生 react-router-dom 完全一致,直接复用原有写法,无需改逻辑:

import { useNavigate } from 'react-router-dom'

export function MainScreen() {
  const navigate = useNavigate()

  return (
    <main style={{ padding: '20px' }}>
      <h1>主窗口</h1>
      {/* 普通跳转 */}
      <button onClick={() => navigate('/search')}>跳转搜索页</button>
      {/* 动态传参跳转 */}
      <button onClick={() => navigate('/user/1001')}>查看用户详情</button>
      {/* 返回上一页 */}
      <button onClick={() => navigate(-1)}>返回</button>
    </main>
  )
}

2. 动态路由参数获取(useParams)

import { useParams } from 'react-router-dom'

export function UserDetailScreen() {
  // 获取路由上的 id 参数
  const { id } = useParams<{ id: string }>()

  return (
    <div>
      <h2>用户详情页</h2>
      <p>用户ID:{id}</p>
    </div>
  )
}

3. 路由守卫(权限控制,登录拦截)

封装高阶组件,实现未登录跳转登录页,桌面端权限控制必备:

// src/guards/AuthGuard.tsx
import { Navigate } from 'react-router-dom'

interface AuthGuardProps {
  children: React.ReactNode
}

export function AuthGuard({ children }: AuthGuardProps) {
  // 判断登录状态(可从 store/preload 读取)
  const isLogin = localStorage.getItem('token') ? true : false

  // 未登录跳转首页
  if (!isLogin) return <Navigate to="/" replace /&gt;
  // 已登录放行
  return children
}

4. 多窗口通信 + 路由联动

通过 preload 暴露方法,主进程打开新窗口,渲染进程触发,路由自动隔离:

// 页面组件内调用
const { App } = window // preload 暴露的 API
<button onClick={() => App.openAboutWindow()}>打开关于窗口</button>

六、生产打包避坑(关键!)

  • 打包前务必校验 窗口 ID 一致性,主进程和渲染进程必须完全匹配
  • 生产环境关闭 devTools,路由文件路径别写错,避免加载失败
  • 路由不要用绝对路径,统一用相对路径,防止跨域/文件找不到
  • 多窗口关闭时,同步清理路由状态,避免内存泄漏

七、底层逻辑简析(看懂不踩坑)

electron-router-dom 本质是路由分发器

  1. 主进程通过窗口 ID 标记路由,开发环境拼接 URL,生产环境拼接文件路径
  2. 渲染进程通过 ID 匹配对应路由组,每个窗口路由栈独立,互不干扰
  3. 内部兼容 react-router-dom v6 核心 API,上层写法无感知,底层做环境适配

八、总结

electron-router-dom 是 Electron + React 开发的路由神器,解决了原生路由最头疼的环境和多窗口问题。基础用法简单易上手,进阶用法(守卫、传参、嵌套)完全兼容 react-router-dom,上手成本极低。

建议大家把这份教程收藏,项目里直接复制配置,再也不用折腾路由兼容问题。如果碰到窗口白屏、路由失效,优先检查窗口 ID 是否一致、环境路径是否正确,90% 的坑都能解决。

点赞+收藏,Electron 开发少走一周弯路,需要完整项目模板的评论区留言~

❌
❌