ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Vue股票交易系统源码拆解:WebSocket、Pinia与订单状态机实战

Vue股票交易系统源码拆解:WebSocket、Pinia与订单状态机实战 简介这是一份基于Vue框架的股票交易系统设计源码面向学习Vue开发、对股票交易系统感兴趣的开发者也适合作为高校课程设计或毕业设计的参考项目。系统采用Vue、JavaScript、CSS与HTML实现附带模拟后端可体验行情展示、买入卖出、资金管理等虚拟交易流程能直观理解组件化开发、路由与状态管理在真实工程中的运用。资源包共23个文件含8个Vue组件、5个JavaScript脚本和3个JSON配置文件另有HTML、CSS、图标及说明文档压缩包大小仅533KB目录结构清晰。组件负责界面与交互脚本处理数据请求和业务逻辑JSON保存路由及接口配置读者可借此了解一个中等复杂度前端项目的代码划分。对于希望从零搭建单页应用或研究股票交易系统的开发者这份源码提供了可运行的完整工程适合学习参考与二次开发。目前已有103人学习。1. 先拆 stock_trading_system从源码目录反推 Vue 交易系统的模块边界拿到一个基于 Vue 框架的 stock_trading_system 源码第一件事不是看页面样式而是把数据流画出来。股票交易系统跟普通管理系统的差别就三点行情是推的不是拉的订单状态是流转的不是静态的金额精度错一位就是事故。这三点直接决定技术选型——WebSocket 做实时通道、Pinia 管跨组件状态、ECharts 画 K线。GitHub 上同类源码不少多数问题不在组件层而在数据流没理顺行情回调直接改组件 data、下单结果散落多个页面、浮点数算金额。下面按「骨架 → 行情 → 交易 → 接口 → 部署」拆解做 Vue 项目实战或准备 vue 面试题可以直接对号入座。2. 路由与目录设计vue-router 配置和交易页面的组件三层拆解2.1 从 api 到 views交易系统源码里的目录分层先看一个能支撑后面所有章节的目录结构。基于 Vue 3 Vite Pinia 搭建的股票交易系统里代码按「接口、状态、视图、复用组件、工具」五块划分src/ ├── api/ │ ├── market.js # 行情接口最新价、历史K线、分时 │ └── trade.js # 交易接口下单、撤单、持仓、成交 ├── components/ │ ├── KLineChart.vue # K线图封装内部只依赖 props │ └── OrderTable.vue # 委托/成交列表纯展示组件 ├── views/ │ ├── quotes/ # 行情列表页自选股、板块 │ ├── trading/ # 交易面板页买卖切换、价格输入 │ └── positions/ # 持仓与资金页 ├── store/ │ ├── market.js # 行情快照、订阅列表 │ └── order.js # 委托、持仓、资金状态 └── utils/ ├── ws.js # WebSocket 封装心跳、重连 └── number.js # 精度计算与格式化这样分层的目的是把行情、订单、页面三件事解耦。KLineChart 不关心数据从哪来只接收 kline 数组并负责渲染OrderTable 不关心订单状态如何变更只把 store 里的委托列表映射成表格。行情数据从 WebSocket 推入 store再通过 computed 属性流到组件比「页面里直接 new WebSocket」的做法更容易定位问题。一个典型的反面案例是交易页自己维护 socket 实例路由切换后没销毁导致重复订阅和内存泄漏——如果这套源码是从老项目迁移来的十有八九还留着这个坑。clone 下来第一步是 vue 安装及环境配置。这类项目对 Node 版本有隐性要求Vite 5 需要 Node 18装依赖时遇到 ERESOLVE 先看 package.json 的 engines 字段而不是急着加 --force在 vscode 里打开后先用扩展装 Volar 而不是 VeturVue 3 的 SFC 类型推断依赖前者。环境对齐后再进路由层看页面怎么串起来。2.2 路由懒加载与全局前置守卫交易页面的鉴权与 redirect 回带行情页允许匿名访问但交易和持仓必须登录这正好用 vue-router 的 meta 标记来区分import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: /quotes, name: quotes, component: () import(/views/quotes/QuoteList.vue), meta: { requireAuth: false } }, { path: /trading, name: trading, component: () import(/views/trading/TradePanel.vue), meta: { requireAuth: true } }, { path: /positions, name: positions, component: () import(/views/positions/PositionList.vue), meta: { requireAuth: true } } ] }) router.beforeEach((to, from) { const token localStorage.getItem(access_token) if (to.meta.requireAuth !token) { return { path: /login, query: { redirect: to.fullPath } } } return true })component 用 () import(...) 做路由懒加载首屏只加载 /quotes 的 chunk交易面板和持仓页在首次进入时才请求这直接影响首屏白屏时间。beforeEach 把原始地址通过 redirect 参数带回登录页登录成功后用 router.push(route.query.redirect) 回到目标页面这是交易系统里最常见的 vue 路由参数用法。两个边界要注意守卫里只判断 token 存在不够token 过期时要在响应拦截器里统一踢回登录页另外不要用 sessionStorage 存 token用户新开标签页后登录态会丢。2.3 交易面板的组件拆分价格输入、数量步进与买卖方向交易面板是 views/trading 下的核心页面常见拆法是 TradePanel 负责编排内部挂 PriceInput、QtyStepper、DirectionSwitch 三个子组件。DirectionSwitch 决定买卖方向PriceInput 和 QtyStepper 的价格、数量联动走 v-model但「预估金额」要在计算属性里算而不是在 watch 里赋值否则输入数字时会抖动script setup import { ref, computed } from vue const direction ref(buy) // buy 买入 | sell 卖出 const price ref(0) const qty ref(100) const feeRate 0.0003 // 佣金费率万三买入即计费 const estimatedAmount computed(() { const amount price.value * qty.value const fee direction.value buy ? amount * feeRate : 0 return (amount fee).toFixed(2) }) /script这个计算属性把费率也纳入预估金额买入时算佣金卖出时另算印花税比只做 price * qty 的版本更接近真实交易场景。组件拆分的原则是每个子组件只做一件事通过 props 和 emit 通信不要在子组件里直接 import store——这样在 Vue 项目实战里才方便单独写单元测试。3. 实时行情链路WebSocket 心跳重连与 ECharts K线渲染3.1 行情消息协议与字段约定type 分发和命名对齐行情模块是整个股票交易系统里最容易出问题的地方。服务端推送的消息通常是 JSON外层带 type 字段做路由data 里是具体业务数据{ type: quote, data: { symbol: 600519, price: 1685.00, high: 1690.00, low: 1672.50, volume: 32000, timestamp: 1710230400000 } }字段命名前后端必须对齐这决定前端要不要写一堆兼容逻辑。常见做法是后端统一下划线命名前端在 api 层做一次 camelCase 转换而不是在组件里散落data.price和data[high]混用。行情消息还有 typeorder、typetrade 推送订单回执约定好枚举后 onmessage 就能按一个映射分发而不是写多个 if 嵌套。这里把消息协议放到 utils/ws.js 旁边单独建一个 messageTypes.js 维护比写在组件里更利于排错。3.2 MarketSocket 封装心跳、指数退避重连与订阅恢复直接 new WebSocket 的写法在交易系统里撑不了十分钟。连接抖动、服务端重启、移动端切换网络都会断线封装要处理三件事心跳、退避重连、订阅恢复。class MarketSocket { constructor(url) { this.url url this.ws null this.reconnectAttempts 0 this.heartbeatTimer null this.subscriptionSet new Set() // 已订阅代码重连后补拉 this.handlers {} // { messageType: callback } } connect() { this.ws new WebSocket(this.url) this.ws.onopen () { this.reconnectAttempts 0 this.startHeartbeat() this.subscriptionSet.forEach((symbol) this.subscribe(symbol)) } this.ws.onmessage (event) this.dispatch(JSON.parse(event.data)) this.ws.onclose () { this.stopHeartbeat() this.scheduleReconnect() } this.ws.onerror () this.ws.close() } subscribe(symbol) { this.subscriptionSet.add(symbol) if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: subscribe, symbol })) } } scheduleReconnect() { const delay Math.min(1000 * 2 ** this.reconnectAttempts, 30000) this.reconnectAttempts 1 setTimeout(() this.connect(), delay) } startHeartbeat() { this.heartbeatTimer setInterval(() { this.ws.send(JSON.stringify({ type: ping, ts: Date.now() })) }, 15000) } stopHeartbeat() { clearInterval(this.heartbeatTimer) this.heartbeatTimer null } dispatch(msg) { const handler this.handlers[msg.type] if (handler) handler(msg.data) } }心跳间隔我一般选 15 秒服务端通常 30 秒无消息就断开连接留一倍余量超过两个心跳周期没等到 pong 就该主动 close 触发重连。退避算法从 1 秒开始翻倍到 30 秒封顶避免服务端恢复时所有客户端同时重连造成雪崩。重连成功后的订阅恢复是很多人漏掉的点用 Set 记录订阅列表在 onopen 里重新发送 subscribe用户不会因为一次断网就丢行情。onerror 里要调用 ws.close()否则浏览器会一直尝试连接onclose 根本不会触发。提示心跳消息不携带业务数据但不要每次发送都重新 JSON.stringify把构造好的字符串缓存起来高频推送时能省不少 GC 压力。3.3 KLineChart 的更新策略setOption 全量刷新与 appendData 增量追加K线图是行情页面最重的组件。决定性能的不是 echarts 本身而是数据更新策略。全量 setOption 在 500 根K线以内没问题分时图每秒推一个点时就必须走 appendData// 分时图场景增量追加一个点x 轴用索引对齐 chart.appendData({ seriesIndex: 0, data: [[nextIndex, lastPrice.price]] }) // 日K/周K场景周期切换时全量刷新 chart.setOption({ xAxis: { data: klines.map((k) formatDate(k.timestamp)) }, series: [{ type: candlestick, data: klines.map((k) [k.open, k.close, k.low, k.high]) }] })appendData 只接受一维数组所以分时图的 series 要用折线而不是K线。这里有个参数细节ECharts 的 candlestick 数据顺序是 [open, close, low, high]不是 [open, high, low, close]很多人在这里把高低点画反排错时先在控制台打印一条数据人工核对。周期切换时的更新策略可以按下面的表约定周期推送频率更新方式备注分时每 3 秒appendData 追加超过 240 个点做头部截断日K收盘后setOption 全量需处理除权复权周K/月K低频setOption 全量切换周期时重新拉取表里「截断」这一点值得展开分时图只保留当天 240 个交易分钟点超出的头部数据要移除否则图表横向范围会越来越长内存也会涨。移除时用 shift 或 slice 都行但组件卸载时一定要调用 chart.dispose()页面上残留的 canvas 实例会让 SPA 越来越卡。4. 订单状态机与 Pinia store从下单到成交回执的数据流4.1 订单状态定义状态机决定接口和 UI 的边界订单是交易系统里状态最多的实体。设计源码时如果状态没有在 store 层集中定义写法就会失控。一张最小可用的状态表状态含义进入条件前端 UI 表现pending待处理下单请求已发出按钮置灰、显示加载submitted已报服务端受理成功显示委托编号partial_filled部分成交成交回报数量小于委托量显示已成交/委托比例filled全部成交累计成交量等于委托量状态置绿、提示成交canceled已撤单主动撤单成功状态置灰rejected已拒绝资金不足或数量非法弹出失败原因状态机先定义好前端代码就好写了交易页面只响应 store 里的 order.status不自己猜测订单到哪一步。设计时的核心原则是——服务端推送的成交回报是唯一事实来源前端不要因为「下单成功」就推测订单一定会成交pending 到 filled 之间必须由推送驱动。4.2 store 设计submitOrder 乐观更新与 applyTradeMessage 合并回执状态机落在 Pinia 里核心是 submitOrder 和 applyTradeMessage 两个 actionimport { defineStore } from pinia import { placeOrder } from /api/trade export const useOrderStore defineStore(order, { state: () ({ orders: [], // 当日委托列表最新在前 positions: {}, // 持仓 { symbol: { qty, avgPrice } } availableCash: 0, // 可用资金 submitting: false // 下单请求是否在途防重复点击 }), actions: { async submitOrder(payload) { // payload: { symbol, side, price, qty, clientOrderId } this.submitting true try { const { orderId } await placeOrder(payload) this.orders.unshift({ orderId, status: pending, side: payload.side, symbol: payload.symbol, filledQty: 0, price: payload.price, qty: payload.qty, createTime: Date.now() }) } finally { this.submitting false } }, applyTradeMessage(msg) { const target this.orders.find((o) o.orderId msg.orderId) if (!target) return target.status msg.status target.filledQty msg.filledQty if (msg.status filled || msg.status partial_filled) { this.updatePosition(target) } }, updatePosition(order) { const pos this.positions[order.symbol] ?? { qty: 0, avgPrice: 0 } if (order.side buy) { const totalCost pos.qty * pos.avgPrice order.filledQty * order.price pos.qty order.filledQty pos.avgPrice totalCost / pos.qty } else { pos.qty - order.filledQty if (pos.qty 0) delete this.positions[order.symbol] } } } })submitOrder 做了乐观更新接口返回只代表「服务端受理」真正的状态变化来自 WebSocket 推送。applyTradeMessage 只合并数据、不发起新请求订单列表的展示就能始终与交易所回执一致。updatePosition 维护平均成本价买入时加权平均卖出时直接减数量减到 0 删除键避免数量为负的脏数据。部分成交也要进 updatePosition否则多笔分批成交后成本价会算错。提示组件里用 storeToRefs(useOrderStore()) 取出 orders 和 positions模板引用时才不会丢掉响应性。直接在 store 上解构 state 会丢失响应式这是 vue 面试题里 Pinia 部分的高频考点。4.3 金额精度为什么用分做单位而不是浮点直接乘下单金额、手续费、持仓成本、资金余额任何一步用浮点相乘都会埋雷。0.1 0.2 的经典问题在交易场景里不是段子而是真金白银价格 3.51 元、数量 100 股3.51 * 100 用二进制浮点表示会得到 350.99999999999994显示层格式化也许看不出来累计一百笔委托后误差就藏不住了。常见的做法是后端返回的金额统一用「分」为单位的整数前端展示时再除 100function toCents(amountYuan) { // 元转分四舍五入到整数 return Math.round(amountYuan * 100) } function formatCents(cents) { // 分转元保留两位小数 return (cents / 100).toFixed(2) }如果后端没这么做前端计算也要先把金额转成分再运算算完转回元。还有一个边界toFixed 对 0.015 这类值在部分浏览器会向下舍入所以转分不能用 parseFloat(amount).toFixed(2) * 100而是 Math.round(amount * 100)。验证自己的封装对不对用 5.99、3.51、0.1 各跑一遍这三个数最容易暴露精度问题。5. api 层封装与幂等axios 拦截器、token 刷新和 clientOrderId5.1 axios 实例与请求拦截器认证头与超时参数交易系统的接口层通常单独抽一个 axios 实例防止与第三方接口互相污染import axios from axios const service axios.create({ baseURL: /api, timeout: 10000 // 默认 10s下单接口单独放宽 }) service.interceptors.request.use((config) { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) export default service超时参数要按接口类型区分行情查询 10 秒足够下单接口我会单独传 timeout 15 秒券商柜台链路长太短的超时会导致服务端已受理但前端报超时用户重复点提交就会下出重复单。认证头统一加在拦截器里业务代码就不用手动往 headers 塞 token。接口层单独抽实例还有个好处联调时在拦截器里统一打印请求日志上线后降掉日志级别即可不需要每个页面里各打一条 console。5.2 响应拦截器业务错误码统一处理与 401 刷新机制后台返回格式通常是 { code, message, data }响应拦截器先解包code 非 0 就 reject不让「部分失败」的数据流到页面service.interceptors.response.use( (resp) { const { code, message, data } resp.data if (code 0) return data if (code 1001) { // token 失效清登录态跳登录页 localStorage.removeItem(access_token) window.location.href /login return Promise.reject(new Error(登录已过期)) } return Promise.reject(new Error(message)) }, (error) { if (error.response?.status 401) { // 部分系统用 refresh_token 换新后重放原请求 // 需要先把原 config 存起来刷新成功后再次发起 } return Promise.reject(error) } )业务错误码表值得在源码里单独维护一份常量文件而不是在代码里散落数字比对code含义前端动作0成功返回 data1001未登录或 token 过期清登录态、跳登录页1002可用资金不足面板提示并禁止提交1003非交易时段显示休市提示按钮置灰2001clientOrderId 重复复用已存在订单不重新下单1002 和 1003 这类错误码的提示要落到具体 UI 上不能只 toast 一下。资金不足时把下单按钮置灰并显示缺口金额非交易时段显示休市倒计时这些细节决定用户愿不愿意把系统当工具用。5.3 clientOrderId下单幂等与超时重试的关键参数网络超时重试是重复下单的根源解决手段是客户端生成唯一委托编号 clientOrderId传给服务端做幂等键function generateClientOrderId() { const timestamp Date.now().toString(36) const random Math.random().toString(36).slice(2, 8) return ${timestamp}-${random} }下单接口携带这个参数超时后前端用同一个 clientOrderId 重试服务端对已受理的相同编号直接返回原订单而不是再撮合一次。clientOrderId 在 store 里要存进订单记录撤单接口也要回传撤单只带 orderId 在交易高峰期会让人怀疑是不是发错单带上 clientOrderId 后服务端可以直接按幂等键定位前端超时重试时也不会把撤单请求重复投递到撮合队列。6. 打包优化与上线排错vite.config.js 拆包和「打包后布局异常」排查方向6.1 构建参数echarts 独立拆包与关闭 sourcemap股票交易系统里 echarts 打包后接近 1MB跟 Vue 本身打在一起首屏加载很吃亏。用 manualChunks 把 echarts 和 Vue 全家桶拆成独立 chunk// vite.config.js export default defineConfig({ base: ./, build: { sourcemap: false, // 产线关掉 sourcemap chunkSizeWarningLimit: 600, rollupOptions: { output: { manualChunks: { echarts: [echarts], vendor: [vue, pinia, vue-router] } } } } })base 配成相对路径 ./ 后模板里的静态资源引用要写成 import 或 new URL(./logo.png, import.meta.url)不能写死 /static/logo.png否则部署到子路径时图片和字体全部 404。6.2 布局异常与缓存失效三个排查位置Vue 本地运行正常、打包后布局错乱是 vue 样式方向的高频问题按出现概率排第一字体图标和背景图 404 导致占位错位排查 Network 面板里是否有 image 请求红了。第二组件库样式按需引入后打包顺序被打乱把全局样式统一在 main.js 入口一次引入避免组件内散落的 style 被 tree-shaking 改变顺序。第三index.html 被 CDN 缓存部署后用户拿到旧 html 引用旧 JS hash。验证手段很直接本地起静态服务器指向 dist 目录用 npx serve dist 复现一遍能复现的问题大概率是相对路径或资源引用不能复现就去看服务器端 html 的 Cache-Control 是否设了 no-cache。JS 和 CSS 带 hash 可以放心永久缓存html 必须每次回源校验。查完这三个位置再上 vue devtools 插件核对 store 里的 order 状态有没有被旧代码写脏基本能把上线后的「玄学问题」收敛成确定原因。本文还有配套的精品资源点击获取
返回列表