当屏幕上的最后一根 K 线不断变化时,背后并不只是一个 WebSocket:图表资源要先在 Vue 中运行,历史数据要转换成 TradingView 认识的格式,实时消息还要准确送到对应的图表。
这篇文章从一名 Vue 开发者的视角出发,用尽量直白的方式拆解本地部署、数据接入以及 PubSub + WebSocket。所有服务地址均使用 xxx 占位,新手也可以顺着流程逐步理解。

一、TradingView 本地部署流程
从 Vue 开发者的角度看,本地部署可以归纳为四步:

⚙️ 第一步:初始化 Vue 项目
先创建 Vue 应用并注册 Pinia。页面负责提供图表容器,Store 负责保存默认交易对、周期、主题和 Widget 状态。
图表容器需要设置明确的宽度和高度,并在 Vue 完成挂载后再初始化。默认 symbol 也应提前准备好,避免 Widget 创建后无法解析交易对。
这一阶段的关键是 挂载:Vue 页面正常运行、Pinia 可以读取状态、图表容器已经出现在 DOM 中。
📦 第二步:加载 TradingView 源码
获得官方 Library 后,需要完整保存入口脚本、bundles、CSS、语言包和类型声明。内部 bundles 由 TradingView 自己管理,不进行重命名、拆分或二次编译。
页面先 加载 standalone.js。脚本执行成功后,window 上会注册 TradingView 全局对象,业务代码才能调用 new TradingView.widget()。
Widget 中的 library_path 指向整套 Library 的访问目录。Widget 会继续从这个目录加载内部脚本和样式,因此入口文件能够访问,并不代表所有资源都已经部署完成。
这一阶段的关键是 完整:浏览器可以加载全部 Library 资源,控制台不存在 TradingView is not defined,也没有 bundles 或样式文件 404。
🔌 第三步:对接数据源并展示 K 线
创建 CustomDataFeed,并将它传入 Widget 的 datafeed 参数。TradingView 会主动调用 Datafeed,而不是由 Vue 页面直接向图表写入全部行情。
onReady() 用于声明支持的周期,resolveSymbol() 返回交易时段、时区和价格精度,getBars() 根据 symbol、resolution、from、to 获取历史数据。
历史接口返回后,需要统一转换为 time、open、high、low、close、volume。Bar.time 使用毫秒时间戳,数据按照时间 升序 排列,再通过 onHistoryCallback() 交给 TradingView。
这一阶段的关键是 格式:图表能显示首屏历史 K 线,并能在切换交易对、周期或向左滚动时继续请求数据。
📡 第四步:完善 WebSocket,实现实时更新
历史 K 线显示后,TradingView 会调用 subscribeBars()。Datafeed 将交易对、周期、subscriberUID 和 onRealtimeCallback 交给 WebSocket 封装。
WebSocket 收到实时消息后,需要将它转换成与历史数据一致的 Bar。Bar.time 与当前最后一根 K 线相同时 更新 当前 K 线,time 进入新周期时追加一根新 K 线。
为避免重复连接,使用 PubSub 按 Topic 保存订阅关系。同一个 WebSocket 可以承载多个交易对和周期,同一 Topic 也可以将行情分发给多个 TradingView callback。
切换交易对或周期时删除旧订阅;页面卸载时销毁 Widget。正式运行时还需要处理断线重连、心跳、异常消息和订阅恢复。
这一阶段的关键是 订阅:历史与实时 K 线连续衔接,切换频道时旧订阅被正确清理,当前 K 线持续更新。
📦 本节总结:先完成 Vue 容器与状态,再加载完整 Library,随后接入历史数据,最后通过 PubSub + WebSocket 补齐实时行情。
二、核心:PubSub + WebSocket 封装逻辑
💡 先理解 PubSub
PubSub 是 Publish/Subscribe 的缩写,也就是"发布/订阅"。可以把它理解成广播:发布者只负责把消息发到某个频道,订阅者只需要监听自己关心的频道,双方不必直接认识。
在 K 线场景中,交易对加周期组成一个 Topic;WebSocket 收到行情后充当 Publisher;TradingView 提供的 onRealtimeCallback 则是 Subscriber。channelToSubscription Map 就是记录 频道 和听众关系的订阅表。
解耦 是 PubSub 最直接的作用:WebSocket 不需要知道页面上有几个图表,图表也不需要理解底层消息协议。
| 角色 | 对应实现 |
|---|---|
| Topic | symbol + resolution |
| Registry | channelToSubscription |
| Subscriber | onRealtimeCallback |
| Publisher | WebSocket message |
🔗 为什么要和 WebSocket 结合
直接在 WebSocket message 中写图表更新逻辑,单个页面也许可以运行,但多个交易对、多个周期或多个图表出现后,回调会越来越难管理。PubSub 加入后,一个 WebSocket 可以承载多个 Topic,同一 Topic 也能同时通知多个 Subscriber。
复用 是两者结合后的核心收益:相同 Topic 只建立一次远端订阅,本地再把同一条行情分发给所有需要它的图表。
这样做还能带来三个好处:
- 连接更少:不需要为每个图表建立一个 WebSocket。
- 切换更稳 :通过
subscriberUID精确删除旧 回调。 - 职责更清楚:WebSocket 管收发,PubSub 管关系,TradingView 管渲染。
📥 订阅:首次出现 Topic 时发送 SUBSCRIBE;Topic 已存在时只追加 callback。
📤 发布:收到消息后还原 Topic,找到订阅项,将消息转成 Bar 并遍历 callbacks。
🧹 取消:按 subscriberUID 删除 callback;最后一个 callback 离开时才发送 UNSUBSCRIBE。
重点代码如下:
ts
type Handler = {
id: string;
callback: (bar: any) => void;
};
type Subscription = {
lastBar: any;
handlers: Handler[];
};
export default class SocketClient {
private socket = new WebSocket("xxx");
// Topic -> Subscription
private channelToSubscription =
new Map<string, Subscription>();
constructor() {
this.socket.addEventListener(
"message",
event => this.publish(event.data)
);
}
subscribeOnStream(
symbolInfo,
resolution,
callback,
subscriberUID,
onResetCache,
lastBar
) {
const topic = this.getTopic(
symbolInfo.name,
resolution
);
const current =
this.channelToSubscription.get(topic);
// 已有 Topic:只增加本地订阅者
if (current) {
current.handlers.push({
id: subscriberUID,
callback,
});
return;
}
// 新 Topic:创建记录并订阅服务端
this.channelToSubscription.set(topic, {
lastBar,
handlers: [{
id: subscriberUID,
callback,
}],
});
this.emit("SUBSCRIBE", [topic]);
}
private publish(rawData: string) {
const message = JSON.parse(rawData);
const topic = this.getTopic(
message.symbol,
message.resolution
);
const item =
this.channelToSubscription.get(topic);
if (!item) return;
const bar = {
time: Number(message.time),
open: Number(message.open),
high: Number(message.high),
low: Number(message.low),
close: Number(message.close),
volume: Number(message.volume),
};
item.lastBar = bar;
// 向 Topic 下的所有 Subscriber 发布
item.handlers.forEach(handler => {
handler.callback(bar);
});
}
unsubscribeFromStream(subscriberUID) {
for (
const [topic, item]
of this.channelToSubscription
) {
item.handlers = item.handlers.filter(
handler =>
handler.id !== subscriberUID
);
// 没有 Subscriber 后取消远端订阅
if (!item.handlers.length) {
this.emit("UNSUBSCRIBE", [topic]);
this.channelToSubscription.delete(topic);
}
}
}
private getTopic(
symbol: string,
resolution: string
) {
return (
symbol.toLowerCase() +
"@kline_" +
resolution
);
}
private emit(method: string, topics: string[]) {
if (
this.socket.readyState !== WebSocket.OPEN
) {
return;
}
this.socket.send(
JSON.stringify({ method, topics })
);
}
}
一致 是实时更新正确的前提:订阅端和消息端使用同一 Topic 规则,实时 Bar.time 与历史 K 线使用同一周期起点。
📡 本节总结:WebSocket 只负责收发,PubSub 负责注册、查找和分发;Map 避免重复订阅,handlers 负责通知 TradingView。
三、TradingView 资料
📘 TradingView 中文开发文档:适合新手先用中文了解 Widget、Datafeed 和图表 API 等术语。页面标注版本为 V20.031,接口细节可能与新版不同,适合作为辅助阅读。
🌐 Advanced Charts 官方文档:最新文档总入口。遇到版本、参数或类型问题时,应优先以这里为准。
📗 官方快速开始:从授权访问、获取 Library、部署静态资源到创建第一个 Widget,适合第一次搭建本地图表时阅读。
📦 Library 包内容:介绍 standalone、ESM、类型声明和 bundles 的作用。部署时不知道哪些资源必须保留,可以从这里确认。
📙 Widget Constructor:查询 library_path、datafeed、symbol、interval、主题、语言、时区和尺寸等初始化参数。
🔌 连接行情数据:解释 TradingView 为什么不提供行情,以及 Datafeed API 与 UDF Adapter 两种接入方式的区别。
🔄 Datafeed API 总览:用于理解图表如何向 Datafeed 请求交易对、历史 Bar 和实时 Bar,以及各类回调之间的关系。
📕 Datafeed 必选方法:详细说明 onReady()、resolveSymbol()、getBars()、subscribeBars() 和 unsubscribeBars() 的参数与规则。
🎨 自定义功能总览:需要调整主题、图表样式、界面元素或交互行为时,可以先从这里判断应使用哪种配置方式。
🧰 Featuresets:用于显示或隐藏工具栏、菜单、搜索、指标按钮和其他内置功能。
🖌️ Overrides:用于设置蜡烛颜色、背景、网格、坐标轴、指标和绘图样式。
📓 API Reference:需要确认 Widget 方法、参数、返回值或 TypeScript 类型时,直接查询这里。
⬆️ 更新 TradingView Library:升级前用于查看更新步骤和兼容性提醒,避免入口文件与 bundles 版本不一致。
📚 本节总结:先用中文资料理解概念,再按"快速开始 → Widget → Datafeed → API Reference"的顺序阅读官方文档,遇到 版本 差异始终以最新版为准。
结语
一张 K 线能够显示并不难,真正值得理解的是数据如何从服务端出发,经过 Datafeed、WebSocket 和 PubSub,最后准确落到图表中的某一根 Bar。
当本地资源、历史数据和实时订阅各自保持清晰边界,图表就不再是一个难以维护的黑盒。未来无论增加交易对、扩展周期还是更换数据服务,改变的只是其中一层,而不是推倒全部重新开始。
也许图表最终呈现的只是一条价格曲线,但它背后体现的,是前端对数据、状态与时间边界的理解。
