普通视图
沪深两市成交额突破1.5万亿
中国信通院:截至2025年6月,我国计算设备算力总规模达到962EFlops
魔法原子:技术总裁吴长征已于半年前淡出管理团队
埃诺格鲁肽(先维盈®)获批用于中国成人体重管理
世界移动通信大会闭幕,AI推动变革趋势明显
声智科技再获吴文俊人工智能科技进步奖
“手搓经济”升温,灵光用户手搓闪应用售千单
澄天伟业:公司液冷产品已实现小批量产品交付
Xiaomi miclaw开启小范围封测
软银寻求最高400亿美元贷款,用于投资OpenAI
半日主力资金加仓电力设备股,抛售石油石化股
恒指午间收盘涨1.84%,恒生科技指数涨3.68%
🚀《JavaScript 灵魂深处:从 V8 引擎的“双轨并行”看执行上下文的演进之路》
引言
“如果你只懂
var和let的语法区别,那你只看到了冰山一角。真正的魔法,藏在 V8 引擎执行上下文的双轨存储架构里。”
在 JavaScript 的发展历程中,有一个著名的“历史遗留问题”——变量提升(Hoisting)。它曾让无数开发者抓狂,也让 JS 背上了“设计缺陷”的骂名。然而,随着 ES6 的诞生,JavaScript 通过一种巧妙的**“双轨并行”策略**,不仅完美兼容了旧代码,还引入了现代化的块级作用域。
今天,我们将结合您提供的完整文档(readme.md 至 8.js),深入 V8 引擎的底层机制,剖析执行上下文、作用域链、变量环境 vs 词法环境的奥秘。特别是针对 7.js 中的经典案例,我们将借助两张精美的示意图,为您揭开 JavaScript 变量管理的终极真相。
📜 第一章:历史的回响——为什么 JavaScript 会有“变量提升”?
1.1 一个“KPI 项目”的意外走红
正如 readme.md 中所言,JavaScript 最初只是 Netscape 为了浏览器竞争而快速推出的“KPI 项目”。设计周期极短,目标简单:给静态页面加点动态效果。
在那个年代,复杂的面向对象特性(如 class, constructor, private 等)并不是首要任务。为了追求最快、最简单的实现方案,设计师做出了两个关键决定:
-
不支持块级作用域:
if,for,while等代码块{}内部声明的变量,直接暴露在外层。 - 引入变量提升:将所有变量声明统一“抬升”到函数顶部,简化编译器的实现逻辑。
1.2 变量提升的“双刃剑”
让我们看看 4.js 中的经典案例:
showName();
console.log(myname);
var myname = "张三";
function showName() {
console.log("函数 showName 执行了");
}
这段代码之所以能运行(不报错),是因为在编译阶段,JS 引擎做了如下处理:
// 编译后的伪代码
function showName() { ... } // 函数声明提升
var myname; // 变量声明提升,初始化为 undefined
showName(); // 输出:函数 showName 执行了
console.log(myname); // 输出:undefined (因为赋值语句还没执行)
myname = "张三"; // 执行赋值
⚠️ 缺陷暴露:
- 变量容易被意外覆盖(见
2.js中的var name遮蔽全局变量)。- 本应销毁的变量因提升而长期驻留内存。
- 代码行为与直觉不符,增加调试难度。
🌍 第二章:ES6 的救赎——“双轨并行”的巧妙设计
面对历史包袱,ES6 没有选择“推倒重来”(那样会破坏海量旧代码),而是采取了一种兼容性极强的解决方案:在执行上下文中实行“双轨并行”存储机制。
2.1 执行上下文的双核架构
当 JavaScript 引擎执行一个函数时,会创建一个执行上下文(Execution Context)。在 ES6 及以后,这个上下文被划分为两个独立但协同工作的区域:
| 轨道 | 名称 | 管理对象 | 特性 | 对应关键字 |
|---|---|---|---|---|
| 轨道一:变量环境 (Variable Environment) | 传统轨道 |
var 声明的变量 |
函数作用域、变量提升、可重复声明 | var |
| 轨道二:词法环境 (Lexical Environment) | 现代轨道 |
let, const 声明的变量 |
块级作用域、暂时性死区 (TDZ)、不可重复声明 |
let, const
|
💡 核心思想:
var继续留在变量环境轨道,享受“提升特权”,保证旧代码正常运行。let/const进入全新的词法环境轨道,支持块级作用域,杜绝提升带来的隐患。- 两条轨道在同一个执行上下文中并行存在,互不干扰却又协同工作。
2.2 词法环境的“栈结构”秘密
readme.md 中提到:“块级作用域中通过 let/const 声明的变量,会被放在词法环境的一个单独的区域中,维护了一个小型栈结构。”
这意味着:
- 每进入一个块级作用域
{},引擎就在词法环境中压入一个新的“帧”(Frame)。 - 变量查找时,优先从栈顶(当前块)开始。
- 块执行完毕,该帧弹出,内部变量立即销毁,外界无法访问。
这正是 6.js 中 for(let i=0;...) 循环后 i 未定义的原因,也是 8.js 中“暂时性死区”产生的根源。
🔍 第三章:实战演练——从 1.js 到 8.js 的全景解析
现在,让我们遍历所有文件,逐一验证上述理论。
🧪 案例 1:作用域链的基础(1.js & 5.js)
// 1.js
let name = "流萤";
function showName(){
console.log(name); // 流萤
if(true){
let name = "大厂的苗子" // 块级变量,不影响外层
}
}
showName();
// 5.js
var globalVar='我是全局变量';
function myFunction() {
var localVar = '我是局部变量';
console.log(globalVar); // 可访问
console.log(localVar); // 可访问
}
myFunction();
console.log(localVar); // ❌ ReferenceError: localVar is not defined
✅ 解析:
1.js展示了let的块级隔离性:块内name不影响块外。5.js展示了函数作用域的边界:localVar仅在函数内有效。
🧪 案例 2:变量提升的陷阱(2.js & 4.js)
// 2.js
var name = '张三';
function showName() {
console.log(name); // undefined (局部变量遮蔽全局)
if(false) {
var name = '李四'; // 声明提升,赋值不执行
}
console.log(name); // undefined
}
showName();
✅ 解析:
var name在函数内被提升,导致全局name被遮蔽。- 即使
if(false)不执行,name仍存在于局部作用域,值为undefined。
🧪 案例 3:块级作用域的胜利(6.js & 8.js)
// 6.js
function foo() {
for(let i=0;i<7;i++) { }
console.log(i); // ❌ ReferenceError: i is not defined
}
foo();
// 8.js
let name = '流萤';
{
console.log(name); // ✅ 输出 "流萤" (访问外层)
let othername = '大厂的苗子';
}
// 若取消注释下方代码,将触发 TDZ
// {
// console.log(name); // ❌ ReferenceError
// let name = '大厂的苗子';
// }
✅ 解析:
6.js证明let循环变量仅限块内。8.js展示两种情况:
- 块内无同名
let→ 访问外层变量。- 块内有同名
let→ 触发暂时性死区 (TDZ),禁止在声明前访问。
🖼️ 第四章:深度图解——7.js 与执行上下文的视觉化
现在,我们来到本文的高潮部分:7.js 的代码与您提供的两张示意图。这两张图完美诠释了“双轨并行”机制在实际运行中的状态变化。
📄 代码回顾
function foo() {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
console.log(a); // 1
console.log(b); // 3
}
console.log(b); // 2
console.log(c); // 4
console.log(d); // ❌ ReferenceError
}
foo();
🖼️ 图一:函数初始化状态(预编译阶段)
![]()
此时,函数刚被调用,引擎完成“预编译”,双轨开始运作:
-
左轨:变量环境
-
a = 1←var a已声明并赋值。 -
c = undefined←var c被提升到函数顶(变量环境顶层),但尚未赋值。
-
-
右轨:词法环境
- 外层帧:
b = 2←let b已初始化。 - 内层帧(块级):
b = undefined,d = undefined← 已绑定但未初始化(处于 TDZ)。
- 外层帧:
📌 关键点:
var c虽在块内代码中书写,却出现在变量环境的顶层;而let b/d则严格限制在词法环境的块级帧中。这就是双轨并行的直观体现。
🖼️ 图二:执行到块内 console.log 时的状态
![]()
程序执行流进入块内,并完成赋值操作,双轨状态发生动态变化:
-
左轨:变量环境
-
a = 1← 保持不变。 -
c = 4←var c = 4已执行,赋值成功!注意它依然位于函数级的变量环境中。
-
-
右轨:词法环境
- 外层帧:
b = 2← 保留,暂时被遮蔽。 - 内层帧(当前激活):
-
b = 3← 块内let b = 3已赋值,遮蔽了外层帧的b。 -
d = 5← 已赋值。
-
- 外层帧:
🔄 查找规则(双轨协同):
console.log(a)→ 引擎查询变量环境 → 找到1。console.log(b)→ 引擎查询词法环境,从栈顶(内层帧)开始 → 找到3(忽略外层b=2)。
🎬 完整执行流程表
| 步骤 | 代码 | 输出/结果 | 原因分析 |
|---|---|---|---|
| 1 | console.log(a) |
1 |
访问变量环境中的 a
|
| 2 | console.log(b) |
3 |
访问词法环境栈顶的 b(块内遮蔽外层) |
| 3 | 块结束 | — | 块级词法环境帧弹出,b=3, d=5 销毁 |
| 4 | console.log(b) |
2 |
恢复访问词法环境外层的 b
|
| 5 | console.log(c) |
4 |
访问变量环境中的 c(函数级有效) |
| 6 | console.log(d) |
❌ Error |
d 位于已销毁的块级词法环境帧中,外界不可见 |
🛠️ 第五章:开发者指南——如何驾驭这套机制?
✅ 最佳实践
-
优先使用
let和const:利用词法环境轨道的块级特性,避免var的提升和函数作用域陷阱。 -
明确作用域边界:用
{}包裹逻辑块,防止变量泄露到不必要的范围。 -
警惕 TDZ:不要在
let/const声明前访问变量,理解这是词法环境的保护机制。 - 利用 DevTools 调试:观察 Scope 面板,你会清晰地看到“Variable”和“Local/Lexical”两个不同的区域。
常见误区
- ❌ “
let也会提升” → 错!let有“绑定提升”,但存在 TDZ,在声明前不可访问。 - ❌ “块级作用域是新的作用域类型” → 不准确!它是词法环境中的“栈帧”,而非独立的作用域类型。
- ❌ “
var在块内无效” → 错!var无视块级,始终提升至变量环境的函数顶层。
🌟 结语:理解执行上下文,就是理解 JavaScript 的灵魂
从 readme.md 的历史回顾,到 7.js 的深度图解,我们走完了一段从“设计缺陷”到“优雅兼容”的旅程。JavaScript 通过变量环境与词法环境的“双轨并行”架构,成功实现了新旧语法的完美融合:既尊重了历史,又拥抱了未来。
下次当你写下 let 或 var 时,请记住:
你不仅仅是在声明一个变量,你是在指挥 V8 引擎在两条不同的轨道上存储数据。
掌握这套机制,你将不再畏惧任何作用域谜题,写出更健壮、更高效的代码。
📚 附录:核心概念速查表
| 概念 | 描述 | 示例 |
|---|---|---|
| 变量提升 |
var 声明移至函数顶 |
var x; x=1; |
| 暂时性死区 (TDZ) |
let/const 声明前不可访问 |
console.log(y); let y=1; → Error |
| 作用域链 | 变量查找路径:当前 → 外层 → 全局 | 内层 b 遮蔽外层 b
|
| 词法环境 | 存储 let/const,支持块级栈结构 |
{ let a=1; } |
| 变量环境 | 存储 var,函数级作用域 |
function(){ var b; } |
| 双轨并行 | 执行上下文中同时存在变量环境和词法环境 |
var 走左轨,let 走右轨 |
🎉 恭喜! 你现在已掌握 JavaScript 执行上下文的核心精髓。无论是面试、工作还是开源贡献,这套知识都将是你最强大的武器。
一日一技|用 NAS 与 LanCache 构建本地游戏缓存服务器
手把手带你用Flutter手搓人生K线
本文基于 Flutter 框架,从 Canvas 绘制、K 线数据结构、蜡烛图核心绘制逻辑、MA 指标实现,到手势冲突优化,全方位拆解金融 APP K 线图开发流程,分享实战问题与解决方案,助力开发者快速实现流畅可落地的 K 线组件。
在金融类 APP 开发中,K 线图是必不可少的组件之一,体验直接可导致用户数量的流失
本文将通过 Flutter 框架,并结合实际的开发经验,从 Canvas 绘制基础、数据结构定义、核心绘制逻辑、技术指标实现到手势系统优化,全方位的拆解 K 线图的开发过程,分享我开发过程中遇到的问题以及解决方案,帮助你掌握 Flutter K 线图开发技巧
先看最终效果
![]()
一、Canvas 绘制基础
首先我们得先学习 Flutter 中的 Canvas 绘制
懂 Canvas 绘制基础可直接跳过这条段,想要在 Flutter 中自定义绘制,核心需要通过 CustomPaint + CustomPainter
在动手之前需要先把 Flutter Canvas 坐标系规规则给理解一下
- 原点 (0,0) 在绘制区域的左上角
- x 轴向右为正
- y 轴向下为正
与我们日常认知的“y轴向上为正”不同,需要记住这一点,这是避免绘制错位的关键
![]()
![]()
简单 Demo
为快速熟悉Canvas的使用方式,我们先实现一个简单的Demo,绘制一个填充圆形和一根线条,掌握Paint配置、坐标计算及Canvas绘制方法:
import 'package:flutter/material.dart';
class CanvasApp extends StatefulWidget {
const CanvasApp({super.key});
@override
State<CanvasApp> createState() => _CanvasAppState();
}
class _CanvasAppState extends State<CanvasApp> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: CustomPaint(painter: DemoPainter()),
);
}
}
class DemoPainter extends CustomPainter {
final fill = Paint()
..style = PaintingStyle.fill
..color = Colors.blue;
final stroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 6
..color = Colors.black;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
canvas.drawCircle(center, 60, fill); // 绘制填充圆形
canvas.drawLine(center, center + Offset(80, -40), stroke); // 绘制线条
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
![]()
上面的 Demo中,通过Paint配置绘制样式(填充/描边、颜色、线宽)
在paint方法中通过Canvas的drawCircle、drawLine方法完成绘制
shouldRepaint 返回false表示不重复绘制,提升性能
![]()
二、K线图数据结构定义
接下来我们就需要先了解 K 线数据接口的定义,进入 K 线的开发
首先需定义规范的数据结构,存储单根K线的核心信息
一根完整的K线包含开盘价、最高价、最低价、收盘价、成交量和时间戳六大核心字段,对应的数据结构如下:
class CandleEntity {
double open; // 开盘价
double high; // 最高价
double low; // 最低价
double close; // 收盘价
double vol; // 成交量
int? time; // 时间戳(毫秒)
}
![]()
CandleEntity 类是K线图开发的“数据载体”,后面所有绘制逻辑(蜡烛、均线)均围绕该类的实例展开
实际开发中,也可以根据需求扩展字段,比如添加均线值列表(maValueList),用于存储单根K线对应的各类均线数据
三、单根K线绘制逻辑
K线图的核心是 Candle 的绘制,单根 Candle 由实体部分(开盘价与收盘价之间的矩形)和影线部分(最高价与最低价之间的线段)组成,而且需区分阳线(涨)和阴线(跌),绘制逻辑如下
价格与屏幕坐标映射
因为Canvas坐标系和实际价格维度不一样,所以得把价格转换成屏幕上的Y坐标。核心逻辑就是用当前K线数据集的最高价、最低价算缩放比例,再把价格映射成屏幕坐标,公式如下:
double getY(double y) => (maxValue - y) * scaleY + _contentRect.top;
![]()
- maxValue 为当前K线数据集的最高价
- scaleY 为价格维度的缩放比例
- _contentRect.top 为绘制区域的顶部坐标
通过这个公式可确保价格越高,对应的屏幕Y坐标越小
单根蜡烛绘制逻辑
单根蜡烛的绘制需处理三个核心细节:阳线与阴线的颜色区分、实体部分的最小高度(避免十字星看不见)、动态影线宽度(根据缩放级别调整,提升视觉体验)
完整代码如下:
/// 绘制单根蜡烛图
/// [curPoint] 当前 K 线数据
/// [canvas] 画布
/// [curX] 当前 K 线的 X 坐标(中心点)
void drawCandle(CandleEntity curPoint, Canvas canvas, double curX) {
// 将价格转换为屏幕 Y 坐标
var high = getY(curPoint.high); // 最高价对应的 Y 坐标
var low = getY(curPoint.low); // 最低价对应的 Y 坐标
var open = getY(curPoint.open); // 开盘价对应的 Y 坐标
var close = getY(curPoint.close); // 收盘价对应的 Y 坐标
double r = mCandleWidth / 2; // 实体半宽
// 动态影线宽度计算:根据缩放级别平滑调整影线宽度,缩放越小影线越粗
double lineR = _calculateDynamicShadowWidth() / 2; // 影线半宽
// 阳线(涨):开盘价 >= 收盘价
if (open >= close) {
// 确保实体有最小可见高度(避免十字星看不见)
if (open - close < mCandleLineWidth) {
open = close + mCandleLineWidth;
}
chartPaint.color = this.chartColors.upColor; // 阳线颜色(如红色)
// 绘制实体矩形(从收盘价到开盘价)
canvas.drawRect(
Rect.fromLTRB(curX - r, close, curX + r, open), chartPaint);
// 绘制上下影线(从最高价到最低价)
canvas.drawRect(
Rect.fromLTRB(curX - lineR, high, curX + lineR, low), chartPaint);
}
// 阴线(跌):收盘价 > 开盘价
else if (close > open) {
// 确保实体有最小可见高度
if (close - open < mCandleLineWidth) {
open = close - mCandleLineWidth;
}
chartPaint.color = this.chartColors.dnColor; // 阴线颜色(如绿色)
// 绘制实体矩形(从开盘价到收盘价)
canvas.drawRect(
Rect.fromLTRB(curX - r, open, curX + r, close), chartPaint);
// 绘制上下影线
canvas.drawRect(
Rect.fromLTRB(curX - lineR, high, curX + lineR, low), chartPaint);
}
}
![]()
上面的代码中,通过判断开盘价与收盘价的大小区分阳阴线,动态调整实体高度和影线宽度,确保在不同缩放级别下,K线都能清晰显示,提升用户体验
![]()
![]()
四、技术指标实现
K线图除了蜡烛本身,还需展示各类技术指标,其中移动平均线(MA)是最常用的指标之一
MA的实现核心是滑动窗口算法,通过维护固定周期的收盘价累加和,计算每个周期的均值,时间复杂度为O(n)
MA均线计算逻辑
/// 计算移动平均线(Moving Average)
/// [dataList] K 线数据列表
/// [maDayList] 均线周期列表,例如 [5, 10, 20] 表示计算 MA5、MA10、MA20
static calcMA(List<KLineEntity> dataList, List<int> maDayList) {
// ma[i] 保存第 i 个周期的累加和
List<double> ma = List<double>.filled(maDayList.length, 0);
if (dataList.isNotEmpty) {
for (int i = 0; i < dataList.length; i++) {
KLineEntity entity = dataList[i];
final closePrice = entity.close;
// 为每个 K 线创建 MA 值列表
entity.maValueList = List<double>.filled(maDayList.length, 0);
// 计算每个周期的 MA 值
for (int j = 0; j < maDayList.length; j++) {
ma[j] += closePrice; // 累加当前收盘价
// 达到周期时开始计算均值
if (i == maDayList[j] - 1) {
entity.maValueList?[j] = ma[j] / maDayList[j];
}
// 滑动窗口:减去最早的值,保持窗口大小
else if (i >= maDayList[j]) {
ma[j] -= dataList[i - maDayList[j]].close;
entity.maValueList?[j] = ma[j] / maDayList[j];
}
}
}
}
}
![]()
上面即是实现 MA均线计算的逻辑,通过双重循环实现多周期MA计算:外层循环遍历所有K线数据,内层循环针对每个均线周期,累加收盘价,当达到周期长度时计算均值,后续通过滑动窗口更新均值(减去滑出窗口的收盘价,加上新的收盘价),确保计算高效
MA均线绘制逻辑
当完成逻辑的计算之后,通过绘制线段实现绘制,核心是获取相邻两根K线的MA值对应的屏幕坐标,调用drawLine方法完成绘制
void drawMaLine(CandleEntity lastPoint, CandleEntity curPoint, Canvas canvas,
double lastX, double curX) {
// 获取均线线条宽度
final lineWidth = _calculateMainIndicatorWidth();
for (int i = 0; i < (curPoint.maValueList?.length ?? 0); i++) {
if (i == 3) break; // 控制均线显示数量(如只显示前3条)
if (lastPoint.maValueList?[i] != 0) {
// 绘制相邻两根K线的MA线段,区分不同均线颜色
drawLine(lastPoint.maValueList?[i], curPoint.maValueList?[i], canvas,
lastX, curX, this.chartColors.getMAColor(i),
lineWidth: lineWidth);
}
}
}
![]()
五、手势系统
交互体验在 K 线图中是非常重要的,必须要支持缩放、拖拽、点击、长按这四个核心的手势
但是 Flutter 的手势系统有一个手势竞技场(Gesture Arena)的机制,导致有手势冲突的问题
下面我提供了解决方案
手势冲突解决方案
问题描述:如果同时用了 HorizontalDrag 拖拽 和 ScaleGesture 缩放,这两个手势会互相抢焦点,导致双指缩放时,水平滑动会被拖拽抢走,缩放就断了,有种卡顿的感觉
解决办法很简单:
用 Listener 组件处理先判断有几根手指在屏幕上,再自动切换是拖拽还是缩放,互不干扰:
- 一根手指(_pointerCount < 2):只走拖拽逻辑,让 K 线图左右滑动,看更早的数据
- 两根及以上手指(_pointerCount ≥ 2):只走缩放逻辑,让 K 线图放大缩小,看细节或看整体
缩放 + 拖拽
Listener(
onPointerDown: (_) => setState(() => _pointerCount++),
onPointerUp: (_) => setState(() => _pointerCount--),
onPointerCancel: (_) => setState(() => _pointerCount--),
child: RawGestureDetector(
scaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
() => ScaleGestureRecognizer(),
(instance) {
instance
..onStart = (details) {
// 保存基线值,用于缩放计算
_scaleBase = 1.0;
_scaleXBase = mScaleX;
// 计算缩放锚点(焦点对应的 K 线索引)
_anchorIndex = painter.calculateSelectedX(details.focalPoint.dx);
}
..onUpdate = (details) {
// 检测手指数量变化,重置基线
if (_pointerCount != _lastPointerCount) {
_scaleBase = details.scale;
_scaleXBase = mScaleX;
_anchorIndex = painter.calculateSelectedX(details.focalPoint.dx);
}
if (_pointerCount < 2) {
// 单指:拖拽,调整滑动偏移量
final delta = details.focalPointDelta.dx / mScaleX;
mScrollX = (mScrollX + delta).clamp(0.0, maxScrollX);
} else {
// 双指:缩放,控制缩放范围(0.2~4.0)
final relativeScale = details.scale / _scaleBase;
mScaleX = (_scaleXBase * relativeScale).clamp(0.2, 4.0);
// 焦点锚定:保持缩放中心不动,提升体验
}
}
..onEnd = (details) {
// 单指拖拽结束:启动惯性滚动
if (_pointerCount == 0 && _lastPointerCount == 1) {
_onFling(details.velocity.pixelsPerSecond.dx);
}
};
},
),
// 长按、点击手势配置
longPressGestureRecognizer: ...,
tapGestureRecognizer: ...
),
);
![]()
其它手势实现
(1)点击手势
点击手势点击主要做两件事:切换十字线显示、画趋势线,通过 TapGestureRecognizer 实现
- 普通模式:点一下 K 线图,十字线就显示 / 隐藏,同时会显示这根 K 线的详细数据,比如开盘价、收盘价
- 趋势线模式:点两下,第一下记起点,第二下记终点,就能画出趋势线
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(TapGestureRecognizer instance) {
instance.onTapUp = (details) {
// 普通点击模式:切换十字线显示状态
if (!widget.isTrendLine &&
painter.isInMainRect(details.localPosition)) {
if (_isCrossLocked) {
// 十字线已显示,点击则隐藏
_isCrossLocked = false;
isOnTap = false;
mInfoWindowStream.sink.add(null); // 清空信息弹窗
} else {
// 十字线未显示,点击则显示并锁定
_isCrossLocked = true;
isOnTap = true;
mSelectX = details.localPosition.dx;
}
notifyChanged();
}
// 趋势线模式:记录点击的坐标点
if (widget.isTrendLine && !isLongPress && enableCordRecord) {
enableCordRecord = false;
Offset p1 = Offset(getTrendLineX(), mSelectY);
// 第一次点击:创建趋势线的起点
if (!waitingForOtherPairofCords) {
lines.add(TrendLine(
p1, Offset(-1, -1), trendLineMax!, trendLineScale!));
}
// 第二次点击:完成趋势线的终点
if (waitingForOtherPairofCords) {
var a = lines.last;
lines.removeLast();
lines.add(
TrendLine(a.p1, p1, trendLineMax!, trendLineScale!));
waitingForOtherPairofCords = false;
} else {
waitingForOtherPairofCords = true;
}
notifyChanged();
}
};
},
),
![]()
(2)长按手势
长按手势长按用来移动十字线、调整趋势线,通过 LongPressGestureRecognizer 实习那
- 普通模式:长按屏幕并移动手指,十字线会跟着手指走,实时显示指到哪里的 K 线信息
- 趋势线模式:长按画好的趋势线,就能拖动调整位置,方便修改
LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<
LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(),
(LongPressGestureRecognizer instance) {
instance
// 长按开始
..onLongPressStart = (details) {
isOnTap = false;
isLongPress = true;
// 普通模式:记录十字线位置
if ((mSelectX != details.localPosition.dx ||
mSelectY != details.globalPosition.dy) &&
!widget.isTrendLine) {
mSelectX = details.localPosition.dx;
notifyChanged();
}
// 趋势线模式:初始化位置记录
if (widget.isTrendLine && changeinXposition == null) {
mSelectX = changeinXposition = details.localPosition.dx;
mSelectY = changeinYposition = details.globalPosition.dy;
notifyChanged();
}
if (widget.isTrendLine && changeinXposition != null) {
changeinXposition = details.localPosition.dx;
changeinYposition = details.globalPosition.dy;
notifyChanged();
}
}
// 长按移动 - 更新十字线位置
..onLongPressMoveUpdate = (details) {
// 普通模式:跟随手指移动十字线
if ((mSelectX != details.localPosition.dx ||
mSelectY != details.globalPosition.dy) &&
!widget.isTrendLine) {
mSelectX = details.localPosition.dx;
mSelectY = details.localPosition.dy;
notifyChanged();
}
// 趋势线模式:移动趋势线
if (widget.isTrendLine) {
// 计算相对移动距离
mSelectX = mSelectX +
(details.localPosition.dx - changeinXposition!);
changeinXposition = details.localPosition.dx;
mSelectY = mSelectY +
(details.globalPosition.dy - changeinYposition!);
changeinYposition = details.globalPosition.dy;
notifyChanged();
}
}
// 长按结束
..onLongPressEnd = (details) {
isLongPress = false;
enableCordRecord = true; // 启用趋势线坐标记录
// 长按结束后锁定十字线,保持显示
if (!widget.isTrendLine) {
_isCrossLocked = true;
isOnTap = true; // 保持 isOnTap 为 true 以显示十字线
} else {
mInfoWindowStream.sink.add(null); // 趋势线模式清空信息弹窗
}
notifyChanged();
};
},
),
double getY(double y) => (maxValue - y) * scaleY + _contentRect.top;
![]()
六、总结
最费时间就是缩放和拖拽的冲突问题
后面借鉴 Interactive Chart 这个开源项目的实现思路,用“Listener + ScaleGesture”实现水平移动和缩放,解决了这个问题,缩放和拖拽都丝滑不卡顿
总结一下,本篇文章主要讲解 Canvas 绘制、坐标映射、K 线图绘制基础,并解决了手势冲突的问题
其实K线图开发看着复杂,只要把绘制、数据处理、手势这几个核心模块拆解开,逐一突破,就能轻松搞定,做出高效、流畅、能落地的组件
本文的思路和代码,大家可以直接用到实际项目里,也能根据业务需求扩展功能(比如MACD、RSI指标、成交量显示、行情标注等),希望能帮到正在做Flutter K线图的小伙伴,少走弯路、快速落地!
参考:
团队招募 | 共同探索技术边界
AI 时代已经到来,当下最好的破局机会,就是加入一家有潜力的 AI 公司
比特鹰致力于将每位成员,打造成 AI 时代的超级个体,在为用户创造价值的同时实现人生梦想
以下岗位持续开放中:
- 后端开发工程师
- 前端开发工程师
- AI 应用开发工程师
- 爬虫工程师
- 大数据开发工程师
- HR 人事
如果您想在 AI 时代实现百倍的个人提升,欢迎加入我们
联系方式:join@biteagle.xyz
前端JS: 数组扁平化
JavaScript 数组扁平化实现详解
一、扁平化概念
数组扁平化是指将一个多维数组转换为一维数组的过程:
// 多维数组
const arr = [1, [2, [3, [4, 5]], 6], 7];
// 扁平化后
// [1, 2, 3, 4, 5, 6, 7]
二、原生方法(ES2019+)
1. Array.prototype.flat()
const arr = [1, [2, [3, [4, 5]], 6], 7];
// 默认只展开一层
console.log(arr.flat()); // [1, 2, [3, [4, 5]], 6, 7]
// 指定展开深度
console.log(arr.flat(2)); // [1, 2, 3, [4, 5], 6, 7]
// 完全展开(Infinity表示无限深度)
console.log(arr.flat(Infinity)); // [1, 2, 3, 4, 5, 6, 7]
// 移除空位
console.log([1, 2, , 3, 4].flat()); // [1, 2, 3, 4]
三、手动实现方法
1. 递归实现(基础版)
function flatten(arr) {
let result = [];
for (let i = 0; i < arr.length; i++) {
if (Array.isArray(arr[i])) {
result = result.concat(flatten(arr[i]));
} else {
result.push(arr[i]);
}
}
return result;
}
// 使用示例
const arr = [1, [2, [3, [4, 5]], 6], 7];
console.log(flatten(arr)); // [1, 2, 3, 4, 5, 6, 7]
2. 递归实现(可指定深度)
function flattenDepth(arr, depth = 1) {
if (depth === 0) return arr.slice(); // 深度为0,直接返回副本
let result = [];
for (let i = 0; i < arr.length; i++) {
if (Array.isArray(arr[i]) && depth > 0) {
result = result.concat(flattenDepth(arr[i], depth - 1));
} else {
result.push(arr[i]);
}
}
return result;
}
// 使用示例
const arr = [1, [2, [3, [4, 5]], 6], 7];
console.log(flattenDepth(arr, 1)); // [1, 2, [3, [4, 5]], 6, 7]
console.log(flattenDepth(arr, 2)); // [1, 2, 3, [4, 5], 6, 7]
console.log(flattenDepth(arr, Infinity)); // [1, 2, 3, 4, 5, 6, 7]
3. 使用reduce实现
function flattenReduce(arr) {
return arr.reduce((result, current) => {
return result.concat(
Array.isArray(current) ? flattenReduce(current) : current
);
}, []);
}
// 带深度的reduce版本
function flattenReduceDepth(arr, depth = 1) {
return depth > 0
? arr.reduce((acc, val) =>
acc.concat(Array.isArray(val)
? flattenReduceDepth(val, depth - 1)
: val
), [])
: arr.slice();
}
4. 使用栈实现(非递归)
function flattenStack(arr) {
const stack = [...arr];
const result = [];
while (stack.length) {
const next = stack.pop();
if (Array.isArray(next)) {
// 将数组元素推入栈中(注意保持顺序)
stack.push(...next.slice().reverse());
} else {
result.push(next);
}
}
return result.reverse();
}
// 优化版本(保持顺序)
function flattenStackOrdered(arr) {
const stack = [];
const result = [];
let current = arr;
let i = 0;
while (current !== undefined) {
if (i < current.length) {
const item = current[i];
i++;
if (Array.isArray(item)) {
// 保存当前状态
stack.push({ current, i });
// 切换到子数组
current = item;
i = 0;
} else {
result.push(item);
}
} else if (stack.length > 0) {
// 恢复上一个状态
const saved = stack.pop();
current = saved.current;
i = saved.i;
} else {
current = undefined;
}
}
return result;
}
5. 使用toString()方法
function flattenToString(arr) {
return arr.toString()
.split(',')
.map(item => {
// 转换回适当的数据类型
const num = Number(item);
return isNaN(num) ? item : num;
});
}
// 注意:这种方法会将所有元素转为字符串再解析
// 只适用于纯数字数组或可转换为字符串的元素
const arr = [1, [2, [3, [4, 5]], 6], 7];
console.log(flattenToString(arr)); // [1, 2, 3, 4, 5, 6, 7]
// 局限性示例
const mixedArr = [1, [2, ['a', ['b', 'c']]], 3];
console.log(flattenToString(mixedArr)); // [1, 2, 'a', 'b', 'c', 3]
总结
推荐方法选择
-
现代项目(支持ES2019+) :直接使用
arr.flat(Infinity) -
需要深度控制:使用递归版本
flattenDepth - 大数组或性能敏感:使用栈实现的非递归版本
-
需要处理循环引用:使用
flattenSafe或完整版 - 简单场景:使用reduce或递归基础版
注意事项
- 方法选择要考虑浏览器兼容性
- 递归方法可能导致栈溢出(深度过大)
- 字符串转换方法有类型丢失问题
- 注意处理稀疏数组和循环引用
- 性能测试显示原生
flat通常最快,栈实现次之
Nest 项目小实践之图书增删改查
写图书新增、修改、删除、详情功能
新建 BookManage/CreateBookModal.tsx
import { Button, Form, Input, Modal, message } from "antd";
import { useForm } from "antd/es/form/Form";
import TextArea from "antd/es/input/TextArea";
interface CreateBookModalProps {
isOpen: boolean;
handleClose: Function
}
const layout = {
labelCol: { span: 6 },
wrapperCol: { span: 18 }
}
export interface CreateBook {
name: string;
author: string;
description: string;
cover: string;
}
export function CreateBookModal(props: CreateBookModalProps) {
const [form] = useForm<CreateBook>();
const handleOk = async function() {
}
return <Modal title="新增图书" open={props.isOpen} onOk={handleOk} onCancel={() => props.handleClose()} okText={'创建'}>
<Form
form={form}
colon={false}
{...layout}
>
<Form.Item
label="图书名称"
name="name"
rules={[
{ required: true, message: '请输入图书名称!' },
]}
>
<Input />
</Form.Item>
<Form.Item
label="作者"
name="author"
rules={[
{ required: true, message: '请输入图书作者!' },
]}
>
<Input />
</Form.Item>
<Form.Item
label="描述"
name="description"
rules={[
{ required: true, message: '请输入图书描述!' },
]}
>
<TextArea/>
</Form.Item>
<Form.Item
label="封面"
name="cover"
rules={[
{ required: true, message: '请上传图书封面!' },
]}
>
<Input/>
</Form.Item>
</Form>
</Modal>
}
在 BookManage/index.tsx 调用
const [isCreateBookModalOpen, setCreateBookModalOpen] = useState(false);
<Button type="primary" htmlType="submit" style={{background: 'green'}} onClick={ () =>setCreateBookModalOpen(true)}>
添加图书
</Button>
<CreateBookModal isOpen={isCreateBookModalOpen} handleClose={() => {
setCreateBookModalOpen(false);
}}></CreateBookModal>
点击添加图书就会展示
![]()
在 interfaces/index.ts 里添加 /book/create 接口
export async function create(book: CreateBook) {
return await axiosInstance.post("/book/create", {
name: book.name,
author: book.author,
description: book.description,
cover: book.cover,
});
}
在 CreateBookModal 组件调用
const handleOk = async function() {
await form.validateFields();
const values = form.getFieldsValue();
try {
const res = await create(values);
if(res.status === 201 || res.status === 200) {
message.success('创建成功');
form.resetFields();
props.handleClose();
}
} catch(e: any) {
message.error(e.response.data.message);
}
}
可以新增图书
![]()
只是没有封面图
添加 BookManage/Coverupload.tsx 组件
import { InboxOutlined } from "@ant-design/icons";
import { message } from "antd";
import Dragger, { type DraggerProps } from "antd/es/upload/Dragger";
interface CoverUploadProps {
value?: string;
onChange?: Function
}
let onChange: Function;
const props: DraggerProps = {
name: 'file',
action: 'http://localhost:3000/book/upload',
method: 'post',
onChange(info) {
const { status } = info.file;
if (status === 'done') {
onChange(info.file.response);
message.success(`${info.file.name} 文件上传成功`);
} else if (status === 'error') {
message.error(`${info.file.name} 文件上传失败`);
}
}
};
const dragger = <Dragger {...props}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或拖拽文件到区域上传</p>
</Dragger>
export function CoverUpload(props: CoverUploadProps) {
onChange = props.onChange!
return props?.value ? <div>
<img src={'http://localhost:3000/' + props.value} alt="封面" width="100" height="100"/>
{dragger}
</div>: <div>
{dragger}
</div>
}
![]()
添加成功
![]()
修改如何做 ? 新建 BookManage/UpdateBookModal.tsx
需要带上 id
import { Button, Form, Input, Modal, message } from "antd";
import { useForm } from "antd/es/form/Form";
import TextArea from "antd/es/input/TextArea";
import { CoverUpload } from "./CoverUpload";
interface UpdateBookModalProps {
id: number;
isOpen: boolean;
handleClose: Function
}
const layout = {
labelCol: { span: 6 },
wrapperCol: { span: 18 }
}
export interface UpdateBook {
id: number;
name: string;
author: string;
description: string;
cover: string;
}
export function UpdateBookModal(props: UpdateBookModalProps) {
const [form] = useForm<UpdateBook>();
const handleOk = async function() {
}
return <Modal title="更新图书" open={props.isOpen} onOk={handleOk} onCancel={() => props.handleClose()} okText={'更新'}>
<Form
form={form}
colon={false}
{...layout}
>
<Form.Item
label="图书名称"
name="name"
rules={[
{ required: true, message: '请输入图书名称!' },
]}
>
<Input />
</Form.Item>
<Form.Item
label="作者"
name="author"
rules={[
{ required: true, message: '请输入图书作者!' },
]}
>
<Input />
</Form.Item>
<Form.Item
label="描述"
name="description"
rules={[
{ required: true, message: '请输入图书描述!' },
]}
>
<TextArea/>
</Form.Item>
<Form.Item
label="封面"
name="cover"
rules={[
{ required: true, message: '请上传图书封面!' },
]}
>
<CoverUpload></CoverUpload>
</Form.Item>
</Form>
</Modal>
}
在 BookManage index.tsx 引入使用
const [isUpdateBookModalOpen, setUpdateBookModalOpen] = useState(false); const [updateId, setUpdateId] = useState(0);
<UpdateBookModal id={updateId} isOpen={isUpdateBookModalOpen} handleClose={() => { setUpdateBookModalOpen(false); setName(''); }}></UpdateBookModal>
<a href="#" onClick={() => { setUpdateId(book.id); setUpdateBookModalOpen(true); }}>编辑</a>
点击编辑
![]()
interfaces/index.ts 里加一下接口
export async function detail(id: number) {
return await axiosInstance.get(`/book/${id}`);
}
UpdateBookModal.tsx 添加
async function query() {
if(!props.id) {
return;
}
try{
const res = await detail(props.id);
const { data } = res;
debugger;
if(res.status === 200 || res.status === 201) {
form.setFieldValue('id', data.id);
form.setFieldValue('name', data.name);
form.setFieldValue('author', data.author);
form.setFieldValue('description', data.description);
form.setFieldValue('cover', data.cover);
}
} catch(e: any){
message.error(e.response.data.message);
}
}
useEffect(() => {
query();
}, [props.id]);
点击编辑 就会带出已有的信息
![]()
更新 interfaces/index.ts
export async function update(book: UpdateBook) {
return await axiosInstance.put('/book/update', {
id: book.id,
name: book.name,
author: book.author,
description: book.description,
cover: book.cover
});
}
UpdateBookModal.tsx 调用
const handleOk = async function() {
await form.validateFields();
const values = form.getFieldsValue();
try {
const res = await update({...values, id: props.id});
if(res.status === 201 || res.status === 200) {
message.success('更新成功');
props.handleClose();
}
} catch(e: any) {
message.error(e.response.data.message);
}
}
可以成功更新
![]()
BookManage index.tsx
<Popconfirm
title="图书删除"
description="确认删除吗?"
onConfirm={() => handleDelete(book.id)}
okText="Yes"
cancelText="No"
>
<a href="#">删除</a>
</Popconfirm>
async function handleDelete(id: number) {
try {
await deleteBook(id);
message.success('删除成功');
setNum(Math.random())
} catch(e: any) {
message.error(e.response.data.message);
}
}
interfaces/index.ts
export async function deleteBook(id: number) { return await axiosInstance.delete(`/book/delete/${id}`); }
成功删除
![]()
后续会新增一些优化部分
- 登录之后怎么保存登录状态?比如有的接口需要登录才能访问,怎么控制?
这需要用 session + cookie 或 jwt 的方式来实现登录状态的保存。
- 数据保存在文件里并不方便,还有啥更好的方式?
保存在 mysql 数据库,用 TypeORM 作为 ORM 框架。
- 后端接口怎么提供 api 文档?
这需要用 swagger
- 文件保存在文件目录下,如果磁盘空间满了怎么办?
可以换用 minio 或者阿里 OSS 等对象存储服务。
- 怎么部署?
前端用 nginx,后端代码用 docker 和 docker compose
- 如何实现验证码?
可以用 nodemailer 发送邮件,然后用 redis 保存验证码数据。
前端导出 Word/Excel/PDF 文件
核心思路是:后端返回文件二进制流 → 前端接收并转换为 Blob → 创建下载链接触发保存
一、核心实现步骤(通用逻辑)
- 后端接口需返回 文件二进制流(Content-Type 对应文件类型),而非 JSON。
- 前端请求时设置
responseType: 'blob',确保接收二进制数据。 - 将 Blob 转换为临时下载链接,模拟点击实现文件保存。
- 清理临时链接,避免内存泄漏。
二、完整代码实现(Axios 版本,最常用)
1. 导出 Excel 文件(.xlsx)
// 安装依赖:npm install axios
import axios from 'axios';
/**
* 导出Excel文件
* @param {string} apiUrl - 接口地址
* @param {object} params - 接口参数(如筛选条件)
* @param {string} fileName - 自定义文件名(无需后缀)
*/
export const exportExcel = async (apiUrl, params, fileName = '导出数据') => {
try {
// 1. 发送请求,指定接收二进制流
const response = await axios({
url: apiUrl,
method: 'GET', // 也可POST,根据后端接口调整
params: params, // POST请用data: params
responseType: 'blob', // 关键:指定返回Blob类型
headers: {
'Content-Type': 'application/json', // 根据后端要求调整
// 如有token,添加认证:
// 'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
// 2. 处理返回的Blob数据
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' // Excel MIME类型
});
// 3. 创建临时下载链接
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `${fileName}.xlsx`; // 文件名+后缀
document.body.appendChild(link);
// 4. 触发下载
link.click();
// 5. 清理资源
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
console.log('Excel文件导出成功');
} catch (error) {
console.error('Excel导出失败:', error);
alert('文件导出失败,请重试');
}
};
// 调用示例
// exportExcel('/api/export/user', { department: '技术部' }, '技术部用户列表');
2. Blob的type值
-
如果你的接口返回的是 .xls 格式(旧版二进制),就用:
const type = 'application/vnd.ms-excel'; const blob = new Blob([response.data], { type: type });下载时文件名后缀用
.xls。 -
如果接口返回的是 .xlsx 格式(新版 XML),就用:
const type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const blob = new Blob([response.data], { type: type });下载时文件名后缀用
.xlsx。
⚠️ 注意:MIME 类型必须和实际文件格式一致,否则可能导致下载的文件无法打开。
3. 导出 Word 文件(.docx)
仅需修改 Blob 的 type 和文件名后缀,核心逻辑完全一致:
// 新版 文件格式.docx
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' // Word MIME类型
});
// 旧版 文件格式.doc
const blob = new Blob([response.data], {
type: 'application/msword'
});
4. 导出 PDF 文件(.pdf)
// 创建 Blob,指定 PDF 类型
const blob = new Blob([response.data], {
type: 'application/pdf'
});