简介:这是一份用于微信小程序开发学习与参考的模拟炒股类项目源码,面向小程序初学者与前端开发者,帮助理解完整的小程序工程结构、图表展示和股票行情相关交互逻辑,同时涵盖页面跳转、数据存储及图表渲染等常见能力。压缩包共46个文件,包括12个JSON配置、11个JavaScript逻辑文件、9个WXSS样式、8个WXML页面结构与5个PNG图片资源,整体仅702KB;其中JSON负责项目与页面配置,JS处理页面逻辑与交互,WXSS定义视觉样式,WXML搭建页面骨架,PNG提供界面图标。项目页面模块清晰,涵盖登录、自选股、个股详情、指数查看及个人中心等典型炒股场景,工具函数与全局配置独立组织,适合边看边改或作为课程设计基础。目前已有422人学习下载,可作为快速上手小程序开发、了解行情图表类界面搭建的实用参考,也适合高校课程设计或毕业设计选题时直接借鉴其工程结构。
1. 模拟炒股小程序为什么值得做:数据、图表、交易三块硬骨头一次练齐
小程序练手项目里,“展示型 Demo”占了大半:一个列表、一个详情页、几张写死的图片,做完对技术的提升很有限。而「微信小程序开发:模拟同花顺炒股小程序(源码+图表)」这类项目,名字叫“炒股”,实际上不接任何真实行情、不产生真实交易,更像一套练手组合题:行情列表、K线分时、自选股、模拟买卖、状态同步,每一块都要自己造,对准备求职小程序开发、或者想彻底把 canvas 图表和页面状态管理啃下来的人来说,这个方向比再做一遍商城划算太多。
标题里“图表”两个字是含金量所在。第三方图表库在小程序里普遍体积大、定制受限,而这个项目的核心做法是用 Canvas 手绘 K 线和分时图,周期、均线、成交量全由数据驱动。接下来我按「数据层怎么造 → 图表怎么画 → 交易怎么算 → 哪些坑真机必踩」的顺序,把这类源码项目最常见的实现路径完整拆给你看。
2. 先拆项目骨架:自造数据、双写状态、组件化图表
一个能跑的模拟炒股小程序,通常包含行情页、个股详情页、交易页、持仓页四个页面,外加一套自选股逻辑。核心难点不在页面多,而在四件事:数据从哪来、页面之间的状态怎么同步、图表画在哪一层、交易规则怎么抽象。我把这个结构叫作“四层骨架”,代码拿到手以后先按这个顺序读,别急着改页面。
2.1 行情数据为什么选择自造而不是接真实接口:从数据可控角度算一笔账
很多人拿到这类项目的第一反应是找真实行情接口,我在一开始就劝你放弃。真实行情接口大多有服务协议和申请门槛,个人开发者能拿到的 key 往往因为域名白名单、请求频率、用途限制被卡住;更现实的是,接口不稳定时 K 线缺根、分时断点,排错成本会远超写业务代码本身。模拟数据则完全可控:断网能跑、测试能跑、给面试官演示也能跑。
我一般会把“行情真实性”控制在两个层面。第一,数据结构真实,日 K 线必须有 open、close、high、low、volume 五个字段,分时数据要有时间和成交量的粒度;第二,数值关系真实,high 必须是当天最高、low 必须是最低、open 和 close 落在二者区间内,否则画出来就是一条乱线。做到这两点,数据源即使是随机生成的,用户也看不出破绽。
2.2 用固定种子随机数生成 K 线:单日 O/H/L/C 的约束关系是关键
K 线生成不是简单抛硬币。我常用的一种做法是:给每支股票一个固定种子(比如股票代码的 hash 值),用 mulberry32 伪随机数算法生成稳定的“走势”,这样每次启动小程序数据一致,调试时不会出现“这次涨下次跌”的玄学问题。下面这段代码就是完整的日 K 生成器。
// 固定种子随机数:同样的 code 永远生成同样的行情序列 function mulberry32(seed) { return function () { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function hashCode(str) { let h = 0; for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0; return h; } // 生成日 K 线:close 是核心,open/high/low 围绕它展开 function generateKLine(code, days = 120) { const rand = mulberry32(hashCode(code)); let close = 10 + rand() * 40; // 初始价格 10~50 元 const list = []; for (let i = 0; i < days; i++) { const change = (rand() - 0.48) * 0.06; // 单日涨跌 -3%~+3% const open = close * (1 + (rand() - 0.5) * 0.02); // 开盘相对昨收 ±1% const closeNext = close * (1 + change); // 收盘由涨跌幅决定 // 关键约束:high 必须大于 open/close,low 必须小于 open/close const high = Math.max(open, closeNext) * (1 + rand() * 0.015); const low = Math.min(open, closeNext) * (1 - rand() * 0.015); list.push({ date: formatDate(i), open: round2(open), close: round2(closeNext), high: round2(high), low: round2(low), volume: Math.floor(5000 + rand() * 20000) }); close = closeNext; // 今天的收盘就是明天的昨收 } return list; }逻辑说明:mulberry32是确定性随机数发生器,输入同一个种子会得到完全相同的序列,这样自选股列表、图表数据在不同页面间保持一致;hashCode(code)把股票代码映射成一个整数种子,不同股票走势不同,同一股票每次启动数据一致。open和closeNext的计算顺序也刻意安排:先算开盘、再算收盘,high 和 low 再取两者的上下外扩,这样四条价格线的逻辑关系永远不会破。
参数上,days = 120表示生成 120 根日 K,覆盖大约半年的交易日;0.015是最高价和最低价的影线余量比例,越大 K 线越长,看起来越“妖”。涨跌幅0.06控制在 ±3%,A 股日常波动一般就在这个范围附近,如果把它改成 0.1 就是 ±5%、改成 0.2 就是 ±10%,走势会变得很夸张,做演示时按需调整。
2.3 全局变量 + Storage 双写:解决“A 页面改了 B 页面不刷新”
模拟炒股小程序最常见的状态问题,是交易页买入成功、自选页添加成功,但持仓页和行情页的数据还是旧的。原因是小程序每个页面是独立实例,页面 A 修改数据后页面 B 不会自动感知。我一般会用“全局变量 + Storage 双写”的轻量方案:全局变量负责页面间快速读取,Storage 负责持久化,页面onShow时统一从全局读一次。
// app.js App({ globalData: { positions: [], // 持仓 favorites: [], // 自选股 cash: 100000 // 虚拟资金 }, onLaunch() { // 冷启动时从 Storage 恢复 this.globalData.positions = wx.getStorageSync('positions') || []; this.globalData.favorites = wx.getStorageSync('favorites') || []; this.globalData.cash = wx.getStorageSync('cash') || 100000; } });// 任意页面里读取和写入 const app = getApp(); app.globalData.positions.push(newTrade); wx.setStorageSync('positions', app.globalData.positions);这里的核心原则是:所有写操作同时更新globalData和Storage,读操作优先走globalData;页面onShow时重新拉一次。注意要直接把整个数组传给setStorageSync,不要只传新增的那一项,否则下次冷启动会丢数据。
2.4 拿到源码先认这五个文件:目录结构与职责
这类项目的源码目录通常不会太散,我建议先按下面这张表找对应关系。
| 路径 | 职责 | 重点关注 |
|---|---|---|
pages/index/ | 行情列表页,自选/全部切换、涨跌幅排序 | 列表复用、滚动加载 |
pages/detail/ | 个股详情页,K线/分时切换、最新价 | 图表组件接入、跨页面通信 |
pages/trade/ | 模拟买卖、持仓、成交记录 | 交易规则、资金计算 |
utils/stock.js | 行情生成器、交易计算、费用计算 | 所有纯逻辑都在这 |
components/kline/ | Canvas 图表组件,K线和分时图 | canvas 生命周期、尺寸适配 |
第五个文件是重中之重。K 线图千万不要写在详情页的onReady里,而是封装成自定义组件,因为走势图、分时图、收益曲线都会用到同一套绘制逻辑;组件化之后顺便解决 canvas 在多个页面间重复初始化的问题。拿到源码先跑通utils/stock.js的导出数据,再去看组件,这是最快的路径。
3. 用 Canvas 手绘 K 线和分时图:图表不是贴图,是自己画的
很多项目里图表是引第三方库或者用 image 标签贴图,前者体积大、定制麻烦,后者根本没法做十字光标这类交互。这类模拟炒股项目给的图表,主流做法是原生 Canvas 2d 手绘。做之前先弄清楚一个前提:小程序里 canvas 有两种形态,老接口canvas type="2d"之前是原生组件,层级最高、容易遮挡弹窗;现在推荐type="2d"的新接口,走同层渲染,可以正常跟 view 一起滚动。下面的代码全部按新接口写。
3.1 坐标转换三件套:价格区间、像素坐标、留白参数
K 线图的本质是把“价格区间”映射到“画布像素矩形”。三个坐标系要分清:数据索引是横轴,价格是纵轴,画布坐标是最终输出。我封装一个布局对象,所有绘制函数都从它取坐标,避免每个函数自己算一遍留白。
// 图表布局:把价格区间换算成画布坐标 function createLayout(width, height, padding) { const left = padding.left || 8; const right = padding.right || 8; const top = padding.top || 20; // 顶部留给最高价标签 const bottom = padding.bottom || 16; return { innerWidth: width - left - right, innerHeight: height - top - bottom, // i: 第几根K线, step: 每根K线占的像素宽度 makeX(i, step) { return left + i * step; }, // price: 当前价格, max/min: 整个图表的最高最低价 makeY(price, max, min) { return top + ((max - price) / (max - min)) * (height - top - bottom); } }; }参数说明:step由 K 线实体宽度和间距相加得到,比如蜡烛宽 7px、间距 2px,step 就是 9px;top = 20是因为最高价标签要画在图表区域上方,不预留会被裁掉;min为 0 时公式会把 0 价映射到图表底部,所以真实绘图时max取最高价、min取最低价而不是 0。这里有个容易出错的地方:makeY的坐标轴方向。画布 y 轴向下增长,价格越高 y 值越小,所以要用(max - price) / (max - min)而不是(price - min) / (max - min)。
3.2 蜡烛图绘制:影线、实体、MA5/MA10/MA20 都从数据算出来
绘制顺序我一般固定为:先算均线数组,再画均线,最后画蜡烛实体。这样交叉处蜡烛能盖住均线,视觉上不会出现均线穿过实体内部的脏线。红涨绿跌是国内行情软件的通用习惯,代码里用两个颜色常量统一控制。
const UP_COLOR = '#ef4444'; // 涨:红 const DOWN_COLOR = '#10b981'; // 跌:绿 const CANDLE_WIDTH = 7; // 蜡烛实体宽度 const CANDLE_GAP = 2; // 蜡烛间距 // 简单均线:MA5 / MA10 / MA20 function calcMA(list, n) { return list.map((item, i) => { if (i < n - 1) return null; const slice = list.slice(i - n + 1, i + 1); return round2(slice.reduce((s, v) => s + v.close, 0) / n); }); } function drawKLine(ctx, list, layout) { const { innerWidth, innerHeight, makeX, makeY } = layout; const allHigh = Math.max(...list.map((it) => it.high)); const allLow = Math.min(...list.map((it) => it.low)); const step = CANDLE_WIDTH + CANDLE_GAP; const totalWidth = list.length * step; // 图表宽度不够时,只画最后能装下的根数,避免 K 线被挤扁 const startIndex = totalWidth > innerWidth ? list.length - Math.floor(innerWidth / step) : 0; const data = list.slice(startIndex); const maColors = [['#f59e0b', 5], ['#3b82f6', 10], ['#a855f7', 20]]; // 先画均线 maColors.forEach(([color, n]) => { const maArr = calcMA(data, n); ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.beginPath(); maArr.forEach((v, i) => { if (v === null) return; const x = makeX(i, step); const y = makeY(v, allHigh, allLow); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke(); }); // 再画蜡烛:影线和实体分开画 data.forEach((item, i) => { const x = makeX(i, step); const isUp = item.close >= item.open; ctx.strokeStyle = ctx.fillStyle = isUp ? UP_COLOR : DOWN_COLOR; // 影线:最高价到最低价的竖线 ctx.beginPath(); ctx.moveTo(x + CANDLE_WIDTH / 2, makeY(item.high, allHigh, allLow)); ctx.lineTo(x + CANDLE_WIDTH / 2, makeY(item.low, allHigh, allLow)); ctx.stroke(); // 实体:open 到 close 的矩形 const yOpen = makeY(item.open, allHigh, allLow); const yClose = makeY(item.close, allHigh, allLow); const top = Math.min(yOpen, yClose); const height = Math.max(Math.abs(yOpen - yClose), 1); ctx.fillRect(x, top, CANDLE_WIDTH, height); }); }两点必须要说明。第一,startIndex的逻辑是在屏幕宽度不足以画全部 120 根 K 线时,只画最后能放下的那一段,否则所有蜡烛会被压成一条线;段数不够、蜡烛太密是这类图表最常见的观感问题。第二,CANDLE_WIDTH = 7是我在真机上试出来的一个舒适值,iPhone 逻辑宽度 375px 时,7px 实体加 2px 间距,一屏能看清约 40 根,缩放后不会糊;改成 5 以下就太挤,10 以上又太粗。
3.3 分时图双区绘制:价格线在上、成交量柱在下
分时图跟 K 线最大的区别是它只有一个交易日的数据,横轴是 240 分钟(A 股 9:30-11:30、13:00-15:00 各 120 分钟),纵轴价格要相对昨收计算。常见做法是把画布上下分成两个区域:上方 70% 画价格折线,下方 30% 画成交量柱,这样一张 canvas 解决两个数据维度,不用叠加两层。
// minuteList: [{ time: '09:31', price: 10.22, volume: 1200 }, ...] // prevClose: 昨收价 function drawMinute(ctx, minuteList, layout, prevClose) { const { innerWidth, innerHeight, makeX, makeY } = layout; const priceAreaHeight = innerHeight * 0.7; const volAreaHeight = innerHeight * 0.3; const maxVol = Math.max(...minuteList.map((it) => it.volume)); // 价格轴范围:昨收上下 2%,超出部分自动裁掉 const minPrice = prevClose * 0.98; const maxPrice = prevClose * 1.02; // 价格折线 ctx.strokeStyle = '#2563eb'; ctx.lineWidth = 1; ctx.beginPath(); minuteList.forEach((item, i) => { const x = makeX(i, innerWidth / 240); const y = ((maxPrice - item.price) / (maxPrice - minPrice)) * priceAreaHeight; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke(); // 昨收基准线:一条水平虚线 ctx.setLineDash([4, 4]); ctx.strokeStyle = '#94a3b8'; const baseY = ((maxPrice - prevClose) / (maxPrice - minPrice)) * priceAreaHeight; ctx.beginPath(); ctx.moveTo(0, baseY); ctx.lineTo(innerWidth, baseY); ctx.stroke(); ctx.setLineDash([]); // 成交量柱:涨红跌绿,宽度固定 1px minuteList.forEach((item, i) => { const x = makeX(i, innerWidth / 240); const h = (item.volume / maxVol) * volAreaHeight; ctx.fillStyle = item.price >= prevClose ? UP_COLOR : DOWN_COLOR; ctx.fillRect(x, priceAreaHeight + volAreaHeight - h, 1, h); }); }分时图有个容易翻车的地方:价格线和成交量柱不在同一个坐标系里,但共用横轴。上面代码里横轴直接用innerWidth / 240做步长,也就是 240 个点均匀铺满整个图表宽度,这样成交量柱的 x 才能和价格折线严格对齐。成交量的高度是按最大值等比例缩放到下方 30% 区域,如果某根量特别大,其他柱会被压得很矮,这是正常现象,不必强行放大。
4. 自选股和模拟交易:虚拟盘也有真规则
很多人觉得模拟盘的交易逻辑随便写写就行,实际上虚拟盘比真实交易更需要规则——因为真实交易有交易所帮你结算,模拟盘所有数字都要自己算。买入撮合、手续费、持仓成本、可卖数量,每一处写错都会让页面上的数字对不上,用户一旦发现“账户总资产不等于现金加市值”,整个项目就废了。
4.1 自选股列表的幂等增删:别用对象存自选
自选股最基础的坑是用“对象”存。很多初学者喜欢favorites[code] = stock,短时间没问题,但页面里遍历Object.keys时顺序完全不可控,还会出现 A 股代码 6 开头、0 开头混排后的诡异排序。我一般只用数组,并且保证增删操作是幂等的——重复添加同一支股票不会产生两条记录。
// 添加自选:已存在则忽略,新加的放最前面 function addFavorite(list, stock) { if (list.some((it) => it.code === stock.code)) return list; const next = [stock, ...list]; wx.setStorageSync('favorites', next); return next; } // 删除自选:过滤掉目标代码 function removeFavorite(list, code) { const next = list.filter((it) => it.code !== code); wx.setStorageSync('favorites', next); return next; }some判断是幂等的关键,不加这一句,用户在详情页反复点“加自选”就会攒出一串重复项。删除用filter而不是splice,因为splice需要先findIndex,两步操作在函数式写法里容易漏判“不存在的代码”,filter一步到位。所有写操作都同步setStorageSync,这样冷启动后自选列表才不会丢。
4.2 买入计算器:一手 100 股、最低佣金 5 元、T+1
模拟盘虽然没有真实资金,但交易规则建议按 A 股来:买入按 100 股一手取整,佣金万 2.5 且最低 5 元,卖出一方额外收印花税。把费用规则做成纯函数,交易页、持仓页、资产统计页用同一套计算,避免各算各的对不上。
// 买入费用:佣金 = max(金额 * 费率, 最低佣金) function calcBuy(price, shares, feeRate = 0.00025, minFee = 5) { const amount = price * shares; const fee = Math.max(amount * feeRate, minFee); return { amount: round2(amount), fee: round2(fee), total: round2(amount + fee) // 实际扣款 = 成交金额 + 佣金 }; } // 可买数量:按手取整,不足一手不能买 function maxBuyable(cash, price) { const lots = Math.floor(cash / (price * 100)); return lots * 100; } // 执行买入:更新现金、写入持仓 function executeBuy(positionList, stock, price, shares) { const { total, fee } = calcBuy(price, shares); const app = getApp(); if (app.globalData.cash < total) return null; app.globalData.cash = round2(app.globalData.cash - total); let pos = positionList.find((it) => it.code === stock.code); if (pos) { // 加仓:持仓成本按摊薄计算,手续费计入成本 const oldTotal = pos.costPrice * pos.shares; pos.shares += shares; pos.costPrice = round2((oldTotal + total) / pos.shares); } else { positionList.push({ code: stock.code, name: stock.name, shares, sellable: 0, // T+1:当日买入不可卖 frozen: shares, costPrice: round2(total / shares), buyDate: formatToday() }); } wx.setStorageSync('positions', positionList); wx.setStorageSync('cash', app.globalData.cash); return { total, fee }; }逻辑说明:加仓时旧持仓市值加上本次总花费(含佣金),再除以总股数,得到摊薄后的持仓成本。这个成本价会比单纯按成交价算略高,但更接近真实账户的“盈亏平衡价”。买入后sellable = 0、frozen = shares,对应 A 股 T+1 规则——当天买的股票当天不能卖出,这个细节对模拟盘的体验感提升非常大。
4.3 持仓的 sellable 与 frozen:当日买入为什么不能马上卖
持仓结构里区分“可卖数量”和“冻结数量”之后,卖出校验就很简单了:查看持仓时判断sellable >= 卖出数量,而不是shares >= 卖出数量。很多新手直接把总股数当可卖数,会出现“上午买入、下午卖出还成功”的假账。跨交易日后需要做解冻,把前一天的frozen转进sellable。
// 每日首次进入交易页时解冻前一天仓位 function unlockPosition(pos, today) { if (pos.buyDate !== today && pos.frozen > 0) { pos.sellable += pos.frozen; pos.frozen = 0; pos.buyDate = today; // 避免重复解冻 } }解冻逻辑放进onShow每次调用一次即可,注意用buyDate !== today做幂等判断。如果忘记更新buyDate,同一笔冻结仓会在每次进页面时重复解冻,持仓总股数不变但sellable会莫名其妙越加越多。这个字段是冻结状态的状态位,也是整个交易模块最容易漏掉的一环。
5. 图表与交易联调的 5 个坑:每个都是真机才能复现的
开发工具里一切正常,一上真机就翻车,这是小程序 canvas 和状态同步的老传统。下面 5 个坑我按“现象 → 原因 → 解决”整理,全部来自实际调试经验,按顺序过一遍能省下大量排查时间。
5.1 最后一根 K 线暴露“未来数据”
现象:早上 10 点打开详情页,K 线图最后一根却是一根完整的 3% 大阳线,仿佛行情已经收盘。原因:行情生成器把“当天”也生成了一根完整日 K,但盘中时分时数据才走到一半,两根数据的“当前价”对不上。解决:生成日 K 时把当天排除,K 线数据截至上一个交易日;当天的最新价单独维护,只在分时图和顶部价格上展示。改动很小,但数据逻辑一下就自洽了。我习惯在生成器参数里加一个excludeToday: true开关,调试时再打开看全量数据。
5.2 canvas 在 scroll-view 里卡住滚动
现象:详情页上下滑动时,手指划到 K 线图区域就卡死,图表区域像一块钢板。原因:老版 canvas 是原生组件,手势事件优先被 canvas 消费,而且原生组件层级最高,盖住了滚动容器的触摸响应。解决:优先把 canvas 迁到type="2d"新接口,走同层渲染后滚动冲突基本消失;如果还在用老接口,临时方案是给 canvas 加catchtouchmove="noop"阻止事件冒泡,或者包一层disable-scroll="{{true}}"。
5.3 iPhone 分时图顶部出现 1px 白边
现象:分时线最高点附近有一条细白边,或者最高价刚好被画布上边缘裁掉。原因:canvas 的 CSS 尺寸用了 rpx 转 px,但换算结果有小数,绘制时按小数像素计算,边界出现亚像素渲染误差;真机上还有 dpr(像素比)缩放,不处理就会在边界处露白。解决:初始化 canvas 时用wx.createSelectorQuery拿到节点实际像素宽度,乘dpr后取整再ctx.scale(dpr, dpr)。这是 canvas 2d 的标准初始化姿势,所有绘制代码都跑在缩放后的坐标系里,一劳永逸。
const query = wx.createSelectorQuery(); query.select('#kline').fields({ node: true, size: true }).exec((res) => { const canvas = res[0].node; const ctx = canvas.getContext('2d'); const dpr = wx.getWindowInfo().pixelRatio; // 取整是关键:小数像素会引发边界渲染问题 canvas.width = Math.floor(res[0].width * dpr); canvas.height = Math.floor(res[0].height * dpr); ctx.scale(dpr, dpr); });5.4 自选股顺序每次进页面都变
现象:昨天添加的自选股,今天打开页面顺序完全变了,有时候还按代码大小升序排。原因:八成是用了对象存自选,比如{ [code]: stock }。JS 对象里整数类型的 key 会按数值自动升序排列,字符串 key 按插入序,而股票代码是数字字符串,两种规则混在一起,遍历顺序根本不可控。解决:统一改用数组存储,新加自选用unshift放头部,删除用filter,整个数组直接存 Storage,顺序一定是稳定的。
5.5 买入成功但持仓页纹丝不动
现象:交易页提示“买入成功”,返回持仓页一看,持仓列表还是旧的,金额也没变。原因:持仓页的数据在onLoad里只读了一次,之后页面实例没有销毁,onLoad不会重新执行;Storage 里的数据明明变了,页面没感知。解决:交易页买入成功后调用一个全局事件通知,持仓页在onShow里重新拉数据。事件通知不用引第三方库,几行代码就能实现。
// utils/event.js:极简事件总线 const listeners = {}; function on(event, fn) { (listeners[event] = listeners[event] || []).push(fn); } function emit(event, data) { (listeners[event] || []).forEach((fn) => fn(data)); }// 持仓页 onShow() { this.refreshPositions(); // 从 app.globalData 重读持仓并 setData } // 交易页买入成功后 emit('trade', { type: 'buy' });强调一点:onShow里刷新是兜底方案,事件通知是即时方案,两者一起用才能覆盖“页面已存在”和“页面被销毁重建”两种场景。只靠事件通知,冷启动进持仓页时会漏掉之前的状态;只靠onShow,从交易页返回时会有几百毫秒的旧数据闪一下。
6. 加分项:把 Demo 做成面试作品
前三章做完,这个项目已经是一个能跑的模拟炒股小程序,但离“拿得出手”还差三件事:数据是死的,交互是点的,战绩是空的。下面三个方向按性价比排序,时间不够就只做第一个。
6.1 模拟 WebSocket 推送:让分时线自己动
真实行情是服务端不断推送 tick,小程序项目里想体现这个能力,可以用定时器模拟推送:每两秒更新一次当前价,重绘分时图的最后一个点。定时器要挂在页面实例上,onUnload和onHide里都要清理,否则页面返回列表后还在后台刷数据,这是闭包清理的经典翻车点。真机演示时长按页面不退出,分时线像真行情一样自己走,说服力瞬间拉满。
startPush() { this.timer = setInterval(() => { const tick = this.data.lastPrice * (1 + (Math.random() - 0.5) * 0.002); this.updateMinute(tick); // 追加一个点、重绘分时图 }, 2000); } onUnload() { if (this.timer) clearInterval(this.timer); }6.2 长按十字光标:把交互精度做出来
K 线图加十字光标,是区分“会画图”和“会做图表交互”的分水岭。实现思路不复杂:touchstart时记录触摸点,通过(x - paddingLeft) / step反推出 K 线索引,再在对应 x 坐标上画一条竖线和价格标签;touchmove时更新索引重绘整张图。关键是要对索引做边界钳制,Math.max(0, Math.min(len - 1, idx)),否则手指划出图表范围时十字光标会飞到画布外面。几百根 K 线重绘一次在真机上完全流畅,不需要做局部刷新优化。
6.3 交易战绩页:收益率、胜率、回撤
模拟盘做完了,总得有张成绩单。我建议加一个轻量统计页:总资产 = 现金 + 各持仓市值;累计收益率 = 总资产 / 初始资金 - 1;每日收盘时把总资产快照存进数组,收益曲线直接用第 3 章的折线绘图逻辑复用;最大回撤 = 历史上从峰值到谷底的最大跌幅,这是面试时能讲出细节的指标,也是普通 Demo 项目里很难看到的函数。
function calcMaxDrawdown(assetCurve) { let peak = assetCurve[0], maxDd = 0; assetCurve.forEach((v) => { peak = Math.max(peak, v); maxDd = Math.max(maxDd, (peak - v) / peak); }); return round2(maxDd * 10000) / 100; // 返回百分比 }我的个人习惯是:把这个统计页放在最后一个做,因为它依赖交易记录、持仓快照、行情数据全部打通,任何一环数据不一致都会直接暴露。做完它,整个项目的闭环才算真正闭合。回想我自己做同类项目时,最大的教训就是一开始急着铺五个页面,结果每个页面都在处理数据不一致,后来改成“先打通一根 K 线的数据链,再把页面复制出去”,效率至少高出一倍。这个顺序你也可以试试,希望帮到你。
本文还有配套的精品资源,点击获取