前端使用CountUp.js制作数字动画效果的教程
在现代网页设计中,动态数字展示能够显著提升用户体验,吸引访客注意力。无论是数据统计、销售数字还是还是评分展示,平滑的数字增长动画都能让信息传递更加生动。CountUp.js 正是一款专门用于创建这种数字动画效果的轻量级 JavaScript 库,本文将详细介绍其使用方法与技巧。
1. 前言
CountUp.js 是一个零依赖的 JavaScript 库,用于创建从一个数字平滑过渡到另一个数字的动画效果。它体积小巧(压缩后仅约 3KB),使用简单,且高度可定制,能够满足各种数字动画需求。
CountUp.js 的特点
- 零依赖,无需引入其他库
- 轻量级,加载迅速
- 高度可配置(动画时长、延迟、小数位数等)
- 支持多种 easing 动画效果
- 支持暂停、恢复、重置等控制
- 兼容所有现代浏览器
2. 快速开始
CountUp.js 有多种引入方式,可根据项目需求选择:
1. 通过 npm 安装
npm install countup.js
然后在项目中导入:
import CountUp from 'countup.js';
2. 直接引入 CDN
<script src="https://cdn.jsdelivr.net/npm/countup.js@2.0.8/dist/countUp.umd.min.js">script>
3. 下载源码
从 GitHub 仓库 下载源码,直接引入本地文件。
2.1. 基本用法
使用 CountUp.js 只需三步:
- 在 HTML 中准备一个用于显示数字的元素
<div id="counter">div>
- 初始化 CountUp 实例
// 获取 DOM 元素
const element = document.getElementById('counter');
// 目标数值
const target = 1000;
// 创建 CountUp 实例
const countUp = new CountUp(element, target);
- 启动动画
// 检查是否初始化成功,然后启动动画
if (!countUp.error) {
countUp.start();
} else {
console.error(countUp.error);
}
3. 配置选项
CountUp.js 提供了丰富的配置选项,让你可以精确控制动画效果:
const options = {
startVal: 0, // 起始值,默认为 0
duration: 2, // 动画时长(秒),默认为 2
decimalPlaces: 0, // 小数位数,默认为 0
useGr0uping: true, // 是否使用千位分隔符,默认为 true
useEasing: true, // 是否使用缓动效果,默认为 true
smartEasingThreshold: 999, // 智能缓动阈值
smartEasingAmount: 300, // 智能缓动数量
separator: ',', // 千位分隔符,默认为 ','
decimal: '.', // 小数点符号,默认为 '.'
prefix: '', // 数字前缀
suffix: '', // 数字后缀
numerals: [] // 数字替换数组,用于本地化
};
// 使用配置创建实例
const countUp = new CountUp(element, target, options);
3.1. 示例:带前缀和后缀的动画
// 显示"$1,234.56"的动画
const options = {
startVal: 0,
duration: 3,
decimalPlaces: 2,
prefix: '$',
suffix: ''
};
const countUp = new CountUp(document.getElementById('price'), 1234.56, options);
countUp.start();
4. 高级控制方法
CountUp.js 提供了多种方法来控制动画过程:
// 开始动画
countUp.start();
// 暂停动画
countUp.pauseResume();
// 重置动画
countUp.reset();
// 更新目标值并重新开始动画
countUp.update(2000);
// 立即完成动画
countUp.finish();
4.1. 示例:带回调函数的动画
// 动画完成后执行回调函数
countUp.start(() => {
console.log('动画完成!');
// 可以在这里执行后续操作
});
5. 实际应用场景
下面是实际应用场景的模拟:
5.1. 数据统计展示
<div class="stats">
<div class="stat-item">
<h3>用户总数h3>
<div class="stat-value" id="users">div>
div>
<div class="stat-item">
<h3>总销售额h3>
<div class="stat-value" id="sales">div>
div>
<div class="stat-item">
<h3>转化率h3>
<div class="stat-value" id="conversion">div>
div>
div>
<script>
// 初始化多个计数器
const usersCounter = new CountUp('users', 12500, { suffix: '+' });
const salesCounter = new CountUp('sales', 458920, { prefix: '$', decimalPlaces: 0 });
const conversionCounter = new CountUp('conversion', 24.5, { suffix: '%', decimalPlaces: 1 });
// 同时启动所有动画
document.addEventListener('DOMContentLoaded', () => {
usersCounter.start();
salesCounter.start();
conversionCounter.start();
});
script>
5.2. 滚动触发动画
结合 Intersection Observer API,实现元素进入视口时触发动画:
<div id="scrollCounter" class="counter">div>
<script>
// 创建计数器实例但不立即启动
const scrollCounter = new CountUp('scrollCounter', 5000);
// 配置交叉观察器
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 元素进入视口,启动动画
scrollCounter.start();
// 只观察一次
observer.unobserve(entry.target);
}
});
});
// 观察目标元素
observer.observe(document.getElementById('scrollCounter'));
script>
5.3. 结合按钮控制
<div id="controlledCounter">div>
<button id="startBtn">开始button>
<button id="pauseBtn">暂停button>
<button id="resetBtn">重置button>
<button id="updateBtn">更新到 2000button>
<script>
const counter = new CountUp('controlledCounter', 1000);
// 按钮事件监听
document.getElementById('startBtn').addEventListener('click', () => {
counter.start();
});
document.getElementById('pauseBtn').addEventListener('click', () => {
counter.pauseResume();
});
document.getElementById('resetBtn').addEventListener('click', () => {
counter.reset();
});
document.getElementById('updateBtn').addEventListener('click', () => {
counter.update(2000);
});
script>
6.自定义缓动函数
CountUp.js 允许你自定义缓动函数,创建独特的动画效果:
// 自定义缓动函数
function myEasing(t, b, c, d) {
// t: 当前时间
// b: 起始值
// c: 变化量 (目标值 - 起始值)
// d: 总时长
t /= d;
return c * t * t * t + b;
}
// 使用自定义缓动函数
const options = {
duration: 2,
easingFn: myEasing
};
const countUp = new CountUp(element, target, options);
countUp.start();
7. 常见问题与解决方案
下面是一些常见问题与解决方案:
7.1. 动画不生效
- 检查元素是否正确获取
- 确保目标值大于起始值(如需从大到小动画,可设置 startVal 大于 target)
- 检查控制台是否有错误信息
7.2. 数字格式问题
- 使用 separator 和 decimal 选项配置数字格式
- 对于特殊数字系统,使用 numerals 选项进行替换
7.3. 性能问题
- 避免在同一页面创建过多计数器实例
- 对于非常大的数字,适当增加动画时长
- 考虑使用滚动触发,而非页面加载时同时启动所有动画
8. 总结
CountUp.js 是一个简单而强大的数字动画库,能够为你的网站增添专业感和活力。它的轻量级特性和丰富的配置选项使其适用于各种场景,从简单的数字展示到复杂的数据可视化。
通过本文介绍的基础用法和高级技巧,你可以轻松实现各种数字动画效果,提升用户体验。无论是个人博客、企业官网还是电商平台,CountUp.js 都能成为你前端工具箱中的得力助手。
参考资源
来源:juejin.cn/post/7542403996917989422
交替打印最容易理解的实现——同步队列
前言
原创不易,禁止转载!
本文旨在实现最简形式的交替打印。理解了同步队列,你可以轻松解决60%以上的多线程面试题。同步队列作为JUC提供的并发原语之一,使用了无锁算法,性能更好,但是却常常被忽略。
交替打印是一类常见的面试题,也是很多人第一次学习并发编程面对的问题,如:
- 三个线程T1、T2、T3轮流打印ABC,打印n次,如ABCABCABCABC.......
- 两个线程交替打印1-100的奇偶数
- N个线程循环打印1-100
很多文章(如: zhuanlan.zhihu.com/p/370130458 )总结了实现交替打印的多种做法:
- synchronized + wait/notify: 使用synchronized关键字和wait/notify方法来实现线程间的通信和同步。
- join() : 利用线程的join()方法来确保线程按顺序执行。
- Lock: 使用ReentrantLock来实现线程同步,通过锁的机制来控制线程的执行顺序。
- Lock + Condition: 在Lock的基础上,使用Condition对象来实现更精确的线程唤醒,避免不必要的线程竞争。
- Semaphore: 使用信号量来控制线程的执行顺序,通过acquire()和release()方法来管理线程的访问。
- 此外还有LockSupport、CountDownLatch、AtomicInteger 等实现方式。
笔者认为,在面试时能够选择一种无bug实现即可。
缺点
这些实现使用的都是原语,也就是并发编程中的基本组件,偏向于底层,同时要求开发者深入理解这些原语的工作原理,掌握很多技巧。
问题在于:如果真正的实践中实现,容易出现 bug,一般也不推荐在生产中使用;
这也是八股文的弊端之一:过于关注所谓的底层实现,忽略了真正的实践。
我们分析这些组件的特点,不外乎临界区锁定、线程同步、共享状态等。以下分析一个实现,看看使用到了哪些技巧:
class Wait_Notify_ACB {
private int num;
private static final Object LOCK = new Object();
private void printABC(int targetNum) {
for (int i = 0; i < 10; i++) {
synchronized (LOCK) {
while (num % 3 != targetNum) {
try {
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num++;
System.out.print(Thread.currentThread().getName());
LOCK.notifyAll();
}
}
}
public static void main(String[] args) {
Wait_Notify_ACB wait_notify_acb = new Wait_Notify_ACB ();
new Thread(() -> {
wait_notify_acb.printABC(0);
}, "A").start();
new Thread(() -> {
wait_notify_acb.printABC(1);
}, "B").start();
new Thread(() -> {
wait_notify_acb.printABC(2);
}, "C").start();
}
}
整体观之,使用的是 synchronized 隐式锁。使用等待队列实现线程同步,while 循环避免虚假唤醒,维护了多线程共享的 num 状态,此外需要注意多个任务的启动和正确终止。
InterruptedException 的处理是错误的,由于我们没有使用到中断机制,可以包装后抛出 IllegalStateException 表示未预料的异常。实践中,也可以设置当前线程为中断状态,待其他代码进行处理。
Lock不应该是静态的,可以改成非静态或者方法改成静态也行。
总之,经过分析可以看出并发原语的复杂性,那么有没有更高一层的抽象来简化问题呢?
更好的实现
笔者在项目的生产环境中遇到过类似的问题,多个线程需要协作,某些线程需要其他线程的结果,这种结果的交接是即时的,也就是说,A线程的结果直接交给B线程进行处理。
更好的实现要求我们实现线程之间的同步,同时应该避免并发修改。我们很自然地想到 SynchronousQueue,使用 CSP 实现 + CompletableFuture,可以减少我们考虑底层的心智负担,方便写出正确的代码。SynchronousQueue 适用于需要在生产者和消费者之间进行直接移交的场景,通常用于线程之间的切换或传递任务。
看一个具体例子:
以下是两个线程交替打印 1 - 100 的实现,由于没有在代码中使用锁,也没有状态维护的烦恼,这也是函数式的思想(减少状态)。
实现思路为:任务1从队列1中取结果,计算,提交给队列2。任务2同理。使用SynchronousQueue 实现直接交接。
private static Stopwatch betterImpl() {
Stopwatch sw = Stopwatch.createStarted();
BlockingQueue<Integer> q1 = new SynchronousQueue<>();
BlockingQueue<Integer> q2 = new SynchronousQueue<>();
int limit = 100;
CompletableFuture<Void> cf1 = CompletableFuture.runAsync(() -> {
while (true) {
Integer i = Uninterruptibles.takeUninterruptibly(q1);
if (i <= limit) {
System.out.println("thread1: i = " + i);
}
Uninterruptibles.putUninterruptibly(q2, i + 1);
if (i == limit - 1) {
break;
}
}
});
CompletableFuture<Void> cf2 = CompletableFuture.runAsync(() -> {
while (true) {
Integer i = Uninterruptibles.takeUninterruptibly(q2);
if (i <= limit) {
System.out.println("thread2: i = " + i);
}
if (i == limit) {
break;
}
Uninterruptibles.putUninterruptibly(q1, i + 1);
}
});
Uninterruptibles.putUninterruptibly(q1, 1);
CompletableFuture.allOf(cf1, cf2).join();
return sw.stop();
}
Uninterruptibles 是 Guava 中的并发工具,很实用,可以避免 try-catch 中断异常这样的样板代码。
线程池配置与本文讨论内容关系不大,故忽略。
一般实践中,阻塞方法都要设置超时时间,这里也忽略了。
这个实现简单明了,性能也不错。如果不需要即时交接,可以替换成缓冲队列(如 ArrayBlockingQueue)。
笔者简单比较了两种实现,结果如下:
private static Stopwatch bufferImpl() {
Stopwatch sw = Stopwatch.createStarted();
BlockingQueue<Integer> q1 = new ArrayBlockingQueue<>(2);
BlockingQueue<Integer> q2 = new ArrayBlockingQueue<>(2);
// ...
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
betterImpl();
bufferImpl();
// 预热
}
Stopwatch result1 = bufferImpl();
Stopwatch result2 = betterImpl();
System.out.println("result1 = " + result1);
System.out.println("result2 = " + result2);
}
// ...
thread2: i = 92
thread1: i = 93
thread2: i = 94
thread1: i = 95
thread2: i = 96
thread1: i = 97
thread2: i = 98
thread1: i = 99
thread2: i = 100
result1 = 490.3 μs
result2 = 469.1 μs
结论:使用 SynchronousQueue 性能更好,感兴趣的读者可以自己写 JMH 比对。
如果你觉得本文对你有帮助的话,欢迎给个点赞加收藏,也欢迎进一步的讨论。
后续我将继续分享并发编程、性能优化等有趣内容,力求做到全网独一份、深入浅出,一周两更,欢迎关注支持。
来源:juejin.cn/post/7532925096828026899
uniapp图片上传添加水印/压缩/剪裁
一、前言
最近遇到一个需求,微信小程序上传图片添加水印的需求,故此有该文章做总结, 功能涵盖定理地位,百度地址解析,图片四角水印,图片压缩,图片压缩并添加水印,图片剪裁,定位授权,保存图片到相册等
二、效果
三、代码实现核心
3.1)添加水印并压缩
核心实现
// 添加水印并压缩
export function addWatermarkAndCompress(options, that, isCompress = false) {
return new Promise((resolve, reject) => {
const {
errLog,
config
} = dealWatermarkConfig(options)
that.watermarkCanvasOption.width = 0
that.watermarkCanvasOption.height = 0
if (!errLog.length) {
const {
canvasId,
imagePath,
watermarkList,
quality = 0.6
} = config
uni.getImageInfo({ // 获取图片信息,以便获取图片的真实宽高信息
src: imagePath,
success: (info) => {
const {
width: oWidth,
height: oHeight,
type,
orientation
} = info; // 获取图片的原始宽高
const fileTypeObj = {
'jpeg': 'jpg',
'jpg': 'jpg',
'png': 'png',
}
const fileType = fileTypeObj[type] || 'png'
let width = oWidth
let height = oHeight
if (isCompress) {
const {
cWidth,
cHeight
} = calcRatioHeightAndWight({
oWidth,
oHeight,
quality,
orientation
})
// 按对折比例缩小
width = cWidth
height = cHeight
}
that.watermarkCanvasOption.width = width
that.watermarkCanvasOption.height = height
that.$nextTick(() => {
// 获取canvas绘图上下文
const ctx = uni.createCanvasContext(canvasId, that);
// 绘制原始图片到canvas上
ctx.drawImage(imagePath, 0, 0, width, height);
// 绘制水印项
const drawWMItem = (ctx, options) => {
const {
fontSize,
color,
text: cText,
position,
margin
} = options
// 添加水印
ctx.setFontSize(fontSize); // 设置字体大小
ctx.setFillStyle(color); // 设置字体颜色为红色
if (isNotEmptyArr(cText)) {
const text = cText.filter(Boolean)
if (position.startsWith('bottom')) {
text.reverse()
}
text.forEach((str, ind) => {
const textMetrics = ctx.measureText(str);
const {
calcX,
calcY
} = calcPosition({
height,
width,
position,
margin,
ind,
fontSize,
textMetrics
})
ctx.fillText(str, calcX, calcY, width);
})
} else {
const textMetrics = ctx.measureText(cText);
const {
calcX,
calcY
} = calcPosition({
height,
width,
position,
margin,
ind: 0,
fontSize,
textMetrics
})
// 在图片底部添加水印文字
ctx.fillText(text, calcX, calcY, width);
}
}
watermarkList.forEach(ele => {
drawWMItem(ctx, ele)
})
// 绘制完成后执行的操作,这里不等待绘制完成就继续执行后续操作,因为我们要导出为图片
ctx.draw(false, () => {
// #ifndef MP-ALIPAY
uni.canvasToTempFilePath({ // 将画布内容导出为图片
canvasId,
x: 0,
y: 0,
width,
height,
fileType,
quality, // 图片的质量,目前仅对 jpg 有效。取值范围为 (0, 1],不在范围内时当作 1.0 处理。
destWidth: width,
destHeight: height,
success: (res) => {
console.log('res.tempFilePath', res)
resolve(res.tempFilePath)
},
fail() {
reject(false)
}
}, that);
// #endif
// #ifdef MP-ALIPAY
ctx.toTempFilePath({ // 将画布内容导出为图片
canvasId,
x: 0,
y: 0,
width: width,
height: height,
destWidth: width,
destHeight: height,
quality,
fileType,
success: (res) => {
console.log('res.tempFilePath', res)
resolve(res.tempFilePath)
},
fail() {
reject(false)
}
}, that);
// #endif
});
})
}
});
} else {
const errStr = errLog.join(';')
showMsg(errStr)
reject(errStr)
}
})
}
3.2)剪切图片
// 剪切图片
export function clipImg(options, that) {
return new Promise((resolve, reject) => {
const {
errLog,
config
} = dealClipImgConfig(options)
that.watermarkCanvasOption.width = 0
that.watermarkCanvasOption.height = 0
if (!errLog.length) {
const {
canvasId,
imagePath,
cWidth,
cHeight,
position
} = config
// 获取图片信息,以便获取图片的真实宽高信息
uni.getImageInfo({
src: imagePath,
success: (info) => {
const {
width,
height
} = info; // 获取图片的原始宽高
// 自定义剪裁范围要在图片内
if (width >= cWidth && height >= cHeight) {
that.watermarkCanvasOption.width = width
that.watermarkCanvasOption.height = height
that.$nextTick(() => {
// 获取canvas绘图上下文
const ctx = uni.createCanvasContext(canvasId, that);
const {
calcSX,
calcSY,
calcEX,
calcEY
} = calcClipPosition({
cWidth,
cHeight,
position,
width,
height
})
// 绘制原始图片到canvas上
ctx.drawImage(imagePath, 0, 0, width, height);
// 绘制完成后执行的操作,这里不等待绘制完成就继续执行后续操作,因为我们要导出为图片
ctx.draw(false, () => {
// #ifndef MP-ALIPAY
uni.canvasToTempFilePath({ // 将画布内容导出为图片
canvasId,
x: calcSX,
y: calcSY,
width: cWidth,
height: cHeight,
destWidth: cWidth,
destHeight: cHeight,
success: (res) => {
console.log('res.tempFilePath',
res)
resolve(res.tempFilePath)
},
fail() {
reject(false)
}
}, that);
// #endif
// #ifdef MP-ALIPAY
ctx.toTempFilePath({ // 将画布内容导出为图片
canvasId,
x: 0,
y: 0,
width: width,
height: height,
destWidth: width,
destHeight: height,
// fileType: 'png',
success: (res) => {
console.log('res.tempFilePath',
res)
resolve(res.tempFilePath)
},
fail() {
reject(false)
}
}, that);
// #endif
});
})
} else {
return imagePath
}
}
})
} else {
const errStr = errLog.join(';')
showMsg(errStr)
reject(errStr)
}
})
}
3.3)canvas画布标签
<!-- 给图片添加的标签 -->
<canvas v-if="watermarkCanvasOption.width > 0 && watermarkCanvasOption.height > 0"
:style="{ width: watermarkCanvasOption.width + 'px', height: watermarkCanvasOption.height + 'px' }"
canvas-id="watermarkCanvas" id="watermarkCanvas" style="position: absolute; top: -10000000rpx;" />
以上代码具体的实现功能不做一一讲解,详细请看下方源码地址
四、源码地址
github: github.com/ArcherNull/…
五、总结
- 图片的操作,例如压缩/剪裁/加水印都是需要借助canvas标签,也就是说需要有canvas实例通过该api实现这些操作
- 当执行 ctx.drawImage(imagePath, 0, 0, width, height) 后,后续的操作的是对内存中的数据,而不是源文件
完结撒花,如果对您有帮助,请一键三连
来源:juejin.cn/post/7513183180092031011
订单表超10亿数据,如何设计Sharding策略?解决跨分片查询和分布式事务?
订单表超10亿数据,如何设计Sharding策略?解决跨分片查询和分布式事务?
引言:
在电商平台高速发展的今天,海量订单处理已成为技术团队必须面对的挑战。当订单数据突破10亿大关,传统单库架构在查询性能、存储容量和运维复杂度上都会遇到瓶颈。
作为有8年经验的Java工程师,我曾主导多个日订单量百万级系统的分库分表改造。今天我将分享从Sharding策略设计到分布式事务落地的完整解决方案,其中包含核心代码实现和实战避坑指南。
一、业务场景分析
1.1 订单数据特点
- 数据量大:日增订单50万+,年增1.8亿
- 访问模式:
- 写操作:高频下单(峰值5000 TPS)
- 读操作:订单查询(用户端+运营端)
- 数据生命周期:热数据(3个月)占80%访问量
1.2 核心挑战
graph LR
A[10亿级订单] --> B[查询性能]
A --> C[存储瓶颈]
A --> D[跨分片聚合]
A --> E[分布式事务]
二、Sharding策略设计
2.1 分片键选择
候选方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
用户ID | 用户维度查询快 | 可能导致数据倾斜 | C端主导业务 |
订单ID | 数据分布均匀 | 用户订单需跨分片查询 | 均匀分布场景 |
商户ID | 商户维度查询快 | C端查询效率低 | B2B平台 |
创建时间 | 冷热数据分离 | 范围查询可能跨分片 | 推荐方案 |
最终方案:复合分片键(用户ID+创建时间)
2.2 分片算法设计
/**
* 自定义复合分片算法
* 分片键:user_id + create_time
*/
public class OrderShardingAlgorithm implements ComplexKeysShardingAlgorithm<Long> {
private static final String USER_KEY = "user_id";
private static final String TIME_KEY = "create_time";
@Override
public Collection<String> doSharding(
Collection<String> availableTargetNames,
ComplexKeysShardingValue<Long> shardingValue) {
Map<String, Collection<Long>> columnMap = shardingValue.getColumnNameAndShardingValuesMap();
List<String> shardingResults = new ArrayList<>();
// 获取用户ID分片值
Collection<Long> userIds = columnMap.get(USER_KEY);
Long userId = userIds.stream().findFirst().orElseThrow();
// 获取时间分片值
Collection<Long> timestamps = columnMap.get(TIME_KEY);
Long createTime = timestamps.stream().findFirst().orElse(System.currentTimeMillis());
// 计算用户分片(16个分库)
int dbShard = Math.abs(userId.hashCode()) % 16;
// 计算时间分片(按月分表)
LocalDateTime dateTime = Instant.ofEpochMilli(createTime)
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
String tableSuffix = dateTime.format(DateTimeFormatter.ofPattern("yyyyMM"));
// 构建目标分片
String targetDB = "order_db_" + dbShard;
String targetTable = "t_order_" + tableSuffix;
shardingResults.add(targetDB + "." + targetTable);
return shardingResults;
}
}
2.3 分片策略配置(ShardingSphere)
# application-sharding.yaml
spring:
shardingsphere:
datasource:
names: ds0,ds1,...,ds15
# 配置16个数据源...
sharding:
tables:
t_order:
actualDataNodes: ds${0..15}.t_order_${202301..202412}
tableStrategy:
complex:
shardingColumns: user_id,create_time
algorithmClassName: com.xxx.OrderShardingAlgorithm
keyGenerator:
column: order_id
type: SNOWFLAKE
三、跨分片查询解决方案
3.1 常见问题及对策
问题类型 | 传统方案痛点 | 优化方案 |
---|---|---|
分页查询 | LIMIT 0,10 扫描全表 | 二次查询法 |
排序聚合 | 内存合并性能差 | 并行查询+流式处理 |
全局索引 | 无法直接建立 | 异步构建ES索引 |
3.2 分页查询优化实现
/**
* 跨分片分页查询优化(二次查询法)
* 原SQL:SELECT * FROM t_order WHERE user_id=1001 ORDER BY create_time DESC LIMIT 10000,10
*/
public Page<Order> shardingPageQuery(Long userId, int pageNo, int pageSize) {
// 第一步:全分片并行查询
List<Order> allShardResults = shardingExecute(
shard -> "SELECT order_id, create_time FROM t_order "
+ "WHERE user_id = " + userId
+ " ORDER BY create_time DESC"
);
// 第二步:内存排序取TopN
List<Long> targetIds = allShardResults.stream()
.sorted(Comparator.comparing(Order::getCreateTime).reversed())
.skip(pageNo * pageSize)
.limit(pageSize)
.map(Order::getOrderId)
.collect(Collectors.toList());
// 第三步:精准查询目标数据
return orderRepository.findByIdIn(targetIds);
}
/**
* 并行执行查询(使用CompletableFuture)
*/
private List<Order> shardingExecute(Function<Integer, String> sqlBuilder) {
List<CompletableFuture<List<Order>>> futures = new ArrayList<>();
for (int i = 0; i < 16; i++) {
final int shardId = i;
futures.add(CompletableFuture.supplyAsync(() -> {
String sql = sqlBuilder.apply(shardId);
return jdbcTemplate.query(sql, new OrderRowMapper());
}, shardingThreadPool));
}
return futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.collect(Collectors.toList());
}
3.3 聚合查询优化
/**
* 分布式聚合计算(如:用户总订单金额)
* 方案:并行查询分片结果 + 内存汇总
*/
public BigDecimal calculateUserTotalAmount(Long userId) {
List<CompletableFuture<BigDecimal>> futures = new ArrayList<>();
for (int i = 0; i < 16; i++) {
futures.add(CompletableFuture.supplyAsync(() -> {
String sql = "SELECT SUM(amount) FROM t_order WHERE user_id = ?";
return jdbcTemplate.queryForObject(
sql, BigDecimal.class, userId);
}, shardingThreadPool));
}
return futures.stream()
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
四、分布式事务解决方案
4.1 方案对比
方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
---|---|---|---|---|
2PC | 强一致 | 差 | 高 | 银行核心系统 |
TCC | 强一致 | 中 | 高 | 资金交易 |
Saga | 最终一致 | 优 | 中 | 订单系统(推荐) |
本地消息表 | 最终一致 | 良 | 低 | 低要求场景 |
4.2 Saga事务实现(订单创建场景)
sequenceDiagram
participant C as 应用
participant O as 订单服务
participant I as 库存服务
participant P as 支付服务
C->>O: 创建订单
O->>I: 预扣库存
I-->>O: 扣减成功
O->>P: 发起支付
P-->>O: 支付成功
O->>C: 返回结果
alt 支付失败
O->>I: 释放库存(补偿)
end
4.3 核心代码实现
/**
* Saga事务管理器(使用Seata框架)
*/
@Service
@Slf4j
public class OrderSagaService {
@Autowired
private InventoryFeignClient inventoryClient;
@Autowired
private PaymentFeignClient paymentClient;
@Transactional
public void createOrder(OrderCreateDTO dto) {
// 1. 创建本地订单(状态:待支付)
Order order = createPendingOrder(dto);
try {
// 2. 调用库存服务(Saga参与者)
inventoryClient.deductStock(
new DeductRequest(order.getOrderId(), dto.getSkuItems()));
// 3. 调用支付服务(Saga参与者)
paymentClient.createPayment(
new PaymentRequest(order.getOrderId(), order.getTotalAmount()));
// 4. 更新订单状态为已支付
order.paySuccess();
orderRepository.update(order);
} catch (Exception ex) {
// 触发Saga补偿流程
log.error("订单创建失败,触发补偿", ex);
handleCreateOrderFailure(order, ex);
throw ex;
}
}
/**
* 补偿操作(需要幂等)
*/
@Compensable(compensationMethod = "compensateOrder")
private void handleCreateOrderFailure(Order order, Exception ex) {
// 1. 释放库存
inventoryClient.restoreStock(order.getOrderId());
// 2. 取消支付(如果已发起)
paymentClient.cancelPayment(order.getOrderId());
// 3. 标记订单失败
order.cancel("系统异常: " + ex.getMessage());
orderRepository.update(order);
}
/**
* 补偿方法(幂等设计)
*/
public void compensateOrder(Order order, Exception ex) {
// 通过状态判断避免重复补偿
if (order.getStatus() != OrderStatus.CANCELLED) {
handleCreateOrderFailure(order, ex);
}
}
}
五、性能优化实践
5.1 分片路由优化
/**
* 热点用户订单查询优化
* 方案:用户分片路由缓存
*/
@Aspect
@Component
public class ShardingRouteCacheAspect {
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
private static final String ROUTE_KEY = "user_route:%d";
@Around("@annotation(org.apache.shardingsphere.api.hint.Hint)")
public Object cacheRoute(ProceedingJoinPoint joinPoint) throws Throwable {
Long userId = getUserIdFromArgs(joinPoint.getArgs());
if (userId == null) {
return joinPoint.proceed();
}
// 1. 查询缓存
String cacheKey = String.format(ROUTE_KEY, userId);
Integer shardId = redisTemplate.opsForValue().get(cacheKey);
if (shardId == null) {
// 2. 计算分片ID(避免全表扫描)
shardId = calculateUserShard(userId);
redisTemplate.opsForValue().set(cacheKey, shardId, 1, TimeUnit.HOURS);
}
// 3. 设置分片Hint强制路由
try (HintManager hintManager = HintManager.getInstance()) {
hintManager.setDatabaseShardingValue(shardId);
return joinPoint.proceed();
}
}
private int calculateUserShard(Long userId) {
// 分片计算逻辑(与分片算法保持一致)
return Math.abs(userId.hashCode()) % 16;
}
}
5.2 冷热数据分离
-- 归档策略示例(每月执行)
CREATE EVENT archive_orders
ON SCHEDULE EVERY 1 MONTH
DO
BEGIN
-- 1. 创建归档表(按年月)
SET @archive_table = CONCAT('t_order_archive_', DATE_FORMAT(NOW(), '%Y%m'));
SET @create_sql = CONCAT('CREATE TABLE IF NOT EXISTS ', @archive_table, ' LIKE t_order');
PREPARE stmt FROM @create_sql; EXECUTE stmt;
-- 2. 迁移数据(6个月前)
SET @move_sql = CONCAT(
'INSERT INTO ', @archive_table,
' SELECT * FROM t_order WHERE create_time < DATE_SUB(NOW(), INTERVAL 6 MONTH)'
);
PREPARE stmt FROM @move_sql; EXECUTE stmt;
-- 3. 删除原表数据
DELETE FROM t_order WHERE create_time < DATE_SUB(NOW(), INTERVAL 6 MONTH);
END
六、避坑指南
6.1 常见问题及解决方案
问题类型 | 现象 | 解决方案 |
---|---|---|
分片键选择不当 | 数据倾斜(70%数据在1个分片) | 增加分片基数(复合分片键) |
分布式事务超时 | 库存释放失败 | 增加重试机制+人工补偿台 |
跨分片查询性能差 | 分页查询超时 | 改用ES做全局搜索 |
扩容困难 | 增加分片需迁移数据 | 初始设计预留分片(32库) |
6.2 必须实现的监控项
graph TD
A[监控大盘] --> B[分片负载]
A --> C[慢查询TOP10]
A --> D[分布式事务成功率]
A --> E[热点用户检测]
A --> F[归档任务状态]
七、总结与展望
分库分表本质是业务与技术的平衡艺术,经过多个项目的实践验证,我总结了以下核心经验:
- 分片设计三原则:
- 数据分布均匀性 > 查询便捷性
- 业务可扩展性 > 短期性能
- 简单可运维 > 技术先进性
- 演进路线建议:
graph LR
A[单库] --> B[读写分离]
B --> C[垂直分库]
C --> D[水平分表]
D --> E[单元化部署]
- 未来优化方向:
- 基于TiDB的HTAP架构
- 使用Apache ShardingSphere-Proxy
- 智能分片路由(AI预测热点)
最后的话:
处理10亿级订单如同指挥一场交响乐——每个分片都是独立乐器,既要保证局部精准,又要实现全局和谐。
好的分库分表方案不是技术参数的堆砌,而是对业务深刻理解后的架构表达。
来源:juejin.cn/post/7519688814395719714
如何将canvas动画导成一个视频?
引言
某一天我突然有个想法,我想用canvas做一个音频可视化的音谱,然后将这个音频导出成视频。
使用canvas实现音频可视化,使用ffmpeg导出视频与音频,看起来方案是可行的,技术也是可行的,说干就干,先写一个demo。
这里我使用vue来搭建项目
- 创建项目
vue create demo
- 安装ffmpeg插件
npm @ffmpeg/ffmpeg @ffmpeg/core
- 组件videoPlayer.vue
这里有个点需要注意:引用@ffmpeg/ffmpeg可能会报错
需要将node_modules中@ffmpeg文件下面的 - ffmpeg-core.js
- ffmpeg-core.wasm
- ffmpeg-core.worker.js
这三个文件复制到public文件下面 - 并且需要在vue。config.js中进行如下配置
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
devServer:{
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
}
}
})
准备好这些后,下面是实现代码
<template>
<div class="wrap" v-loading="loading" element-loading-text="正在下载视频。。。">
<div>
<input type="file" @change="handleFileUpload" accept="audio/*" />
<button @click="playAudio">播放</button>
<button @click="pauseAudio">暂停</button>
</div>
<div class="canvas-wrap">
<canvas ref="canvas" id="canvas"></canvas>
</div>
</div>
</template>
<script>
import RainDrop from './rain'
import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';
export default {
name: 'canvasVideo',
data() {
return {
frames: [],
recording: false,
ffmpeg: null,
x: 0,
loading: false,
canvasCtx: null,
audioContext: null,
analyser: null,
bufferLength: null,
dataArray: null,
audioFile: null,
audioElement: null,
audioSource: null,
// 谱频个数
barCount: 64,
// 宽度
barWidth: 10,
marginLeft: 10,
player: false,
rainCount: 200,
rainDrops: [],
pausePng: null,
offscreenCanvas: null
};
},
mounted() {
this.ffmpeg = createFFmpeg({ log: true });
this.initFFmpeg();
},
methods: {
async initFFmpeg() {
await this.ffmpeg.load();
this.initCanvas()
},
startRecording() {
this.recording = true;
this.captureFrames();
},
stopRecording() {
this.recording = false;
this.exportVideo();
},
async captureFrames() {
const canvas = this.canvasCtx.canvas;
const imageData = canvas.toDataURL('image/png');
this.frames.push(imageData);
},
async exportVideo() {
this.loading = true
this.recording = false
const { ffmpeg } = this;
console.log('frames', this.frames)
try {
for (let i = 0; i < this.frames.length; i++) {
const frame = this.frames[i];
const frameData = await fetchFile(frame);
ffmpeg.FS('writeFile', `frame${i}.png`, frameData);
}
// 将音频文件写入 FFmpeg 文件系统
ffmpeg.FS('writeFile', 'audio.mp3', await fetchFile(this.audioFile));
// 使用 FFmpeg 将帧编码为视频
await ffmpeg.run(
'-framerate', '30', // 帧率 可以收费
'-i', 'frame%d.png', // 输入文件名格式
'-i', 'audio.mp3', // 输入音频
'-c:v', 'libx264', // 视频编码器
'-c:a', 'aac', // 音频编码器
'-pix_fmt', 'yuv420p', // 像素格式
'-vsync', 'vfr', // 同步视频和音频
'-shortest', // 使视频长度与音频一致
'output.mp4' // 输出文件名
);
const files = ffmpeg.FS('readdir', '/');
console.log('文件系统中的文件:', files);
const data = ffmpeg.FS('readFile', 'output.mp4');
const url = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
const a = document.createElement('a');
a.href = url;
a.download = 'output.mp4';
a.click();
} catch (e) {
console.log('eeee', e)
}
this.loading = false
},
initCanvas() {
const dom = document.getElementById('canvas');
this.canvasCtx = dom.getContext('2d');
const p = document.querySelector('.canvas-wrap')
console.log('p', p.offsetWidth)
this.canvasCtx.canvas.width = p.offsetWidth;
this.canvasCtx.canvas.height = p.offsetHeight;
console.log('canvasCtx', this.canvasCtx)
this.initAudioContext()
this.createRainDrops()
},
handleFileUpload(event) {
const file = event.target.files[0];
if (file) {
this.audioFile = file
const fileURL = URL.createObjectURL(file);
this.loadAudio(fileURL);
}
},
loadAudio(url) {
this.audioElement = new Audio(url);
this.audioElement.addEventListener('error', (e) => {
console.error('音频加载失败:', e);
});
this.audioSource = this.audioContext.createMediaElementSource(this.audioElement);
this.audioSource.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
},
playAudio() {
if (this.audioContext.state === 'suspended') {
this.audioContext.resume().then(() => {
console.log('AudioContext 已恢复');
this.audioElement.play();
this.player = true
this.draw();
});
} else {
this.audioElement.play().then(() => {
this.player = true
this.draw();
}).catch((error) => {
console.error('播放失败:', error);
});
}
},
pauseAudio() {
if (this.audioElement) {
this.audioElement.pause();
this.player = false
this.stopRecording()
}
},
initAudioContext() {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256;
this.dataArray = new Uint8Array(this.barCount);
},
bar() {
let barHeight = 20;
const allBarWidth = this.barCount * this.barWidth + this.marginLeft * (this.barCount - 1)
const left = (this.canvasCtx.canvas.width - allBarWidth) / 2
let x = left
for (let i = 0; i < this.barCount; i++) {
barHeight = this.player ? this.dataArray[i] : 0
// console.log('barHeight', barHeight)
// 创建线性渐变
const gradient = this.canvasCtx.createLinearGradient(0, 0, this.canvasCtx.canvas.width, 0); // 从左到右渐变
gradient.addColorStop(0.2, '#fff'); // 起始颜色
gradient.addColorStop(0.5, '#ff5555');
gradient.addColorStop(0.8, '#fff'); // 结束颜色
// 设置阴影属性
this.canvasCtx.shadowColor = i <= 10 ? '#fff' : i > 54 ? '#fff' : '#ff5555';
this.canvasCtx.shadowBlur = 5;
this.canvasCtx.fillStyle = gradient;
this.canvasCtx.fillRect(x, this.canvasCtx.canvas.height - barHeight / 2 - 100, this.barWidth, barHeight / 2);
this.canvasCtx.shadowColor = i <= 10 ? '#fff' : i > 54 ? '#fff' : '#ff5555';
this.canvasCtx.shadowBlur = 5;
this.canvasCtx.beginPath();
this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - barHeight / 2 - 99, 5, 0, Math.PI, true)
// this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - barHeight / 2, 5, 0, Math.PI, false)
this.canvasCtx.closePath();
this.canvasCtx.fill()
this.canvasCtx.shadowColor = i <= 10 ? '#fff' : i > 54 ? '#fff' : '#ff5555';
this.canvasCtx.shadowBlur = 5;
this.canvasCtx.beginPath();
// this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - barHeight / 2 - 100, 5, 0, Math.PI, true)
this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - 100, 5, 0, Math.PI, false)
this.canvasCtx.closePath();
this.canvasCtx.fill()
x += this.barWidth + this.marginLeft;
}
},
draw() {
if (this.player) requestAnimationFrame(this.draw);
this.startRecording()
// 获取频谱数据
this.analyser.getByteFrequencyData(this.dataArray);
this.canvasCtx.fillStyle = 'rgb(0, 0, 0)';
this.canvasCtx.fillRect(0, 0, this.canvasCtx.canvas.width, this.canvasCtx.canvas.height); // 清除画布
this.bar()
this.rainDrops.forEach((drop) => {
drop.update();
drop.draw(this.canvasCtx);
});
},
// 创建雨滴对象
createRainDrops() {
for (let i = 0; i < this.rainCount; i++) {
this.rainDrops.push(new RainDrop(this.canvasCtx.canvas.width, this.canvasCtx.canvas.height, this.canvasCtx));
}
},
}
};
</script>
当选择好音频文件点击播放时如下图
点击暂停则可对已经播放过的音频时长进行视频录制下载
如果有什么其他问题欢迎在评论区交流
来源:juejin.cn/post/7521685642431053863
MCP简介:从浏览器截图的自动化说起
在当今 AI 飞速发展的时代,大型语言模型 (LLM) 如 Claude
、ChatGPT
等已经在代码生成、内容创作等方面展现出惊人的能力。然而,这些强大的模型存在一个明显的局限性——它们通常与外部系统和工具隔离,无法直接访问或操作用户环境中的资源和工具。
而 Model Context Protocol
(MCP) 的出现,正是为了解决这一问题。
什么是MCP?
Model Context Protocol
(MCP) 是由 Anthropic
公司推出的一个开放协议,它标准化了应用程序如何向大型语言模型 (LLM) 提供上下文和工具的方式。我们可以将 MCP 理解为 AI 应用的"USB-C 接口"——就像 USB-C 为各种设备提供了标准化的连接方式,MCP 为 AI 模型提供了与不同数据源和工具连接的标准化方式。
简单来说,MCP可以做到以下事情:
- 读取和写入本地文件
- 查询数据库
- 执行命令行操作
- 控制浏览器
- 与第三方 API 交互
这极大地扩展了 AI 助手的能力边界,使其不再仅限于对话框内的文本交互。
MCP的架构
以上图片来源于 MCP 官方文档
MCP 的架构相对简单,主要包含两个核心组件:
- MCP 服务器 (Server):提供工具和资源的服务端,可以使用任何编程语言实现,只要能够通过
stdout
/stdin
或 HTTP 通信。 - MCP 客户端 (Client):使用 MCP 服务器提供的工具和资源的 AI 应用,如
Claude Desktop
、Cursor
编辑器等。
MCP 服务器向客户端提供两种主要能力:
- 工具 (Tools):可执行的函数,允许 AI 执行特定操作
- 资源 (Resources):提供给 AI 的上下文信息,如文件内容、数据库结构等
浏览器自动化:MCP的实际应用
为了更直观地理解 MCP 的强大之处,让我们看一个案例:使用 Playwright MCP
服务器进行浏览器自动化。
Playwright
是一个由 Microsoft
开发的浏览器自动化工具,可以控制 Chrome
、Firefox
、Safari
等主流浏览器。通过 Playwright MCP
服务器,我们可以让 AI 助手直接操作浏览器,执行各种任务。
先讲讲使用场景
- 博客写作。当我写博客时,我需要打开浏览器,打开目标网站,然后截图,并保存到本地特定的目录中,并在
markdown
中引用图片地址。 - 端到端测试。当我需要测试网站时,我需要打开浏览器,打开目标网站,然后进行一些操作,比如填写表单、点击按钮等。就算有
Playwright
的测试框架,但仍需要人工介入,比如自定义data-cy
,浏览器操作一遍保存 playwright 的测试代码并扔给 cursor 生成测试。
场景一:博客写作的图片自动化
作为技术博主,我经常需要在文章中引用网站截图来说明问题或展示效果。在传统流程中,这个过程相当繁琐:
- 打开浏览器访问目标网站
- 使用截图工具截取所需区域
- 保存截图到特定目录
- 处理图片(可能需要裁剪、压缩等)
- 在
Markdown
文件中手动添加图片链接 - 确认图片正确显示
这个过程不仅耗时,而且容易出错。使用 Playwright MCP
,整个流程可以简化为:
请访问 https://tailwindcss.com,截取首页顶部导航栏区域,保存到 @public/images/ 下,并生成 markdown 图片引用代码
Cursor 通过 MCP 协议会:
- 自动打开网站
- 精确定位并截取导航栏元素
- 保存到指定目录
- 自动生成符合博客格式的图片引用代码
这不仅节省了时间,还保证了图片引用的一致性和准确性。对于需要多张截图的长篇技术文章,效率提升尤为显著。
更进阶的应用还包括:
- 自动为截图添加高亮或注释
- 对比同一网站在不同设备上的显示效果
- 跟踪网站的 UI 变化并自动更新文章中的截图
场景二:端到端测试的自动化
端到端测试是前端开发中的重要环节,但传统方式存在诸多痛点:
- 繁琐的测试编写:即使使用
Cypress
等工具,编写测试脚本仍需要手动规划测试路径、定位元素、设计断言等 - 元素选择器维护:需要在代码中添加特定属性(如
data-cy
)用于测试,且这些选择器需要随着 UI 变化而维护 - 测试代码与产品代码分离:测试逻辑往往与开发逻辑分离,导致测试更新滞后于功能更新
- 复杂交互流程难以模拟:多步骤的用户操作(如表单填写、多页面导航)需要精确编排
即便使用 Chrome 的 DevTools
的 Recorder
功能,也只能生成 Playwright
的测试代码,并且需要人工介入,比如自定义 data-cy
,浏览器操作一遍保存 playwright 的测试代码并扔给 cursor 生成测试。
或者通过 cursor 与 recorder 提效后的环节:
- 让 cursor 在关键位置插入
data-cy
属性 - 使用
Chrome DevTools
的Recorder
功能生成测试代码 - 将测试代码扔给 cursor 生成测试
而通过 Playwright MCP
,开发者可以自然语言描述测试场景,让 Cursor 直接生成并执行测试:
用户:测试我的登录流程:访问 http://localhost:3000/login,使用测试账号 test@example.com 和密码 Test123!,验证登录成功后页面应跳转到仪表盘并显示欢迎信息
Cursor 会:
- 在必要位置插入
data-cy
属性 - 自动访问登录页面
- 填写表单并提交
- 验证跳转和欢迎信息
- 报告测试结果
- 生成可复用的
Playwright
测试代码
这种方式不仅降低了编写测试的门槛,还能根据测试结果智能调整测试策略。例如,如果登录按钮位置变化,Cursor 可以通过视觉识别重新定位元素,而不是简单地报告选择器失效。
对于快速迭代的项目尤其有价值:
- 在代码修改后立即验证功能完整性
- 快速生成回归测试套件
- 模拟复杂的用户行为路径
- 根据用户反馈自动创建针对性测试
这两个场景说明,MCP 不仅仅是连接 AI 与工具的技术桥梁,更是能够实质性改变开发者工作流程的革新力量。通过消除重复性工作,开发者可以将更多精力集中在创意和解决问题上。
示例:使用executeautomation/mcp-playwright
executeautomation/mcp-playwright
是一个基于 Playwright
的 MCP 服务器实现,它提供了一系列工具,使得 AI 助手能够:
- 打开网页
- 截取网页或元素截图
- 填写表单
- 点击按钮
- 提取网页内容
- 执行
JavaScript
代码 - 等待网页加载或元素出现
下面以一个简单的场景为例:让 AI 助手打开一个网站并截图。
传统方式下,这个任务可能需要你:
- 安装
Playwright
- 编写自动化脚本
- 配置环境
- 运行脚本
- 处理截图结果
而使用 MCP,整个过程可以简化为与 AI 助手的对话:
用户:请打开 Google 首页并截图
AI 助手:好的,我将为您打开 Google 首页并截图。
[AI 助手通过 MCP 控制浏览器,打开 google.com 并截图]
AI 助手:已成功截图,这是 Google 首页的截图。[显示截图]
整个过程中,用户不需要编写任何代码,AI 助手通过 MCP 服务器直接控制浏览器完成任务。
Playwright MCP 服务器的安装与配置
如果你想尝试使用 Playwright MCP
服务器,可以按照以下步骤进行设置:
- 使用
npm
安装Playwright MCP
服务器:
npm install -g @executeautomation/playwright-mcp-server
- 配置
Claude Desktop
客户端(以 MacOS 为例):
编辑配置文件~/Library/Application\ Support/Claude/claude_desktop_config.json
,添加以下内容:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@executeautomation/playwright-mcp-server"]
}
}
}
- 重启
Claude
客户端,你会看到一个新的 "Attach MCP" 按钮。 - 点击该按钮,选择
Playwright MCP
服务器,现在你的 AI 助手就可以控制浏览器了!
在 Cursor 中使用 Playwright MCP
Cursor
是一款集成了 AI 能力的代码编辑器,它也支持 MCP 协议。我们可以在 Cursor
中配置 Playwright MCP
服务器,使 AI 助手能够在开发过程中直接操作浏览器。
配置步骤
- 首先确保已安装
Playwright MCP
服务器:
npm install -g @executeautomation/playwright-mcp-server
- 在
Cursor
中配置 MCP 服务器,有两种方式:
方式一:通过配置文件(推荐)
编辑
~/.cursor/mcp.json
文件(如果不存在则创建),添加以下内容:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@executeautomation/playwright-mcp-server"]
}
}
}
方式二:通过项目配置
在项目根目录下创建
.cursor/mcp.json
文件,内容同上。这样配置的 MCP 服务器只在当前项目中可用。 - 重启
Cursor
编辑器,使配置生效。
使用场景示例
在 Cursor
中使用 Playwright MCP
可以大大提升前端开发和测试效率。以下是一些常见的使用场景:
- 快速页面测试:
在开发 Web 页面时,可以让 AI 助手直接打开页面,检查渲染效果,无需手动切换到浏览器。
用户:请打开我当前开发的页面 http://localhost:3000,检查响应式布局在移动设备上的显示效果
Cursor:[通过 Playwright MCP 打开页面并进行移动设备模拟,然后截图展示结果]
- 自动化截图对比:
在进行 UI 改动时,可以让 AI 助手截取改动前后的页面对比图。
用户:我刚修改了导航栏的样式,请打开 http://localhost:3000,截图并与 production 环境 myapp.com 的页面进行对比
Cursor:[使用 Playwright MCP 分别截取两个环境的页面,并进行对比分析]
- 交互测试:
让 AI 助手模拟用户交互,验证功能是否正常工作。
用户:请测试我的登录表单,打开 http://localhost:3000/login,使用测试账号填写表单并提交,检查是否成功登录
Cursor:[使用 Playwright MCP 打开页面,填写表单并提交,验证登录流程]
- 开发过程中的实时调试:
在编码过程中,可以让 AI 助手实时检查页面变化。
用户:我刚刚修改了 Button 组件的样式,请打开组件预览页面检查不同状态下的按钮外观
Cursor:[打开页面,截取不同状态的按钮截图,并分析样式是否符合预期]
通过这些场景,我们可以看到,Playwright MCP
在 Cursor
中的应用不仅简化了前端开发工作流,还提供了更直观的开发体验,让 AI 助手成为开发过程中的得力助手。
MCP 的优势与局限性
优势
- 扩展 AI 能力:让 AI 助手能够与外部系统交互,大大扩展其应用场景
- 标准化接口:提供统一的协议,降低 AI 工具集成的复杂度
- 安全可控:用户可以审核 AI 助手的操作请求,确保安全
- 灵活扩展:可以根据需要开发自定义 MCP 服务器
局限性
- 新兴技术:MCP 仍处于发展早期,协议可能会变化
- 远程开发限制:MCP 服务器需要在本地机器上运行,远程开发环境可能存在问题
- 资源支持:部分 MCP 客户端如
Cursor
尚未支持resources
/prompts
功能
Cursor 的 MCP 支持限制:
未来展望
MCP 作为一种连接 AI 与外部系统的标准化协议,有着广阔的应用前景:
- 智能化开发工作流:AI 助手可以更深入地参与到开发流程中,自动化执行测试、部署等任务
- 数据分析与可视化:AI 助手可以直接访问数据库,生成分析报告和可视化结果
- 跨平台自动化:统一的协议使 AI 助手能够操作不同平台和工具
- 个性化智能助手:用户可以配置自己的 MCP 服务器,创建专属于自己工作流的 AI 助手
结语
Model Context Protocol
(MCP) 正在打破 AI 助手与外部世界之间的壁垒,使 AI 能够更加深入地融入我们的工作流程。从浏览器自动化到代码编辑器集成,MCP 展示了 AI 与传统工具结合的强大潜力。
以前可以说,Cursor
虽然代码敲的好,但它不能直接操作浏览器,不能直接操作数据库,不能直接操作文件系统,开发这个流程还是需要我频繁接手的。
现在来说,需要我们接手的次数会越来越少。
最后再推荐两个 MCP 相关的资源:
参考资料
来源:juejin.cn/post/7481861001189621800
理解 devDependencies:它们真的不会被打包进生产代码吗?
在前端开发中,很多开发者都有一个常见误解:package.json
中的 devDependencies
是开发时依赖,因此不会被打包到最终的生产环境代码中。这个理解在一定条件下成立,但在真实项目中,打包工具(如 Vite、Webpack 等)并不会根据 devDependencies
或 dependencies
的位置来决定是否将依赖打包到最终的 bundle 中,而是完全俗义于代码中是否引用了这些模块。
本文将通过一个实际例子来说明这个问题,并提出一些实践建议来避免误用。
一、dependencies
vs devDependencies
回顾
在 package.json
中,我们通常会看到两个依赖字段:
{
"dependencies": {
"lodash": "^4.17.21"
},
"devDependencies": {
"vite": "^5.0.0"
}
}
dependencies
:运行时依赖,通常用于项目在生产环境中运行所需的库。devDependencies
:开发时依赖,通常用于构建、测试、打包等过程,比如 Babel、ESLint、Vite 等。
很多人认为把某个库放到 devDependencies
中就意味着它不会被打包进最终代码,但这只是约定俗成,并非构建工具的实际行为。
二、一个实际例子:lodash
被错误地放入 devDependencies
我们以一个使用 Vite 构建的库包为例:
目录结构:
my-lib/
├── src/
│ └── index.ts
├── package.json
├── vite.config.ts
└── tsconfig.json
src/index.ts
import _ from 'lodash';
export function capitalizeName(name: string) {
return _.capitalize(name);
}
错误的 package.json
{
"name": "my-lib",
"version": "1.0.0",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"scripts": {
"build": "vite build"
},
"devDependencies": {
"vite": "^5.0.0",
"lodash": "^4.17.21",
"typescript": "^5.4.0"
}
}
注意:lodash
被放到了 devDependencies
中,而不是 dependencies
中。
构建后结果:
执行 npm run build
后,你会发现 lodash
的代码被打包进了最终输出的 bundle 中,尽管它被标记为 devDependencies
。
dist/
├── index.js ← 包含 lodash 的代码
├── index.mjs
└── index.d.ts
三、为什么会发生这种情况?
构建工具(如 Vite、Webpack)在处理打包时,并不会关心某个依赖是 dependencies
还是 devDependencies
。
它只会扫描你的代码:
- 如果你
import
了某个模块(如lodash
),构建工具会把它包含进 bundle 中,除非你通过external
配置显式告诉它不要打包进来。 - 你放在
devDependencies
中只是告诉 npm install:这个依赖只在开发阶段需要,npm install --production
时不会安装它。
换句话说,打包行为取决于代码,而不是依赖声明。
四、修复方式:将运行时依赖移到 dependencies
为了正确构建一个可以发布的库包,应该:
{
"dependencies": {
"lodash": "^4.17.21"
},
"devDependencies": {
"vite": "^5.0.0",
"typescript": "^5.4.0"
}
}
这样使用你库的开发者才能在安装你的包时自动获取 lodash
。
五、如何防止此类问题?
1. 使用 peerDependencies
(推荐给库开发者)
如果你希望使用者自带 lodash
,而不是你来打包它,可以这样配置:
{
"peerDependencies": {
"lodash": "^4.17.21"
}
}
同时在 Vite 配置中加上:
export default defineConfig({
build: {
lib: {
entry: 'src/index.ts',
name: 'MyLib'
},
rollupOptions: {
external: ['lodash'], // 不打包 lodash
}
}
})
这样打包出来的 bundle 中就不会再包含 lodash 的代码。
2. 使用构建工具的 external 配置
像上面这样将 lodash
标为 external 可以避免误打包。
3. 静态分析工具检测
使用像 depcheck 或 eslint-plugin-import 等工具,可以帮你发现未声明或声明错误的依赖。
六、总结
依赖位置 | 作用说明 |
---|---|
dependencies | 生产环境运行时必须使用的库 |
devDependencies | 开发、构建过程所需的工具库 |
peerDependencies | 你的库需要,但由使用者提供的依赖(库开发推荐) |
构建工具不会参考 package.json
中依赖的位置来决定是否打包,而是基于代码的实际引用。作为库作者,你应该确保:
- 所有运行时依赖都放在
dependencies
或peerDependencies
; - 构建工具正确配置 external,避免不必要地打包外部依赖;
- 使用工具检查依赖定义的一致性。
来源:juejin.cn/post/7530180739729555491
使用three.js搭建3d隧道监测-2
加载基础线条与地面效果
在我们的隧道监控系统中,地面网格和方向指示器是重要的视觉元素,它们帮助用户理解空间关系和导航方向。
1. 网格地面的创建与优化
javascript
// 初始化场景中的地面
const addGround = () => {
const size = 40000; // 网格大小
const divisions = 100; // 分割数(越高越密集)
// 主网格线颜色(亮蓝色)
const color1 = 0x6E7DB9; // 蓝色
// 次网格线颜色(深蓝色)
const color2 = 0x282C3C; // 深蓝色
const gridHelper = new THREE.GridHelper(size, divisions, color1, color2);
// 调整网格线的透明度和材质
gridHelper.material.opacity = 1;
gridHelper.material.transparent = true;
gridHelper.material.depthWrite = false; // 防止网格阻挡其他物体的渲染
// 设置材质的混合模式以实现发光效果
gridHelper.material.blending = THREE.AdditiveBlending;
gridHelper.material.vertexColors = false;
// 增强线条对比度
gridHelper.material.color.setHex(color1);
gridHelper.material.linewidth = 100;
// 旋转网格,使其位于水平面
gridHelper.rotation.x = Math.PI;
sceneRef.current.add(gridHelper);
};
知识点: Three.js 中的网格地面实现技术
- GridHelper:Three.js 提供的辅助对象,用于创建二维网格,常用于表示地面或参考平面
- 材质优化:通过设置
depthWrite = false
避免渲染排序问题,防止网格阻挡其他物体
- 混合模式:
AdditiveBlending
混合模式使重叠线条颜色叠加,产生发光效果
- 性能考量:网格分割数(divisions)会影响性能,需要在视觉效果和性能间平衡
- 旋转技巧:通过
rotation.x = Math.PI
将默认垂直的网格旋转到水平面
这种科幻风格的网格地面在虚拟现实、数据可视化和游戏中非常常见,能够提供空间参考而不显得过于突兀。
2. 动态方向指示器的实现
javascript
const createPolygonRoadIndicators = (dis) => {
const routeIndicationGeometry = new THREE.PlaneGeometry(3024, 4000); // 创建平面几何体
// 创建文本纹理的辅助函数
const getTextCanvas = (text) => {
const width = 200;
const height = 300;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.font = "bold 40px Arial"; // 设置字体大小和样式
ctx.fillStyle = '#949292'; // 设置字体颜色
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, width / 2, height / 2);
return canvas;
};
// 创建方向1文本平面
const textMap = new THREE.CanvasTexture(getTextCanvas('方向1'));
const textMaterial = new THREE.MeshBasicMaterial({
map: textMap,
transparent: true,
depthTest: false
});
const plane = new THREE.Mesh(routeIndicationGeometry, textMaterial);
plane.castShadow = false;
plane.position.set(1024, 0, 1400);
plane.rotateX(-Math.PI / 2);
// 创建方向2文本平面
const textMap1 = new THREE.CanvasTexture(getTextCanvas('方向2'));
const textMaterial1 = new THREE.MeshBasicMaterial({
map: textMap1,
transparent: true,
depthTest: false
});
const plane1 = new THREE.Mesh(routeIndicationGeometry, textMaterial1);
plane1.castShadow = false;
plane1.position.set(1024, 0, -1400);
plane1.rotateX(-Math.PI / 2);
// 创建箭头指示器
const loader = new THREE.TextureLoader();
const texture = loader.load('/image/arrow1.png', (t) => {
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(1, 1);
});
const geometryRoute = new THREE.PlaneGeometry(1024, 1200);
const materialRoute = new THREE.MeshStandardMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide, // 确保可以从两个面看见
});
const plane2 = new THREE.Mesh(geometryRoute, materialRoute);
plane2.receiveShadow = false;
plane2.position.set(1000, 0, 0);
plane2.rotateX(dis==="left"?-Math.PI / 2:Math.PI / 2);
// 将所有元素组合成一个组
const group = new THREE.Gr0up();
group.add(plane2, plane, plane1);
group.scale.set(0.4, 0.4, 0.4);
group.position.set(dis==="left"?500:500-4000, 0, 0);
return group;
};
知识点: Three.js 中的动态文本与指示器实现技术
- Canvas 纹理:使用 HTML Canvas 动态生成文本,然后转换为 Three.js 纹理,这是在 3D 场景中显示文本的高效方法
- CanvasTexture:Three.js 提供的特殊纹理类型,可以直接从 Canvas 元素创建纹理,支持动态更新
- 透明度处理:通过设置
transparent: true
和适当的depthTest
设置解决透明纹理的渲染问题
- 几何体组织:使用
THREE.Gr0up
将多个相关的 3D 对象组织在一起,便于统一变换和管理
- 条件旋转:根据参数
dis
动态决定箭头的朝向,实现可配置的方向指示
- 纹理重复:通过
RepeatWrapping
和repeat
设置可以控制纹理的重复方式,适用于创建连续的纹理效果
这种动态方向指示器在导航系统、虚拟导览和交互式地图中非常有用,可以为用户提供直观的方向引导。
3.地面方向指示器实现
在隧道监控系统中,方向指示是帮助用户理解空间方向和导航的关键元素。我们实现了一套包含文本标签和箭头的地面方向指示系统。
javascript
import * as THREE from "three";
const createPolygonRoadIndicators = (dis) => {
const routeIndicationGeometry = new THREE.PlaneGeometry(3024, 4000); // 创建平面几何体
const getTextCanvas = (text) => {
const width = 200;
const height = 300;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.font = "bold 40px Arial"; // 设置字体大小和样式
ctx.fillStyle = '#949292'; // 设置字体颜色
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, width / 2, height / 2);
return canvas;
};
const textMap = new THREE.CanvasTexture(getTextCanvas('方向1'));
const textMaterial = new THREE.MeshBasicMaterial({ map: textMap, transparent: true, depthTest: false }); // 创建材质,depthTest解决黑色块问题
const plane = new THREE.Mesh(routeIndicationGeometry, textMaterial);
plane.castShadow = false; // 不投影阴影"
plane.position.set(1024, 0, 1400);
plane.rotateX(-Math.PI / 2);
const textMap1 = new THREE.CanvasTexture(getTextCanvas('方向2'));
const textMaterial1 = new THREE.MeshBasicMaterial({ map: textMap1, transparent: true, depthTest: false }); // 创建材质,depthTest解决黑色块问题
const plane1 = new THREE.Mesh(routeIndicationGeometry, textMaterial1);
plane1.castShadow = false; // 不投影阴影
plane1.position.set(1024, 0, -1400);
plane1.rotateX(-Math.PI / 2);
const loader = new THREE.TextureLoader();
const texture = loader.load('/image/arrow1.png', (t) => {
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(1, 1);
});
const geometryRoute = new THREE.PlaneGeometry(1024, 1200);
const materialRoute = new THREE.MeshStandardMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide, // 确保可以从两个面看见
});
const plane2 = new THREE.Mesh(geometryRoute, materialRoute);
plane2.receiveShadow = false; // 不接收阴影
plane2.position.set(1000, 0, 0);
plane2.rotateX(dis==="left"?-Math.PI / 2:Math.PI / 2);
const group = new THREE.Gr0up();
group.add(plane2, plane, plane1);
group.scale.set(0.4, 0.4, 0.4);
group.position.set(dis==="left"?500:500-4000, 0, 0);
return group;
};
export default createPolygonRoadIndicators;
知识点: Three.js 中的地面方向指示器实现技术
- 平面投影标记:使用
PlaneGeometry
创建平面,通过旋转使其平行于地面,形成"地面投影"效果
- 使用
rotateX(-Math.PI / 2)
将平面从垂直旋转到水平位置
- 动态文本生成:使用 Canvas API 动态生成文本纹理
getTextCanvas
函数创建一个临时 Canvas 并在其上绘制文本
- 使用
CanvasTexture
将 Canvas 转换为 Three.js 可用的纹理
- 这种方法比使用 3D 文本几何体更高效,特别是对于频繁变化的文本
- 纹理渲染优化:
transparent: true
启用透明度处理,使背景透明
depthTest: false
禁用深度测试,解决半透明纹理的渲染问题,防止出现"黑色块"
castShadow: false
和receiveShadow: false
避免不必要的阴影计算
- 方向性指示:使用箭头纹理创建明确的方向指示
- 通过
TextureLoader
加载外部箭头图像
- 根据
dis
参数动态调整箭头方向(rotateX(dis==="left"?-Math.PI / 2:Math.PI / 2)
)
side: THREE.DoubleSide
确保从任何角度都能看到箭头
- 组织与缩放:
- 使用
THREE.Gr0up
将相关元素(文本标签和箭头)组织在一起
- 通过
group.scale.set(0.4, 0.4, 0.4)
统一调整组内所有元素的大小
- 根据方向参数设置整个组的位置,实现左右两侧不同的指示效果
- 纹理重复设置:
RepeatWrapping
和repeat.set(1, 1)
控制纹理的重复方式
- 这为创建连续的纹理效果提供了基础,虽然本例中设为1(不重复)
这种地面方向指示系统在大型空间(如隧道、机场、展馆)的导航中特别有用,为用户提供直观的方向感,不会干扰主要视觉元素。
隧道指示牌制作
在隧道监控系统中,指示牌是引导用户和提供空间信息的重要元素。我们实现了一种复合结构的隧道指示牌,包含支柱、横梁和信息板。
javascript
import * as THREE from 'three';
import {TextGeometry} from "three/examples/jsm/geometries/TextGeometry";
/**
* 创建石头柱子(竖直 + 横向)
* @returns {THREE.Gr0up} - 返回包含柱子和横梁的组
*/
const createStonePillar = () => {
const pillarGr0up = new THREE.Gr0up();
// 创建六边形的竖直柱子
const pillarGeometry = new THREE.CylinderGeometry(6, 6, 340, 6); // 直径12, 高度340, 六边形柱体
const pillarMaterial = new THREE.MeshStandardMaterial({color: 0x808080}); // 石头颜色
const pillar = new THREE.Mesh(pillarGeometry, pillarMaterial);
pillar.position.set(0, 0, 0);
// 创建第一根横向长方体
const beam1Geometry = new THREE.BoxGeometry(100, 10, 0.1);
const beam1Material = new THREE.MeshStandardMaterial({color: 0x808080});
const beam1 = new THREE.Mesh(beam1Geometry, beam1Material);
beam1.position.set(-50, 150, 0);
// 创建第二根横向长方体
const beam2Geometry = new THREE.BoxGeometry(100, 10, 0.1);
const beam2Material = new THREE.MeshStandardMaterial({color: 0x808080});
const beam2 = new THREE.Mesh(beam2Geometry, beam2Material);
beam2.position.set(-50, 130, 0);
// 将柱子和横梁添加到组
pillarGr0up.add(pillar);
pillarGr0up.add(beam1);
pillarGr0up.add(beam2);
return pillarGr0up;
};
/**
* 创建一个用于绘制文本的 Canvas
* @param {string} text - 要绘制的文本
* @returns {HTMLCanvasElement} - 返回 Canvas 元素
*/
const getTextCanvas = (text) => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 设置 Canvas 尺寸
const fontSize = 32;
canvas.width = 512;
canvas.height = 128;
// 设置背景色
context.fillStyle = '#1E3E9A'; // 蓝底
context.fillRect(0, 0, canvas.width, canvas.height);
// 设置文本样式
context.font = `${fontSize}px Arial`;
context.fillStyle = '#ffffff'; // 白色文本
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(text, canvas.width / 2, canvas.height / 2);
return canvas;
};
/**
* 创建交通指示牌并添加到场景中
* @param {Object} sceneRef - React ref 对象,指向 Three.js 的场景
* @returns {Promise<THREE.Gr0up>} - 返回创建的指示牌组
*/
export default (sceneRef, png, dis) => {
const createSignBoard = async () => {
const signGr0up = new THREE.Gr0up();
const loader = new THREE.TextureLoader();
loader.load(png, texture => {
// 创建一个平面作为标志背景
const signGeometry = new THREE.PlaneGeometry(100, 50); // 宽100,高50
texture.encoding = THREE.sRGBEncoding // 设置纹理的颜色空间
texture.colorSpace = THREE.SRGBColorSpace;
const signMaterial = new THREE.MeshStandardMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide,
})
const sign = new THREE.Mesh(signGeometry, signMaterial);
sign.position.set(-60, 140, 0.3)
signGr0up.add(sign);
})
// 创建并添加石头柱子
const pillar = createStonePillar();
signGr0up.add(pillar);
if (dis == "left") {
signGr0up.position.set(370, 180, 3750); // 左侧位置
} else {
signGr0up.rotateY(Math.PI); // 旋转180度
signGr0up.position.set(-370 - 2000, 180, 3450 - 7200); // 右侧位置
}
signGr0up.add(pillar);
sceneRef.current.add(signGr0up);
return signGr0up; // 返回整个组
};
// 调用创建指示牌函数
return createSignBoard().then((signGr0up) => {
console.log('交通指示牌创建完成:', signGr0up);
return signGr0up;
});
};
知识点: Three.js 中的复合结构与指示牌实现技术
- 模块化设计:将指示牌分解为柱子、横梁和信息板三个主要组件,便于维护和复用
- 几何体组合:使用简单几何体(圆柱体、长方体、平面)组合构建复杂结构
CylinderGeometry
创建六边形柱体作为支撑
BoxGeometry
创建横向支撑梁
PlaneGeometry
创建平面显示信息
- 空间层次:使用
THREE.Gr0up
将相关元素组织在一起,便于整体变换和管理
- 纹理映射:使用
TextureLoader
加载外部图像作为指示牌内容
- 设置
colorSpace = THREE.SRGBColorSpace
确保颜色正确显示
- 使用
side: THREE.DoubleSide
使平面从两面都可见
- 条件定位:根据
dis
参数动态决定指示牌的位置和朝向
- 使用
rotateY(Math.PI)
旋转180度实现方向反转
- Canvas 动态文本:使用
getTextCanvas
函数创建动态文本纹理
- 可以方便地生成不同内容和样式的文本标识
- 异步处理:使用 Promise 处理纹理加载的异步过程,确保资源正确加载
- 返回 Promise 使调用者可以在指示牌创建完成后执行后续操作
这种组合式设计方法允许我们创建高度可定制的指示牌,适用于隧道、道路、建筑内部等多种场景,同时保持代码的可维护性和可扩展性。
多渲染器协同工作机制
在我们的项目中,实现了 WebGL 渲染器、CSS2D 渲染器和 CSS3D 渲染器的协同工作:
const initRenderer = () => {
// WebGL 渲染器
rendererRef.current = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
logarithmicDepthBuffer: true
});
rendererRef.current.setSize(window.innerWidth, window.innerHeight);
rendererRef.current.setPixelRatio(Math.min(window.devicePixelRatio, 2));
rendererRef.current.shadowMap.enabled = true;
rendererRef.current.shadowMap.type = THREE.PCFSoftShadowMap;
rendererRef.current.outputEncoding = THREE.sRGBEncoding;
rendererRef.current.toneMapping = THREE.ACESFilmicToneMapping;
containerRef.current.appendChild(rendererRef.current.domElement);
};
const initCSS2DScene = () => {
// CSS2D 渲染器
css2DRendererRef.current = new CSS2DRenderer();
css2DRendererRef.current.setSize(window.innerWidth, window.innerHeight);
css2DRendererRef.current.domElement.style.position = 'absolute';
css2DRendererRef.current.domElement.style.top = '0';
css2DRendererRef.current.domElement.style.pointerEvents = 'none';
containerRef.current.appendChild(css2DRendererRef.current.domElement);
};
const initCSS3DScene = () => {
// 初始化 CSS3DRenderer
css3DRendererRef.current = new CSS3DRenderer();
css3DRendererRef.current.setSize(sizes.width, sizes.height);
css3DRendererRef.current.domElement.style.position = 'absolute';
css3DRendererRef.current.domElement.style.top = '0px';
css3DRendererRef.current.domElement.style.pointerEvents = 'none'; // 确保CSS3D元素不阻碍鼠标事件
containerRef.current.appendChild(css3DRendererRef.current.domElement);
};
知识点: Three.js 支持多种渲染器同时工作,每种渲染器有不同的优势:
- WebGLRenderer:利用 GPU 加速渲染 3D 内容,性能最佳
- CSS2DRenderer:将 HTML 元素作为 2D 标签渲染在 3D 空间中,适合信息标签
- CSS3DRenderer:将 HTML 元素转换为 3D 对象,支持 3D 变换,适合复杂 UI
多渲染器协同可以充分发挥各自优势,实现复杂的混合现实效果。
后期处理管线设计
项目中实现了基于 EffectComposer 的后期处理管线:
const initPostProcessing = () => {
composerRef.current = new EffectComposer(rendererRef.current);
// 基础渲染通道
const renderPass = new RenderPass(sceneRef.current, cameraRef.current);
composerRef.current.addPass(renderPass);
// 环境光遮蔽通道
const ssaoPass = new SSAOPass(
sceneRef.current,
cameraRef.current,
window.innerWidth,
window.innerHeight
);
ssaoPass.kernelRadius = 16;
ssaoPass.minDistance = 0.005;
ssaoPass.maxDistance = 0.1;
composerRef.current.addPass(ssaoPass);
// 抗锯齿通道
const fxaaPass = new ShaderPass(FXAAShader);
const pixelRatio = rendererRef.current.getPixelRatio();
fxaaPass.material.uniforms['resolution'].value.x = 1 / (window.innerWidth * pixelRatio);
fxaaPass.material.uniforms['resolution'].value.y = 1 / (window.innerHeight * pixelRatio);
composerRef.current.addPass(fxaaPass);
};
知识点: 后期处理(Post-processing)是一种在 3D 场景渲染完成后对图像进行额外处理的技术:
- EffectComposer:Three.js 中的后期处理管理器,可以将多个处理效果组合在一起
- RenderPass:基础渲染通道,将场景渲染到目标缓冲区
- SSAOPass:屏幕空间环境光遮蔽,增强场景深度感和真实感
- FXAAShader:快速近似抗锯齿,提高图像质量
后期处理可以大幅提升画面质量,添加如景深、发光、色彩校正等专业效果。
多层次动画系统
项目实现了一个多层次的动画系统:
// 骨骼动画控制
const getActions = (animations, model) => {
const mixer = new THREE.AnimationMixer(model);
const mixerArray = [];
mixerArray.push(mixer);
const actions = {};
animations.forEach((clip) => {
const action = mixer.clipAction(clip);
actions[clip.name] = action;
});
return {actions, mixerArray};
};
// 动画播放控制
const playActiveAction = (actions, name, startTime = true, loopType = THREE.LoopOnce, clampWhenFinished = true) => {
const action = actions[name];
if (!action) return;
action.reset();
action.clampWhenFinished = clampWhenFinished;
action.setLoop(loopType);
if (startTime) {
action.play();
}
};
知识点: Three.js 提供了多种动画技术:
- AnimationMixer:用于播放和控制模型骨骼动画的核心类,相当于动画播放器
- AnimationClip:包含一组关键帧轨道的动画数据,如"走路"、"跑步"等动作
- AnimationAction:控制单个动画的播放状态,包括播放、暂停、循环设置等
- 动画混合:可以实现多个动画之间的平滑过渡,如从走路切换到跑步
合理使用这些技术可以创建流畅、自然的角色动画和场景变换。
第一人称视角控制算法
项目实现了一种先进的第一人称视角控制算法:
const animate1 = () => {
requestRef1.current = requestAnimationFrame(animate1);
if (isFirstPerson && robotRef.current) {
// 获取机器人的世界坐标
const robotWorldPosition = new THREE.Vector3();
robotRef.current.getWorldPosition(robotWorldPosition);
// 计算摄像机位置偏移
const offset = new THREE.Vector3(0, 140, 20);
// 获取机器人的前方方向向量
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(robotRef.current.quaternion);
const lookAheadDistance = 150;
// 计算摄像头位置和视线目标
const targetCameraPosition = robotWorldPosition.clone().add(offset);
const lookAtPosition = robotWorldPosition.clone().add(forward.multiplyScalar(lookAheadDistance));
// 使用 TWEEN 实现平滑过渡
cameraTweenRef.current = new TWEEN.Tween(cameraRef.current.position)
.to({
x: targetCameraPosition.x,
y: targetCameraPosition.y,
z: targetCameraPosition.z,
}, 1000)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(() => {
cameraRef.current.lookAt(lookAtPosition);
controlsRef.current.target.set(lookAtPosition.x, lookAtPosition.y, lookAtPosition.z);
})
.start();
}
};
知识点: 第一人称相机控制涉及多个关键技术:
- 世界坐标计算:通过
getWorldPosition()
获取对象在世界坐标系中的位置
- 四元数旋转:使用
applyQuaternion()
将向量按对象的旋转方向进行变换
- 向量运算:通过向量加法和标量乘法计算相机位置和视线方向
- 平滑过渡:使用 TWEEN.js 实现相机位置的平滑变化,避免生硬的跳变
- lookAt:让相机始终"看着"目标点,实现跟随效果
这种技术常用于第一人称游戏、虚拟导览等应用。
递归资源释放算法
项目实现了一种递归资源释放算法,用于彻底清理 Three.js 资源:
const disposeSceneObjects = (object) => {
if (!object) return;
// 递归清理子对象
while (object.children.length > 0) {
const child = object.children[0];
disposeSceneObjects(child);
object.remove(child);
}
// 清理几何体
if (object.geometry) {
object.geometry.dispose();
}
// 清理材质
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach(material => disposeMaterial(material));
} else {
disposeMaterial(object.material);
}
}
// 清理纹理
if (object.texture) {
object.texture.dispose();
}
};
// 清理材质的辅助函数
const disposeMaterial = (material) => {
if (!material) return;
// 清理所有纹理属性
const textureProperties = [
'map', 'normalMap', 'roughnessMap', 'metalnessMap',
'emissiveMap', 'bumpMap', 'displacementMap',
'alphaMap', 'lightMap', 'aoMap', 'envMap'
];
textureProperties.forEach(prop => {
if (material[prop] && material[prop].dispose) {
material[prop].dispose();
}
});
material.dispose();
};
知识点: WebGL 资源管理是 3D 应用开发中的关键挑战:
- JavaScript 垃圾回收的局限性:虽然 JS 有自动垃圾回收,但 WebGL 资源(如纹理、缓冲区)需要手动释放
- 深度优先遍历:通过递归算法遍历整个场景图,确保所有对象都被正确处理
- 资源类型处理:不同类型的资源(几何体、材质、纹理)需要不同的释放方法
- 内存泄漏防护:不正确的资源管理是 WebGL 应用中最常见的内存泄漏原因
合理的资源释放策略对长时间运行的 3D 应用至关重要,可以避免性能下降和浏览器崩溃。
资源预加载与缓存策略
项目实现了资源预加载与缓存策略:
// 资源管理器
const ResourceManager = {
// 资源缓存
cache: new Map(),
// 预加载资源
preload: async (resources) => {
const loader = new GLTFLoader();
// 并行加载所有资源
const loadPromises = resources.map(resource => {
return new Promise((resolve, reject) => {
loader.load(
resource.url,
(gltf) => {
ResourceManager.cache.set(resource.id, {
data: gltf,
lastUsed: Date.now(),
refCount: 0
});
resolve(gltf);
},
undefined,
reject
);
});
});
return Promise.all(loadPromises);
},
// 获取资源
get: (id) => {
const resource = ResourceManager.cache.get(id);
if (resource) {
resource.lastUsed = Date.now();
resource.refCount++;
return resource.data;
}
return null;
},
// 释放资源
release: (id) => {
const resource = ResourceManager.cache.get(id);
if (resource) {
resource.refCount--;
if (resource.refCount <= 0) {
// 可以选择立即释放或稍后由缓存清理机制释放
}
}
}
};
知识点: 3D 应用中的资源管理策略:
- 预加载:提前加载关键资源,减少用户等待时间
- 并行加载:使用 Promise.all 并行加载多个资源,提高加载效率
- 资源缓存:使用 Map 数据结构存储已加载资源,避免重复加载
- 引用计数:跟踪资源的使用情况,只有当引用计数为零时才考虑释放
- 最近使用时间:记录资源最后使用时间,可用于实现 LRU (最近最少使用) 缓存策略
这种资源管理策略可以平衡内存使用和加载性能,适用于资源密集型的 3D 应用。
总结
通过这个隧道监控可视化系统的开发,我们深入实践了 Three.js 的多项高级技术,包括多渲染器协同、后期处理、动画系统、相机控制和资源管理等。这些技术不仅适用于隧道监控,还可以应用于数字孪生、产品可视化、教育培训等多个领域。
希望这次分享对大家了解 Web 3D 开发有所帮助!如有任何问题或改进建议,非常欢迎与我交流讨论。我将在后续分享中带来更多 Three.js 开发的实用技巧和最佳实践。
来源:juejin.cn/post/7540129382540247103
前端如何判断用户设备
在前端开发中,判断用户设备类型是常见需求,可通过浏览器环境检测、设备能力特征分析等方式实现。以下是具体实现思路及代码示例:
一、通过User-Agent检测设备类型
原理:User-Agent是浏览器发送给服务器的标识字符串,包含设备、系统、浏览器等信息。
实现步骤:
- 提取
navigator.userAgent
字符串 - 通过正则表达式匹配特征关键词
// 设备检测工具函数
function detectDevice() {
const userAgent = navigator.userAgent.toLowerCase();
const device = {};
// 判断是否为移动设备
const isMobile = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
device.isMobile = isMobile;
// 具体设备类型
if (/(iphone|ipad|ipod)/i.test(userAgent)) {
device.type = 'ios';
device.model = /iphone/i.test(userAgent) ? 'iPhone' : 'iPad';
} else if (/android/i.test(userAgent)) {
device.type = 'android';
// 提取Android版本
const androidVersion = userAgent.match(/android (\d+\.\d+)/);
device.version = androidVersion ? androidVersion[1] : '未知';
} else if (/windows phone/i.test(userAgent)) {
device.type = 'windows phone';
} else if (/macint0sh/i.test(userAgent)) {
device.type = 'mac';
} else if (/windows/i.test(userAgent)) {
device.type = 'windows';
} else {
device.type = '其他';
}
// 判断是否为平板(需结合屏幕尺寸进一步确认)
device.isTablet = (/(ipad|android tablet|windows phone 8.1|kindle|nexus 7)/i.test(userAgent)) && !device.isMobile;
// 浏览器类型
if (/chrome/i.test(userAgent)) {
device.browser = 'Chrome';
} else if (/firefox/i.test(userAgent)) {
device.browser = 'Firefox';
} else if (/safari/i.test(userAgent) && !/chrome/i.test(userAgent)) {
device.browser = 'Safari';
} else if (/msie|trident/i.test(userAgent)) {
device.browser = 'IE/Edge';
} else {
device.browser = '未知';
}
return device;
}
// 使用示例
const deviceInfo = detectDevice();
console.log('设备类型:', deviceInfo.type);
console.log('是否为移动设备:', deviceInfo.isMobile);
console.log('浏览器:', deviceInfo.browser);
二、结合屏幕尺寸与触摸事件检测
原理:移动设备通常屏幕较小,且支持触摸操作,而PC设备以鼠标操作为主。
function enhanceDeviceDetection() {
const device = detectDevice(); // 基于User-Agent的检测
// 1. 屏幕尺寸检测(响应式设备类型)
if (window.innerWidth <= 768) {
device.layout = 'mobile'; // 移动端布局
} else if (window.innerWidth <= 1024) {
device.layout = 'tablet'; // 平板布局
} else {
device.layout = 'desktop'; // 桌面端布局
}
// 2. 触摸事件支持检测
device.hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// 3. 指针类型检测(WebKit特有属性,判断鼠标/触摸/笔)
if (navigator.maxTouchPoints === 0) {
device.pointerType = 'mouse';
} else if (navigator.maxTouchPoints > 2) {
device.pointerType = 'pen';
} else {
device.pointerType = 'touch';
}
return device;
}
三、设备能力API检测(更准确的现代方案)
原理:通过浏览器原生API获取设备硬件特性,避免User-Agent被伪造的问题。
async function detectDeviceByAPI() {
const device = {};
// 1. NavigatorDevice API(需HTTPS环境)
if (navigator.device) {
try {
const deviceInfo = await navigator.device.getCapabilities();
device.brand = deviceInfo.brand; // 设备品牌
device.model = deviceInfo.model; // 设备型号
device.vendor = deviceInfo.vendor; // 厂商
} catch (error) {
console.log('NavigatorDevice API获取失败:', error);
}
}
// 2. 屏幕像素密度(区分高清屏)
device.retina = window.devicePixelRatio >= 2;
// 3. 电池状态(移动端常用)
if (navigator.getBattery) {
navigator.getBattery().then(battery => {
device.batteryLevel = battery.level;
device.batteryCharging = battery.charging;
});
}
return device;
}
四、框架/库方案(简化实现)
如果项目中使用框架,可直接使用成熟库:
- react-device-detect(React专用)
- mobile-detect.js(轻量级通用库)
- ua-parser-js(专业User-Agent解析库)
五、注意事项
- User-Agent不可靠:用户可手动修改UA,或某些浏览器(如微信内置浏览器)会伪装UA。
- 结合多种检测方式:建议同时使用User-Agent、屏幕尺寸、触摸事件等多重检测,提高准确性。
- 响应式设计优先:现代开发中更推荐通过CSS媒体查询(
@media
)实现响应式布局,而非完全依赖设备检测。 - 性能优化:避免频繁检测设备,可在页面加载时缓存检测结果。
六、面试延伸问题
- 为什么User-Agent检测不可靠?请举例说明。
- 在iOS和Android上,如何区分手机和平板?
- 如果用户强制旋转屏幕(如手机横屏),设备检测结果需要更新吗?如何处理?
通过以上方案,可全面检测用户设备类型、系统、浏览器及硬件特性,为前端适配提供依据。
来源:juejin.cn/post/7515378780371501082
我用Python写了个实时板块资金热力图 🎨💰
我用Python写了个实时板块资金热力图 🎨💰
大家好,这里是花姐,今天带来一个有点“热辣滚烫”的Python实战项目——实时板块资金热力图!🔥
这两年,股市的热度时高时低,但大家对资金流向的关注度始终不减。有没有办法直观地看到哪些板块在吸金,哪些板块在被资金抛弃呢?答案是:当然有!
于是,我撸起袖子,用 Python + Streamlit + AkShare + Plotly 搞了一款实时的资金流向可视化工具,颜值爆表,还能自动刷新,堪称炒股助手!🎯
📌 需求分析
在金融市场里,资金流向是一个很重要的指标,主力资金的流入流出往往决定了一个板块的短期走势。
我们希望做到:
✅ 实时获取资金流向数据,并展示行业板块的资金进出情况。
✅ 可视化呈现数据,用颜色区分资金净流入和净流出,一眼就能看出哪些板块是**“香饽饽”,哪些是“弃儿”**。
✅ 自动刷新,让用户无需手动点刷新,信息一直是最新的。
有了目标,就开始撸代码吧!🚀
🛠 技术栈
- Streamlit:Python神器,一键搞定Web应用。
- AkShare:国内行情数据神器,能直接获取资金流向数据。
- Plotly:强大的可视化库,这次用它做树状热力图。
- Pandas:数据处理少不了它。
📊 代码实现
1. 获取资金流向数据
股市数据当然得从靠谱的地方获取,我们用 AkShare 的 stock_sector_fund_flow_rank
来搞定资金流数据:
import akshare as ak
import pandas as pd
def process_data(indicator):
"""获取并处理资金流数据"""
try:
raw = ak.stock_sector_fund_flow_rank(
indicator=indicator,
sector_type="行业资金流"
)
df = raw.rename(columns={'名称': '板块名称'})
df['资金净流入(亿)'] = df['主力净流入-净额'] / 100000000 # 转换为“亿”
df['资金净流入(亿)'] = df['资金净流入(亿)'].round(2) # 保留两位小数
df['涨跌幅'] = pd.to_numeric(df['涨跌幅'], errors='coerce')
df['流向强度'] = abs(df['资金净流入(亿)'])
return df.dropna(subset=['资金净流入(亿)'])
except Exception as e:
print(f"数据获取失败: {e}")
return pd.DataFrame()
这样,我们可以用 process_data("今日")
来获取今日的行业板块资金流数据。
2. 生成热力图 🎨
有了数据,接下来就是可视化部分了,我们用 Plotly 画一个树状热力图:
import plotly.express as px
COLOR_SCALE = [
[0.0, "#00ff00"], # 绿色(流出最大)
[0.45, "#dfffdf"], # 浅绿色(小幅流出)
[0.5, "#ffffff"], # 白色(平衡点)
[0.55, "#ffe5e5"], # 浅红色(小幅流入)
[1.0, "#ff0000"] # 红色(流入最大)
]
def generate_heatmap(df):
"""生成树状热力图"""
fig = px.treemap(
df,
path=['板块名称'],
values='流向强度',
color='资金净流入(亿)',
color_continuous_scale=COLOR_SCALE,
hover_data={
'涨跌幅': ':%',
'资金净流入(亿)': ':'
},
height=800
)
return fig
这张图的颜色代表资金的流向,红色表示资金流入,绿色表示资金流出,一眼就能看出主力资金的动向!
3. 使用 Streamlit 构建交互界面
有了数据和图表,我们用 Streamlit 搭建一个 Web 界面:
import streamlit as st
from datetime import datetime
def sidebar_controls():
with st.sidebar:
st.header("控制面板")
indicator = st.radio("分析周期", ["今日", "5日", "10日"], index=0, horizontal=True)
refresh_interval = st.slider("自动刷新间隔 (秒)", 60, 3600, 60, 60)
return indicator, refresh_interval
def main_display(df):
st.title("📊 资金流向热力图")
st.caption(f"数据更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
if not df.empty:
st.plotly_chart(generate_heatmap(df), use_container_width=True)
else:
st.warning("⚠️ 数据获取失败,请检查网络连接")
4. 自动刷新系统 ⏳
资金流是动态的,当然不能只显示静态数据,我们加个 自动刷新功能:
import time
def auto_refresh_system(refresh_interval):
time.sleep(refresh_interval)
st.rerun()
5. 整合一键运行 🚀
if __name__ == "__main__":
st.set_page_config(layout="wide")
indicator, refresh_interval = sidebar_controls()
df = process_data(indicator)
main_display(df)
auto_refresh_system(refresh_interval)
只要运行 streamlit run app.py
,就能看到实时的资金流向热力图了!🎉
🎯 成果展示
运行之后,你会看到一个 大屏可视化的热力图,资金流向一目了然:
- 颜色:红色代表资金流入,绿色代表流出,越深代表金额越大。
- 自动刷新,完全不用手动点击更新!
- 交互性:鼠标悬停可以查看详细数据。
这比在 Excel 里手动分析好用多了吧?😆
最终源码(粘贴过来就能用)
import streamlit as st
import akshare as ak
import plotly.express as px
import pandas as pd
import time
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
# 自定义颜色映射
COLOR_SCALE = [
[0.0, "#00ff00"], # 绿色(资金流出最大)
[0.45, "#dfffdf"], # 浅绿色(小幅流出)
[0.5, "#ffffff"], # 白色(平衡点)
[0.55, "#ffe5e5"], # 浅红色(小幅流入)
[1.0, "#ff0000"] # 红色(资金流入最大)
]
# 数据预处理增强版
@st.cache_data(ttl=300)
def process_data(indicator):
"""强化数据预处理逻辑"""
try:
raw = ak.stock_sector_fund_flow_rank(
indicator=indicator,
sector_type="行业资金流"
)
raw.columns = raw.columns.str.replace(indicator, '', regex=False)
# 数值转换
df = raw.rename(columns={'名称': '板块名称'})
df['资金净流入(亿)'] = df['主力净流入-净额'] / 100000000
df['资金净流入(亿)'] = df['资金净流入(亿)'].round(2)
df['涨跌幅'] = pd.to_numeric(df['涨跌幅'], errors='coerce')
# 流向强度计算
df['流向强度'] = abs(df['资金净流入(亿)'])
return df.dropna(subset=['资金净流入(亿)'])
except Exception as e:
st.error(f"数据错误: {str(e)}")
return pd.DataFrame()
# 热力图生成引擎
def generate_heatmap(df):
"""生成符合金融行业标准的树状热力图"""
fig = px.treemap(
df,
path=['板块名称'],
values='流向强度',
color='资金净流入(亿)',
color_continuous_scale=COLOR_SCALE,
range_color=[-max(abs(df['资金净流入(亿)'].min()), abs(df['资金净流入(亿)'].max())),
max(abs(df['资金净流入(亿)'].min()), abs(df['资金净流入(亿)'].max()))],
color_continuous_midpoint=0,
branchvalues='total',
hover_data={
'涨跌幅': ':%',
'资金净流入(亿)': ':',
'主力净流入-净占比': ':%'
},
height=1000
)
# 高级样式配置
fig.update_traces(
texttemplate=(
"<b>%{label}</b><br>"
"📈%{customdata[0]}%<br>"
"💰%{customdata[1]}亿"
),
hovertemplate=(
"<b>%{label}</b><br>"
"涨跌幅: %{customdata[0]}%<br>"
"资金净流入: <b>%{customdata[1]}</b>亿<br>"
"主力占比: %{customdata[2]}%"
),
textfont=dict(size=18, color='black')
)
fig.update_layout(
margin=dict(t=0, l=0, r=0, b=0),
coloraxis_colorbar=dict(
title="资金流向(亿)",
ticks="inside",
thickness=20,
len=0.6,
y=0.7
)
)
return fig
# 侧边栏控件组
def sidebar_controls():
with st.sidebar:
st.header("控制面板")
indicator = st.radio(
"分析周期",
["今日", "5日", "10日"],
index=0,
horizontal=True
)
refresh_interval = st.slider(
"自动刷新间隔 (秒)",
min_value=60, max_value=3600,
value=60, step=60,
help="设置自动刷新间隔,默认1分钟"
)
return indicator, refresh_interval
# 主界面
def main_display(df):
st.title("📊 资金流向热力图")
st.caption(f"数据更新于: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
if not df.empty:
with st.spinner("生成可视化..."):
st.plotly_chart(generate_heatmap(df), use_container_width=True)
# 动态摘要面板
with st.expander("📌 实时快报", expanded=True):
col1, col2, col3 = st.columns(3)
col1.metric("🔥 最强流入",
f"{df['资金净流入(亿)'].max():.2f}亿",
df.loc[df['资金净流入(亿)'].idxmax(), '板块名称'])
col2.metric("💧 最大流出",
f"{df['资金净流入(亿)'].min():.2f}亿",
df.loc[df['资金净流入(亿)'].idxmin(), '板块名称'])
col3.metric("⚖️ 多空比",
f"{len(df[df['资金净流入(亿)']>0])}:{len(df[df['资金净流入(亿)']<0])}",
f"总净额 {df['资金净流入(亿)'].sum():.2f}亿")
else:
st.warning("⚠️ 数据获取失败,请检查网络连接")
# 自动刷新系统
def auto_refresh_system(refresh_interval):
time.sleep(refresh_interval)
st.rerun()
print("数据刷新了")
# 主程序
if __name__ == "__main__":
st.set_page_config(layout="wide")
indicator, refresh_interval = sidebar_controls()
df = process_data(indicator)
main_display(df)
auto_refresh_system(refresh_interval)
🏆 总结
这个项目用 Python 搞定了:
✅ 数据抓取(AkShare)
✅ 数据处理(Pandas)
✅ 交互式可视化(Plotly)
✅ Web 界面(Streamlit)
✅ 自动刷新机制
一句话总结:用最少的代码,做出了最直观的金融数据可视化!💡
对于炒股的朋友,这个工具可以帮助你快速了解市场资金流向,不用再看密密麻麻的表格了!📈
喜欢的话,记得点个赞❤️,咱们下次见!🎉
来源:juejin.cn/post/7492990918702137380
前端获取本地文件目录内容
前端获取本地文件目录内容
一、核心原理说明
由于浏览器的 “沙箱安全机制”,前端 JavaScript 无法直接访问本地文件系统,必须通过用户主动授权(如选择目录操作)才能获取文件目录内容。目前主流实现方案基于两种 API:传统 File API(兼容性优先)和现代 FileSystem Access API(功能优先),以下将详细介绍两种方案的实现流程、代码示例及适用场景。
二、方案一:基于 File API 实现(兼容性首选)
1. 方案概述
通过隐藏的 <input type="file">
标签(配置 webkitdirectory
和 directory
属性)触发用户选择目录操作,用户选择后通过 files
属性获取目录下所有文件的元数据(如文件名、大小、相对路径等)。该方案兼容几乎所有现代浏览器(包括 Chrome、Firefox、Safari 等),但仅支持 “一次性获取选中目录内容”,无法递归遍历子目录或修改文件。
2. 完整使用示例
2.1 HTML 结构(含 UI 交互区)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>File API 目录访问示例</title>
<!-- 引入 Tailwind 简化样式(也可自定义 CSS) -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
.file-item { display: flex; align-items: center; padding: 8px; border-bottom: 1px solid #eee; }
.file-icon { margin-right: 8px; font-size: 18px; }
.file-info { flex: 1; }
.file-size { color: #666; font-size: 14px; }
</style>
</head>
<body class="p-8 bg-gray-50">
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow">
<h2 class="text-2xl font-bold mb-4">File API 目录内容获取</h2>
<!-- 触发按钮(隐藏原生 input) -->
<button id="selectDirBtn" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
选择本地目录
</button>
<input type="file" id="dirInput" webkitdirectory directory style="display: none;">
<!-- 文件列表展示区 -->
<div class="mt-4 border rounded-lg max-h-80 overflow-y-auto">
<div id="fileList" class="p-4 text-center text-gray-500">
请选择目录以查看文件列表
</div>
</div>
</div>
<script>
// 2.2 JavaScript 逻辑实现
const dirInput = document.getElementById('dirInput');
const selectDirBtn = document.getElementById('selectDirBtn');
const fileList = document.getElementById('fileList');
// 1. 点击按钮触发原生 input 选择目录
selectDirBtn.addEventListener('click', () => {
dirInput.click();
});
// 2. 监听目录选择变化,处理文件数据
dirInput.addEventListener('change', (e) => {
const selectedFiles = e.target.files; // 获取选中目录下的所有文件(含子目录文件)
if (selectedFiles.length === 0) {
fileList.innerHTML = '<div class="p-4 text-center text-gray-500">未选择任何文件</div>';
return;
}
// 3. 解析文件数据并渲染到页面
let fileHtml = '';
Array.from(selectedFiles).forEach(file => {
// 判断是否为目录(通过 type 为空且 size 为 0 间接判断)
const isDir = file.type === '' && file.size === 0;
// 获取文件在目录中的相对路径(webkitRelativePath 为非标准属性,但主流浏览器支持)
const relativePath = file.webkitRelativePath || file.name;
// 格式化文件大小(辅助函数)
const fileSize = isDir ? '—' : formatFileSize(file.size);
fileHtml += `
<div class="file-item">
<span class="file-icon ${isDir ? 'text-yellow-500' : 'text-gray-400'}">
${isDir ? '📁' : '📄'}
</span>
<div class="file-info">
<div class="font-medium">${file.name}</div>
<div class="text-xs text-gray-500">${relativePath}</div>
</div>
<div class="file-size text-sm">${fileSize}</div>
</div>
`;
});
fileList.innerHTML = fileHtml;
});
// 辅助函数:格式化文件大小(Bytes → KB/MB/GB)
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const units = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}`;
}
</script>
</body>
</html>
3. 关键特性与限制
- 优势:兼容性强(支持 Chrome 15+、Firefox 4+、Safari 6+),无需额外依赖,实现简单。
- 限制:
- 无法直接识别 “目录” 类型,需通过
type
和size
间接判断; - 仅能获取选中目录下的 “扁平化文件列表”,无法递归获取子目录结构;
- 无文件读写能力,仅能获取元数据。
三、方案二:基于 FileSystem Access API 实现(功能优先)
1. 方案概述
FileSystem Access API 是 W3C 正在标准化的现代 API(目前主要支持 Chromium 内核浏览器,如 Chrome 86+、Edge 86+),提供 “目录选择、递归遍历、文件读写、持久化权限” 等更强大的能力。通过 window.showDirectoryPicker()
直接请求用户授权,授权后可主动遍历目录结构,支持复杂的文件操作。
2. 完整使用示例
2.1 HTML 结构(含子目录遍历功能)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>FileSystem Access API 目录访问示例</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.dir-tree-item { padding: 4px 0 4px 16px; border-left: 1px solid #eee; }
.dir-header { display: flex; align-items: center; cursor: pointer; padding: 4px 0; }
.dir-icon { margin-right: 8px; }
.file-meta { color: #666; font-size: 14px; margin-left: 8px; }
</style>
</head>
<body class="p-8 bg-gray-50">
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow">
<h2 class="text-2xl font-bold mb-4">FileSystem Access API 目录遍历</h2>
<!-- 触发目录选择按钮 -->
<button id="openDirBtn" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">
打开并遍历目录
</button>
<!-- 目录树展示区 -->
<div class="mt-4 border rounded-lg p-4 max-h-80 overflow-y-auto">
<div id="dirTree" class="text-gray-500">
请点击按钮选择目录
</div>
</div>
</div>
<script>
// 2.2 JavaScript 逻辑实现(含递归遍历)
const openDirBtn = document.getElementById('openDirBtn');
const dirTree = document.getElementById('dirTree');
openDirBtn.addEventListener('click', async () => {
try {
// 1. 检查浏览器兼容性
if (!window.showDirectoryPicker) {
alert('您的浏览器不支持该功能,请使用 Chrome 或 Edge 浏览器');
return;
}
// 2. 请求用户选择目录(获取 DirectoryHandle 对象)
const dirHandle = await window.showDirectoryPicker({
mode: 'read', // 权限模式:read(只读)/ readwrite(读写)
startIn: 'documents' // 默认打开目录(可选:documents、downloads 等)
});
// 3. 递归遍历目录结构并渲染
dirTree.innerHTML = '<div class="text-center text-gray-500">正在读取目录...</div>';
const treeHtml = await renderDirectoryTree(dirHandle, 0);
dirTree.innerHTML = treeHtml;
} catch (err) {
// 捕获用户取消选择或权限拒绝错误
if (err.name === 'AbortError') {
dirTree.innerHTML = '<div class="text-center text-gray-500">用户取消选择</div>';
} else {
dirTree.innerHTML = `<div class="text-center text-red-500">错误:${err.message}</div>`;
console.error('目录访问失败:', err);
}
}
});
/**
* 递归渲染目录树
* @param {DirectoryHandle} handle - 目录/文件句柄
* @param {number} depth - 目录深度(用于缩进)
* @returns {string} 目录树 HTML
*/
async function renderDirectoryTree(handle, depth) {
const isDir = handle.kind === 'directory';
const indent = 'margin-left: ' + (depth * 16) + 'px;'; // 按深度缩进
let itemHtml = '';
if (isDir) {
// 处理目录:添加展开/折叠功能
itemHtml += `
<div class="dir-header" style="${indent}" onclick="toggleDir(this)">
<span class="dir-icon text-yellow-500">📁</span>
<span class="font-medium">${handle.name}</span>
<span class="file-meta">(目录)</span>
</div>
<div class="dir-children" style="display: none;">
`;
// 遍历目录下的所有子项(递归)
for await (const childHandle of handle.values()) {
itemHtml += await renderDirectoryTree(childHandle, depth + 1);
}
itemHtml += '</div>'; // 闭合 dir-children
} else {
// 处理文件:获取文件大小等元数据
const file = await handle.getFile();
const fileSize = formatFileSize(file.size);
itemHtml += `
<div style="${indent} display: flex; align-items: center; padding: 4px 0;">
<span class="dir-icon text-gray-400">📄</span>
<span>${handle.name}</span>
<span class="file-meta">${fileSize}</span>
</div>
`;
}
return itemHtml;
}
// 目录展开/折叠切换(全局函数,用于 HTML 内联调用)
function toggleDir(el) {
const children = el.nextElementSibling;
children.style.display = children.style.display === 'none' ? 'block' : 'none';
el.querySelector('.dir-icon').textContent = children.style.display === 'none' ? '📁' : '📂';
}
// 复用文件大小格式化函数(同方案一)
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const units = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}`;
}
</script>
</body>
</html>
3. 关键特性与限制
- 优势:
- 直接识别 “目录 / 文件” 类型(通过
handle.kind
); - 支持递归遍历目录结构,可实现 “目录树” 交互;
- 提供文件读写能力(通过
fileHandle.createWritable()
); - 可请求持久化权限(
handle.requestPermission()
),下次访问无需重新授权。
- 限制:兼容性差,仅支持 Chromium 内核浏览器,Firefox 和 Safari 暂不支持。
四、两种方案对比分析
对比维度 | 方案一(File API) | 方案二(FileSystem Access API) |
---|---|---|
浏览器兼容性 | 强(支持所有现代浏览器) | 弱(仅 Chromium 内核浏览器) |
目录识别能力 | 间接判断(依赖 type 和 size) | 直接识别(handle.kind) |
目录遍历能力 | 仅扁平化列表,无递归支持 | 支持递归遍历,可构建目录树 |
文件操作能力 | 仅读取元数据,无读写能力 | 支持文件读写、删除等完整操作 |
权限持久化 | 不支持(每次刷新需重新选择) | 支持(可请求持久化权限) |
交互体验 | 依赖隐藏 input,体验较基础 | 原生 API 调用,体验更流畅 |
适用场景 | 兼容性优先的简单目录查看需求 | 现代浏览器下的复杂文件管理需求 |
五、注意事项与最佳实践
- 安全合规:无论哪种方案,都必须通过 “用户主动操作” 触发授权(如点击按钮),禁止自动触发目录选择,否则浏览器会拦截操作。
- 错误处理:需捕获 “用户取消选择”(AbortError)和 “权限拒绝”(PermissionDeniedError)等错误,避免页面展示异常。
- 兼容性适配:可通过 “特性检测” 实现方案降级,例如:
if (window.showDirectoryPicker) {
// 使用方案二(FileSystem Access API)
} else {
// 使用方案一(File API)
}
- 性能优化:遍历大量文件时(如超过 1000 个文件),建议使用 “分页加载” 或 “虚拟滚动”,避免一次性渲染导致页面卡顿。
- 隐私保护:不建议存储用户本地文件路径等敏感信息,仅在前端临时处理文件数据,避免隐私泄露风险。
来源:juejin.cn/post/7542308569641074724
用代码绘制独一无二的七夕玫瑰(Trae版)
前言
七夕,这个充满浪漫气息的传统节日,总是让人心生期待。对于程序员来说,虽然我们日常与代码为伴,但浪漫的心思也从不缺席。今年七夕,不妨用一种特别的方式表达爱意——用代码绘制一朵玫瑰花,送给那个特别的他/她。
编程与浪漫的结合
程序员的世界里,代码是我们的语言,逻辑是我们的画笔。虽然我们不常在言语上表达情感,但通过代码,我们可以创造出独一无二的浪漫。
绘制一朵玫瑰花,不仅是一次技术的挑战,更是一份心意的传递。在这个特别的日子里,用代码绘制的玫瑰花,或许能成为你表达爱意的特别方式。
依旧是让我们的ai编程大师Trae出手,看看能不能有惊艳的效果
第一次的提问,生成的效果很差
然后我就让他搜索一下目前互联网上的玫瑰花demo,模仿一下
这次看得出是一朵花,但是没有叶子,花瓣得仔细看才有,所以再次提问
一运行报错了,不要慌,我让我们得ai编程大师Trae 他自己修复一下
过了一分钟,Trae修复了之前的报错,看起来还是不错的,还可以支持旋转,很有艺术感的气氛,非常好~
Trae的实现思路
1. 结构优化
- 花瓣结构 :引入了多层花瓣概念(5层),每层花瓣具有不同的形状、大小和卷曲效果,使花朵更加立体和真实
- 花茎改进 :增加了花茎长度,调整了半径,并添加了椭圆横截面、自然弯曲和小刺,增强真实感
- 叶子优化 :增加了叶子数量,采用交错排列,并实现了更复杂的叶子形状,包括中脉、宽度变化和向下弯曲效果
创建花茎和花茎的弯曲程度
玫瑰花的叶子,设计一定的曲度,看起来像真实的叶子
粒子系统优化
- 粒子数量 :增加了粒子总数从20,000到30,000,提供更细腻的视觉效果
- 粒子分配 :优化了花朵(70%)、花茎(20%)和叶子(10%)的粒子分配比例
- 粒子大小和透明度 :根据粒子在花朵、花茎和叶子中的位置,以及与中心的距离,动态调整粒子大小和透明度
总结
在这个充满爱的节日里,程序员也可以用自己独特的方式表达浪漫。
用代码绘制一朵玫瑰花,不仅是一次有趣的编程实践,更是一份特别的礼物,希望这个小小的创意能为你的七夕增添一份特别的浪漫,如果你还有其他想法,可以把你的idea发给Trae,让他帮助你完成你的idea,对他/她进行爱意的表达,祝愿天下有情人终成眷属。
在实际开发中,你可以根据具体需求进一步优化和调整样式。希望这篇文章能对你有所帮助!
来源:juejin.cn/post/7542501413760761896
Nginx+Keepalive 实现高可用并启用健康检查模块
1. 目标效果
keepalived 负责监控 192.168.1.20 和 192.168.1.30 这两台负载均衡的服务器,并自动选择一台作为主服务器。用户访问 http://192.168.1.10 时,由主服务器接收该请求。当 keepalived 检测到主服务器不可访问时,会将备服务器升级为主服务器,从而实现高可用。
在主服务器中,通过 nginx(tengine)实现负载均衡,将访问请求分流到 192.168.1.100 和 192.168.1.200 这两台业务服务器。 nginx 中的健康检查模块会检测业务服务器状态,如果检测到 192.168.1.100 不可访问,则不再将访问请求发送给该服务器。
2. 部署 Keepalived
2.1 主机 IP
主机 | IP |
---|---|
虚拟 IP | 192.168.1.10 |
主服务器 | 192.168.1.20 |
备服务器 | 192.168.1.30 |
2.2 主服务器设置
官方配置说明文档:Keepalived for Linux
yun install vim epel-release keepalived -y
cp /etc/keepalived/keepalived.conf /etc/keepalived/keepalived.conf.bak
cat > /etc/keepalived/keepalived.conf <<EOF
! Configuration File for keepalived
global_defs {
router_id nginx01
}
vrrp_script check_nginx {
script "/etc/keepalived/nginx_check.sh"
interval 2
weight 2
}
vrrp_instance VI_1 {
state MASTER
interface eth0
virtual_router_id 51
priority 100
advert_int 1
authentication {
auth_type PASS
auth_pass linalive
}
virtual_ipaddress {
192.168.1.10
}
track_script {
check_nginx
}
}
EOF
touch /etc/keepalived/nginx_check.sh && chmod +x /etc/keepalived/nginx_check.sh
cat > /etc/keepalived/nginx_check.sh <<EOF
#!/bin/bash
if ! pgrep -x "nginx" > /dev/null; then
systemctl restart nginx
sleep 2
if ! pgrep -x "nginx" > /dev/null; then
pkill keepalived
fi
fi
EOF
2.2 备服务器设置
yun install vim epel-release keepalived -y
cp /etc/keepalived/keepalived.conf /etc/keepalived/keepalived.conf.bak
cat > /etc/keepalived/keepalived.conf <<EOF
! Configuration File for keepalived
global_defs {
router_id nginx02
}
vrrp_script check_nginx {
script "/etc/keepalived/nginx_check.sh"
interval 2
weight 2
}
vrrp_instance VI_1 {
state BACKUP
interface eth0
virtual_router_id 51
priority 90
advert_int 1
authentication {
auth_type PASS
auth_pass linalive
}
virtual_ipaddress {
192.168.1.10
}
track_script {
check_nginx
}
}
EOF
touch /etc/keepalived/nginx_check.sh && chmod +x /etc/keepalived/nginx_check.sh
cat > /etc/keepalived/nginx_check.sh <<EOF
#!/bin/bash
if ! pgrep -x "nginx" > /dev/null; then
systemctl restart nginx
sleep 2
if ! pgrep -x "nginx" > /dev/null; then
pkill keepalived
fi
fi
EOF
3. 部署 Tengine (主备服务器)
3.1 准备 Tengine 压缩文件
下载 tengine 压缩文件,将文件上传到 /opt 文件夹下。下载地址:The Tengine Web Server
本文章编写时,最新版是:tengine-3.1.0.tar.gz
3.2 解压并编译
yum install -y gcc gcc-c++ make pcre-devel zlib-devel openssl-devel
tar zxvf /opt/tengine-3.1.0.tar.gz -C /opt
cd /opt/tengine-3.1.0
# configure 有众多的参数可设置,可使用 ./configure --help 进行查看
# 按照官方说法默认应该是开启了健康检查模块,但实测需要手动添加参数
./configure --add-module=modules/ngx_http_upstream_check_module/
make && make install
3.3 添加服务项
cat > /etc/systemd/system/nginx.service <<EOF
[Unit]
Description=The Tengine HTTP and reverse proxy server
After=network.target
[Service]
Type=forking
PIDFile=/usr/local/nginx/logs/nginx.pid
ExecStartPre=/usr/local/nginx/sbin/nginx -t
ExecStart=/usr/local/nginx/sbin/nginx
ExecReload=/usr/local/nginx/sbin/nginx -s reload
ExecStop=/usr/local/nginx/sbin/nginx -s stop
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
3.4 编辑 nginx 配置文件
此处配置的内容项可参考官方文档:ngx_http_upstream_check_module
# tengine 默认的安装路径是 /usr/local/nginx
# 配置文件路径: /usr/local/nginx/conf/nginx.conf
# /favicon.ico 是接口地址,需替换成真实的 api 接口
worker_processes auto;
events {
worker_connections 1024;
}
http {
upstream cluster1 {
server 192.168.1.100:8082;
server 192.168.1.200:8089;
check interval=3000 rise=2 fall=5 timeout=1000 type=http;
check_http_send "HEAD /favicon.ico HTTP/1.0\r\n\r\n";
check_http_expect_alive http_2xx http_3xx;
}
server {
listen 80;
server_name localhost;
location / {
index Index.aspx;
proxy_pass http://cluster1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /status {
check_status;
access_log off;
}
}
}
3.5 启动服务并访问
使用 systemctl start nginx
启动服务,并访问 localhost:80/status
查看健康检查报表页
4. 写在最后
来源:juejin.cn/post/7483314478957232138
创建型模式:抽象工厂模式
什么是抽象工厂模式
抽象工厂模式是一种创建型设计模式,它提供一个接口来创建一系列相关或相互依赖的对象家族,而无需指定它们的具体类。简单来说,抽象工厂模式是工厂模式的升级版,它不再只生产一种产品,而是生产一整套产品。
抽象工厂vs工厂方法:关键区别
- 工厂方法模式:关注单个产品的创建,一个工厂创建一种产品
- 抽象工厂模式:关注产品族的创建,一个工厂创建多种相关产品
这就像一个生产手机的工厂(工厂方法)和一个生产整套电子设备(手机、平板、耳机)的工厂(抽象工厂)的区别。
抽象工厂模式的核心实现
// 产品A接口
public interface ProductA {
void operationA();
}
// 产品B接口
public interface ProductB {
void operationB();
}
// 产品A1实现
public class ConcreteProductA1 implements ProductA {
@Override
public void operationA() {
System.out.println("产品A1的操作");
}
}
// 产品A2实现
public class ConcreteProductA2 implements ProductA {
@Override
public void operationA() {
System.out.println("产品A2的操作");
}
}
// 产品B1实现
public class ConcreteProductB1 implements ProductB {
@Override
public void operationB() {
System.out.println("产品B1的操作");
}
}
// 产品B2实现
public class ConcreteProductB2 implements ProductB {
@Override
public void operationB() {
System.out.println("产品B2的操作");
}
}
// 抽象工厂接口
public interface AbstractFactory {
ProductA createProductA();
ProductB createProductB();
}
// 具体工厂1 - 创建产品族1(A1+B1)
public class ConcreteFactory1 implements AbstractFactory {
@Override
public ProductA createProductA() {
return new ConcreteProductA1();
}
@Override
public ProductB createProductB() {
return new ConcreteProductB1();
}
}
// 具体工厂2 - 创建产品族2(A2+B2)
public class ConcreteFactory2 implements AbstractFactory {
@Override
public ProductA createProductA() {
return new ConcreteProductA2();
}
@Override
public ProductB createProductB() {
return new ConcreteProductB2();
}
}
抽象工厂模式的关键点
- 产品接口:为每种产品定义接口
- 具体产品:实现产品接口的具体类
- 抽象工厂接口:声明一组创建产品的方法
- 具体工厂:实现抽象工厂接口,创建一个产品族
- 产品族:一组相关产品的集合(例如PC系列组件、移动系列组件)
实际应用示例:跨平台UI组件库
下面通过一个跨平台UI组件库的例子来展示抽象工厂模式的强大应用:
// ===== 按钮组件 =====
public interface Button {
void render();
void onClick();
}
// Windows按钮
public class WindowsButton implements Button {
@Override
public void render() {
System.out.println("渲染Windows风格的按钮");
}
@Override
public void onClick() {
System.out.println("Windows按钮点击效果");
}
}
// MacOS按钮
public class MacOSButton implements Button {
@Override
public void render() {
System.out.println("渲染MacOS风格的按钮");
}
@Override
public void onClick() {
System.out.println("MacOS按钮点击效果");
}
}
// ===== 复选框组件 =====
public interface Checkbox {
void render();
void toggle();
}
// Windows复选框
public class WindowsCheckbox implements Checkbox {
@Override
public void render() {
System.out.println("渲染Windows风格的复选框");
}
@Override
public void toggle() {
System.out.println("Windows复选框切换状态");
}
}
// MacOS复选框
public class MacOSCheckbox implements Checkbox {
@Override
public void render() {
System.out.println("渲染MacOS风格的复选框");
}
@Override
public void toggle() {
System.out.println("MacOS复选框切换状态");
}
}
// ===== 文本框组件 =====
public interface TextField {
void render();
void getText();
}
// Windows文本框
public class WindowsTextField implements TextField {
@Override
public void render() {
System.out.println("渲染Windows风格的文本框");
}
@Override
public void getText() {
System.out.println("获取Windows文本框内容");
}
}
// MacOS文本框
public class MacOSTextField implements TextField {
@Override
public void render() {
System.out.println("渲染MacOS风格的文本框");
}
@Override
public void getText() {
System.out.println("获取MacOS文本框内容");
}
}
// ===== GUI工厂接口 =====
public interface GUIFactory {
Button createButton();
Checkbox createCheckbox();
TextField createTextField();
}
// Windows GUI工厂
public class WindowsFactory implements GUIFactory {
@Override
public Button createButton() {
return new WindowsButton();
}
@Override
public Checkbox createCheckbox() {
return new WindowsCheckbox();
}
@Override
public TextField createTextField() {
return new WindowsTextField();
}
}
// MacOS GUI工厂
public class MacOSFactory implements GUIFactory {
@Override
public Button createButton() {
return new MacOSButton();
}
@Override
public Checkbox createCheckbox() {
return new MacOSCheckbox();
}
@Override
public TextField createTextField() {
return new MacOSTextField();
}
}
如何使用抽象工厂模式
// 应用类 - 与具体工厂解耦
public class Application {
private Button button;
private Checkbox checkbox;
private TextField textField;
// 构造函数接收一个抽象工厂
public Application(GUIFactory factory) {
button = factory.createButton();
checkbox = factory.createCheckbox();
textField = factory.createTextField();
}
// 渲染表单
public void renderForm() {
System.out.println("=== 开始渲染表单 ===");
button.render();
checkbox.render();
textField.render();
System.out.println("=== 表单渲染完成 ===");
}
// 表单操作
public void handleForm() {
System.out.println("\n=== 表单交互 ===");
button.onClick();
checkbox.toggle();
textField.getText();
}
}
// 客户端代码
public class GUIDemo {
public static void main(String[] args) {
// 检测当前操作系统
String osName = System.getProperty("os.name").toLowerCase();
GUIFactory factory;
// 根据操作系统选择合适的工厂
if (osName.contains("windows")) {
factory = new WindowsFactory();
System.out.println("检测到Windows系统,使用Windows风格UI");
} else {
factory = new MacOSFactory();
System.out.println("检测到非Windows系统,使用MacOS风格UI");
}
// 创建并使用应用 - 注意应用不依赖于具体组件类
Application app = new Application(factory);
app.renderForm();
app.handleForm();
}
}
运行结果(Windows系统上)
检测到Windows系统,使用Windows风格UI
=== 开始渲染表单 ===
渲染Windows风格的按钮
渲染Windows风格的复选框
渲染Windows风格的文本框
=== 表单渲染完成 ===
=== 表单交互 ===
Windows按钮点击效果
Windows复选框切换状态
获取Windows文本框内容
运行结果(MacOS系统上)
检测到非Windows系统,使用MacOS风格UI
=== 开始渲染表单 ===
渲染MacOS风格的按钮
渲染MacOS风格的复选框
渲染MacOS风格的文本框
=== 表单渲染完成 ===
=== 表单交互 ===
MacOS按钮点击效果
MacOS复选框切换状态
获取MacOS文本框内容
抽象工厂模式的常见应用场景
- 跨平台UI工具包:为不同操作系统提供一致的界面组件
- 数据库访问层:支持多种数据库系统(MySQL、Oracle、MongoDB等)
- 游戏开发:创建不同主题的游戏元素(中世纪、未来、童话等)
- 多环境配置系统:为开发、测试、生产环境提供不同实现
- 电子设备生态系统:创建配套的产品(手机、耳机、手表都来自同一品牌)
- 多主题应用:切换应用的视觉主题(暗色模式/亮色模式)
抽象工厂模式的实际案例
许多知名框架和库使用抽象工厂模式,如:
- Java的JDBC:
ConnectionFactory
创建特定数据库的连接 - Spring Framework:通过BeanFactory创建和管理各种组件
- javax.xml.parsers.DocumentBuilderFactory:创建DOM解析器
- Hibernate:
SessionFactory
为不同数据库创建会话
抽象工厂模式的优点
- 产品一致性保证:确保一个工厂创建的产品相互兼容
- 隔离具体类:客户端与具体类隔离,只与接口交互
- 开闭原则:引入新产品族不需要修改现有代码
- 替换产品族:可以整体替换产品族(如UI主题切换)
抽象工厂模式的缺点
- 扩展困难:添加新的产品类型需要修改工厂接口及所有实现
- 复杂度增加:产品较多时,类的数量会急剧增加
- 接口污染:接口中可能包含部分工厂不支持的创建方法
抽象工厂的实现变体
使用反射简化工厂实现
public class ReflectiveFactory implements GUIFactory {
private String packageName;
public ReflectiveFactory(String stylePrefix) {
packageName = "com.example.gui." + stylePrefix.toLowerCase();
}
@Override
public Button createButton() {
return (Button) createComponent("Button");
}
@Override
public Checkbox createCheckbox() {
return (Checkbox) createComponent("Checkbox");
}
@Override
public TextField createTextField() {
return (TextField) createComponent("TextField");
}
private Object createComponent(String type) {
try {
Class<?> clazz = Class.forName(packageName + "." + type);
return clazz.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("无法创建组件", e);
}
}
}
带有默认实现的抽象工厂
public abstract class BaseGUIFactory implements GUIFactory {
// 提供默认实现
@Override
public TextField createTextField() {
return new DefaultTextField(); // 所有平台通用的默认实现
}
// 其他方法需要子类实现
@Override
public abstract Button createButton();
@Override
public abstract Checkbox createCheckbox();
}
实现抽象工厂的设计考虑
- 产品族边界:明确定义哪些产品属于同一族
- 接口设计:保持工厂接口精简,避免方法爆炸
- 工厂选择机制:考虑如何选择/切换具体工厂
- 扩展策略:提前考虑如何添加新产品类型
- 组合与单一职责:大型产品族可考虑拆分为多个子工厂
抽象工厂模式最佳实践
- 适度使用:当确实需要创建一系列相关对象时才使用
- 懒加载:考虑延迟创建产品,而不是一次创建所有产品
- 结合其他模式:与单例、原型、构建者等模式结合使用
- 依赖注入:通过依赖注入框架传递工厂
- 配置驱动:使用配置文件或注解选择具体工厂实现
// 使用配置驱动的工厂
public class ConfigurableGUIFactory {
public static GUIFactory getFactory() {
String factoryType = ConfigLoader.getProperty("ui.factory");
switch (factoryType) {
case "windows": return new WindowsFactory();
case "macos": return new MacOSFactory();
case "web": return new WebFactory();
default: throw new IllegalArgumentException("未知UI工厂类型");
}
}
}
抽象工厂与依赖倒置原则
抽象工厂是实现依赖倒置原则的绝佳方式:高层模块不依赖于低层模块,两者都依赖于抽象。
// 不好的设计:直接依赖具体类
public class BadForm {
private WindowsButton button; // 直接依赖具体实现
private WindowsCheckbox checkbox;
public void createUI() {
button = new WindowsButton(); // 硬编码创建具体类
checkbox = new WindowsCheckbox();
}
}
// 好的设计:依赖抽象
public class GoodForm {
private Button button; // 依赖接口
private Checkbox checkbox;
private final GUIFactory factory; // 依赖抽象工厂
public GoodForm(GUIFactory factory) {
this.factory = factory;
}
public void createUI() {
button = factory.createButton(); // 通过工厂创建
checkbox = factory.createCheckbox();
}
}
抽象工厂模式小结
抽象工厂模式是一种强大但需谨慎使用的创建型模式。它在需要一套相关产品且系统不应依赖于产品的具体类时非常有用。这种模式有助于确保产品兼容性,并为产品族提供统一的创建接口。
适当应用抽象工厂模式可以使代码更具灵活性和可维护性,但也要避免过度设计导致的复杂性。理解产品族的概念和如何设计良好的抽象工厂接口是掌握这一模式的关键。
来源:juejin.cn/post/7491963395284549669
Spring Boot Admin:一站式监控微服务,这个运维神器真香!
关注我的公众号:【编程朝花夕拾】,可获取首发内容。
01 引言
在现代微服务架构中,应用实例的数量动辄成百上千。传统的逐个登录服务器查看日志、检查状态的方式早已变得低效且不现实。
因此,一个集中化、可视化、且能提供实时健康状态的管理平台变得至关重要。Spring Boot Admin
(SBA) 正是为了满足这一需求而生的强大工具。
然而,各种厂商的云服务提供了各种监控服务解决客户的各种痛点。Spring Boot Admin
这样的工具似乎关注度没有那么高。小编也是无意间发现这款产品,分享给大家。
02 简介
Spring Boot Admin
是一个用于管理和监控 Spring Boot
应用程序的开源社区项目。它并非官方 Spring
项目,但在社区中备受推崇并被广泛使用。
其核心原理是:一个作为“服务器”(Server)的中央管理端,通过收集并展示众多作为“客户端”(Client)的 Spring Boot 应用的监控信息。
Spring Boot Admin
通过集成 Spring Boot Actuator 端点来获取应用数据,并提供了一个友好的 Web UI
界面来展示这些信息。
主要分为两部分:
- 服务端:监控平台
- 客户端:业务端
SpringBoot
的版本和Spring Boot Admin
有一定的对应关系:
GitHub
地址:github.com/codecentric…
文档地址:docs.spring-boot-admin.com/
03 Admin服务端
服务的端配置相当简单,只需要引入依赖,启动增加注解。服务端的基础配置就算完成了。
3.1 基础配置
Maven依赖
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-server</artifactId>
<version>${latest.version}</version>
</dependency>
增加注解
@EnableAdminServer
这两个配置就可访问项目的IP+端口
,进入管理页面了。
3.2 增加鉴权
为了数据安全,可以增加鉴权。拥有账号和密码方可进入。
Maven依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
配置文件
# 设置自己的用户名和密码
spring.security.user.name=admin
spring.security.user.password=123456
输入对应的用户名和密码就可以进入了。
3.3 增加邮件推送
官方提供了各种通知,也可以自定义,如图:
我们以邮件通知为例。
Maven依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
配置
# 邮箱配置
spring.mail.host=smtp.163.com
spring.mail.port=25
spring.mail.username=用户名
spring.mail.password=*****[授权码]
# 发送和接受邮箱
spring.boot.admin.notify.mail.to=wsapplyjob@163.com
spring.boot.admin.notify.mail.from=wsapplyjob@163.com
客户端下线之后会触发邮件:
04 Adamin
客户端
因为服务端是依赖Spring Boot Actuator
端点来获取应用数据,所以我们需要开放期其所有的服务。
4.1 基础配置
Maven依赖
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-client</artifactId>
<version>${latest.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# 服务端地址
spring.boot.admin.client.url=http://127.0.0.1:8081
# 鉴权信息
spring.boot.admin.client.username=admin
spring.boot.admin.client.password=123456
# 开发所有的暴漏的信息
management.endpoints.web.exposure.include=*
4.2 监控界面
进入之后,我们就会发现上面的页面。点击应用墙,就会展示所有监控的实例。进入之后如图:
进入之后就可以看到五大块。其中②就是我们之前看到的日志级别的控制。还包含了缓存、计划任务、映射甚至类之间的依赖关系。
因为界面支持中文,里面具体的功能就不做描述,感兴趣的可以自己的探索。
4.3 日志配置增加日志
默认的日志进去只有日志的级别,并不会打印日志。
这是官方的描述:
我们增加配置:
logging.file.name=/var/log/boot-log.log
logging.pattern.file=%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr(${PID}){magenta} %clr(---){faint} %clr([.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wEx (2)
启动查看结果:
我们就可以看到信的菜单:日志文件
4.4 缓存
【缓存】是没有数据的:
缓存依赖
<!-- 监控缓存需要的依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
代码
触发缓存任务之后,就会出现缓存的管理:
4.5 计划任务
【计划任务】和缓存基本一样,但是无需引入第三方依赖。使用@Scheduled
即可。
监控结果:
05 小结
Spring Boot Admin
以其简洁的配置、强大的功能和友好的界面,成为了 Spring Boot
微服务监控领域的事实标准。它极大地降低了监控和运维的复杂度,让开发者能够更专注于业务逻辑开发。
对于中小型规模的微服务集群,直接使用 SBA 是一个高效且成本低廉的解决方案。
赶快去探索里面不同的功能的吧!
来源:juejin.cn/post/7542450691911155762
儿子不收拾玩具,我用AI给他量身定制开发一个APP,这下舒服了
1. 前言
比上班更可怕的是什么?是加班。
比加班更可怕的是什么?是固定加班,也就是 996,大小周。
作为一个荣获 996 福报的牛马,我认为我的际遇已经很可怕了。
没想到还有比这更可怕的,拖着被996折腾过的疲惫身体回家后。我儿子向我展示他一天的劳动成果。
这时候你肯定会说让他收起来不就行了?这时候我应该拿出标志性的礼貌三问:你有对象吗?你有儿子吗?你让你儿子收他就收吗?
不会吧,你儿子收啊。那我和你换个儿子吧。
我对我儿子威逼利诱什么招式都试过了,他每次就3招我就没辙了:
爸爸,我累了你帮我收吧。
爸爸,地上的玩具太多了你和我一起收吧,收着收着这小子就不见了。
爸爸,我要睡觉了,晚安。
每天晚上我都要花时间收拾这个烂摊子,收过十几次后我后知后觉有了个想法。
平时我工作的时候,一个5分钟就能手写完搞定的配置我都要花10分钟写个脚本自动化。
为啥不能让收玩具这件事情自动化呢?我可是个优雅的程序员啊。重复做一个动作在我这应该是严格禁止的才对。
所以我打算做一个自动收玩具的机器。
不是哥们,这我真做不了。在我这自动化是什么意思呢?
不需要自己动手干的就是自动化,把配置做到管理后台,让运营自己去配置算不算自动化?那必须是的呀。
那么,想一种办法让我儿子自己把玩具收起来是不是自动化?那也必须是的呀。
自动化的定义就是不需要自己动手干,管是机器干还是人干,反正不要我来干。
说干就干,我儿子特别喜欢数字,迷恋加法。那不就盖了帽了。给他安排一个任务APP,收完一件玩具就加分,他肯定特满足特有成就感。
我虽然是一个前端后端运维测试攻城狮,但我的的确确没有开发过APP。除了大学要交 Android 作业抱过同学大腿喵了一眼,从那之后我就下定决定干后端去了。因为艺术细菌不是说本人没有,是本人想有但它对我不感冒啊。
但是别忘了,现在是 AI 的时代。产品的活我不会,AI 来。APP 开发我不会,AI 来。貌似不需要后端,那我只能当当测试了。
2. 正片开始
我调研了一圈,目前有几种方案可以实现:
- 直接刚原生
- 退而求其次,flutter
- 一退再退,直接uniapp 网页糊上
原生做起来体验最好,但是搭个环境真是要了我的老命了,所以弃之。
flutter总体感觉不错,但是要另外学一门语言,想想我就脑壳疼,亦弃之。
uni-app 看起来不咋滴,蛮多人吐槽但也有真正的案例。但我发现它能云打包,不用我在本地配一堆乱七八糟的。下载一个HBuilder 就行了,虽然很多人吐槽这个 IDE,但关我啥事?是 AI 来写代码,又不是我写代码,尽管恶心 AI 去。选它选它
2.1 画原型图
Cursor,Gemini,claude code 我都试了,Gemini的设计感最强,豆包的体验最好。豆包的效果看起来非常的奈斯啊!
2.2 开发
有了原型那就好办了,直接贴图让cursor 或者 claude code 对着实现就行了。
这里要吐槽一下claude code,不能粘贴板直接贴图,只能把图片拖进去,差评。
现在可以粘贴图片了,Mac 可以尝试用ctrl+v(没错,不是command+v)
把所有的原型图贴进去之后,输入这句简单的Prompt,claude code 就会开始干活了。
请根据原型图,使用uniapp 开发一个app
2.3 加需求
第一版完成了他的使命,最近儿子有点腻烦了,收个玩具磨磨蹭蹭的。不行,我得想点法子,加点东西让他保持新鲜感,然后养成习惯,以后就不用我管了,想想就非常的苏胡啊。
所以为了调动他的积极性,更营造一个紧张的氛围,我加入了倒计时功能:
接下来有个新功能。我想为任务增加一个计时完成功能:
1. 完成任务时,不再是简单的点击即可;
2. 完成任务时,应该提供一个开始完成按钮,然后启动倒计时
3. 创建任务时,应该配置预计完成时间
4. 完成任务时,遵循规则:a.如果在预计时间的一半完成可以得到2倍的分数;b.如果超过一半时间完成则得到1.5倍分数;c.如果超时完成则得到1倍分数
直接把需求丢给AI实现去,自己测试测试,没问题就打包。
2.3 测试打包
先浏览器运行看看效果,可以 F12 切换成手机视图看有没有挤压之类的。
测试没问题就直接打包。因为我是尊贵的 Android 用户,所以我只跑了 Android 打包。
我坦白,uni-app部分我基本是看这个老哥的:juejin.cn/post/729631…
2.4 看看效果
来源:juejin.cn/post/7538276577605632046
别再混淆了!一文彻底搞懂System.identityHashCode与Object.hashCode的区别
在Java开发中,哈希码的使用无处不在,但许多开发者对
System.identityHashCode()
和Object.hashCode()
的区别仍然模糊不清。本文将深入剖析二者的核心区别,并通过实际代码演示它们在不同场景下的行为差异。
一、本质定义:两种哈希码的起源
- Object.hashCode()
- 所有Java对象的默认方法(定义在
Object
类中) - 可被子类重写(通常基于对象内容计算)
// 默认实现(未重写时)
public native int hashCode();
- 所有Java对象的默认方法(定义在
- System.identityHashCode()
System
类提供的静态工具方法- 无视任何重写,始终返回JVM原始哈希码
public static native int identityHashCode(Object x);
二、核心区别对比(表格速查)
特性 | Object.hashCode() | System.identityHashCode() |
---|---|---|
是否可重写 | ✅ 子类可重写改变行为 | ❌ 行为固定不可变 |
对重写的敏感性 | 返回重写后的自定义值 | 永远返回JVM原始哈希码 |
null 处理 | 调用抛NullPointerException | 安全返回0 |
返回值一致性 | 内容改变时可能变化 | 对象生命周期内永不改变 |
典型用途 | HashMap /HashSet 等基于内容的集合 | IdentityHashMap 等身份敏感操作 |
三、关键差异深度解析
1. 重写行为对比(核心区别)
class CustomObject {
private int id;
// 重写hashCode(基于内容)
@Override
public int hashCode() {
return id * 31;
}
}
public static void main(String[] args) {
CustomObject obj = new CustomObject();
obj.id = 100;
System.out.println("hashCode: " + obj.hashCode()); // 3100
System.out.println("identityHashCode: " + System.identityHashCode(obj)); // 356573597
}
输出说明:
✅ hashCode()
返回重写后的计算值
✅ identityHashCode()
无视重写,返回JVM原始哈希
2. null安全性对比
Object obj = null;
// 抛出NullPointerException
try {
System.out.println(obj.hashCode());
} catch (NullPointerException e) {
System.out.println("调用hashCode()抛NPE");
}
// 安全返回0
System.out.println("identityHashCode(null): "
+ System.identityHashCode(obj));
3. 哈希码不变性验证
String str = "Hello";
int initialIdentity = System.identityHashCode(str);
str = str + " World!"; // 修改对象内容
// 身份哈希码保持不变
System.out.println(initialIdentity == System.identityHashCode(str)); // true
四、经典应用场景
1. 使用Object.hashCode()的场景
// 在HashMap中作为键(依赖内容哈希)
Map<Student, Grade> gradeMap = new HashMap<>();
Student s = new Student("2023001", "张三");
gradeMap.put(s, new Grade(90));
// 重写需遵守规范:内容相同则哈希码相同
class Student {
private String id;
private String name;
@Override
public int hashCode() {
return Objects.hash(id, name);
}
}
2. 使用identityHashCode()的场景
场景1:实现身份敏感的容器
// IdentityHashMap基于身份哈希而非内容
IdentityHashMap<Object, String> identityMap = new IdentityHashMap<>();
String s1 = new String("ABC");
String s2 = new String("ABC");
identityMap.put(s1, "第一对象");
identityMap.put(s2, "第二对象"); // 不同对象,均可插入
System.out.println(identityMap.size()); // 2
场景2:检测hashCode是否被重写
boolean isHashCodeOverridden(Object obj) {
return obj.hashCode() != System.identityHashCode(obj);
}
// 测试
System.out.println(isHashCodeOverridden(new Object())); // false
System.out.println(isHashCodeOverridden(new String("Test"))); // true
场景3:调试对象内存关系
Object objA = new Object();
Object objB = objA; // 指向同一对象
// 身份哈希相同证明是同一对象
System.out.println(System.identityHashCode(objA)
== System.identityHashCode(objB)); // true
五、底层机制揭秘
- 存储位置:身份哈希码存储在对象头中
- 生成时机:首次调用
hashCode()
或identityHashCode()
时生成 - 计算规则:通常基于内存地址,但JVM会优化(非直接地址)
- 不变性:一旦生成,在对象生命周期内永不改变
六、总结与最佳实践
方法 | 选用原则 |
---|---|
Object.hashCode() | 需要基于对象内容的哈希逻辑时使用 |
System.identityHashCode() | 需要对象身份标识时使用 |
黄金准则:
- 当对象作为
HashMap
等内容敏感容器的键时 → 重写hashCode()
+equals()
- 当需要对象身份标识(如调试、
IdentityHashMap
)时 → 使用identityHashCode()
- 永远不要在重写的
hashCode()
中调用identityHashCode()
,这违反哈希契约!
通过合理选择这两种哈希码,可以避免常见的
HashMap
逻辑错误和身份混淆问题。理解它们的差异,将使你的Java代码更加健壮高效!
来源:juejin.cn/post/7519797197925367818
DeepSeek回答过于笼统,提示词如何优化
针对DeepSeek回答过于笼统的问题,可通过以下方法优化,使输出更具体、详细:
一、优化提示词设计
- 明确具体要求
在提问中嵌入「背景+限制+示例」,例如:
"作为跨境电商运营新手,请详细说明如何优化亚马逊产品标题(要求包含SEO关键词布局、字符数限制、禁用词清单,并给出3个具体案例)"。
- 强制结构化输出
使用模板化指令:
"请按以下框架回答:问题背景→核心原理→实施步骤→注意事项→参考案例"
或要求特定格式:
`"用带注释的Python代码演示数据清洗流程,每个步骤添加中文注释说明"。 - 动态调整抽象层级
通过关键词控制详细程度:
- 追加指令:
"请展开说明第三点中的用户画像构建方法"
- 降低理解门槛:`"我是中学生,请用生活案例解释区块链技术"。
- 追加指令:
二、参数调整与功能设置
- 关键参数配置
- 提高
max_tokens
至300-500(延长输出长度) - 设置
temperature=0.7
(增强创造性,避免模板化)。
- 提高
- 启用深度思考模式
勾选界面左下角的「深度思考」选项,激活R1模型的专业分析能力,适合需要逻辑推导的复杂问题。 - 文件辅助增强
上传相关文档作为背景资料:
`"基于附件中的销售数据表,请逐月分析用户复购率变化趋势,并输出可视化图表建议"。
三、迭代优化技巧
- 追问细化
对笼统回答二次提问:
"请补充第一步'市场调研'中竞品分析的具体方法论"
"能否用表格对比方案A和方案B的优缺点?"
。
- 对抗模糊话术
添加反制指令:
"避免概括性描述,需包含可量化的执行标准"
`"拒绝理论阐述,直接给出操作手册式指引"。 - 多模态输出引导
要求混合内容形式:
`"请结合流程图+代码片段+ bullet points 解释API对接流程"。
四、进阶解决方案
若常规方法仍不理想,可尝试:
- 本地部署R1模型
通过Ollama平台运行本地模型,配合Cherry Studio客户端的知识库功能,训练专属应答模板。 - API定制开发
在火山引擎API中设置system_prompt
参数,预定义回答风格:
{"role":"system", "content":"你是一位擅长拆解复杂问题的经济学教授,回答需包含数学模型、现实案例和批判性思考"}
效果对比示例
原始提问 | 优化后提问 |
---|---|
"如何提升转化率?" | "作为护肤品电商运营,请制定小红书618促销转化率提升方案,要求:①分预热期/爆发期设计3种UGC玩法 ②ROI预估表格 ③规避平台限流的5个实操技巧" |
通过以上方法,可系统化解决回答笼统的问题。
来源:juejin.cn/post/7497075881467428873
Vue-Command-Component:让弹窗开发不再繁琐
前言
在Vue项目开发中,弹窗组件的管理一直是一个令人头疼的问题。传统的声明式弹窗开发方式需要管理大量的状态变量、处理复杂的props传递,甚至可能面临多个弹窗嵌套时的状态管理困境。今天给大家介绍一个能够彻底改变这种开发体验的库:Vue-Command-Component。
为什么需要命令式组件?
在传统的Vue开发中,弹窗的使用通常是这样的:
<template>
<el-dialog v-model="visible" title="提示">
<span>这是一段信息</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
const visible = ref(false)
const handleConfirm = () => {
// 处理确认逻辑
visible.value = false
}
</script>
这种方式存在几个明显的问题:
- 需要手动管理弹窗的显示状态
- 组件代码和业务逻辑混杂在一起
- 多个弹窗时代码会变得非常臃肿
- 弹窗之间的嵌套关系处理复杂
Vue-Command-Component 解决方案
使用Vue-Command-Component,上述问题都可以得到优雅的解决。来看看它是如何使用的:
import { useDialog } from "@vue-cmd/element-plus";
const CommandDialog = useDialog()
// 直接调用函数显示弹窗
CommandDialog(<div>这是一段信息</div>)
是的,就是这么简单!一行代码就能唤起一个弹窗,不需要管理状态,不需要写模板,一切都变得如此流畅。
核心特性
1. 极简API设计
无需管理状态,一个函数调用搞定所有事情,符合直觉的开发体验。
2. 完整的类型支持
提供完整的TypeScript类型支持,开发体验一流。
3. 灵活的控制能力
提供了多种控制方式:
destroy
:销毁弹窗hide
/show
:控制弹窗显示/隐藏destroyWithResolve
/destroyWithReject
:支持Promise风格的控制
4. 强大的扩展性
支持多种UI框架:
- Element Plus
- Naive UI
- Vant
- ...更多框架支持中
5. 原生特性支持
完整支持原生组件的所有特性:
- 属性传递
- 事件处理
- 插槽支持
- Provide/Inject
安装
# 使用 npm
npm install @vue-cmd/core @vue-cmd/element-plus
# 使用 yarn
yarn add @vue-cmd/core @vue-cmd/element-plus
# 使用 pnpm
pnpm add @vue-cmd/core @vue-cmd/element-plus
# 使用 bun
bun add @vue-cmd/core @vue-cmd/element-plus
实战示例
基础用法
import { useDialog } from "@vue-cmd/element-plus";
const CommandDialog = useDialog()
// 基础弹窗
CommandDialog(<Content />)
// 带配置的弹窗
CommandDialog(<Content />, {
attrs: {
title: '标题',
width: '500px'
}
})
嵌套弹窗
import { useDialog } from "@vue-cmd/element-plus";
const CommandDialog = useDialog()
CommandDialog(
<div onClick={() => {
// 在弹窗中打开新的弹窗
CommandDialog(<div>内层弹窗</div>)
}}>
外层弹窗
</div>
)
Promise风格控制
import { useDialog } from "@vue-cmd/element-plus";
import { useConsumer } from "@vue-cmd/core";
const CommandDialog = useDialog()
// 在弹窗组件内部
const FormComponent = defineComponent({
setup() {
const consumer = useConsumer()
const handleSubmit = (data) => {
// 提交数据后关闭弹窗
consumer.destroyWithResolve(data)
}
return () => <Form onSubmit={handleSubmit} />
}
})
// Promise风格的控制
try {
const result = await CommandDialog(<FormComponent />).promise
console.log('表单提交结果:', result)
} catch (error) {
console.log('用户取消或出错:', error)
}
多UI框架支持
// Element Plus
import { useDialog as useElementDialog } from "@vue-cmd/element-plus";
// Naive UI
import { useModal, useDrawer } from "@vue-cmd/naive";
// Vant
import { usePopup } from "@vue-cmd/vant";
const ElementDialog = useElementDialog()
const NaiveModal = useModal()
const VantPopup = usePopup()
// 使用不同的UI框架
ElementDialog(<Content />)
NaiveModal(<Content />)
VantPopup(<Content />)
写在最后
Vue-Command-Component 为Vue开发者带来了一种全新的弹窗开发方式。它不仅简化了开发流程,还提供了更强大的控制能力。如果你的项目中有大量弹窗交互,不妨尝试一下这个库,相信它会为你带来更好的开发体验。
相关链接
来源:juejin.cn/post/7501963430640615436
以赛育才,绽放创新之花:鲲鹏全面助力PAC2025巅峰对决
近日,第12届并行应用挑战赛(Parallel Application Challenge,简称“PAC2025”)在内蒙古鄂尔多斯圆满落幕。本届竞赛由全国信标委算力标准工作组指导,ACM中国高性能计算专家委员会与中国智能计算产业联盟联合主办,全球计算联盟高性能计算产发委协办,北京并行科技股份有限公司承办,吸引了来自全国各地高校、科研机构和产业界的200余支精英战队参与角逐。鲲鹏作为大赛的独家算力基座和核心技术支持平台,全程赋能赛事,助力打造了一场汇聚智慧与创新的并行计算盛会。
自2024年起,PAC大赛全面基于鲲鹏硬件平台,开创了“以赛促研、以赛促用、以赛育才”的自主算力生态新阶段。参赛作品基于鲲鹏处理器及KUPL并行编程框架进行代码优化与应用研发,切实推动了鲲鹏平台生态的融合与高性能计算算法的创新突破。
本届大赛最终有27支顶级战队挺进决赛,在应用赛道和优化赛道上涌现出兼具技术深度与产业价值的优秀作品。这些作品广泛应用于气象模拟、生物医药、大模型推理等关键领域,实现了显著的性能提升。通过实战检验,鲲鹏凭借其多核异构设计、高并发低功耗优势及完善的开发工具链,为参赛选手提供了稳定、高效的技术支持。选手们深度挖掘鲲鹏片上内存资源,结合向量化计算、矩阵计算、内存绑定、任务调度优化等先进技术,实现了性能的突破,充分体现了鲲鹏平台“从可用、好用到广泛用”的技术成熟度。
PAC2025的成功举办不仅展示了鲲鹏作为自主创新算力基座的强大实力,也标志着国内算力生态正从“追赶”迈向“引领”。这不仅是一场高水平的技术竞赛,更是一次凝聚行业共识、培育创新人才、推动产业升级的重要里程碑。
展望未来,鲲鹏将继续携手高校、科研机构及产业伙伴,深化“产学研用”深度融合,持续加大人才培养和生态建设投入,为我国高性能计算事业注入源源不断的强劲动力,共同迎接智能时代的到来。
以下为本届竞赛获奖名单:
附:
别再说你会 new Object() 了!JVM 类加载的真相,绝对和你想的不一样
当我们编写
new Object()
时,JVM 背后到底发生了怎样的故事?类加载过程中的初始化阶段究竟暗藏哪些玄机?
一、引言:从一段简单代码说起
先来看一个看似简单的 Java 代码片段:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
当我们执行这段代码时,背后却隐藏着 JVM 复杂的类加载机制。.java
文件经过编译变成 .class
字节码文件,这些"静态"的字节码需要被 JVM 动态地加载、处理并最终执行。这就是类加载过程的神奇之处。
类加载机制是 Java 语言的核心基石,它赋予了 Java "一次编写,到处运行" 的能力。理解这一过程,不仅能帮助我们编写更高效的代码,更是面试中的高频考点。
二、类生命周期:七个阶段的完整旅程
在深入类加载过程之前,我们先来了解类的完整生命周期。一个类在 JVM 中从加载到卸载,总共经历七个阶段:
阶段 | 描述 | 是否必须 | 特点 | JVM规范要求 |
---|---|---|---|---|
加载(Loading) | 查找并加载类的二进制数据 | 是 | 将字节码读入内存,生成Class对象 | 强制 |
验证(Verification) | 确保被加载的类正确无误 | 是 | 安全验证,防止恶意代码 | 强制 |
准备(Preparation) | 为类变量分配内存并设置初始零值 | 是 | 注意:不是程序员定义的初始值 | 强制 |
解析(Resolution) | 将符号引用转换为直接引用 | 否 | 可以在初始化后再进行 | 可选 |
初始化(Initialization) | 执行类构造器 <clinit>() 方法 | 是 | 初始化类而不是对象 | 强制 |
使用(Using) | 正常使用类的功能 | 是 | 类的使命阶段 | - |
卸载(Unloading) | 从内存中释放类数据 | 否 | 由垃圾回收器负责 | 可选 |
前五个阶段(加载、验证、准备、解析、初始化)统称为类加载过程。
三、类加载过程的五个步骤详解
3.1 加载阶段:寻找类的旅程
加载阶段是类加载过程的起点,主要完成三件事情:
- 通过类的全限定名获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
// 示例:不同的类加载方式
public class LoadingExample {
public static void main(String[] args) throws Exception {
// 通过类加载器加载
Class<?> clazz1 = ClassLoader.getSystemClassLoader().loadClass("java.lang.String");
// 通过Class.forName加载(默认会初始化)
Class<?> clazz2 = Class.forName("java.lang.String");
// 通过字面常量获取(不会触发初始化)
Class<?> clazz3 = String.class;
System.out.println("三种方式加载的类是否相同: " +
(clazz1 == clazz2 && clazz2 == clazz3));
}
}
3.2 验证阶段:安全的第一道防线
验证阶段确保 Class 文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息不会危害虚拟机自身的安全。
验证类型 | 验证内容 | 失败后果 |
---|---|---|
文件格式验证 | 魔数(0xCAFEBABE)、版本号、常量池 | ClassFormatError |
元数据验证 | 语义验证、继承关系(如是否实现抽象方法) | IncompatibleClassChangeError |
字节码验证 | 逻辑验证、跳转指令合法性 | VerifyError |
符号引用验证 | 引用真实性、访问权限(如访问private方法) | NoSuchFieldError、NoSuchMethodError |
3.3 准备阶段:零值初始化的奥秘
这是最容易产生误解的阶段! 在准备阶段,JVM 为**类变量(static变量)**分配内存并设置初始零值,注意这不是程序员定义的初始值。
public class PreparationExample {
// 准备阶段后 value = 0,而不是 100
public static int value = 100;
// 准备阶段后 constantValue = 200(因为有final修饰)
public static final int constantValue = 200;
// 实例变量 - 准备阶段完全不管
public int instanceValue = 300;
}
各种数据类型的零值对照表:
数据类型 | 零值 | 数据类型 | 零值 |
---|---|---|---|
int | 0 | boolean | false |
long | 0L | float | 0.0f |
double | 0.0 | char | '\u0000' |
引用类型 | null | short | (short)0 |
关键区别:只有**类变量(static变量)**在准备阶段分配内存和初始化零值,实例变量会在对象实例化时随对象一起分配在堆内存中。
3.4 解析阶段:符号引用到直接引用的转换
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。这个过程可以在初始化之后再进行,这是为了支持Java的动态绑定特性。
解析主要针对以下四类符号引用:
引用类型 | 解析目标 | 可能抛出的异常 |
---|---|---|
类/接口解析 | 将符号引用解析为具体类/接口 | NoClassDefFoundError |
字段解析 | 解析字段所属的类/接口 | NoSuchFieldError |
方法解析 | 解析方法所属的类/接口 | NoSuchMethodError |
接口方法解析 | 解析接口方法所属的接口 | AbstractMethodError |
3.5 初始化阶段:执行类构造器 <clinit>()
这是类加载过程的最后一步,也是真正开始执行类中定义的Java程序代码的一步。
JVM规范严格规定的六种初始化触发情况:
- 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时
// new指令 - 创建类的实例
Object obj = new Object();
// getstatic指令 - 读取类的静态字段
int value = MyClass.staticField;
// putstatic指令 - 设置类的静态字段
MyClass.staticField = 100;
// invokestatic指令 - 调用类的静态方法
MyClass.staticMethod();
- 使用java.lang.reflect包的方法对类进行反射调用时
// 反射调用会触发类的初始化
Class<?> clazz = Class.forName("com.example.MyClass");
- 当初始化一个类时,发现其父类还没有进行过初始化
class Parent {
static { System.out.println("Parent初始化"); }
}
class Child extends Parent {
static { System.out.println("Child初始化"); }
}
// 初始化Child时会先初始化Parent
- 虚拟机启动时,用户指定的主类(包含main()方法的那个类)
// 执行 java MyApp 时,MyApp类会被初始化
public class MyApp {
public static void main(String[] args) {
System.out.println("应用程序启动");
}
}
- 使用JDK7新加入的动态语言支持时
// 使用MethodHandle等动态语言特性
MethodHandles.Lookup lookup = MethodHandles.lookup();
- 一个接口中定义了JDK8新加入的默认方法时,如果这个接口的实现类发生了初始化,要先将接口进行初始化
interface MyInterface {
// JDK8默认方法会触发接口初始化
default void defaultMethod() {
System.out.println("默认方法");
}
}
3.6 使用阶段:类的使命实现
当类完成初始化后,就进入了使用阶段。这是类生命周期中最长的阶段,类的所有功能都可以正常使用:
public class UsageStageExample {
public static void main(String[] args) {
// 类已完成初始化,进入使用阶段
MyClass obj = new MyClass(); // 创建对象实例
obj.instanceMethod(); // 调用实例方法
MyClass.staticMethod(); // 调用静态方法
int value = MyClass.staticVar;// 访问静态变量
}
}
class MyClass {
public static int staticVar = 100;
public int instanceVar = 200;
public static void staticMethod() {
System.out.println("静态方法");
}
public void instanceMethod() {
System.out.println("实例方法");
}
}
在使用阶段,类可以:
- 创建对象实例
- 调用静态方法和实例方法
- 访问和修改静态字段和实例字段
- 被其他类引用和继承
3.7 卸载阶段:生命的终结
类的卸载是生命周期的最后阶段,但并不是必须发生的。一个类被卸载需要满足以下条件:
- 该类所有的实例都已被垃圾回收
- 加载该类的ClassLoader已被垃圾回收
- 该类对应的java.lang.Class对象没有被任何地方引用
public class UnloadingExample {
public static void main(String[] args) throws Exception {
// 使用自定义类加载器加载类
CustomClassLoader loader = new CustomClassLoader();
Class<?> clazz = loader.loadClass("com.example.TemporaryClass");
// 创建实例并使用
Object instance = clazz.newInstance();
System.out.println("类已加载并使用: " + clazz.getName());
// 解除所有引用,使类和类加载器可被回收
clazz = null;
instance = null;
loader = null;
// 触发GC,可能卸载类
System.gc();
System.out.println("类和类加载器可能已被卸载");
}
}
class CustomClassLoader extends ClassLoader {
// 自定义类加载器实现
}
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
四、关键辨析:类初始化 vs. 对象实例化
这是本文的核心观点,也是大多数开发者容易混淆的概念。让我们通过一个对比表格来清晰区分:
特性 | 类初始化 (Initialization) | 对象实例化 (Instantiation) |
---|---|---|
触发时机 | 类被首次"主动使用"时(JVM控制) | 遇到new关键字时(程序员控制) |
发生次数 | 一次(每个类加载器范围内) | 多次(可以创建多个对象实例) |
核心方法 | <clinit>() 方法 | <init>() 方法(构造函数) |
操作目标 | 类本身(初始化静态变量/类变量) | 对象实例(初始化实例变量) |
内存区域 | 方法区(元空间) | Java堆 |
执行内容 | 静态变量赋值、静态代码块 | 实例变量赋值、实例代码块、构造函数 |
public class InitializationVsInstantiation {
// 类变量 - 在<clinit>()方法中初始化
public static String staticField = initStaticField();
// 实例变量 - 在<init>()方法中初始化
public String instanceField = initInstanceField();
// 静态代码块 - 在<clinit>()方法中执行
static {
System.out.println("静态代码块执行");
}
// 实例代码块 - 在<init>()方法中执行
{
System.out.println("实例代码块执行");
}
public InitializationVsInstantiation() {
System.out.println("构造方法执行");
}
private static String initStaticField() {
System.out.println("静态变量初始化");
return "static value";
}
private String initInstanceField() {
System.out.println("实例变量初始化");
return "instance value";
}
public static void main(String[] args) {
System.out.println("=== 第一次创建对象 ===");
new InitializationVsInstantiation();
System.out.println("\n=== 第二次创建对象 ===");
new InitializationVsInstantiation();
}
}
输出结果:
静态变量初始化
静态代码块执行
=== 第一次创建对象 ===
实例变量初始化
实例代码块执行
构造方法执行
=== 第二次创建对象 ===
实例变量初始化
实例代码块执行
构造方法执行
五、深度实战:初始化顺序全面解析
现在,让我们通过一个综合示例来回答开篇的思考题:如果一个类同时包含静态变量、静态代码块、实例变量、实例代码块和构造方法,它们的执行顺序是怎样的?在存在继承关系时又会如何变化?
5.1 单类初始化顺序
public class InitializationOrder {
// 静态变量
public static String staticField = "静态变量";
// 静态代码块
static {
System.out.println(staticField);
System.out.println("静态代码块");
}
// 实例变量
public String field = "实例变量";
// 实例代码块
{
System.out.println(field);
System.out.println("实例代码块");
}
// 构造方法
public InitializationOrder() {
System.out.println("构造方法");
}
public static void main(String[] args) {
System.out.println("第一次实例化:");
new InitializationOrder();
System.out.println("\n第二次实例化:");
new InitializationOrder();
}
}
输出结果:
静态变量
静态代码块
第一次实例化:
实例变量
实例代码块
构造方法
第二次实例化:
实例变量
实例代码块
构造方法
关键发现:
- 静态代码块只在类第一次加载时执行一次
- 实例代码块在每次创建对象时都会执行
- 执行顺序:静态变量/代码块 → 实例变量/代码块 → 构造方法
5.2 继承关系下的初始化顺序
class Parent {
// 父类静态变量
public static String parentStaticField = "父类静态变量";
// 父类静态代码块
static {
System.out.println(parentStaticField);
System.out.println("父类静态代码块");
}
// 父类实例变量
public String parentField = "父类实例变量";
// 父类实例代码块
{
System.out.println(parentField);
System.out.println("父类实例代码块");
}
// 父类构造方法
public Parent() {
System.out.println("父类构造方法");
}
}
class Child extends Parent {
// 子类静态变量
public static String childStaticField = "子类静态变量";
// 子类静态代码块
static {
System.out.println(childStaticField);
System.out.println("子类静态代码块");
}
// 子类实例变量
public String childField = "子类实例变量";
// 子类实例代码块
{
System.out.println(childField);
System.out.println("子类实例代码块");
}
// 子类构造方法
public Child() {
System.out.println("子类构造方法");
}
public static void main(String[] args) {
System.out.println("第一次实例化子类:");
new Child();
System.out.println("\n第二次实例化子类:");
new Child();
}
}
输出结果:
父类静态变量
父类静态代码块
子类静态变量
子类静态代码块
第一次实例化子类:
父类实例变量
父类实例代码块
父类构造方法
子类实例变量
子类实例代码块
子类构造方法
第二次实例化子类:
父类实例变量
父类实例代码块
父类构造方法
子类实例变量
子类实例代码块
子类构造方法
关键发现:
- 父类静态代码块 → 子类静态代码块 → 父类实例代码块 → 父类构造方法 → 子类实例代码块 → 子类构造方法
- 静态代码块只执行一次,实例代码块每次创建对象都执行
- 父类优先于子类初始化
5.3 进阶案例:包含静态变量初始化的复杂情况
public class ComplexInitialization {
public static ComplexInitialization instance = new ComplexInitialization();
public static int staticVar = 100;
public int instanceVar = 200;
static {
System.out.println("静态代码块: staticVar=" + staticVar);
}
{
System.out.println("实例代码块: instanceVar=" + instanceVar + ", staticVar=" + staticVar);
}
public ComplexInitialization() {
System.out.println("构造方法: instanceVar=" + instanceVar + ", staticVar=" + staticVar);
}
public static void main(String[] args) {
System.out.println("main方法开始");
new ComplexInitialization();
}
}
输出结果:
实例代码块: instanceVar=200, staticVar=0
构造方法: instanceVar=200, staticVar=0
静态代码块: staticVar=100
main方法开始
实例代码块: instanceVar=200, staticVar=100
构造方法: instanceVar=200, staticVar=100
关键发现:
- 静态变量
staticVar
在准备阶段被初始化为0 - 在初始化阶段,按顺序执行静态变量赋值和静态代码块
- 当执行
instance = new ComplexInitialization()
时,staticVar
还未被赋值为100(还是0) - 这解释了为什么第一次输出时
staticVar=0
六、面试常见问题与解答
6.1 高频面试题解析
Q1: 下面代码的输出结果是什么?为什么?
public class InterviewQuestion {
public static void main(String[] args) {
System.out.println(Child.value);
}
}
class Parent {
static int value = 100;
static { System.out.println("Parent静态代码块"); }
}
class Child extends Parent {
static { System.out.println("Child静态代码块"); }
}
A: 输出结果为:
Parent静态代码块
100
解析: 通过子类引用父类的静态字段,不会导致子类初始化,这是类加载机制的一个重要特性。
Q2: 接口的初始化与类有什么不同?
A: 接口的初始化与类类似,但有重要区别:
- 接口也有
<clinit>()
方法,由编译器自动生成 - 接口初始化时不需要先初始化父接口
- 只有当程序首次使用接口中定义的非常量字段时,才会初始化接口
6.2 类加载机制的实际应用
1. 单例模式的优雅实现:
public class Singleton {
private Singleton() {}
private static class SingletonHolder {
static {
System.out.println("SingletonHolder初始化");
}
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
这种实现利用了类加载机制的特性:只有在真正调用 getInstance()
时才会加载 SingletonHolder
类,实现了懒加载且线程安全。
2. 常量传播优化:
public class ConstantExample {
public static final String CONSTANT = "Hello";
public static void main(String[] args) {
System.out.println(CONSTANT);
}
}
编译时,常量 CONSTANT
的值会被直接内联到使用处,不会触发类的初始化。
七、总结与思考
通过本文的深入分析,我们可以总结出以下几个关键点:
- 类加载过程五个阶段:加载 → 验证 → 准备 → 解析 → 初始化,每个阶段都有其特定任务
- 关键区别:
- 初始化阶段是初始化类(执行
<clinit>()
),而不是初始化对象(执行<init>()
) - 类静态变量在准备阶段分配内存并设置零值,在初始化阶段赋实际值
- 实例变量在对象实例化时分配内存和初始化
- 初始化阶段是初始化类(执行
- 初始化顺序原则:
- 父类优先于子类
- 静态优先于实例
- 变量定义顺序决定初始化顺序
- 实际应用:理解类加载机制有助于我们编写更高效的代码,如实现懒加载的单例模式、理解常量内联优化等
希望本文能帮助你深入理解JVM类加载机制,下次遇到相关面试题时,相信你一定能游刃有余!
来源:juejin.cn/post/7541339617489797163
判断 Python 代码是不是 AI 写的几个简单方法
作者:Laurel W
来源:Adobe
作为一名数据科学和数学老师,我其实不介意我的学生使用像 ChatGPT 这样的 LLM,只要它是用来辅助他们学习,而不是取代学习过程。加州理工学院的申请文书指南启发了我为编程和机器学习课制定 AI 使用政策:
哪些是加州理工申请文书中不道德的 AI 使用方式?
- 直接从 AI 生成器复制粘贴
- 依赖 AI 生成内容来列提纲或起草文书
- 用 AI 生成的内容替换你独特的声音和语气
- 翻译用其他语言写的文书
哪些是加州理工申请文书中道德的 AI 使用方式?
- 使用 Grammarly 或 Microsoft Editor 这类 AI 工具检查语法拼写
- 用 AI 生成问题或练习题,帮助启动头脑风暴
- 用 AI 来研究申请流程
如果你还在想你使用 AI 是否道德,问问自己:你请一位值得信赖的大人做同样的事,合适吗?比如请老师帮你改语法错拼写错?当然可以!但让老师帮你写一篇文书草稿,然后你稍微改改就交了?绝对不行。
话虽如此,最近我收到了一些代码作业,一看就完全是 AI 写的,于是我整理了一些“AI 征兆”。当然,人类和 AI 写的代码现在越来越难分辨。但 ChatGPT / Claude / DeepSeek 这些生成的代码,还是有一些共同特征,一看就不对劲。
注释
我希望所有学生都能像 ChatGPT 那样写那么详细的注释吗?当然希望。但他们会吗?肯定不会。
🚩 注释过于详细或者风格怪异,是使用 AI 的信号。
比如,DeepSeek 生成的代码里你会看到用三引号写一段 docstring 来当注释,而不是用标准的 #,尽管那段 docstring 并没有说明参数或返回值:
def find_squares_adding_to_zero(mod):
"""Find two non-zero numbers a and b such that a^2 + b^2 ≡ 0 mod n."""
for a in range(1, mod): # Start from 1 to exclude a = 0
for b in range(a, mod): # Start from a to avoid redundant pairs
if (a2 + b2) % mod == 0:
return (a, b)
return
我觉得这用来注释一行代码的方式很不自然,尤其是我在课上明确教的是用 # 来写单行注释,而这段也不是一个正经的 docstring。
注释里用特殊符号也可能是 AI 写的。除非是我写给很多人看的代码,我自己是不会去找像 “≡” 这样的符号来写注释的。我敢说我的学生大概也不会这么做。
Lambda 表达式
别误会——我喜欢 lambda 表达式,觉得它们是 Python 中很独特且有价值的功能。我认为 lambda 是用来写简洁、临时的小函数的,直接作为参数传给像 map()、filter() 或 sorted() 这种函数才合适。用得好很亮眼。但 lambda 表达式一旦滥用、用错地方,或者还没讲到就乱用,就是个警示。
🚩 lambda 用得不合时宜,说明设计不够认真,或者太依赖 AI。
比如,要是我在还没系统讲 lambda 表达式前,收到这么一份作业代码,那我肯定第一时间就怀疑是 ChatGPT 写的:
from functools import lru_cache
fib = (lambda f: (lambda x: f(f, x)))(lambda self, n:
n if n < 2 else self(self, n-1) + self(self, n-2))
Generate the first 10 Fibonacci numbers
fib_sequence = [fib(n) for n in range(10)]
print(fib_sequence)
这种写法根本不适合用 lambda,而且太复杂了,不可能是一个第一次学 Python 的学生自己写出来的。
库的使用
库导入的位置
🚩 有时候,LLM 会把 import 放在奇怪的位置(不是在文件最上面)。
这种情况我只见过几次,但如果你已经跟学生强调过 import 要放在开头或集中在一起,那这种写法就可疑了。
用了什么库:
🚩 用某个库完成其实不需要它的任务,尤其是没讲过的冷门库,也可能是 AI 写的迹象。
我们再看看上面那段 lambda 代码:
from functools import lru_cache
fib = (lambda f: (lambda x: f(f, x)))(lambda self, n:
n if n < 2 else self(self, n-1) + self(self, n-2))
Generate the first 10 Fibonacci numbers
fib_sequence = [fib(n) for n in range(10)]
print(fib_sequence)
如果你还没讲过 functools 这个库,那学生很可能是用 Google 或 LLM 才知道的。用这些工具探索库当然没问题,但也可能说明实现方式就是 AI 生成的。
命名和格式风格不一致
这个一看就懂。如果作业要求某个字段或变量要用特定命名,AI 写出来的代码往往会给出更泛、更“解释性”的名字,但反而不自然。比如,下面这段找所有小于 n 的素数的代码,一个普通学生可能不会写得这么工整、变量名这么“讲究”:
def sieve_of_eratosthenes(limit):
"""Return a list of all prime numbers less than the given limit."""
if limit <= 2:
return []
Initialize a boolean array to track prime numbers
is_prime = [True] * limit
is_prime[0] = is_prime[1] = False # 0 and 1 are not primes
for num in range(2, int(limit**0.5) + 1):
if is_prime[num]:
Mark multiples of num as non-prime
for multiple in range(num * num, limit, num):
is_prime[multiple] = False
Collect all prime numbers
primes = [num for num, prime in enumerate(is_prime) if prime]
return primes
Example usage
limit = 50 # Change this value to find primes less than a different number
primes = sieve_of_eratosthenes(limit)
print(f"Primes less than {limit}: {primes}")
我自己可能会用 n 而不是 limit,函数名也会更随意些,比如叫 prime_finder。当然,一些特别讲究的学生也可能会写成这样,所以我一般是结合其他迹象一起判断是不是 AI 写的。
逻辑错误
逻辑错误纸面上是最明显的红旗。但实际上很难判断——到底是语言模型出错了,还是学生自己没理解透?
归根结底,LLM 是基于统计的模型,用训练数据学到的模式来预测下一个 token(比如字、词或子词)。它们擅长生成连贯、有上下文的文本,但并不真正理解、也不具备推理或算法思维。这导致它们在需要精确逻辑的任务上容易出错,比如解数学题或者写代码。有时候会给出看上去靠谱但其实错的答案,或者根本没搞清楚复杂的逻辑结构。比如我见过它们在数学题里索引错、没处理边界条件、甚至输出完全错误。
🚩 如果代码有逻辑错误或者推理错误,那可能是 AI 写的。
理想情况下,一个认真点的学生会先检查并修正这些错误再交作业。但实际情况是,很多代码交上来根本不能跑,或者输出就错了。
最后提醒一下,其实很难定一个硬性规则来判断是不是 AI 写的代码,所以我建议老师先别急着指责,而是带着好奇去了解学生的思路。如果我怀疑一个学生太依赖 AI,我可能会让他白板手写一个类似的题目,或者问问他对这个解法的理解。只要引导得当、细节到位,我希望未来能培养出的是会把 AI 当工具,而不是当拐杖的专业人士。
来源:juejin.cn/post/7486694184407531555
CSS 黑科技之多重边框:为网页添彩
在前端开发的奇妙世界里,CSS 总是能给我们带来意想不到的惊喜。今天,就让我们一同探索 CSS 的一个有趣特性 —— 多重边框,看看它如何为我们的网页设计增添独特魅力。
什么是多重边框
在传统认知中,一个元素通常只有一层边框。但借助 CSS 的box-shadow属性,我们可以突破这一限制,轻松实现多重边框效果。box-shadow属性原本用于为元素添加阴影,不过通过巧妙设置,它能化身为创造多重边框的利器。
如何实现多重边框
实现多重边框的关键在于对box-shadow属性的灵活运用。下面是一个简单示例:
div {
box-shadow: 0 0 0 5px red, 0 0 0 10px blue;
}
在这段代码中,box-shadow属性接受了两组值,每组值都定义了一个 “边框”。具体来说,0 0 0 5px red表示第一个边框:前两个0分别表示水平和垂直方向的偏移量,这里都为 0,即不偏移;第三个0表示模糊半径为 0,也就是边框清晰锐利;5px表示扩展半径,即边框的宽度;red则是边框的颜色。同理,0 0 0 10px blue定义了第二个边框,宽度为 10px,颜色为蓝色。通过这样的方式,我们就为div元素创建了两层不同颜色和宽度的边框。
多重边框的应用场景
- 突出重要元素:在网页中,有些元素需要特别突出显示,比如导航栏、重要按钮等。使用多重边框可以让这些元素在页面中脱颖而出,吸引用户的注意力。
- 营造层次感:多重边框能够为元素增加层次感,使页面看起来更加丰富和立体。在设计卡片式布局时,这种效果尤为明显,可以让卡片更加生动有趣。
- 创意设计:对于追求独特风格的网页设计,多重边框提供了无限的创意空间。可以通过调整边框的颜色、宽度、模糊度等参数,创造出各种独特的视觉效果,展现出与众不同的设计风格。
注意事项
- 性能问题:虽然多重边框效果很酷,但过多地使用复杂的box-shadow属性可能会影响页面性能,尤其是在移动设备上。因此,在实际应用中需要权衡效果和性能,避免过度使用。
- 兼容性:不同浏览器对box-shadow属性的支持程度略有差异。在使用时,要确保在主流浏览器上进行充分测试,必要时可以添加浏览器前缀来保证兼容性。
CSS 的多重边框特性为前端开发者提供了一种简单而强大的方式来增强网页的视觉效果。通过合理运用这一特性,我们能够打造出更加美观、富有创意的网页界面。希望大家在今后的前端开发中,大胆尝试多重边框,让自己的网页作品更加出彩!
来源:juejin.cn/post/7472233713416110089
希尔伯特曲线:降维打击与空间填充的艺术
在数学和计算机科学的交汇处,存在着一种令人着迷的几何结构——希尔伯特曲线(Hilbert Curve)。这种由德国数学家大卫·希尔伯特于1891年提出的连续空间填充曲线,不仅挑战了我们对维度的直观认知,更在现代技术领域发挥着举足轻重的作用。
一、初识希尔伯特曲线:维度穿梭的钥匙
希尔伯特曲线的核心在于其空间填充性和连续性。想象一条无限延伸的细线,它以一种巧妙的方式弯曲、折叠,最终能够填满整个二维平面(或更高维度的空间),这就是希尔伯特曲线的魔力。尽管它是连续的,但由于其分形特性,这条曲线在任何一点都不可导。
希尔伯特曲线的构建基于递归的思想。以最经典的二维希尔伯特曲线为例,它从一个正方形开始,将其四等分,然后用一条线段连接这四个小正方形的中心点,形成一个基本的“U”形。接下来,对每个小正方形重复这个过程,不断细分、连接,最终在无限递归下,这条曲线将覆盖整个正方形内的每一个点, 如下图所示。
另一个关键特性是局部性与全局性。 在希尔伯特曲线的映射过程中,相邻的一维线段在高维空间中仍然倾向于保持局部邻近性(尽管并非绝对)。这意味着,在一维序列中相近的点,在二维或三维空间中也往往彼此靠近。
二、希尔伯特曲线家族:多样的空间填充策略
希尔伯特曲线并非孤立存在,它属于一个更大的空间填充曲线家族,每个成员都有其独特的构建方式和应用场景。
- 经典二维希尔伯特曲线: 最基础的形式,奠定了空间填充曲线的基础。
- 三维希尔伯特曲线: 将递归扩展到三维,用于体数据索引和空间数据库。
- 皮亚诺曲线(Peano Curve): 早于希尔伯特曲线,采用“九宫格”分割,但可能产生交叉点。
- 摩尔曲线(Moore Curve): 一种闭合的空间填充曲线,首尾相连,适合循环遍历。
- Z阶曲线(Morton Curve): 基于坐标的二进制交错编码(Morton码),计算高效,广泛用于数据库索引(如Geohash)。
- 自适应变体: 根据数据密度动态调整递归深度,优化存储和查询效率。
三、希尔伯特曲线的深远意义:超越几何的维度
希尔伯特曲线的意义远不止于其几何形态,它在数学、计算机科学、甚至哲学层面都产生了深远的影响。
1. 数学意义:
- 挑战维度直觉: 希尔伯特曲线证明了一维曲线可以覆盖高维空间,颠覆了人们对维度的传统认知。
- 分形几何的早期范例: 它展示了自相似性和无限递归的数学美感,为分形几何的发展奠定了基础。
- 拓扑学应用: 为连续映射和空间压缩提供了理论支持。
2. 计算机科学与工程应用:
- 空间索引: 在数据库中高效处理多维数据(如地图坐标、图像像素),通过希尔伯特排序优化范围查询。
- 图像处理: 将二维图像转换为一维序列,用于压缩或渐进传输。
- 并行计算: 分配高维数据到计算节点时,保持数据的局部性以减少通信开销。
- 路径规划: 机器人导航或PCB布线中,生成覆盖整个区域的连续扫描路径。
3. 哲学与认知影响:
希尔伯特曲线模糊了维度的界限,引发了对“维度”本质的哲学思考,挑战了传统几何学的直观认知。
四、空间填充曲线的对比:各有千秋
曲线类型 | 连续性 | 交叉点 | 局部性保留 | 应用场景 |
---|---|---|---|---|
希尔伯特曲线 | 连续 | 无 | 较好 | 数据库索引、图像处理 |
皮亚诺曲线 | 连续 | 有 | 较差 | 理论数学 |
Z阶曲线 | 不连续 | 无 | 中等 | 地理哈希、GPU计算 |
从上表可以看出,不同的空间填充曲线在连续性、交叉点、局部性保留等方面各有特点,适用于不同的应用场景。希尔伯特曲线以其良好的局部性保留和无交叉点的特性,在数据库索引和图像处理等领域表现出色。
总结:维度之桥,应用之光
希尔伯特曲线及其变体不仅在理论数学中揭示了维度的奇妙性质,更在计算机科学中成为处理高维数据的关键工具。其核心价值在于将高维问题映射到低维空间并保持局部性,从而在效率和实用性之间找到平衡。随着大数据和人工智能时代的到来,希尔伯特曲线及其衍生技术将在更多领域展现其独特的魅力和应用价值。 无论是理解宇宙的结构,还是优化数据的存储与查询,希尔伯特曲线都为我们提供了一把穿越维度的钥匙,一座连接理论与应用的桥梁。
来源:juejin.cn/post/7470453022801068042
计算初始化内存总长度
计算初始化内存总长度
问题背景
在一个系统中,需要执行一系列的内存初始化操作。每次操作都会初始化一个特定地址范围的内存。这些操作范围可能会相互重叠。我们需要计算所有操作完成后,被初始化过的内存空间的总长度。
核心定义
- 操作范围: 每一次内存初始化操作由一个范围
[start, end]
定义,它代表一个左闭右开的区间[start, end)
。这意味着地址start
被包含,而地址end
不被包含。 - 内存长度: 对于一个操作
[start, end]
,其初始化的内存长度为end - start
。
关键假设
- 所有初始化操作都会成功执行。
- 同一块内存区域允许被重复初始化。例如,操作
[2, 5)
和[4, 7)
是允许的,它们有重叠部分[4, 5)
。
任务要求
给定一组内存初始化操作 cmdsOfMemInit
,计算所有操作完成后,被初始化过的内存空间的总长度。这等同于计算所有给定区间的并集的总长度。
输入格式
cmdsOfMemInit
: 一个二维数组(或列表的列表),代表一系列的内存初始化操作。
- 数组长度:
1 <= cmdsOfMemInit.length <= 100000
- 每个元素
cmdsOfMemInit[i]
是一个包含两个整数[start, end]
的数组。 - 区间范围:
0 <= start < end <= 10^9
- 数组长度:
输出格式
- 一个整数,表示最终被初始化过的内存空间的总长度。
样例说明
样例 1
- 输入:
[[2, 4], [3, 7], [4, 6]]
- 输出:
5
- 解释:
- 我们有三个区间:
[2, 4)
,[3, 7)
,[4, 6)
。 - 合并
[2, 4)
和[3, 7)
: 因为它们有重叠部分([3, 4)
),所以可以合并成一个更大的区间[2, 7)
。 - 合并
[2, 7)
和[4, 6)
: 新的区间[4, 6)
完全被[2, 7)
覆盖。合并后的结果仍然是[2, 7)
。 - 所有操作完成后,最终被初始化的内存区域是
[2, 7)
。 - 总长度为
7 - 2 = 5
。
- 我们有三个区间:
样例 2
- 输入:
[[3, 7], [2, 4], [10, 30]]
- 输出:
25
- 解释:
- 我们有三个区间:
[3, 7)
,[2, 4)
,[10, 30)
。 - 合并
[3, 7)
和[2, 4)
: 它们有重叠部分,合并后的区间为[2, 7)
。 - 合并
[2, 7)
和[10, 30)
: 这两个区间没有重叠,因为10
大于7
。它们是两个独立的初始化区域。 - 所有操作完成后,最终的初始化内存区域由两个不相交的区间组成:
[2, 7)
和[10, 30)
。 - 总长度是这两个独立区间长度之和:
(7 - 2) + (30 - 10) = 5 + 20 = 25
。
- 我们有三个区间:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
/**
* 解决“内存空间长度”问题的方案类。
*/
public class Solution {
/**
* 计算一系列内存初始化操作覆盖的总内存空间长度。
*
* @param cmdsOfMemInit 一个二维数组,每个内部数组 [start, end] 代表一个左闭右开的内存初始化区间。
* @return 最终初始化的内存空间的总长度。
*/
public long totalInitializedLength(int[][] cmdsOfMemInit) {
// --- 1. 处理边界情况 ---
// 如果输入为空或没有操作,则总长度为 0。
if (cmdsOfMemInit == null || cmdsOfMemInit.length == 0) {
return 0;
}
// --- 2. 按区间的起始地址(start)对所有操作进行升序排序 ---
// 这是合并区间的关键前提步骤。
// Comparator.comparingInt(a -> a[0]) 是一个简洁的写法,表示按内部数组a的第一个元素排序。
Arrays.sort(cmdsOfMemInit, Comparator.comparingInt(a -> a[0]));
// --- 3. 合并重叠和连续的区间 ---
// 使用一个 List 来存储合并后的、不重叠的区间。
List<int[]> mergedIntervals = new ArrayList<>();
// 首先将第一个区间(起始地址最小)加入合并列表作为基础。
mergedIntervals.add(cmdsOfMemInit[0]);
// 遍历排序后的其余区间
for (int i = 1; i < cmdsOfMemInit.length; i++) {
int[] currentInterval = cmdsOfMemInit[i];
// 获取合并列表中的最后一个区间,用于比较
int[] lastMerged = mergedIntervals.get(mergedIntervals.size() - 1);
// 检查当前区间是否与最后一个合并区间重叠或连续。
// 因为区间是 [start, end) 左闭右开,所以当 currentInterval 的 start <= lastMerged 的 end 时,
// 它们就需要合并。例如 [2,4) 和 [4,6) 应该合并为 [2,6)。
if (currentInterval[0] <= lastMerged[1]) {
// --- 合并区间 ---
// 如果有重叠/连续,则更新最后一个合并区间的结束地址。
// 新的结束地址是两个区间结束地址中的较大者。
// 例如,合并 [2,7) 和 [4,6) 时,新的 end 是 max(7, 6) = 7,结果为 [2,7)。
lastMerged[1] = Math.max(lastMerged[1], currentInterval[1]);
} else {
// --- 不重叠,添加新区间 ---
// 如果没有重叠,则将当前区间作为一个新的、独立的合并区间添加到列表中。
mergedIntervals.add(currentInterval);
}
}
// --- 4. 计算合并后区间的总长度 ---
// 使用 long 类型来存储总长度,防止因数值过大(坐标可达10^9)而溢出。
long totalLength = 0;
// 遍历所有不重叠的合并区间
for (int[] interval : mergedIntervals) {
// 累加每个区间的长度 (end - start)
totalLength += (long) interval[1] - interval[0];
}
// --- 5. 返回结果 ---
return totalLength;
}
public static void main(String[] args) {
Solution sol = new Solution();
// 样例1
int[][] cmds1 = {{2, 4}, {3, 7}, {4, 6}};
System.out.println("样例1 输入: [[2, 4], [3, 7], [4, 6]]");
System.out.println("样例1 输出: " + sol.totalInitializedLength(cmds1)); // 预期: 5
// 样例2
int[][] cmds2 = {{3, 7}, {2, 4}, {10, 30}};
System.out.println("\n样例2 输入: [[3, 7], [2, 4], [10, 30]]");
System.out.println("样例2 输出: " + sol.totalInitializedLength(cmds2)); // 预期: 25
// 边界测试
int[][] cmds3 = {{1, 5}, {6, 10}};
System.out.println("\n边界测试 输入: [[1, 5], [6, 10]]");
System.out.println("边界测试 输出: " + sol.totalInitializedLength(cmds3)); // 预期: 8 (4+4)
}
*/
}
来源:juejin.cn/post/7527154276223336488
鸿蒙模块间资源引用
跨模块资源访问-程序包结构-应用框架 - 华为HarmonyOS开发者
根据官方文档和项目实践,以下是关于跨模块资源访问的总结:
1. 跨模块资源访问的核心目标
- 资源共享:通过 HAR(Harmony Archive)和 HSP(Harmony Shared Package)模块,实现资源(如文本、图片、样式等)的复用,减少冗余定义。
- 模块化开发:支持功能模块的独立开发和维护,提升开发效率和代码可维护性。
2. 资源访问方式
- 直接引用:
- 使用
$r('app.type.name')
或$rawfile('name')
访问当前模块资源。 - 使用
$r('[hsp].type.name')
或$rawfile('[hsp].name')
访问 HSP 模块资源。
- 使用
- 动态 API 访问:
- 通过
resourceManager
接口(如getStringSync
、getMediaContentSync
)动态获取资源。 - 使用
createModuleContext
创建其他模块的上下文,获取其resourceManager
对象。
- 通过
3. 资源优先级规则
- 优先级从高到低:
- 当前模块(HAP/HSP):自身模块的资源优先级最高。
- 依赖的 HAR/HSP 模块:
- 如果多个依赖模块中存在同名资源,按照依赖顺序覆盖(依赖顺序靠前的优先级更高)。
4. 官方文档补充
- 资源隔离与访问控制:
- 类似腾讯云 CAM(访问管理)的权限设计,HarmonyOS 通过模块化设计实现资源的逻辑隔离。
- 开发者可以通过显式依赖和资源命名规范避免冲突。
- 跨模块通信:
- 除了资源访问,还可以通过模块间接口调用实现功能共享。
5. 最佳实践
- 命名规范:为资源文件添加模块前缀(如
hsp1_icon.png
),避免命名冲突。 - 依赖管理:在
oh-package.json5
中明确模块依赖顺序,确保资源优先级符合预期。 - 动态加载:对于插件化场景,优先使用
resourceManager
动态加载资源。
6. 适用场景
- 多模块共享通用资源(如主题、图标、多语言文本)。
- 动态加载不同模块的资源(如插件化设计)。
如果需要进一步分析具体实现或优化建议,请告诉我!
来源:juejin.cn/post/7541339617489616939
JavaScript V8 引擎原理
相关问题
JavaScript事件循环
- 调用栈:这里存放着所有执行中的代码块(函数)。当一个函数被调用时,它被添加到栈中;当返回值被返回时它从栈中被移除。
- 消息队列:当异步事件发生时(如点击事件、文件读取完成等),对应的回调函数会被添加到消息队列中。如果调用栈为空,事件循环将从队列中取出一个事件处理。
- 微任务队列:与消息队列类似,但处理优先级更高。微任务(如Promise的回调)在当前宏任务执行完毕后、下-个宏任务开始前执行。
- 宏任务与微任务:宏任务包括整体的脚本执行、setTimeout、setlnterval等;微任务包括Promise回调.process.nextTick等。事件循环的每个循环称为一个tick,每个tick会先执行所有可执行的微任务,再执行一个宏任务。
- 调用栈:这里存放着所有执行中的代码块(函数)。当一个函数被调用时,它被添加到栈中;当返回值被返回时它从栈中被移除。
- 消息队列:当异步事件发生时(如点击事件、文件读取完成等),对应的回调函数会被添加到消息队列中。如果调用栈为空,事件循环将从队列中取出一个事件处理。
- 微任务队列:与消息队列类似,但处理优先级更高。微任务(如Promise的回调)在当前宏任务执行完毕后、下-个宏任务开始前执行。
- 宏任务与微任务:宏任务包括整体的脚本执行、setTimeout、setlnterval等;微任务包括Promise回调.process.nextTick等。事件循环的每个循环称为一个tick,每个tick会先执行所有可执行的微任务,再执行一个宏任务。
V8引擎中的垃圾回收机制如何工作?
V8引擎使用的垃圾回收策略主要基于“分代收集”(Generational Garbage Collection)的理念:
- 新生代(Young Generation):这部分主要存放生存时间短的小对象。新生代空间较小,使用Scavenge算法进行高效的垃圾回收。Scavenge算法采用复制的方式工作,它将新生代空间分为两半,活动对象存放在一半中,当这一半空间用完时,活动对象会被复制到另一半,非活动对象则被清除。
- 老生代(Old Generation):存放生存时间长或从新生代中晋升的大对象。老生代使用Mark-Sweep(标记-清除)和 Mark-Compact (标记-压缩)算法进行垃圾回收。标记-清除算法在标记阶段标记所有从根节点可达的对象,清除阶段则清除未被标记的对象。标记-压缩算法在清除未标记对象的同时,将存活的对象压缩到内存的一端,减少碎片。
V8引擎使用的垃圾回收策略主要基于“分代收集”(Generational Garbage Collection)的理念:
- 新生代(Young Generation):这部分主要存放生存时间短的小对象。新生代空间较小,使用Scavenge算法进行高效的垃圾回收。Scavenge算法采用复制的方式工作,它将新生代空间分为两半,活动对象存放在一半中,当这一半空间用完时,活动对象会被复制到另一半,非活动对象则被清除。
- 老生代(Old Generation):存放生存时间长或从新生代中晋升的大对象。老生代使用Mark-Sweep(标记-清除)和 Mark-Compact (标记-压缩)算法进行垃圾回收。标记-清除算法在标记阶段标记所有从根节点可达的对象,清除阶段则清除未被标记的对象。标记-压缩算法在清除未标记对象的同时,将存活的对象压缩到内存的一端,减少碎片。
V8 引擎是如何优化其性能的?
V8引擎通过多种方式优化JavaScript的执行性能:
- 即时编译(JIT):V8将JavaScript代码编译成更高效的机器代码而不是传统的解释执行。V8采用了一个独特的两层编译策略,包括基线编译器(lgnition)和优化编译器(TurboFan)。lgnition生成字节码,这是一个相对较慢但内存使用较少的过程。而 TurboFan 则针对热点代码(执行频率高的代码)进行优化,生成更快的机器代码。
- 内联缓存(lnline Caching):V8使用内联缓存技术来减少属性访问的时间。当访问对象属性时,V8会在代码中嵌入缓存信息,记录属性的位置,以便后续的属性访问可以直接使用这些信息,避免再次查找,从而加速属性访问。
- 隐藏类(Hidden Classes):尽管JavaScript是一种动态类型语言,V8引擎通过使用隐藏类来优化对象的存储和访问。每当对象被实例化或修改时,V8会为对象创建或更新隐藏类,这些隐藏类存储了对象属性的布局信息,使得属性访问更加迅速。
V8引擎通过多种方式优化JavaScript的执行性能:
- 即时编译(JIT):V8将JavaScript代码编译成更高效的机器代码而不是传统的解释执行。V8采用了一个独特的两层编译策略,包括基线编译器(lgnition)和优化编译器(TurboFan)。lgnition生成字节码,这是一个相对较慢但内存使用较少的过程。而 TurboFan 则针对热点代码(执行频率高的代码)进行优化,生成更快的机器代码。
- 内联缓存(lnline Caching):V8使用内联缓存技术来减少属性访问的时间。当访问对象属性时,V8会在代码中嵌入缓存信息,记录属性的位置,以便后续的属性访问可以直接使用这些信息,避免再次查找,从而加速属性访问。
- 隐藏类(Hidden Classes):尽管JavaScript是一种动态类型语言,V8引擎通过使用隐藏类来优化对象的存储和访问。每当对象被实例化或修改时,V8会为对象创建或更新隐藏类,这些隐藏类存储了对象属性的布局信息,使得属性访问更加迅速。
引擎基础
冯·诺依曼结构

冯·诺依曼结构
解释和编译
Java 编译为 class 文件,然后执行
JavaScript 属于解释型语言,它需要在代码执行时,将代码编译为机器语言。
ast (Abstract Syntax Tree)

• Interpreter 逐行读取代码并立即执行。
• Compiler 读取您的整个代码,进行一些优化,然后生成优化后的代码。
Java 编译为 class 文件,然后执行
JavaScript 属于解释型语言,它需要在代码执行时,将代码编译为机器语言。
ast (Abstract Syntax Tree)
• Interpreter 逐行读取代码并立即执行。
• Compiler 读取您的整个代码,进行一些优化,然后生成优化后的代码。
JavaScript引擎
JavaScript 其实有众多引擎,只不过v8 是我们最为熟知的。
- V8 (Google),用 C++编写,开放源代码,由 Google 丹麦开发,是 Google Chrome 的一部分,也用于 Node.js.
- JavascriptCore (Apple),开放源代码,用于 webkit 型浏览器,如 Safari,2008年实现了编译器和字节码解释器,升级为了 SquirreFish。苹果内部代号为“Nitro”的 Javascript 引擎也是基于 JavascriptCore 引擎的。
- Rhino,由Mozilla 基金会管理,开放源代码,完全以Java 编写,用于 HTMLUnit
- SpiderMonkey (Mozilla),第一款 Javascript 引擎,早期用于 Netscape Navigator,现时用于 Mozilla Firefox。
- Nodejs 整个架构


:::info 谷歌的Chrome 使用 V8
Safari 使用 JavaScriptCore,
Firefox 使用 SpiderMonkey。
:::
- V8的处理过程
- 始于从网络中获取 JavaScript 代码。
V8 解析源代码并将其转化为抽象语法树(AST abstract syntax tree)。
- 基于该AST,Ignition 基线解释器可以开始做它的事情,并产生字节码。
- 在这一点上,引擎开始运行代码并收集类型反馈。
- 为了使它运行得更快,字节码可以和反馈数据一起被发送到TurboFan 优化编译器。优化编译器在此基础上做出某些假设,然后产生高度优化的机器代码。
- 如果在某些时候,其中一个假设被证明是不正确的,优化编译器就会取消优化,并回到解释器中。
JavaScript 其实有众多引擎,只不过v8 是我们最为熟知的。
- V8 (Google),用 C++编写,开放源代码,由 Google 丹麦开发,是 Google Chrome 的一部分,也用于 Node.js.
- JavascriptCore (Apple),开放源代码,用于 webkit 型浏览器,如 Safari,2008年实现了编译器和字节码解释器,升级为了 SquirreFish。苹果内部代号为“Nitro”的 Javascript 引擎也是基于 JavascriptCore 引擎的。
- Rhino,由Mozilla 基金会管理,开放源代码,完全以Java 编写,用于 HTMLUnit
- SpiderMonkey (Mozilla),第一款 Javascript 引擎,早期用于 Netscape Navigator,现时用于 Mozilla Firefox。
- Nodejs 整个架构
:::info 谷歌的Chrome 使用 V8
Safari 使用 JavaScriptCore,
Firefox 使用 SpiderMonkey。
:::
- V8的处理过程
- 始于从网络中获取 JavaScript 代码。
V8 解析源代码并将其转化为抽象语法树(AST abstract syntax tree)。
- 基于该AST,Ignition 基线解释器可以开始做它的事情,并产生字节码。
- 在这一点上,引擎开始运行代码并收集类型反馈。
- 为了使它运行得更快,字节码可以和反馈数据一起被发送到TurboFan 优化编译器。优化编译器在此基础上做出某些假设,然后产生高度优化的机器代码。
- 如果在某些时候,其中一个假设被证明是不正确的,优化编译器就会取消优化,并回到解释器中。
垃圾回收算法

垃圾回收,又称为:GC (garbage collection)。
GC 即 Garbage Collection,程序工作过程中会产生很多垃圾,这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而GC 就是负责回收垃圾的,因为他工作在引擎内部,所以对于我们前端来说, GC 过程是相对比较无感的,这一套引擎执行而对我们又相对无感的操作也就是常说的垃圾回收机制了当然也不是所有语言都有 GC,一般的高级语言里面会自带GC,比如 Java、Python、Javascript 等,也有无GC的语言,比如C、C++等,那这种就需要我们程序员手动管理内存了,相对比较麻烦
垃圾回收,又称为:GC (garbage collection)。
GC 即 Garbage Collection,程序工作过程中会产生很多垃圾,这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而GC 就是负责回收垃圾的,因为他工作在引擎内部,所以对于我们前端来说, GC 过程是相对比较无感的,这一套引擎执行而对我们又相对无感的操作也就是常说的垃圾回收机制了当然也不是所有语言都有 GC,一般的高级语言里面会自带GC,比如 Java、Python、Javascript 等,也有无GC的语言,比如C、C++等,那这种就需要我们程序员手动管理内存了,相对比较麻烦
“垃圾”的定义
- “可达性”,有没有被引用,没有被引用的变量,“不可达的变量”
- 变量会在栈中存储,对象在堆中存储
- 我们知道写代码时创建一个基本类型、对象、函数都是需要占用内存的,但是我们并不关注这些,因为这是引擎为我们分配的,我们不需要显式手动的去分配内存,那么 JavaScript 引擎是如何发现并清理垃圾的呢?
- “可达性”,有没有被引用,没有被引用的变量,“不可达的变量”
- 变量会在栈中存储,对象在堆中存储
- 我们知道写代码时创建一个基本类型、对象、函数都是需要占用内存的,但是我们并不关注这些,因为这是引擎为我们分配的,我们不需要显式手动的去分配内存,那么 JavaScript 引擎是如何发现并清理垃圾的呢?
引用计数算法
相信这个算法大家都很熟悉,也经常听说。
它的策略是跟踪记录每个变量值被使用的次数
- 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1 如果同一个值又被赋给另一个变量,那么引用数加1
- 如果该变量的值被其他的值覆盖了,则引用次数減1
- 当这个值的引用次数变为0的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运
- 行的时候清理掉引用次数为0的值占用的内存
:::info 这个算法最怕的就是循环引用(相互引用),还有比如 JavaScript 中不恰当的闭包写法
:::
相信这个算法大家都很熟悉,也经常听说。
它的策略是跟踪记录每个变量值被使用的次数
- 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1 如果同一个值又被赋给另一个变量,那么引用数加1
- 如果该变量的值被其他的值覆盖了,则引用次数減1
- 当这个值的引用次数变为0的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运
- 行的时候清理掉引用次数为0的值占用的内存
:::info 这个算法最怕的就是循环引用(相互引用),还有比如 JavaScript 中不恰当的闭包写法
:::
优点
- 引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为0时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾
- 而标记清除算法需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的GC,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以
- 引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为0时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾
- 而标记清除算法需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的GC,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以
弊端
- 它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的
- 它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的
标记清除(Mark-Sweep)算法
:::info 从根对象进行检测,先标记再清除
:::
- 标记清除(Mark-Sweep),目前在 JavaScript引擎里这种算法是最常用的,到目前为止的大多数浏览器的 Javascript引擎都在采用标记清除算法,各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 Javascript引擎在运行垃圾回收的频率上有所差异。
- 此算法分为标记和清除两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁
- 当变量进入执行环境时,反转某一位(通过一个二进制字符来表示标记),又或者可以维护进入环境变量和离开环境变量这样两个列表,可以自由的把变量从一个列表转移到另一个列表。
- 引擎在执行GC(使用标记清除算法)时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多, 我们称之为一组根对象,而所谓的根对象,其实在浏览器环境中包括又不止于全局Window对象、文档DOM树
- 整个标记清除算法大致过程就像下面这样:
- 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
- 然后从各个根对象开始遍历,把不是垃圾的节点改成1
- 清理所有标记为O的垃圾,销毁并回收它们所占用的内存空间
- 最后,把所有内存中对象标记修改为O,等待下一轮垃圾回收
:::info 从根对象进行检测,先标记再清除
:::
- 标记清除(Mark-Sweep),目前在 JavaScript引擎里这种算法是最常用的,到目前为止的大多数浏览器的 Javascript引擎都在采用标记清除算法,各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 Javascript引擎在运行垃圾回收的频率上有所差异。
- 此算法分为标记和清除两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁
- 当变量进入执行环境时,反转某一位(通过一个二进制字符来表示标记),又或者可以维护进入环境变量和离开环境变量这样两个列表,可以自由的把变量从一个列表转移到另一个列表。
- 引擎在执行GC(使用标记清除算法)时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多, 我们称之为一组根对象,而所谓的根对象,其实在浏览器环境中包括又不止于全局Window对象、文档DOM树
- 整个标记清除算法大致过程就像下面这样:
- 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
- 然后从各个根对象开始遍历,把不是垃圾的节点改成1
- 清理所有标记为O的垃圾,销毁并回收它们所占用的内存空间
- 最后,把所有内存中对象标记修改为O,等待下一轮垃圾回收
优点
- 标记清除算法的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和 1)就可以为其标记,非常简单
- 标记清除算法的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和 1)就可以为其标记,非常简单
弊端
- 标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了内存碎片(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题
- 那如何找到合适的块呢?
:::danger 在插入值的时候去解决,最大化使用内存空间,即:通过插入的形式,提升内存空间使用
:::
- 我们可以采取下面三种分配策略
- First-fit,找到大于等于 size 的块立即返回
- Best-fit,遍历整个空闲列表,返回大于等于 size 的最小分块
- Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fit 和 Best-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择
- 综上所述,标记清除算法或者说策略就有两个很明显的缺点
- 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
- 分配速度慢,因为即便是使用 First-fit策略,其操作仍是一个0(n)的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢
:::info 归根结底,标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以完美解决了
:::
- 标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了内存碎片(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题
- 那如何找到合适的块呢?
:::danger 在插入值的时候去解决,最大化使用内存空间,即:通过插入的形式,提升内存空间使用
:::
- 我们可以采取下面三种分配策略
- First-fit,找到大于等于 size 的块立即返回
- Best-fit,遍历整个空闲列表,返回大于等于 size 的最小分块
- Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fit 和 Best-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择
- 综上所述,标记清除算法或者说策略就有两个很明显的缺点
- 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
- 分配速度慢,因为即便是使用 First-fit策略,其操作仍是一个0(n)的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢
:::info 归根结底,标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以完美解决了
:::
标记整理(Mark-Compact)算法
:::color1 有碎片就整理,整理的过程是有消耗的,所以就会有新生代、老生代
:::
- 而标记整理(Mark-Compact)算法就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存
:::color1 有碎片就整理,整理的过程是有消耗的,所以就会有新生代、老生代
:::
- 而标记整理(Mark-Compact)算法就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存
Unix/windows/Android/iOS系统中内存碎片空间思想
内存碎片化是所有系统都面临的挑战,不同操作系统和环境中的处理策略各有侧重,但也有其共通之处。以下是不同系统在内存碎片处理上的比较:
内存碎片化是所有系统都面临的挑战,不同操作系统和环境中的处理策略各有侧重,但也有其共通之处。以下是不同系统在内存碎片处理上的比较:
V8引擎中的标记-整理算法
- 标记阶段:识别未使用的对象,标记为垃圾。
- 整理阶段:将存活对象移动到连续区域,释放大块内存空间,减少外部碎片。
- 标记阶段:识别未使用的对象,标记为垃圾。
- 整理阶段:将存活对象移动到连续区域,释放大块内存空间,减少外部碎片。
电脑系统(Unix/Linux vs Windows)
- 内存管理:均使用分页机制,但Linux更倾向于预防碎片,Windows依赖内存压缩。
- 处理策略:Linux通过 slab 分配器优化内存分配,Windows通过内存压缩技术。
- 相同点:分页和交换机制,内存不足时回收内存。
- 不同点:Linux更注重预防,Windows依赖内存压缩,处理方式不同。
- 内存管理:均使用分页机制,但Linux更倾向于预防碎片,Windows依赖内存压缩。
- 处理策略:Linux通过 slab 分配器优化内存分配,Windows通过内存压缩技术。
- 相同点:分页和交换机制,内存不足时回收内存。
- 不同点:Linux更注重预防,Windows依赖内存压缩,处理方式不同。
移动终端(Android vs iOS)
- 内存管理:Android基于Linux,采用内存回收和进程优先级管理;iOS使用更严格的内存管理。
- 处理策略:Android通过Activity生命周期管理内存,iOS通过ARC自动管理。
- 相同点:内存不足时回收内存,依赖垃圾回收机制。
- 不同点:Android更灵活,支持后台进程保活;iOS更严格,强制回收。
- 内存管理:Android基于Linux,采用内存回收和进程优先级管理;iOS使用更严格的内存管理。
- 处理策略:Android通过Activity生命周期管理内存,iOS通过ARC自动管理。
- 相同点:内存不足时回收内存,依赖垃圾回收机制。
- 不同点:Android更灵活,支持后台进程保活;iOS更严格,强制回收。
内存碎片化挑战
- 内部碎片:内存分配导致的未使用空间,需优化分配策略。
- 外部碎片:分散的空闲空间,需整理或置换策略。
- 处理目标:桌面系统注重稳定性,移动设备关注响应和功耗。
- 内部碎片:内存分配导致的未使用空间,需优化分配策略。
- 外部碎片:分散的空闲空间,需整理或置换策略。
- 处理目标:桌面系统注重稳定性,移动设备关注响应和功耗。
工具与分析
- Unix/Linux:使用
top
、htop
、vmstat
等工具。 - Windows:依赖任务管理器和性能监视器。
- 移动设备:Android用Android Profiler,iOS用Instruments。
总结: 不同系统在内存碎片处理上各有特色,但都旨在优化内存使用效率。V8引擎通过标记-整理减少碎片,而操作系统如Unix/Linux和Windows,以及移动系统如Android和iOS则采用不同的内存管理策略,以适应各自的性能和资源需求。
- Unix/Linux:使用
top
、htop
、vmstat
等工具。 - Windows:依赖任务管理器和性能监视器。
- 移动设备:Android用Android Profiler,iOS用Instruments。
总结: 不同系统在内存碎片处理上各有特色,但都旨在优化内存使用效率。V8引擎通过标记-整理减少碎片,而操作系统如Unix/Linux和Windows,以及移动系统如Android和iOS则采用不同的内存管理策略,以适应各自的性能和资源需求。
内存管理
:::info V8的垃圾回收策略主要基于分代式垃圾回收机制,V8中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收
:::

:::info V8的垃圾回收策略主要基于分代式垃圾回收机制,V8中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收
:::
新生代
- 当新加入对象时,它们会被存储在使用区。然而,当使用区快要被写满时,垃圾清理操作就需要执行。在开始垃圾回收之前,新生代垃圾回收器会对使用区中的活动对象进行标记。标记完成后,活动对象将会被复制到空闲区并进行排序。然后,垃圾清理阶段开始,即将非活动对象占用的空间清理掉。最后,进行角色互换,将原来的使用区变成空闲区,将原来的空闲区变成使用区。
- 如果一个对象经过多次复制后依然存活,那么它将被认为是生命周期较长的对象,且会被移动到老生代中进行管理。
- 除此之外,还有一种情况,如果复制一个对象到空闲区时,空闲区的空间占用超过了25%,那么这个对象会被直接晋升到老生代空间中。25%比例的设置是为了避免影响后续内存分配,因为当按照 Scavenge 算法回收完成后, 空闲区将翻转成使用区,继续进行对象内存分配。
:::info 一直在开辟空间,达到一定程度,就回晋升到老生代
:::
- 当新加入对象时,它们会被存储在使用区。然而,当使用区快要被写满时,垃圾清理操作就需要执行。在开始垃圾回收之前,新生代垃圾回收器会对使用区中的活动对象进行标记。标记完成后,活动对象将会被复制到空闲区并进行排序。然后,垃圾清理阶段开始,即将非活动对象占用的空间清理掉。最后,进行角色互换,将原来的使用区变成空闲区,将原来的空闲区变成使用区。
- 如果一个对象经过多次复制后依然存活,那么它将被认为是生命周期较长的对象,且会被移动到老生代中进行管理。
- 除此之外,还有一种情况,如果复制一个对象到空闲区时,空闲区的空间占用超过了25%,那么这个对象会被直接晋升到老生代空间中。25%比例的设置是为了避免影响后续内存分配,因为当按照 Scavenge 算法回收完成后, 空闲区将翻转成使用区,继续进行对象内存分配。
:::info 一直在开辟空间,达到一定程度,就回晋升到老生代
:::
老生代
- 不同于新生代,老生代中存储的内容是相对使用频繁并且短时间无需清理回收的内容。这部分我们可以使用标记整理进行处理。
- 从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象
- 清除阶段老生代垃圾回收器会直接将非活动对象进行清除。
- 不同于新生代,老生代中存储的内容是相对使用频繁并且短时间无需清理回收的内容。这部分我们可以使用标记整理进行处理。
- 从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象
- 清除阶段老生代垃圾回收器会直接将非活动对象进行清除。
并行回收
:::info 思想类似于 花两个人的钱,让一个人干三个人的活
:::

:::info 思想类似于 花两个人的钱,让一个人干三个人的活
:::
全停顿标记
这个概念看字眼好像不好理解,其买如果用前端开发的术语来解释,就是阻塞。
虽然我们的 GC操作被放到了主进程与子进程中去处理,但最终的结果还是主进程被较长时间占用。
在JavaScript的V8引擎中,全停顿标记(Full Stop-the-world Marking)是垃圾回收(GC)过程中的一个重要环节。
这个过程涉及到V8的垃圾回收器暂停JavaScript程序的执行,以便进行垃圾回收的标记阶段。全停顿标记是为了确保在回收内存前正确标记所有活动对象(即正在使用的对象)和非活动对象(即可以清除的对象)。
这个概念看字眼好像不好理解,其买如果用前端开发的术语来解释,就是阻塞。
虽然我们的 GC操作被放到了主进程与子进程中去处理,但最终的结果还是主进程被较长时间占用。
在JavaScript的V8引擎中,全停顿标记(Full Stop-the-world Marking)是垃圾回收(GC)过程中的一个重要环节。
这个过程涉及到V8的垃圾回收器暂停JavaScript程序的执行,以便进行垃圾回收的标记阶段。全停顿标记是为了确保在回收内存前正确标记所有活动对象(即正在使用的对象)和非活动对象(即可以清除的对象)。
全停顿标记的工作原理
1.停止执行:当执行到全停顿标记阶段时,V8引擎会暂停正在执行的JavaScript代码,确保没有任何Javascript代码在运行。这个停顿是必需的,因为在标记活动对象时,对象的引用关系需要保持不变。
2. 标记阶段:在这个阶段,垃圾回收器遍历所有根对象(例如全局变量、活跃的函数的局部变量等),从这些根对象开始,递归地访问所有可达的对象。每访问到一个对象,就将其标记为活动(1)的。
- 恢复执行:标记完成后,V8引擎会恢复JavaScript代码的执行,进入垃圾回收的清除或压缩阶段。
1.停止执行:当执行到全停顿标记阶段时,V8引擎会暂停正在执行的JavaScript代码,确保没有任何Javascript代码在运行。这个停顿是必需的,因为在标记活动对象时,对象的引用关系需要保持不变。
2. 标记阶段:在这个阶段,垃圾回收器遍历所有根对象(例如全局变量、活跃的函数的局部变量等),从这些根对象开始,递归地访问所有可达的对象。每访问到一个对象,就将其标记为活动(1)的。
- 恢复执行:标记完成后,V8引擎会恢复JavaScript代码的执行,进入垃圾回收的清除或压缩阶段。
全停顿的影响及优化
全停顿标记虽然对于确保内存被正确管理是必要的,但它会对应用程序的性能产生影响,特别是在垃圾回收发生时, 应用程序的响应时间和性能会短暂下降。为了缓解这种影响,V8引擎采用了几种策略:
• 增量标记 (Incremental Marking):为了减少每次停顿的时间,V8实现了增量标记,即将标记过程分成多个小部分进行,介于JavaScript执行的间隙中逐步完成标记。
• 并发标记(Concurrent Marking):V8引擎的更高版本中引入了并发标记,允许垃圾回收标记阶段与JavaScript代码的执行同时进行,进一步减少停顿时间。
• 延迟清理(Lazy Sweeping):标记完成后的清理阶段也可以延迟执行,按需进行,以减少单次停顿的时间。
这些优化措施有助于提高应用的响应速度和整体性能,特别是在处理大量数据和复杂操作时,确保用户体验不会因垃圾回收而受到较大影响。
切片标记
- 增量就是将一次 GC标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记
三色标记
我们这里的会,表示的是一个中间状态,为什么会有这个中间状态呢?
• 白色指的是未被标记的对象
• 灰色指自身被标记,成员变量(该对象的引用对象)未被标记 • 黑色指自身和成员变量皆被标记
在V8引擎中使用的三色标记算法是一种用于垃圾回收的有效方法,特别是在进行增量和并发标记时。这个算法通过给对象着色(白色、灰色、黑色)来帮助标记和回收垃圾。
工作原理
- 初始化:
- 白色:初始状态,所有对象都标记为白色,表示这些对象可能是垃圾,如果在标记过程中没有被访问到,最终将被清理。
- 灰色:表示对象已经被标记(访问过),但该对象的引用还没有完全检查完。
- 黑色:表示该对象及其所有引用都已经被完全访问过,并且已经标记。
- 标记过程:
- 垃圾回收开始时,从根集合(如全局变量、活跃的堆栈帧中的局部变量等)出发,将所有根对象标记为灰色。
- 逐一处理灰色对象:将灰色对象标记为黑色,并将其直接引用的所有白色对象转变为灰色。这个过程不断重复,直到没有灰色对象为止。
- 扫描完成:
- 所有从根可达的对象最终都会被标记为黑色。所有仍然为白色的对象被认为是不可达的,因此将被视为垃圾并在清除阶段被回收。
- 初始化:
优点
- 健壮性:三色标记算法非常适合增量和并发的垃圾回收,因为它能够确保即使在应用程序继续执行的情况下也能正确地标记活动对象。
- 防止漏标:通过灰色和黑色的严格区分,算法确保所有可达的对象都会被遍历和标记,防止错误地回收正在使用的对象。
- 效率:虽然在垃圾回收期间会有增加的计算开销,但三色标记算法可以与应用程序的执行并行进行,减少了GC停顿的时间,提高了应用的响应性和性能。
应用
- 在实际应用中,V8和其他现代JavaScript引擎使用这种算法进行内存管理,优化了动态内存的使用,减少了垃圾回收对应用性能的影响。这对于要求高性能和实时响应的Web应用程序尤其重要。
写屏障(增量中修改引用)
- 这一机制用于处理在增量标记进行时修改引用的处理,可自行修改为灰色
在V8引擎中,写屏障(Write Barrier)是垃圾回收(GC)的一个关键机制,尤其是在增量和并发垃圾回收过程中发挥着至关重要的作用。写屏障主要用来维持垃圾回收中的三色不变性,在对象写操作期间动态地更新对象的可达性信息。
作用
- 保持三色不变性,在使用三色标记算法中,写屏障帮助维持所谓的三色不变性。这意味着系统确保如果一个黑色对象(已经被完全扫描的对象)引用了一个白色对象(尚未被扫描的对象,可能是垃圾),那么这个白色对象应当转变为灰色(标记但尚未扫描完毕的对象),从而避免错误的垃圾回收。
- 处理指针更新,当一个对象的指针被更新(例如,一个对象的属性被另一个对象替换),写屏障确保关于这些对象的垃圾回收元数据得到适当的更新。这是确保垃圾回收器正确识别活动对象和非活动对象的必要步骤。
类型
- Pre-Write Barrier(预写屏障),这种类型的写屏障在实际更新内存之前执行。它主要用于某些特定类型的垃圾回收算法,比如分代垃圾回收,以保持老年代和新生代之间的引用正确性。
- Post-Write Barrier(后写屏障),这是最常见的写屏障类型,发生在对象的指针更新之后。在V8中,当黑色对象指向白色对象时,后写屏障会将该白色对象标记为灰色,确保它不会在当前垃圾回收周期中被错误地回收。
实现细节
- 在V8引擎中,写屏障通常由简短的代码片段实现,这些代码片段在修改对象属性或数组元素时自动执行。例如,每当JavaScript代码或内部的V8代码试图写入一个对象的属性时,写屏障代码会检查是否需要更新垃圾回收的元数据。
惰性清理
- 增量标记只是用于标记活动对象和非活动对象,真正的清理释放内存,则V8采用的是惰性清理(Lazy Sweeping)方案。
- 在增量标记完成后,进行清理。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 Javascript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕。
并发回收
:::info 本质是切片,然后去插入,做一些动作
:::
- react 中的 Concurrent 吗?
- 我们想想 React演进过程,是不是就会觉得从并行到并发的演进变得很合了呢?
- 并发挥收其实是更进一步的切片,几乎完全不阻塞主进程。
:::success 分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率
:::
怎么理解内存泄露?
怎么解决内存泄露,代码层面如何优化?
- 减少查找
- 减少变量声明
- 使用 Performance + Memory 分析内存与性能
运行机制
- 浏览器主进程
- 协调控制其他子进程(创建、销毁)
- 浏览器界面显示,用户交互,前进、后退、收藏
- 将渲染进程得到的内存中的Bitmap,绘制到用户界面上
- 存储功能等
- 第三方插件进程
- 每种类型的插件对应一个进程,仅当使用该插件时才创建
- GPU进程
- 用于3D绘制等
- 渲染进程,就是我们说的浏览器内核
- 排版引擎 Blink 和 JavaScript 引擎V8 都是运行在该进程中,将HTML、CSS和 JavaScript 转换为用户可以与之交互的网页
- 协调控制其他子进程(创建、销毁)
- 浏览器界面显示,用户交互,前进、后退、收藏
- 将渲染进程得到的内存中的Bitmap,绘制到用户界面上
- 存储功能等
- 每种类型的插件对应一个进程,仅当使用该插件时才创建
- 用于3D绘制等
- 排版引擎 Blink 和 JavaScript 引擎V8 都是运行在该进程中,将HTML、CSS和 JavaScript 转换为用户可以与之交互的网页
- 负责页面渲染,脚本执行,事件处理等
- 每个tab页一个渲染进程
- 出于安全考虑,渲染进程都是运行在沙箱模式下
- 网络进程
- 负责页面的网络资源加载,之前作为一个模块运行在浏览器主进程里面,最近才独立成为一个单独的进程
浏览器事件循环
:::info 在 Chrome 中,事件循环的执行是由浏览器的渲染引擎(例如 Blink)和V8 引擎配合完成的。V8负责 JavaScript 代码的执行,Blink 负责浏览器的渲染和用户界面的更新
:::
执行任务的顺序
先执行当前执行栈同步任务,再执行(微任务),再执行(宏任务)
宏任务
:::info 在 Chrome的源码中,并未直接出现“宏任务”这一术语,但在 Javascript 运行时引擎(V8)以及事件循环 (Event Loop)相关的实现中,宏任务和微任务的概念是非常重要的。
实际上,“宏任务”这一术语来源于 Javascript 事件循环的抽象,它只是帮助我们理解任务的执行顺序和时机。
:::
可以将每次执行栈执行的代码当做是一个宏任务
- I/O
- setTimeout
- setinterval
- setImmediate
- requestAnimationFrame
微任务
当宏任务执行完,会在渲染前,将执行期间所产生的所有微任务都执行完
- process.nextTick
- MutationObserver
- Promise.then catch finally
完整鏊体流程
- 执行当前执行栈同步任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 执行栈同步任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 宏任务执行完毕,开始检查渲染,然后 GUI线程接管渲染
- 渲染完毕后, JS线程继续接管,开始下一个宏任务(从事件队列中获取)
Node事件循环机制
与浏览器事件循环机制的不同
- 在 Node.js 中,为了更高效地管理和调度各种类型的异步任务。这种设计使得 Node.js 能够在单线程环境中有效地处理大量的并发任务。下
- Node.js 的事件循环(Event Loop)是一个处理异步操作的机制,它会按照顺序依次执行不同阶段任务。事件循环机制中分为多个阶段,每个阶段都有自己的任务队列,包括:
- Timers 阶段:
- 处理 setTimeout 和 setInterval 调度的回调函数。
- 如果指定的时间到了,回调函数会被放入这个队列。
- Pending Callbacks 阶段:
- 处理一些1/0操作的回调,比如 TCP 错误类型的回调。
- 这些回调并不完全由开发者控制,而是由操作系统调度的。
- Idle, Prepare 阶段:
- 仅供内部使用的阶段。
- Poll 阶段:
- 获取新的1/0事件,执行1/0回调函数。
- 通常情况下,这个阶段会一直等待,直到有新的!/0 事件到来。
- Check 阶段:
- 处理 :setImmediate 调度的回调函数。
- etImmediate 的回调会在这个阶段执行,比 setTimeout 更早。
- Close Callbacks 阶段:
- 处理一些关闭的回调函数,比如 socket.on('close', ... ) °
- 处理 setTimeout 和 setInterval 调度的回调函数。
- 如果指定的时间到了,回调函数会被放入这个队列。
- 处理一些1/0操作的回调,比如 TCP 错误类型的回调。
- 这些回调并不完全由开发者控制,而是由操作系统调度的。
- 仅供内部使用的阶段。
- 获取新的1/0事件,执行1/0回调函数。
- 通常情况下,这个阶段会一直等待,直到有新的!/0 事件到来。
- 处理 :setImmediate 调度的回调函数。
- etImmediate 的回调会在这个阶段执行,比 setTimeout 更早。
- 处理一些关闭的回调函数,比如 socket.on('close', ... ) °
多个队列的必要性
不同类型的异步任务有不同的优先级和处理方式。使用多个队列可以确保这些任务被正确地调度和执行:
- Timers 和 Poll 阶段的区别:
- setTimeout 和 setInterval 的回调在 Timers 阶段执行,这些回调函数依赖于计时器的到期时间。
- Poll 阶段处理大多数1/0 回调,这是事件循环的主要阶段,处理大部分异步1/O操作。
- mmediate 与 Timeout 的不同:
- setImmediate 的回调函数在 Check 阶段执行,这是在当前事件循环周期结束后立即执行。
- setTimeout 的回调函数则是在 Timers 阶段执行,它可能会延迟到下一个事件循环周期,甚至更久。
- 处理关闭回调:
- Close Callbacks 阶段专门处理如 socket.on('close')这样的回调,以确保在资源释放时执行。
Chrome 任务调度机制
V8与Blink的调度系统密切相关。
:::info Blink 是 Chrome 中的渲染引擎
V8是 Chrome 中的 JavaScript 引擎
:::
Blink 是 Chrome 浏览器中的渲染引擎,负责页面的渲染和绘制任务。V8与 Blink 会协同工作,确保 JavaScript 的执行与页面渲染能够平稳进行。
Blink Scheduler:docs.google.com/document/d/…
接下来我们了解一下 Blink scheduler,一个用于优化 Blink 主线程任务调度的方案,旨在解决现有调度系统中的一些问题。
将任务不断安排到主线程的消息循环中,会导致Blink 主线程阻塞。造成诸多问题:
- 有限的优先级设置-任务按照发布顺序执行,或者可以明确地延迟,但这可能导致一些重要的任务(如输入处理) 被不那么紧急的任务占用优先执行权。
- 缺乏与系统其他部分的协调-比如图形管线虽然已知有输入事件的传递、显示刷新等时序要求,但这些信息无法及时传递给Blink。
- 无法适应不同的使用场景 -某些任务(如垃圾回收)在用户交互时进行非常不合适。
为了解决以上问题,出现了 Blink Scheduler 调度器,它能够更灵活控制任务按照给定优先级执行
- 关键特点
- 调度器的主要功能是决定在特定时刻哪个任务应当执行。
- 调度器提供了更高级的API替代现有的主线程任务调度接口,任务不再是抽象的回调函数,而是更具体、具有明确标签和元数据的对象。例如,输入任务会被明确标记,并附带附加元数据。
- 调度器可以根据系统状态做出更明智的任务决策,而不是依赖给定死的静态优先级。
gitlab.mpi-klsb.mpg.de/eweyulu/qui…
- 性能验证和工具
- 为了验证调度器的效果,文章提到了多项基准测试和性能指标,例如:
- 队列等待时间:衡量任务从发布到执行的延迟。
- 输入事件延迟:衡量输入事件的处理时间。
- 渲染平滑度(jank):衡量渲染的平滑性,避免出现卡顿。
- 页面加载时间:跟踪页面加载时间的变化。
其他资料
- V8:v8.dev/docs/torque
- chromium 中promise:chromium.googlesource.com/v8/v8/+/ref…
- V8定义:chromium.googlesource.com/v8/v8/+/ref…
- Node V8:nodejs.org/en/learn/ge…
- ibuv-in-node-js:http://www.geeksforgeeks.org/libuv-in-no…
- Faster JavaScript calls:v8.dev/blog/adapto…
- blink scheduler:docs.google.com/document/d/…
作者:若梦plus
来源:juejin.cn/post/7493386024878833715
来源:juejin.cn/post/7493386024878833715
Go 语言未来会取代 Java 吗?
Go 语言未来会取代 Java 吗?
(八年 Java 开发的深度拆解:从业务场景到技术底层)
开篇:面试官的灵魂拷问与行业焦虑
前年面某大厂时,技术负责人突然抛出问题:“如果让你重构公司核心系统,会选 Go 还是 Java?”
作为写了八年 Java 的老开发,我本能地想强调 Spring 生态和企业级成熟度,但对方随即展示的 PPT 让我冷汗直冒 —— 某金融公司用 Go 重构交易系统后,QPS 从 5 万飙升到 50 万,服务器成本降低 70%。这让我陷入沉思:当云原生和 AI 浪潮来袭,Java 真的要被 Go 取代了吗?
今天从 业务场景、技术本质、行业趋势 三个维度,结合实战代码和踩坑经验,聊聊我的真实看法。
一、业务场景对比:Go 的 “闪电战” vs Java 的 “持久战”
先看三个典型业务场景,你会发现两者的差异远不止 “性能” 二字。
场景 1:高并发抢购(电商大促)
Go 实现(Gin 框架) :
func main() {
router := gin.Default()
router.GET("/seckill", func(c *gin.Context) {
// 轻量级goroutine处理请求
go func() {
// 直接操作Redis库存
if err := redisClient.Decr("stock").Err(); err != nil {
c.JSON(http.StatusOK, gin.H{"result": "fail"})
return
}
c.JSON(http.StatusOK, gin.H{"result": "success"})
}()
})
router.Run(":8080")
}
性能数据:单机轻松支撑 10 万 QPS,p99 延迟 < 5ms。
Java 实现(Spring Boot + 虚拟线程) :
@RestController
public class SeckillController {
@GetMapping("/seckill")
public CompletableFuture<ResponseEntity<String>> seckill() {
return CompletableFuture.supplyAsync(() -> {
// 虚拟线程处理IO操作
if (redisTemplate.opsForValue().decrement("stock") < 0) {
return ResponseEntity.ok("fail");
}
return ResponseEntity.ok("success");
}, Executors.newVirtualThreadPerTaskExecutor());
}
}
性能数据:Java 21 虚拟线程让 IO 密集型场景吞吐量提升 7 倍,p99 延迟从 165ms 降至 23ms。
核心差异:
- Go:天生适合高并发,Goroutine 调度和原生 Redis 操作无额外开销。
- Java:依赖 JVM 调优,虚拟线程虽大幅提升性能,但需配合线程池和异步框架。
场景 2:智能运维平台(云原生领域)
Go 实现(Ollama + gRPC) :
func main() {
// 启动gRPC服务处理AI推理请求
server := grpc.NewServer()
pb.RegisterAIAnalysisServer(server, &AIHandler{})
go func() {
if err := server.Serve(lis); err != nil {
log.Fatalf("Server exited with error: %v", err)
}
}()
// 采集节点数据(百万级设备)
for i := 0; i < 1000000; i++ {
go func(nodeID int) {
for {
data := collectMetrics(nodeID)
client.Send(data) // 通过channel传递数据
}
}(i)
}
}
优势:轻量级 Goroutine 高效处理设备数据采集,gRPC 接口响应速度比 REST 快 30%。
Java 实现(Spring Cloud + Kafka) :
@Service
public class MonitorService {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
public void collectMetrics(int nodeID) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(100);
executor.scheduleAtFixedRate(() -> {
String data =采集数据(nodeID);
kafkaTemplate.send("metrics-topic", data);
}, 0, 1, TimeUnit.SECONDS);
}
}
挑战:传统线程池在百万级设备下内存占用飙升,需配合 Kafka 分区和 Consumer Gr0up 优化。
核心差异:
- Go:云原生基因,从采集到 AI 推理全链路高效协同。
- Java:生态依赖强,需整合 Spring Cloud、Kafka 等组件,部署复杂度高。
场景 3:企业 ERP 系统(传统行业)
Java 实现(Spring + Hibernate) :
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
// 复杂业务逻辑注解
@PrePersist
public void validateOrder() {
if (totalAmount < 0) {
throw new BusinessException("金额不能为负数");
}
}
}
优势:Spring 的事务管理和 Hibernate 的 ORM 完美支持复杂业务逻辑,代码可读性高。
Go 实现(GORM + 接口组合) :
type Order struct {
ID uint `gorm:"primaryKey"`
UserID uint
Total float64
}
func (o *Order) Validate() error {
if o.Total < 0 {
return errors.New("金额不能为负数")
}
return nil
}
func CreateOrder(ctx context.Context, order Order) error {
if err := order.Validate(); err != nil {
return err
}
return db.Create(&order).Error
}
挑战:需手动实现事务和复杂校验逻辑,代码量比 Java 多 20%。
核心差异:
- Java:企业级成熟度,框架直接支持事务、权限、审计等功能。
- Go:灵活性高,但需手动实现大量基础功能,适合轻量级业务。
二、技术本质:为什么 Go 在某些场景碾压 Java?
从 并发模型、内存管理、性能调优 三个维度,深挖两者的底层差异。
1. 并发模型:Goroutine vs 线程 / 虚拟线程
Go 的 Goroutine:
- 轻量级:每个 Goroutine 仅需 2KB 栈空间,可轻松创建百万级并发。
- 调度高效:基于 GMP 模型,避免内核级上下文切换,IO 阻塞时自动释放线程。
Java 的虚拟线程(Java 21+) :
- 革命性改进:每个虚拟线程仅需几百字节内存,IO 密集型场景吞吐量提升 7 倍。
- 兼容传统代码:无需修改业务逻辑,直接将
new Thread()
替换为Thread.startVirtualThread()
。
性能对比:
- HTTP 服务:Go 的 Gin 框架单机 QPS 可达 5 万,Java 21 虚拟线程 + Netty 可达 3 万。
- 消息处理:Go 的 Kafka 消费者单节点处理速度比 Java 快 40%。
2. 内存管理:逃逸分析 vs 分代 GC
Go 的逃逸分析:
- 栈优先分配:对象若未逃逸出函数,直接在栈上分配,减少 GC 压力。
- 零拷贝优化:
io.Reader
接口直接操作底层缓冲区,避免内存复制。
Java 的分代 GC:
- 成熟但复杂:新生代采用复制算法,老年代采用标记 - 压缩,需通过
-XX:G1HeapRegionSize
等参数调优。 - 内存占用高:同等业务逻辑下,Java 堆内存通常是 Go 的 2-3 倍。
典型案例:
某金融公司用 Go 重构风控系统后,内存占用从 8GB 降至 3GB,GC 停顿时间从 200ms 缩短至 10ms。
3. 性能调优:静态编译 vs JIT 编译
Go 的静态编译:
- 启动快:编译后的二进制文件直接运行,无需预热 JVM。
- 可预测性强:性能表现稳定,适合对延迟敏感的场景(如高频交易)。
Java 的 JIT 编译:
- 动态优化:运行时将热点代码编译为机器码,长期运行后性能可能反超 Go。
- 依赖调优经验:需通过
-XX:CompileThreshold
等参数平衡启动时间和运行效率。
实测数据:
- 启动时间:Go 的 HTTP 服务启动仅需 20ms,Java Spring Boot 需 500ms。
- 长期运行:持续 24 小时压测,Java 的吞吐量可能比 Go 高 10%(JIT 优化后)。
三、行业趋势:Go 在蚕食 Java 市场,但 Java 不会轻易退场
从 市场数据、生态扩展、技术演进 三个维度,分析两者的未来走向。
1. 市场数据:Go 在高速增长,Java 仍占主导
- 份额变化:Go 在 TIOBE 排行榜中从 2020 年的第 13 位升至 2025 年的第 7 位,市场份额突破 3%。
- 薪资对比:Go 开发者平均薪资比 Java 高 20%,但 Java 岗位数量仍是 Go 的 5 倍。
典型案例:
- 字节跳动:核心推荐系统用 Go 重构,QPS 提升 3 倍,成本降低 60%。
- 招商银行:核心交易系统仍用 Java,但微服务网关和监控平台全面转向 Go。
2. 生态扩展:Go 拥抱 AI,Java 深耕企业级
Go 的 AI 集成:
- 工具链完善:通过 Ollama 框架可直接调用 LLM 模型,实现智能运维告警。
- 性能优势:Go 的推理服务延迟比 Python 低 80%,适合边缘计算场景。
Java 的企业级护城河:
- 大数据生态:Hadoop、Spark、Flink 等框架仍深度依赖 Java。
- 移动端统治力:尽管 Kotlin 流行,Android 系统底层和核心应用仍用 Java 开发。
3. 技术演进:Go 和 Java 都在进化
Go 的发展方向:
- 泛型完善:Go 1.18 + 支持泛型,减少重复代码(如
PrintSlice
函数可适配任意类型)。 - WebAssembly 集成:计划将 Goroutine 编译为 Wasm,实现浏览器端高并发。
Java 的反击:
- Project Loom:虚拟线程已转正,未来将支持更细粒度的并发控制。
- Project Valhalla:引入值类型,减少对象装箱拆箱开销,提升性能 15%。
四、选型建议:Java 开发者该如何应对?
作为八年 Java 老兵,我的 技术选型原则 是:用最合适的工具解决问题,而非陷入语言宗教战争。
1. 优先选 Go 的场景
- 云原生基础设施:API 网关、服务网格、CI/CD 工具链(如 Kubernetes 用 Go 开发)。
- 高并发实时系统:IM 聊天、金融交易、IoT 数据采集(单机 QPS 需求 > 1 万)。
- AI 推理服务:边缘计算节点、实时推荐系统(需低延迟和高吞吐量)。
2. 优先选 Java 的场景
- 复杂企业级系统:ERP、CRM、银行核心业务(需事务、权限、审计等功能)。
- Android 开发:系统级应用和性能敏感模块(如相机、传感器驱动)。
- 大数据处理:离线分析、机器学习训练(Hadoop/Spark 生态成熟)。
3. 混合架构:Go 和 Java 共存的最佳实践
- API 网关用 Go:处理高并发请求,转发到 Java 微服务。
- AI 推理用 Go:部署轻量级模型,结果通过 gRPC 返回给 Java 业务层。
- 数据存储用 Java:复杂查询和事务管理仍由 Java 服务处理。
代码示例:Go 调用 Java 微服务
// Go客户端
conn, err := grpc.Dial("java-service:8080", grpc.WithInsecure())
if err != nil {
log.Fatalf("连接失败: %v", err)
}
defer conn.Close()
client := pb.NewJavaServiceClient(conn)
resp, err := client.ProcessData(context.Background(), &pb.DataRequest{Data: "test"})
if err != nil {
log.Fatalf("调用失败: %v", err)
}
fmt.Println("Java服务返回:", resp.Result)
// Java服务端
@GrpcService
public class JavaServiceImpl extends JavaServiceGrpc.JavaServiceImplBase {
@Override
public void processData(DataRequest request, StreamObserver<DataResponse> responseObserver) {
String result =复杂业务逻辑(request.getData());
responseObserver.onNext(DataResponse.newBuilder().setResult(result).build());
responseObserver.onCompleted();
}
}
五、总结:焦虑源于未知,成长来自行动
回到开篇的问题:Go 会取代 Java 吗? 我的答案是:短期内不会,但长期会形成互补格局。
- Java 的不可替代性:企业级成熟度、Android 生态、大数据框架,这些优势难以撼动。
- Go 的不可阻挡性:云原生、高并发、AI 集成,这些领域 Go 正在建立新标准。
作为开发者,与其焦虑语言之争,不如:
- 掌握 Go 的核心优势:学习 Goroutine 编程、云原生架构,参与开源项目(如 Kubernetes)。
- 深耕 Java 的护城河:研究虚拟线程调优、Spring Boot 3.2 新特性,提升企业级架构能力。
- 拥抱混合开发:在 Java 项目中引入 Go 模块,或在 Go 服务中调用 Java 遗留系统。
最后分享一个真实案例:某电商公司将支付核心用 Java 保留,抢购服务用 Go 重构,大促期间 QPS 从 5 万提升到 50 万,系统总成本降低 40%。这说明,语言只是工具,业务价值才是终极目标。
来源:juejin.cn/post/7540597161224536090
我为什么在团队里,强制要求大家用pnpm而不是npm?
最近,我在我们前端团队里推行了一个“强制性”的规定:所有新项目,必须使用pnpm
作为包管理工具;所有老项目,必须在两个月内,逐步迁移到pnpm
。
这个决定,一开始在团队里是有阻力的。
有同事问:“老大,npm用得好好的,为啥非要换啊?我们都习惯了。”
也有同事说:“yarn不也挺快的吗?再换个pnpm,是不是在瞎折腾?”
我理解大家的疑问。但我之所以要用“强制”这个词,是因为在我看来,在2025年的今天,继续使用npm或yarn,就像是明明有高铁可以坐,你却非要坚持坐绿皮火车一样,不是不行,而是没必要。
这篇文章,我就想把我的理由掰开揉碎了,讲给大家听。
npm和yarn的“原罪”:那个又大又慢的node_modules
在聊pnpm的好处之前,我们得先搞明白,npm和yarn(特指yarn v1)到底有什么问题。
它们最大的问题,都源于一个东西——扁平化的node_modules
。
你可能觉得奇怪,“扁平化”不是为了解决npm v2时代的“依赖地狱”问题吗?是的,它解决了老问题,但又带来了新问题:
1. “幽灵依赖”(Phantom Dependencies)
这是我最不能忍受的一个问题。
举个例子:你的项目只安装了A
包(npm install A
)。但是A
包自己依赖了B
包。因为是扁平化结构,B
包也会被提升到node_modules
的根目录。
结果就是,你在你的代码里,明明没有在package.json
里声明过B
,但你却可以import B from 'B'
,而且代码还能正常运行!
这就是“幽灵依赖”。它像一个幽灵,让你的项目依赖关系变得混乱不堪。万一有一天,A
包升级了,不再依赖B
了,你的项目就会在某个意想不到的地方突然崩溃,而你甚至都不知道B
是从哪来的。
2. 磁盘空间的巨大浪费
如果你电脑上有10个项目,这10个项目都依赖了lodash
,那么在npm/yarn的模式下,你的磁盘上就会实实在在地存着10份一模一样的lodash
代码。
对于我们这些天天要开好几个项目的前端来说,电脑的存储空间就这么被日积月累地消耗掉了。
3. 安装速度的瓶颈
虽然npm和yarn都有缓存机制,但在安装依赖时,它们仍然需要做大量的I/O操作,去复制、移动那些文件。当项目越来越大,node_modules
动辄上G的时候,那个安装速度,真的让人等到心焦。
pnpm是怎么解决这些问题的?——“符号链接”
好了,现在主角pnpm登场。pnpm的全称是“performant npm”,意为“高性能的npm”。它解决上面所有问题的核心武器,就两个字:链接。
pnpm没有采用扁平化的node_modules
结构,而是创建了一个嵌套的、有严格依赖关系的结构。
1. 彻底告别“幽灵依赖”
在pnpm的node_modules
里,你只会看到你在package.json
里明确声明的那些依赖。
你项目里依赖的A
包,它自己所依赖的B
包,会被存放在node_modules/.pnpm/
这个特殊的目录里,然后通过 符号链接(Symbolic Link) 的方式,链接到A
包的node_modules
里。
这意味着,在你的项目代码里,你根本访问不到B
包。你想import B
?对不起,直接报错。这就从结构上保证了,你的项目依赖关系是绝对可靠和纯净的。
2. 磁盘空间的“终极节约”
pnpm会在你的电脑上创建一个“全局内容可寻址存储区”(content-addressable store),通常在用户主目录下的.pnpm-store
里。
你电脑上所有项目的所有依赖,都只会在这个全局仓库里,实实在在地只存一份。
当你的项目需要lodash
时,pnpm不会去复制一份lodash
到你的node_modules
里,而是通过 硬链接(Hard Link) 的方式,从全局仓库链接一份过来。硬链接几乎不占用磁盘空间。
这意味着,就算你有100个项目都用了lodash
,它在你的硬盘上也只占一份的空间。这个特性,对于磁盘空间紧张的同学来说,简直是福音。
3. 极速的安装体验
因为大部分依赖都是通过“链接”的方式实现的,而不是“复制”,所以pnpm在安装依赖时,大大减少了磁盘I/O操作。
它的安装速度,尤其是在有缓存的情况下,或者在安装一个已经存在于全局仓库里的包时,几乎是“秒级”的。这种“飞一般”的感觉,一旦体验过,就再也回不去了。
为什么我要“强制”?
聊完了技术优势,再回到最初的问题:我为什么要“强制”推行?
因为包管理工具的统一,是前端工程化规范里最基础、也最重要的一环。
如果一个团队里,有人用npm,有人用yarn,有人用pnpm,那就会出现各种各样的问题:
- 不一致的
lock
文件:package-lock.json
,yarn.lock
,pnpm-lock.yaml
互相冲突,导致不同成员安装的依赖版本可能不完全一致,引发“在我电脑上是好的”这种经典问题。 - 不一致的依赖结构:用npm的同事,可能会不小心写出依赖“幽灵依赖”的代码,而用pnpm的同事拉下来,代码直接就跑不起来了。
在一个团队里,工具的统一,是为了保证环境的一致性和协作的顺畅。而pnpm,在我看来,就是当前这个时代下,包管理工具的“最优解”。
所以,这个“强制”,不是为了搞独裁,而是为了从根本上提升我们整个团队的开发效率和项目的长期稳定性。
最后的经验
从npm到yarn,再到pnpm,前端的包管理工具一直在进化。
pnpm用一种更先进、更合理的机制,解决了过去遗留下的种种问题。它带来的不仅仅是速度的提升,更是一种对“依赖关系纯净性”和“工程化严谨性”的保障。
我知道,改变一个人的习惯很难。但作为团队的负责人,我有责任去选择一条更高效、更正确的路,然后带领大家一起走下去。
如果你还没用过pnpm,我强烈建议你花十分钟,在你的新项目里试一试🙂。
来源:juejin.cn/post/7530180321619656745
多线程爬虫案例
多线程爬虫的使用主要是为了提高网络爬虫的效率和性能。以下是几个关键原因:
- 提高速度:
- 并行处理:多线程爬虫可以同时处理多个请求,从而大大减少总的爬取时间。例如,如果一个单线程爬虫需要10秒来下载一个网页,而一个多线程爬虫可以同时下载10个网页,那么在同样的时间内,多线程爬虫可以完成更多的任务。
- 资源利用:
- 充分利用CPU和网络带宽:现代计算机通常具有多核CPU和高速网络连接,多线程爬虫可以更好地利用这些资源,避免单线程爬虫中的空闲等待时间(如等待网络响应)。
- 容错性:
- 任务隔离:多线程爬虫中,每个线程可以独立运行,即使某个线程出现错误或被阻塞,其他线程仍然可以继续工作,提高了整个爬虫系统的稳定性和可靠性。
- 复杂任务管理:
- 任务分配:对于复杂的爬虫任务,可以将不同的任务分配给不同的线程,例如,一个线程负责下载页面,另一个线程负责解析页面内容,还有一个线程负责存储数据。
- 适应动态环境:
- 灵活调整:多线程爬虫可以根据实际情况动态调整线程数量,例如,在网络状况良好时增加线程数以加快爬取速度,而在网络拥塞时减少线程数以避免对目标网站造成过大压力。
多线程爬取网站案例
# -*- coding: UTF-8 -*-
'''
@Project :网络爬虫
@File :11-多线程爬虫案例.py
@IDE :PyCharm
@Author :慕逸
@Date :09/11/2024 19:54
'''
# https://www.doutupk.com/search?type=photo&more=1&keyword=%E9%9D%93%E4%BB%94&page=3
import requests
from urllib import request
from lxml import etree
import threading
from queue import Queue
import winsound
class Producer(threading.Thread):
headers = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'
}
def __init__(self, page_queue, img_queue):
super().__init__()
self.page_queue = page_queue
self.img_queue = img_queue
def run(self):
while True:
if self.page_queue.empty():
break
url = self.page_queue.get()
self.parse_page(url)
def parse_page(self, url):
response = requests.get(url, headers=self.headers)
text = response.text
# print(text)
html = etree.HTML(text)
imgs = html.xpath("//div[@class='pic-content text-center']//img[@class!='gif']")
img_names = html.xpath(
"//div[@class='pic-content text-center']//img[@class!='gif']/following-sibling::p/text()")
for img, img_name in zip(imgs, img_names):
img_url = img.get('data-original')
# request.urlretrieve(img_url, 'E:/Study/code/Python/图片爬取/斗图啦/{}'.format(img_name) + str(random.randint(1, 10000)) + '.jpg')
# print(img_name + "-下载完成~~" + img_url)
self.img_queue.put((img_url, img_name))
class Consumer(threading.Thread):
def __init__(self, page_queue, img_queue):
super().__init__()
self.page_queue = page_queue
self.img_queue = img_queue
def run(self):
while True:
if self.img_queue.empty() and self.page_queue.empty():
break
img = self.img_queue.get()
img_url, file_name = img
file_name = file_name.replace('.','').replace('?','')
request.urlretrieve(img_url, 'E:/Study/code/Python/图片爬取/斗图啦/{}.jpg'.format(file_name))
print(file_name + "-下载完成~~" + img_url)
def main():
winsound.Beep(1500, 500)
page_queue = Queue(100)
img_queue = Queue(500)
for i in range(1, 18):
url = "https://www.doutupk.com/search?type=photo&more=1&keyword=%E9%9D%93%E4%BB%94&page={}".format(i)
# parse_page(url)
page_queue.put(url)
# break
# 创建5个线程
for i in range(5):
t = Producer(page_queue, img_queue)
t.start()
for i in range(5):
t = Consumer(page_queue, img_queue)
t.start()
if __name__ == '__main__':
main()
winsound.Beep(1000, 500)
- 生产者类负责从队列中获取页面 URL,解析页面并提取图片信息,然后将图片信息放入图片队列中。
- 消费者类负责从图片队列中获取图片信息,下载图片并保存到本地。
来源:juejin.cn/post/7435257934344798248
Tauri 2.0 桌面端自动更新方案
前言
最近在研究 Tauri 2.0 如何自动更新,跟着官网教程来了一遍,发现并不顺利,踩了很多坑,不过好在最后终于走通了,今天整理一下供大家参考。
第一步
自动更新利用的是 Tauri 的 Updater 组件,所以这里需要安装一下:
PNPM 执行这个(笔者用的 PNPM):
pnpm tauri add updater
NPM 执行这个:
npm run tauri add updater
接着在 /src-tauri/tauri.conf.json
文件中添加以下配置:
{
"bundle": {
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "你的公钥",
"endpoints": ["https://releases.myapp.com/latest.json"]
}
}
}
其中:
createUpdaterArtifacts
为是否创建更新包,设置为 true 即可。根据官网介绍,未来发布的 V3 版本将无需设置。pubkey
是公钥,用于和私钥匹配(私钥在开发环境配置,并在打包时自动携带)。但此时我们还没有,所以需要生成一下,执行以下命令生成密钥对:
PNPM 执行这个:
pnpm tauri signer generate -w ~/.tauri/myapp.key
NPM 执行这个:
npm run tauri signer generate -- -w ~/.tauri/myapp.key
执行时会要求输入一个密码用来保护密钥,也可以直接按回车跳过,建议还是输入一个:
输入(或跳过)之后,将会继续生成,生成之后进入刚才我们指定的目录
~/.tauri
:
打开公钥
myapp.key.pub
然后将上面的pubkey
替换掉。
私钥的话,打开
myapp.key
然后执行以下方法设置到环境变量:
macOS 和 Linux 执行这个(笔者是 macOS):
export TAURI_SIGNING_PRIVATE_KEY="你的私钥"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="你刚才输入的密码,没有就不用设置。"
Windows 使用 Powershell 执行这个:
$env:TAURI_SIGNING_PRIVATE_KEY="你的私钥"
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD="你刚才输入的密码,没有就不用设置。"
endpoints
用于 Tauri 检查更新,是一个数组,所以可以设置多个,将会依次尝试可用的 URL,URL 指向放置在服务器的用于存储版本信息的 JSON 文件(也可以使用 API 的形式,这里不介绍了),格式如下:
{
"version": "1.0.1",
"notes": "更新说明",
"pub_date": "2025-05-21T03:29:28.626Z",
"platforms": {
"darwin-aarch64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVTU0xJb2k1U3J6ZVFoUWo3R2lMTm5EdzhoNUZTKzdsY0g1NktOOTFNL2RMM0JVVVl4b0k3bFB0MkhyL3pKOHRYZ0x0RVdUYzdyWVJvNDBtRDM0OGtZa2d0RWl0VTBqSndrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzQ3Nzk1MTY5CWZpbGU6bXktdGF1cmktYXBwLmFwcC50YXIuZ3oKS1N0UDl5MHRteUd0RHJ6anlSMXBSWmNJUlNKb1pYTDFvK2EvUjArTlBpbXVGN3pnQlA0THhhVUd4S3JrZy9lNHBNbWVSU2VoaCswN25xNEFPcmtUQnc9PQo=",
"url": "macOS 包下载地址"
}
}
}
将此 JSON 文件放置在服务器,然后将上面的
endpoints
数组里的地址替换为这个 JSON 的真实地址。
其中:
version
是版本号,升级时需要大于当前用户使用的版本。notes
是更新说明,可以向用户说明本次更新的内容。pub_date
是更新日期,非必填。platform
是更新的平台,这里我以 macOS 为例,Windows 同理。signature
是每次打包后的签名,所以每次都不一样,macOS 默认在/src-tauri/target/release/bundle/macos/my-tauri-app.app.tar.gz.sig
这个位置,将这个文件打开,复制里面的内容替换即可。
第二步
配置好以后,就可以在应用内调用 check
方法进行更新了,比如在用户每次应用启动后。以下是从检查更新到更新完成的全流程的必要代码:
import { check } from '@tauri-apps/plugin-updater'
import { relaunch } from '@tauri-apps/plugin-process'
const fetchVersion = async () => {
const update = await check()
if (update) {
console.log(`found update ${update.version} from ${update.date} with notes ${update.body}`)
let downloaded = 0
let contentLength = 0
// 也可以分开调用 update.download() 和 update.install()
await update.downloadAndInstall(event => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength
console.log(`started downloading ${event.data.contentLength} bytes`)
break
case 'Progress':
downloaded += event.data.chunkLength
console.log(`downloaded ${downloaded} from ${contentLength}`)
break
case 'Finished':
console.log('download finished')
break
}
})
console.log('update installed')
// 此处 relaunch 前最好询问用户
await relaunch()
}
}
代码已经很简洁了,相信大家能看懂,但还是简单说一下:
首先调用 check
方法。检查之后,check
方法会返回一个 update
对象,如果检查到有更新,该对象会包含上面的版本更新信息,也包含一个 downloadAndInstall
方法。
执行 downloadAndInstall
方法,该方法执行完之后就代表安装成功了,会在下次启动时更新为新版本。当然也可以立即生效,只需要调用 relaunch
方法重启应用即可,但重启前最好提醒用户。
源码(经测试已经成功实现自动更新)已经上传到 Github:github.com/reallimengz…
来源:juejin.cn/post/7506832196582408226
ESLint + Husky 如何只扫描发生改动的文件?
背景
最近公司对代码质量抓得很严, 出台了一系列组合拳:
- 制定前端编码规范
- 在本地使用git提交代码时进行代码质量检查
- 在CI/CD流水线上, 用sonarQube设置了一个代码质量达标阈值,不达标的话无法构建部署
- 除了运用工具之外,还增加了定期的CodeReview
- 单元测试,线上合并代码时用大模型进行CodeReview也在路上...
今天先说说,在本地使用git提交代码时进行代码质量检查如何实现。现在进入主题
Step1 配置ESLint校验规则
在这一步,踩了一个大坑。现在安装ESLint, 安装的都是ESLint v9.x版本,ESLint v9+的配置文件与之前不太一样了。不管是问大模型,还是上网搜,搜出来的ESLint安装配置方式90%以上都是ESLint V8及以下版本的配置方法。按照那种方式配,会吃很多瘪。
能看懂的,简单一点的报错比如说:
- .eslintignore文件不再被支持,应该在
eslint.config.js
或eslint.config.ts
配置文件中,使用ignores
属性来指定哪些文件或目录需要被忽略。
(node:13688) ESLintIgnoreWarning: The ".eslintignore" file is no longer supported. Switch to using the "ignores" property in "eslint.config.js": https://eslint.org/docs/latest/use/configure/migration-guide#ignoring-files (Use
node --trace-warnings ...to show where the warning was created) Oops! Something went wrong! :( ESLint: 9.25.1)
, - 改成
ignores
又报错,对象字面量只能指定已知属性,并且“ignores”不在类型“ESLintConfig”中
,被大模型忽悠了一回。在 ESLint 9.x 中,应该使用ignorePatterns
来指定要忽略的文件或文件夹,而不是ignores
。 jiti
包版本不匹配, 需要升级
Oops! Something went wrong! :( ESLint: 9.25.1 Error: You are using an outdated version of the 'jiti' library. Please update to the latest version of 'jiti' to ensure compatibility and access to the latest features.
- 未安装eslint-define-config模块
Oops! Something went wrong! :( ESLint: 9.25.1 Error: Cannot find module 'eslint-define-config'
不太容易看懂的报错比如说 ESLint 没有找到适用于文件 src/main.ts
的配置规则。0:0 warning File ignored because no matching configuration was supplied
, 按照大模型的提示,逐一检查了ESLint 配置文件的路径是否正确,确保 root: true
配置生效; TypeScript 和 Vue 插件及解析器配置是否正确; ignorePatterns
是否误忽略了 src
文件夹; 检查 tsconfig.json
中的 include
配置; 手动检查文件是否被 ESLint 正确解析
pnpm eslint --config ./eslint.config.ts src/main.ts
忙活了一圈,未能解决问题。大模型排查技术工具最新版本的故障问题能力偏弱。无奈只能在网上搜,一篇一篇的看与试错。最终验证通过是方案是采用@eslint/config生成eslint v9版本的配置文件。
pnpm create @eslint/config
做7个选择(每个选项的含义一眼就能看懂)之后,就能妥妥地生成eslint配置文件。
Step2 配置Husky
这一步比较简单,虽然Husky最新版本的配置方法与先前的版本不一样了。但新版本的配置比老版本的要简单一些。
✅ 1. 安装Husky v9+版本
pnpm add -D husky
✅ 2. Husky v9+版本初始化
npx husky init
这会自动:
- 创建
.husky/
目录 - 在
.husky/
下添加pre-commit
hook 示例 - 在package.json中添加
"prepare": "husky install"
脚本
这一步有个小坑,就是如果npx husky init
第一次因为某种原因运行失败,第二次再运行,不会生成.husky目录。解决方法也很简单粗暴,卸载husky重新安装。
✅ 3. 在package.json
配置检查指令
{
"scripts": {
"lint": "run-s lint:*",
"lint:eslint": "eslint src/**/*.{ts,tsx,vue} --debug --cache",
"lint:prettier": "prettier --check ./",
"lint:style": "stylelint \"src/**/*.{vue,css,less}\" --fix",
},
}
✅ 4. 修改 .husky/pre-commit
hook
# 检查指令
pnpm lint
Step3 配置ESLint增量检测
为什么要配置增量检测呢,原因有两点:
- ESLint全量检测执行的很慢,如果不加
--debug
参数,很长一段时间,看不到任何输出,会让人误以为卡死了 - 开发业务功能的时间本来就捉襟见肘,对于已有项目,当期要偿还历史技术债务的话,时间不允许。
那么如何做增量检查呢?最质朴的思路就是利用git能监测暂存区代码变更的能力,然后利用ESlint对变更的文件执行代码质量检查。这里有两处要注意一下,一是检查暂存区变更的文件,要过滤掉删除的文件,只检查新增,修改,重命名,复制的文件。另外,当没有匹配类型的文件时,files=$(git diff --cached --name-only --diff-filter=AMRC | grep -E '\.(ts|tsx|vue)$')
会抛出一个exit 1
的异常,造成改了(ts|tsx|vue)
之外的文件不能正常提交,所以要在后面加一个|| true
进行兜底。
#!/bin/bash
# set -e
# set -x
trap 'echo "Error at line $LINENO"; exit 1' ERR
# 注意这里加了 || true
files=$(git diff --cached --name-only --diff-filter=AMRC | grep -E '\.(ts|tsx|vue)$' || true)
if [ -z "$files" ]; then
echo "No changed ts/tsx/vue files to check."
exit 0
fi
echo "Running ESLint on the following files:"
echo "$files"
# 用 xargs -r 只有在有输入时才执行
echo "$files" | xargs -r npx eslint
echo "All files passed ESLint."
exit 0
Step4 测试效果
修改 src
下的某个 main.ts
文件,故意触发代码质量问题,然后提交。
- 情形1 通过命令行提交,eslint校验未通过,阻断提交,且是增量校验。
git add . && git commit -m "测试"
- 情形2 通过UI界面提交,成功阻断提交
至此大功告成,结果令人满意,如果你的项目也需要实现这样的功能的话,拿走不谢。
后记
业务背景是这样的:gitlab上有个填写公司的仓库,有个提交代码的仓库,现在要将提交代码的仓库的代码变更记录,添加到填写工时的议题评论列表中,只要按照 feat: 跨项目提交测试 #194
(#194
是填写工时的议题id)这样的格式填写提交语,就能实现在评论列表添加代码变更链接的效果。
在.husky目录下添加prepare-commit-msg文件,内容如下:
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# 仅当手动写 commit message 时执行
if [ "$2" = "merge" ] || [ "$2" = "squash" ]; then
exit 0
fi
file="$1"
msg=$(cat "$file")
# 查找是否包含 #数字 格式的 Issue 编号
issue_number=$(echo "$msg" | grep -Eo '#[0-9]+' | head -n1 | sed 's/#//')
if [ -n "$issue_number" ]; then
# 自定义项目路径
project_path="research-and-development/algorithm/项目名"
# 如果已经包含路径,则不重复添加
echo "$msg" | grep -q "$project_path" && exit 0
echo "" >>"$file"
echo "Related to $project_path#$issue_number" >>"$file"
fi
需要注意的是,你使用的gitlab版本必须大于v15,才支持跨项目议题关联功能
来源:juejin.cn/post/7497800812317147170
小米正式官宣开源!杀疯了!
最近,和往常一样在刷 GitHub Trending 热榜时,突然看到又一个开源项目冲上了 Trending 榜单。
一天之内就狂揽数千 star,仅仅用两三天时间,star 数就迅速破万,增长曲线都快干垂直了!
出于好奇,点进去看了看。
好家伙,这居然还是小米开源的项目,相信不少小伙伴也刷到了。
这个项目名为:ha_xiaomi_home。
全称:Xiaomi Home Integration for Home Assistant。
原来这就是小米开源的 Home Assistant 米家集成,一个由小米官方提供支持的 Home Assistant 集成组件,它可以让用户在 Home Assistant 平台中使用和管理小米 IoT 智能设备。
Home Assistant 大家知道,这是一款开源的家庭自动化智能家居平台,以其开放性和兼容性著称,其允许用户将家中的智能设备集成到一个统一的系统中进行管理和控制,同时支持多种协议和平台。
通过 Home Assistant,用户可以轻松地实现智能家居的自动化控制,如智能灯光、智能安防、智能温控等,所以是不少智能家居爱好者的选择。
另外通过安装集成(Integration),用户可以在 Home Assistant 上实现家居设备的自动化场景创建,并且还提供了丰富的自定义功能,所以一直比较受 DIY 爱好者们的喜爱。
大家知道,小米在智能家居领域的战略布局一直还挺大的,IoT 平台的连接设备更是数以亿记,大到各种家电、电器,小到各种摄像头、灯光、开关、传感器,产品面铺得非常广。
那这次小米开源的这个所谓的米家集成组件,讲白了就是给 Home Assistant 提供官方角度的支持。
而这对于很多喜欢折腾智能家居或者 IoT 物联网设备的小伙伴来说,无疑也算是一个不错的消息。
ha_xiaomi_home 的安装方法有好几种,包括直接 clone 安装,借助 HACS 安装,或者通过 Samba 或 FTPS 来手动安装等。
但是官方是推荐直接使用 git clone 命令来下载并安装。
cd config
git clone https://github.com/XiaoMi/ha_xiaomi_home.git
cd ha_xiaomi_home
./install.sh /config
原因是,这样一来当用户想要更新至特定版本时,只需要切换相应 Tag 即可,这样会比较方便。
比如,想要更新米家集成版本至 v1.0.0,只需要如下操作即可。
cd config/ha_xiaomi_home
git checkout v1.0.0
./install.sh /config
安装完成之后就可以去 Home Assistant 的设置里面去添加集成了,然后使用小米账号登录即可。
其实在这次小米官方推出 Home Assistant 米家集成之前,市面上也有一些第三方的米家设备集成,但是多多少少会有一些不完美的地方,典型的比如设备状态响应延时,所以导致体验并不是最佳。
与这些第三方集成相比,小米这次新推出的官方米家集成无论是性能还是安全性都可以更期待一下。
如官方所言,Home Assistant 米家集成提供了官方的 OAuth 2.0 登录方式,并不会在 Home Assistant 中保存用户的账号密码,同时账号密码也不再需提供给第三方,因此也就避免了账号密码泄露的风险。
但是这里面仍然有一个问题需要注意,项目官方也说得很明确:虽说 Home Assistant 米家集成提供了 OAuth 的登录方式,但由于 Home Assistant 平台的限制,登录成功后,用户的小米用户信息(包括设备信息、证书、 token 等)会明文保存在 Home Assistant 的配置文件中。因此用户需要保管好自己的 Home Assistant 配置文件,确保不要泄露。
这个项目开源之后,在网上还是相当受欢迎的,当然讨论的声音也有很多。
小米作为一家商业公司,既然专门搞了这样一个开源项目来做 HA 米家集成,这对于他们来说不管是商业还是产品,肯定都是有利的。
不过话说回来,有了这样一个由官方推出的开源集成组件,不论是用户体验还是可玩性都会有所提升,这对于用户来说也未尝不是一件好事。
那关于这次小米官方开源的 Home Assistant 米家集成项目,大家怎么看呢?
注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。
来源:juejin.cn/post/7454170332712386572
离开大厂一年后的牛马,又回到了草原
牛马终究还是回到了草原
大家好呀,今天开始继续更新文章了,最近一个月因为换了城市,调整状态花了不少时间,现在才有些动力继续更新,今天来聊聊最近一年多的经历
1.回二线城市
23年12月的时候,我从北京阿里离职了,回到离家近的武汉,拿了一个国企的offer,也是在那家国企待了一年多的时间。
至于为什么会选择回武汉,主要觉得在一线城市买不起房,而且长期在外离家远,漂泊感太重,还不如早点回二线城市定居下来,免得以后年龄大了更不好回来。所以,当时看武汉的机会主要找稳定的工作,除了国企offer,还拿过高校,以及其他偏稳定的公司offer。综合考虑后,才选的国企
回来之后没到两月,就有些后悔了。虽然回来之前做好了心理预期,工资会降低,只是国企的工资也低的过于少了吧,开始几个月工资只有原来的零头。而且,呆了一段时间后发现,这个国企在北京才是研发中心,武汉这边作为分中心,只是北京的“内包”而已。
项目经理都是在北京,很多还都是校招进去工作才两三年的,就让管武汉这边几个人的工作。这些人也没啥管理经验,就只是不停给底下人施压。而且经常搞的操作就是,项目都是有时间要求的,开始的时候不拉人进来干活,等到时间比较紧的时候,才拉人进来,这就导致武汉的人收到任务时,都是重要且紧急的工作。又或者正常工作日没安排活,等到要放假,或者下班之前,才给安排活,让自行加班完成,故意恶心人
2.上着北京的班,拿着武汉的工资
这个事在24年的时候,已经发过吐槽了,就发张截图看看
3.成长的焦虑
我在北京的时候,除了阿里,之前也呆过一家互联网公司。裁员是比较常见的事情,虽然我还没被裁过。
国企这边虽然说相对比较稳定,但是自从我这批大概20个人入职后,感觉在走下坡路了,不仅福利变少,而且也变卷了,还传出后面也要搞末尾淘汰的事。我呆了一年的时候,感觉比在一线时更害怕,假如我在这里被裁了,或者呆满三年不续签了,可能很难再找到合适的工作了,自身成长和工作年限严重不匹配,想到这,我终于还是下了决定再去一线城市
找下家的经历并不顺利,国企的项目可以说就是玩具而已,完全拿不出手。只能拿之前阿里的项目去讲。有些一线互联网大厂,面试了好几轮,好不容易都过了。在焦急等待了数周后,最后给我说横向比较有更合适的了,就不考虑了。其实大家也都明白是因为啥原因不给发。
4.空窗期
经历了几个月的找工作经历后,终于是拿到上海一家公司offer,于是也就顺理成章准备跑路了。趁着中间有半个月的空闲时间,去西边青海和西藏游玩了一次。那边景点之间距离远,在携程上报跟团游就行,价钱不高,比较推荐去青海游玩,那的牛肉和羊肉非常新鲜,值得尝试,3天的时间,每天都能看不一样的风景。
5.上海
已经到上海快两个月,开始逐渐习惯天天下雨的节奏,现在下班还是比较早的,下班的时候还能见着落日,比较WLB,没有互联网大厂那么的卷,却也是有些压力的。下面是第一周下班时路上的照片
今天就写到这吧,下次有新鲜事再分享~
来源:juejin.cn/post/7522315126491856923
AI一定会淘汰程序员,并且已经开始淘汰程序员
昨儿中午吃着饭,不知道为啥突然和同事聊起来AI发展的事儿了。虽然我们只是"🐂🐎",但是不妨碍咱们坐井观天,瞎扯淡聊聊天。
他的主要观点是:现阶段的AI绝对无法取代程序员,大家有点过度恐慌了。AI是程序员的工具,就像从记事本升级到IDE一样。
我的主要观点是:AI一定会取代大量的程序员,尤其是初级程序员。后续程序员岗将在软件公司内的比重降低,取而代之的是产品、需求和算法岗。
诚然,他说的也有一定的道理,就目前AI发展的速度和质量来看,其实大家的确有点儿过度恐慌了。
AI的确在一定程度上替代的程序员的工作,但是大多内容仍然是程序员群体在产出,这个是不容否认的事实。
不过另一个不容否认的事实是,我们越来越依赖AI了。原来大家写代码一般都是啥不会了,就直接去网上搜。比如说js怎么截断从第2位到最后一位,想不起来了,搜一搜。
现在有了AI,一般的操作都是在上面写个注释,然后回车一下,AI会自动帮你补全代码,连搜的功夫都省了。
由此俺也big胆的预言一把,CSD*、掘*、百*这些资讯类,尤其是做程序员这个方向的资讯网站会越来越没落。
原因是:有了问题,但是没有了搜索。没有搜索就没有共享解决方案的必要,因为没人看。没人看所以不再有人分享,最后Over。
Ps:或许我被啪啪打脸呢?
之前的AI编码工具我就有在用,包括阿里的通义,百度的文心,或者是IDEA里面内嵌的编码助手,以及之前CodeGeex等等吧,确确实实是提高了效率的。
这个阶段其实重点提升的是搜索的效率,有问题直接问AI,AI直接给出答案。95%的答案都是正确的,不必再去网上费劲巴拉的找了。
后来更上一层楼,Claude2、3直接翻倍的提升了开发效率。我不必再实现了,我只需要阐述需求,AI会自动给出答案。我需要的是把内容Copy下来,然后整合进我的工程里面。
后面的工作就还是原来的老步骤了,测试一下,没啥问题的话就发版,提测了。
但是现在这个阶段又进步了,TREA2.0 SOLO出道直接整合了全流程,整个过程直接变成了"智能体"把控,不再需要我的干涉了。
我需要做的工作就是阐述清楚我的需求,然后让TREA去实现。这个过程中可能也就只需要我关注一下实现的方向不要有太大的偏差,及时纠正一下(但是到目前为止没遇到过需要纠正的情况)。
也就是说,现在AI已经从原来的片面生成代码,到后面的理解需求生成单文件代码,到现在生成工程化代码了。
而我们的角色也从一个砌墙工人,拿到了一把能自动打灰的铲子
。
到后面变成一个小组长,拿着尺子和吊锤看他们盖的墙有没有问题
。
到现在变成包工头看着手底下的工人一层层的盖起大楼
。
但是!
你说工地上是工人多,还是组长多,还是包工头多?真的需要这么多包工头嘛?
来源:juejin.cn/post/7530570160840458250
Three.js-硬要自学系列29之专项学习透明贴图
什么是透明贴图
- 核心作用:像「镂空剪纸」一样控制物体哪些部位透明/不透明
(想象:给树叶模型贴图,透明部分让树叶边缘自然消失而非方形边缘)
- 技术本质:一张 黑白图片(如 PNG 带透明通道),其中:
- 黑色区域 → 模型对应位置 完全透明(消失)
- 白色区域 → 模型 完全不透明(显示)
- 灰色过渡 → 半透明效果(如玻璃边缘)
示例:游戏中的铁丝网、树叶、破碎特效等镂空物体常用此技术
常见问题与解决方案
问题现象 | 原因 | 解决方法(代码) |
---|---|---|
贴图完全不透明 | 忘记开 transparent | material.transparent = true |
边缘有白边/杂色 | 半透明像素混合错误 | material.alphaTest = 0.5 |
模型内部被穿透 | 透明物体渲染顺序错乱 | mesh.renderOrder = 1 |
技巧:透明贴图需搭配 基础颜色贴图(map) 使用,两者共同决定最终外观
实际应用场景
- 游戏植被:草地用方形面片+草丛透明贴图,节省性能
- UI 元素:半透明的警示图标悬浮在 3D 物体上
- 破碎效果:物体裂开时边缘碎片渐变透明
- AR 展示:透明背景中叠加虚拟模型(类似宝可梦 GO)
实践案例一
效果如图
实现思路
通过canvas绘制内容,canvasTexture用来转换为3d纹理
const canvas = document.createElement('canvas'),
ctx = canvas.getContext('2d');
canvas.width = 64;
canvas.height = 64;
ctx.fillStyle = '#404040';
ctx.fillRect(0, 0, 32, 32);
ctx.fillStyle = '#808080';
ctx.fillRect(32, 0, 32, 32);
ctx.fillStyle = '#c0c0c0';
ctx.fillRect(0, 32, 32, 32);
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(32, 32, 32, 32);
const texture = new THREE.CanvasTexture(canvas);
这里画布大小设置为64*64,被均匀分割为4份,并填充不同的颜色
接下来创建一个立方体,为其贴上透明度贴图alphaMap
,设置transparent:true
这很关键
const geo = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({
color: 'deepskyblue',
alphaMap: texture, // 透明度贴图
transparent: true,
opacity: 1,
side: THREE.DoubleSide
});
如果你尝试将transparent
配置改为false
, 你将看到如下效果
同样我们尝试修改canvas绘制时候的填充色,来验证黑白镂空情况
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, 32, 32);
ctx.fillStyle = '#000';
ctx.fillRect(32, 0, 32, 32);
ctx.fillStyle = '#000';
ctx.fillRect(0, 32, 32, 32);
ctx.fillStyle = '#fff';
ctx.fillRect(32, 32, 32, 32);
如图所示,黑色消失,白色显示保留
总结本案例需要掌握的API
CanvasTexture
这是Texture
的子类,它用于将动态绘制的 2D Canvas
内容(如图表、文字、实时数据)转换为 3D 纹理,使得HTML Canvas
元素可以作为纹理映射到3d物体表面
它支持实时更新,默认needsUpdate
为true
应用场景
- 动态数据可视化:将实时图表(如温度曲线)映射到 3D 面板。
- 文字标签:在 3D 物体表面显示可变文字(如玩家名称)。
- 程序化纹理:通过算法生成图案(如噪波、分形)。
- 交互式绘制:用户画布涂鸦实时投射到 3D 模型(如自定义 T 恤设计)。
性能优化
- 避免频繁更新:若非必要,减少
needsUpdate=true
的调用频率。 - 合理尺寸:Canvas 尺寸建议为 2 的幂(如 256×256, 512×512),兼容纹理映射。
- 复用 Canvas:对静态内容,复用已生成的纹理而非重新创建。
- 替代方案:静态图像用
TextureLoader
,视频用VideoTexture
,以降低开销。
需要注意
- 跨域限制:若 Canvas 包含外部图片,需设置
crossOrigin="anonymous"
。 - 清晰度问题:高缩放比例可能导致模糊,可通过
texture.anisotropy = renderer.capabilities.getMaxAnisotropy()
改善。 - 内存管理:不再使用的纹理调用
texture.dispose()
释放资源。
实践案例二
效果如图
实现思路
从图上可以看出,立方体每个面上有多个矩形小方块,每个方块都被赋予不同的颜色,创建grid
方法来实现生产多个矩形小方块
const drawMethod = {};
drawMethod.grid = (ctx, canvas, opt={} ) => {
opt.w = opt.w || 4;
opt.h = opt.h || 4;
opt.colors = opt.colors || ['#404040', '#808080', '#c0c0c0', '#f0f0f0'];
opt.colorI = opt.colorI || [];
let i = 0;
const len = opt.w * opt.h,
sizeW = canvas.width / opt.w, // 网格宽度
sizeH = canvas.height / opt.h; // 网格高度
while(i<len) {
const x = i % opt.w,
y = Math.floor(i / opt.w);
ctx.fillStyle = typeof opt.colorI[i] === 'number' ? opt.colors[opt.colorI[i]] : opt.colors[i % opt.colors.length];
ctx.fillRect(x * sizeW, y * sizeH, sizeW, sizeH);
i++;
}
}
实现透明贴图
const canvas = document.createElement('canvas'),
ctx = canvas.getContext('2d');
canvas.width = 64;
canvas.height = 64;
const texture = new THREE.CanvasTexture(canvas);
const geo = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({
color: 'deepskyblue',
alphaMap: texture,
transparent: true,
opacity: 1,
side: THREE.DoubleSide
});
这里要注意,canvas上并未绘制任何内容,我们将在loop循环中调用grid
方法进行绘制
let frame = 0,
lt = new Date(); // 上一次时间
const maxFrame = 90, // 最大帧数90帧
fps = 20; // 每秒20帧
function loop() {
const now = new Date(), // 当前时间
secs = (now - lt) / 1000, // 时间差
per = frame / maxFrame; // 进度
if (secs > 1 / fps) { // 时间差大于1/20
const colorI = [];
let i = 6 * 6;
while (i--) {
colorI.push(Math.floor(4 * Math.random()))
}
drawMethod.grid(ctx, canvas, {
w: 6,
h: 6,
colorI: colorI
});
texture.needsUpdate = true; // 更新纹理
mesh.rotation.y = Math.PI * 2 * per;
renderer.render(scene, camera);
frame += fps * secs; // 帧数累加
frame %= maxFrame; // 帧数取模,防止帧数溢出
lt = now;
}
// 渲染场景和相机
requestAnimationFrame( loop );
}
你可以看到这里每个面上被绘制了36个小矩形,并通过一下代码,随机填充颜色
while (i--) {
colorI.push(Math.floor(4 * Math.random()))
}
以上就是本章的所有内容,这里并未展示完整案例代码,是希望大家能动手练一练,很多的概念,看似晦涩难懂,实则动手尝试下的话秒懂。
来源:juejin.cn/post/7513158069419048997
JMeter 多台压力机分布式测试(Windows)
JMeter 多台压力机分布式测试(Windows)
1. 背景
- 在单台压力机运行时,出现了端口冲突问题,如
JMeter port already in use
。 - 压力机机器权限限制,无法修改默认端口配置。
- 为避免端口冲突且提升压力机的压力能力,考虑使用多台机器(多台JMeter压力机)分布式压测。
2.环境说明
- Master IP:
192.20.10.7
- Slave1 IP:
192.20.10.8
- Slave2 IP:
192.20.10.9
- JMeter版本均为 5.5
- Java版本均为 1.8+
- 网络可互通,防火墙端口放通
- RMI 注册端口:1099
- RMI 远程对象端口:50000(默认,可配置)
3. Master 节点配置
3.1 修改 jmeter.properties
(JMETER_HOME/bin/jmeter.properties
)
properties复制# 远程主机列表,逗号分隔
remote_hosts=192.20.10.8,192.20.10.9
# 禁用RMI SSL,避免额外复杂度
server.rmi.ssl.disable=true
# Master的回调地址,设置为本机可达IP(用于Slave回调)
client.rmi.localhostname=192.20.10.7
# 关闭插件联网上报,提升启动速度
jmeter.pluginmanager.report_stats=false
2.2 启动 JMeter GUI
- 直接运行
jmeter.bat
打开GUI - 加载测试脚本(
*.jmx
) - 确认脚本和依赖文件已同步到所有Slave节点同路径
3. Slave 节点配置(192.20.10.8 和 192.20.10.9)
3.1 修改各自的 jmeter.properties
(JMETER_HOME/bin/jmeter.properties
)
Slave1(192.20.10.8):
# 远程RMI服务监听端口
server_port=1099
# RMI通信本地端口(避免冲突,Slave1用50000)
server.rmi.localport=50000
# 禁用RMI SSL
server.rmi.ssl.disable=true
# 远程机器回调绑定IP(本机IP)
java.rmi.server.hostname=192.20.10.8
# 关闭插件联网上报
jmeter.pluginmanager.report_stats=false
Slave2(192.20.10.9):
server_port=1099
server.rmi.localport=50001
server.rmi.ssl.disable=true
java.rmi.server.hostname=192.20.10.9
jmeter.pluginmanager.report_stats=false
3.2 启动Slave服务
在每台Slave机器的 bin
目录,执行:
set JVM_ARGS=-Djava.rmi.server.hostname=192.20.10.8 #可选配置
jmeter-server.bat
(Slave2替换IP为 192.20.10.9
)
看到类似 Using local port: 50002 Created remote object: UnicastServerRef2 [liveRef:XXXX
表示启动成功。
如启动异常,可以打开jmeter-server.log
查看日志。
3.2 验证监听端口
netstat -an | findstr 1099
TCP 0.0.0.0:1099 0.0.0.0:0 LISTENING
TCP [::]:1099 [::]:0 LISTENING
netstat -an | findstr 50002
TCP 0.0.0.0:50002 0.0.0.0:0 LISTENING
TCP [::]:50002 [::]:0 LISTENING
4. 测试执行流程
- 确保Master和所有Slave机器的JMeter和Java版本一致。
- 确保测试脚本和依赖文件(如CSV等)在所有Slave路径相同。
- 启动所有Slave的
jmeter-server.bat
,确保端口监听正常。 - 在Master GUI加载脚本。
- 点击菜单
Run -> Remote Start 或 Remote Start All
,Master向所有Slave发起远程测试。 11.JMeter GUI的日志样例如下:
INFO o.a.j.e.ClientJMeterEngine: Sending properties {}
o.a.j.e.ClientJMeterEngine: sent run command to
o.a.j.e.DistributedRunner: Remote engines have been started
o.a.j.g.u.JMeterMenuBar: setRunning
o.a.j.g.u.JMeterMenuBar: setRunning
jmeter-server.bat的日志如下,出现如下字段
Starting the test on host ...
Finished the test on host ...
- Master GUI的 Listener(Summary Report等)会显示所有Slave合并的结果。
5. 其他注意事项
- 防火墙放通Master和Slave的
server_port
(1099)、server.rmi.localport
端口。 - Master
client.rmi.localhostname
必须是Slave可访问的Master IP。 - 禁用插件联网统计,避免卡顿。
- 避免使用重型监听器,使用轻量级监听器提升性能。
来源:juejin.cn/post/7530141922499149862
Spring高级开发:状态机/事件/插件
Spring 状态机与事件机制插件开发示例
我将为您演示一个完整的 Spring 状态机实现,包含状态机配置、事件机制和可扩展的插件体系。以下是一个订单处理系统的示例:
1. 添加依赖(pom.xml)
<dependencies>
<!-- Spring State Machine -->
<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-starter</artifactId>
<version>4.2.0</version>
</dependency>
<!-- Spring Event 相关 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
<version>6.0.9</version>
</dependency>
</dependencies>
2. 定义核心接口和类
2.1 状态和事件枚举
// 状态枚举
public enum OrderState {
NEW,
PAYMENT_PENDING,
PAID,
SHIPPED,
DELIVERED,
CANCELLED
}
// 事件枚举
public enum OrderEvent {
CREATE_ORDER,
INITIATE_PAYMENT,
PAYMENT_COMPLETED,
SHIP_ORDER,
DELIVERY_CONFIRMED,
CANCEL_ORDER
}
2.2 插件接口定义
// 状态变更监听器接口
public interface StateChangeListener {
void onStateChange(OrderState from, OrderState to, String orderId);
}
// 订单操作插件接口
public interface OrderOperationPlugin {
void execute(String orderId, OrderState currentState);
}
// 插件管理器接口
public interface PluginManager {
void registerStateChangeListener(StateChangeListener listener);
void notifyStateChange(OrderState from, OrderState to, String orderId);
void executePlugins(String orderId, OrderState currentState);
}
3. 实现状态机配置
@Configuration
@EnableStateMachine
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
@Autowired
private List<OrderOperationPlugin> plugins;
@Override
public void configure(StateMachineStateConfigurer<OrderState, OrderEvent> states) throws Exception {
states
.withStates()
.initial(OrderState.NEW)
.state(OrderState.PAYMENT_PENDING)
.junction(OrderState.PAID)
.end(OrderState.DELIVERED)
.end(OrderState.CANCELLED)
.fork(OrderState.SHIPPED);
}
@Override
public void configure(StateMachineTransitionConfigurer<OrderState, OrderEvent> transitions) throws Exception {
transitions
.withExternal()
.source(OrderState.NEW).target(OrderState.PAYMENT_PENDING)
.event(OrderEvent.CREATE_ORDER)
.and().withExternal()
.source(OrderState.PAYMENT_PENDING).target(OrderState.PAID)
.event(OrderEvent.INITIATE_PAYMENT)
.and().withExternal()
.source(OrderState.PAID).target(OrderState.SHIPPED)
.event(OrderEvent.SHIP_ORDER)
.and().withExternal()
.source(OrderState.SHIPPED).target(OrderState.DELIVERED)
.event(OrderEvent.DELIVERY_CONFIRMED)
.and().withExternal()
.source(OrderState.NEW).target(OrderState.CANCELLED)
.event(OrderEvent.CANCEL_ORDER)
.and().withExternal()
.source(OrderState.PAYMENT_PENDING).target(OrderState.CANCELLED)
.event(OrderEvent.CANCEL_ORDER);
}
@Bean
public PluginManager pluginManager() {
return new DefaultPluginManager(plugins);
}
}
4. 插件实现
4.1 默认插件管理器
@Component
public class DefaultPluginManager implements PluginManager {
private final List<StateChangeListener> stateChangeListeners = new ArrayList<>();
private final List<OrderOperationPlugin> operationPlugins;
public DefaultPluginManager(List<OrderOperationPlugin> plugins) {
this.operationPlugins = plugins;
}
@Override
public void registerStateChangeListener(StateChangeListener listener) {
stateChangeListeners.add(listener);
}
@Override
public void notifyStateChange(OrderState from, OrderState to, String orderId) {
stateChangeListeners.forEach(listener -> listener.onStateChange(from, to, orderId));
}
@Override
public void executePlugins(String orderId, OrderState currentState) {
operationPlugins.forEach(plugin -> plugin.execute(orderId, currentState));
}
}
4.2 示例插件实现
// 日志记录插件
@Component
public class LoggingPlugin implements OrderOperationPlugin {
@Override
public void execute(String orderId, OrderState currentState) {
System.out.println("订单 " + orderId + " 当前状态: " + currentState + " - 正在记录日志");
}
}
// 邮件通知插件
@Component
public class EmailNotificationPlugin implements OrderOperationPlugin {
@Override
public void execute(String orderId, OrderState currentState) {
System.out.println("订单 " + orderId + " 当前状态: " + currentState + " - 发送邮件通知");
}
}
// 库存管理插件
@Component
public class InventoryUpdatePlugin implements OrderOperationPlugin {
@Override
public void execute(String orderId, OrderState currentState) {
if (currentState == OrderState.PAID) {
System.out.println("订单 " + orderId + " 已支付,正在更新库存");
}
}
}
5. 状态监听器实现
@Component
public class AuditStateChangeListener implements StateChangeListener {
@Override
public void onStateChange(OrderState from, OrderState to, String orderId) {
System.out.println("订单状态变更: " + orderId + " 从 " + from + " 到 " + to + " - 审计记录已创建");
}
}
6. 服务层实现
@Service
public class OrderService {
@Autowired
private StateMachine<OrderState, OrderEvent> stateMachine;
@Autowired
private PluginManager pluginManager;
public OrderService() {
// 注册监听器
pluginManager.registerStateChangeListener(new AuditStateChangeListener());
}
public void handleOrderEvent(String orderId, OrderEvent event) {
try {
stateMachine.start();
OrderState currentState = stateMachine.getState().getId();
// 发送事件
stateMachine.sendEvent(event);
OrderState newState = stateMachine.getState().getId();
// 触发状态变更监听器
if (currentState != newState) {
pluginManager.notifyStateChange(currentState, newState, orderId);
}
// 执行操作插件
pluginManager.executePlugins(orderId, newState);
} catch (Exception e) {
System.err.println("处理订单事件失败: " + e.getMessage());
} finally {
stateMachine.stop();
}
}
}
7. 控制器示例
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping("/{orderId}/events")
public ResponseEntity<String> sendEvent(@PathVariable String orderId, @RequestParam String event) {
try {
OrderEvent orderEvent = OrderEvent.valueOf(event.toUpperCase());
orderService.handleOrderEvent(orderId, orderEvent);
return ResponseEntity.ok("事件已处理: " + event);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body("无效的事件类型: " + event);
}
}
@GetMapping("/{orderId}/status")
public ResponseEntity<String> checkStatus(@PathVariable String orderId) {
// 这里应该从存储中获取当前状态,为简化示例返回固定值
return ResponseEntity.ok("订单 " + orderId + " 当前状态: 示例状态");
}
}
8. 可扩展性说明
如何添加新插件:
@Component
public class NewFeaturePlugin implements OrderOperationPlugin {
@Override
public void execute(String orderId, OrderState currentState) {
// 新功能逻辑
}
}
如何添加新状态监听器:
@Component
public class NewStateChangeListener implements StateChangeListener {
@Override
public void onStateChange(OrderState from, OrderState to, String orderId) {
// 新监听器逻辑
}
}
使用示例:
# 创建订单
POST /orders/123/events?event=CREATE_ORDER
# 发起支付
POST /orders/123/events?event=INITIATE_PAYMENT
# 发货
POST /orders/123/events?event=SHIP_ORDER
# 确认送达
POST /orders/123/events?event=DELIVERY_CONFIRMED
# 取消订单
POST /orders/123/events?event=CANCEL_ORDER
这个实现具有以下特点:
- 灵活的状态机配置:使用 Spring StateMachine 配置订单状态流转
- 可扩展的插件系统:通过接口设计支持轻松添加新插件
- 事件驱动架构:利用状态变更事件触发相关业务逻辑
- 良好的分离关注点:核心状态机逻辑与业务插件解耦
- 易于维护和测试:各组件之间通过接口通信,便于单元测试和替换实现
您可以根据具体业务需求扩展更多状态、事件和插件功能。
来源:juejin.cn/post/7512237186647916571
PYTHON多智能体系统中的协同智能
使用 LangChain 等 Python 工具,可以更轻松地实现多智能体系统,从而创建超越简单自动化的智能系统。
译自Collaborative Intelligence in Multiagent Systems With Python,作者 Oladimeji Sowole。
近年来,大型语言模型 (LLM)通过生成类似人类的文本、解决复杂问题和自主执行任务,重新定义了人工智能的能力。
然而,随着任务变得更加复杂和跨学科,单个 AI 模型可能并不总是足够。这就是多智能体系统(MAS) 在 LLM 中的概念发挥作用的地方。MAS 允许多个 AI 智能体协作,每个智能体专门负责问题的不同方面,共同努力实现共同目标。
本教程将使用Python探索 LLM 中多智能体系统的最新趋势。我们将介绍什么是多智能体系统、它们为什么重要以及如何使用 LangChain 等工具使用 Python 分步实现它们。
什么是多智能体系统?
多智能体系统 (MAS) 是一个环境,其中多个自主智能体相互交互、合作甚至竞争以解决问题。每个智能体都有自己的能力、优势和关注领域,使系统能够更有效地处理复杂的任务。这些系统在需要协作、并行任务执行甚至协商的场景中表现出色。
在 LLM 中,多智能体系统可以:
- 协作完成需要多个专业领域的任务(例如,一个智能体专注于数学,而另一个智能体处理自然语言理解)。
- 互相协商以解决目标冲突。
- 并行解决复杂的、多步骤的问题,提高速度和准确性。
多智能体系统的用例
- 财务规划: 一个智能体可以专注于分析股票趋势,而另一个智能体可以预测市场的未来行为。
- 医疗保健: 一个智能体专注于诊断分析,而另一个智能体协助患者病史回顾,共同为全面的医疗保健建议提供帮助。
- 供应链优化: 智能体可以专门从事物流、采购或需求预测,从而改善整个供应链的决策。
为什么要使用多智能体系统?
- 专业化: 不同的智能体专门负责不同的任务,使解决问题更加高效。
- 并行性: 智能体可以同时工作,显着减少完成多步骤操作所需的时间。
- 协作: 多个智能体共同努力,利用其独特的优势来实现最佳结果。
- 适应性: 智能体可以实时协商或调整策略,以适应不断变化的任务。
使用 Python 设置多智能体系统
让我们从理论转向实践。在本节中,我们将演示如何使用 Python 和 LangChain 库构建多智能体系统,该库允许不同 LLM 支持的智能体之间无缝交互。
安装依赖项
首先,我们需要安装 LangChain 并设置一个 LLM 服务,例如 OpenAI。
pip install langchain openai
您还需要一个 OpenAI API 密钥,您可以通过注册 OpenAI 的 API 服务来获取。
初始化智能体和工具
首先,我们将定义我们的 LLM(GPT 模型)以及我们的智能体将使用的一组工具。这些工具可以是任何东西,从计算器到网络搜索功能。让我们初始化协作解决涉及信息检索和数学计算的任务的智能体。
from langchain.agents import initialize_agent, load_tools
from langchain.llms import OpenAI
# Initialize OpenAI model
llm = OpenAI(api_key="your_openai_api_key", model="gpt-4")
# Load tools (agents) such as search and calculator
tools = load_tools(["serpapi", "calculator"], llm)
# Initialize a multi-agent system
multi_agent = initialize_agent(
tools,
llm,
agent_type="multi-agent",
verbose=True
)
# Example task: Find the exchange rate of USD to EUR and calculate for 1500 units
task = "What is the current exchange rate of USD to EUR? Multiply it by 1500."
# Run the multi-agent system to complete the task
result = multi_agent.run(task)
print(result)
工作原理
- 智能体协作: 在此示例中,一个智能体使用搜索工具(例如 SERP API)获取实时汇率,而另一个智能体使用计算器工具将汇率乘以 1,500。
- 任务分解: LLM 将任务分解为子任务(获取汇率和执行计算),并将这些子任务分配给相应的智能体。
构建复杂的智能体系统
现在我们已经看到了一个基本示例,让我们构建一个更复杂的系统,该系统涉及多个智能体来解决问题的不同部分。考虑一个场景,我们正在构建一个旅行助手,它可以处理与预订航班、查看天气和执行预算计算相关的多个查询。
分步代码:旅行助手多智能体系统
# Define task-specific tools
from langchain.tools import Tool
# Weather checking tool
def get_weather(city):
return f"The weather in {city} is sunny with a temperature of 25°C."
# Flight booking tool
def book_flight(destination, date):
return f"Flight to {destination} on {date} has been booked."
# Budget calculation tool
def calculate_budget(amount, expenses):
remaining = amount - sum(expenses)
return f"Your remaining budget is {remaining}."
# Define our agents
weather_tool = Tool("get_weather", get_weather)
flight_tool = Tool("book_flight", book_flight)
budget_tool = Tool("calculate_budget", calculate_budget)
# Combine agents int0 a multi-agent system
tools = [weather_tool, flight_tool, budget_tool]
multi_agent = initialize_agent(tools, llm, agent_type="multi-agent", verbose=True)
# Example task
task = """
I want to book a flight to Paris for December 20th, check the weather in Paris,
and calculate my remaining budget if I have $2000 and my expenses are $500 and $300.
"""
# Execute the multi-agent system
result = multi_agent.run(task)
print(result)
发生了什么?
- 航班智能体:
book_flight
智能体处理任务的航班预订部分。 - 天气智能体:
get_weather
智能体检索巴黎的天气数据。 - 预算智能体:
calculate_budget
智能体根据用户的输入计算用户的剩余预算。
在这种情况下,每个代理都负责解决更大问题中的特定部分,并协同工作以提供全面的结果。整个过程由 LLM 驱动,LLM 协调代理的工作。
多代理系统的先进用例
医疗保健协作
在医疗保健领域,不同的代理可以专注于患者治疗过程的不同部分。例如:
- 一个代理可以分析医学影像。
- 另一个代理审查患者的病史。
- 第三个代理提供诊断建议。
通过协同工作,这些代理可以生成一份综合报告,帮助做出更准确、更快速的医疗决策。
供应链优化
多代理系统可用于管理供应链的不同方面:
- 物流代理跟踪运输时间。
- 采购代理监控库存水平。
- 预测代理预测未来需求。
它们共同可以优化供应链,减少延误,降低成本,提高整体效率。
结论
多代理系统 (MAS) 代表了人工智能驱动解决方案发展中的一个突破性趋势。通过允许多个代理协同工作,每个代理都有自己的专业领域,MAS 极大地提高了大规模问题解决任务的效率和有效性。借助 LangChain 等 Python 工具,实现多代理系统变得越来越容易,使开发人员能够创建超越简单自动化的智能系统。
您是否想探索与 AI 代理和 Python 合作的可能性?阅读 Andela 的博客,了解如何在 Python 中使用 LangGraph 开发主 AI 代理!
本文在云云众生(yylives.cc/)首发,欢迎大家访问。
来源:juejin.cn/post/7426999391653281801
RAG技术的PDF智能问答系统
关键要点
- 系统基于RAG(检索增强生成)技术,允许用户上传PDF并进行智能问答。
- 使用Ollama的deepseek-r1模型和FAISS向量数据库,支持普通对话和基于PDF的问答模式。
- 提供简洁的Web界面,支持文件拖拽上传和多轮对话。
- 研究表明,系统适合处理PDF内容查询,但性能可能因PDF复杂性而异。
- 系统基于RAG(检索增强生成)技术,允许用户上传PDF并进行智能问答。
- 使用Ollama的deepseek-r1模型和FAISS向量数据库,支持普通对话和基于PDF的问答模式。
- 提供简洁的Web界面,支持文件拖拽上传和多轮对话。
- 研究表明,系统适合处理PDF内容查询,但性能可能因PDF复杂性而异。
系统概述
这个PDF智能问答系统是一个基于RAG技术的工具,旨在帮助用户通过上传PDF文件进行智能交互。它结合了Ollama的deepseek-r1模型和FAISS向量数据库,确保回答基于文档知识,适合学生、专业人士和研究人员快速获取PDF信息。
这个PDF智能问答系统是一个基于RAG技术的工具,旨在帮助用户通过上传PDF文件进行智能交互。它结合了Ollama的deepseek-r1模型和FAISS向量数据库,确保回答基于文档知识,适合学生、专业人士和研究人员快速获取PDF信息。
主要功能
- PDF处理:支持上传PDF文件,自动分块,并使用FAISS存储内容。
- 问答模式:提供普通对话模式(无PDF)和文档问答模式(有PDF),支持多轮对话。
- 用户界面:简洁的Web界面,支持拖拽上传,实时显示对话,并提供清空和重新生成功能。
- PDF处理:支持上传PDF文件,自动分块,并使用FAISS存储内容。
- 问答模式:提供普通对话模式(无PDF)和文档问答模式(有PDF),支持多轮对话。
- 用户界面:简洁的Web界面,支持拖拽上传,实时显示对话,并提供清空和重新生成功能。
技术细节
系统使用LangChain库处理PDF,Gradio构建界面,需安装ollama并确保deepseek-r1模型可用。环境配置包括创建虚拟环境和安装依赖,如langchain、faiss-cpu等。
系统使用LangChain库处理PDF,Gradio构建界面,需安装ollama并确保deepseek-r1模型可用。环境配置包括创建虚拟环境和安装依赖,如langchain、faiss-cpu等。
详细报告
引言
PDF智能问答系统!该系统利用检索增强生成(RAG)技术,根据您上传的PDF文件内容提供准确且上下文相关的回答。通过结合大型语言模型和高效的信息检索能力,我们旨在为您创造一个无缝、智能的文档交互体验。
无论您是学生、专业人士还是研究人员,这个工具都能帮助您快速查找和理解PDF中的信息,无需手动搜索。系统设计用户友好,界面简洁,支持文件拖拽上传和实时对话,适合各种用户群体。
PDF智能问答系统!该系统利用检索增强生成(RAG)技术,根据您上传的PDF文件内容提供准确且上下文相关的回答。通过结合大型语言模型和高效的信息检索能力,我们旨在为您创造一个无缝、智能的文档交互体验。
无论您是学生、专业人士还是研究人员,这个工具都能帮助您快速查找和理解PDF中的信息,无需手动搜索。系统设计用户友好,界面简洁,支持文件拖拽上传和实时对话,适合各种用户群体。
主要功能
PDF文件处理
- 上传和分块:您可以上传任何PDF文件,系统会自动将其分解为可管理的块。这有助于高效索引和检索信息。
- 向量数据库存储:我们使用FAISS(Facebook AI Similarity Search),一个高性能向量数据库,存储这些块的嵌入表示。这确保了当您提问时,能够快速、准确地检索相关信息。
- 上传和分块:您可以上传任何PDF文件,系统会自动将其分解为可管理的块。这有助于高效索引和检索信息。
- 向量数据库存储:我们使用FAISS(Facebook AI Similarity Search),一个高性能向量数据库,存储这些块的嵌入表示。这确保了当您提问时,能够快速、准确地检索相关信息。
智能问答功能
- 两种操作模式:
- 普通对话模式:当未上传PDF时,系统作为标准聊天机器人运行,使用基础模型回答一般问题。
- 文档问答模式:上传PDF后,系统切换到此模式,从PDF中检索相关信息以回答问题,确保答案具体且准确。
- 上下文维护:系统跟踪对话历史,支持多轮对话。这意味着您可以提出后续问题,系统会理解之前的上下文。
- 两种操作模式:
- 普通对话模式:当未上传PDF时,系统作为标准聊天机器人运行,使用基础模型回答一般问题。
- 文档问答模式:上传PDF后,系统切换到此模式,从PDF中检索相关信息以回答问题,确保答案具体且准确。
- 上下文维护:系统跟踪对话历史,支持多轮对话。这意味着您可以提出后续问题,系统会理解之前的上下文。
用户界面
- 简洁直观:我们的Web界面设计简单,您可以拖放PDF文件上传,聊天窗口支持实时交互。
- 交互控制:提供清空对话历史和重新生成回答的功能,让您掌控对话,确保流畅的用户体验。
- 简洁直观:我们的Web界面设计简单,您可以拖放PDF文件上传,聊天窗口支持实时交互。
- 交互控制:提供清空对话历史和重新生成回答的功能,让您掌控对话,确保流畅的用户体验。
工作原理
系统的核心是检索增强生成(RAG)方法。以下是简化后的工作流程:
系统的核心是检索增强生成(RAG)方法。以下是简化后的工作流程:
PDF上传和处理
- 当您上传PDF时,系统使用LangChain库中的
RecursiveCharacterTextSplitter
将其加载并分割为较小的块。 - 每个块使用Ollama的
deepseek-r1
模型嵌入(转换为计算机可理解的数值表示),并存储在FAISS向量数据库中。
- 当您上传PDF时,系统使用LangChain库中的
RecursiveCharacterTextSplitter
将其加载并分割为较小的块。 - 每个块使用Ollama的
deepseek-r1
模型嵌入(转换为计算机可理解的数值表示),并存储在FAISS向量数据库中。
问题回答
- 当您提问时,系统首先检查是否上传了PDF。
- 如果上传了PDF,它会使用FAISS向量存储的检索器找到与问题最相关的块。
- 然后,这些相关块和您的提问一起传递给
deepseek-r1
模型,生成基于两者结合的回答。 - 如果未上传PDF,模型会基于其预训练知识回答问题。
研究表明,这种方法在处理文档查询时效果显著,但PDF内容的复杂性(如图表或格式问题)可能影响性能。证据倾向于认为,对于结构化文本,系统表现最佳,但对于复杂文档,可能需要调整分块参数。
- 当您提问时,系统首先检查是否上传了PDF。
- 如果上传了PDF,它会使用FAISS向量存储的检索器找到与问题最相关的块。
- 然后,这些相关块和您的提问一起传递给
deepseek-r1
模型,生成基于两者结合的回答。 - 如果未上传PDF,模型会基于其预训练知识回答问题。
研究表明,这种方法在处理文档查询时效果显著,但PDF内容的复杂性(如图表或格式问题)可能影响性能。证据倾向于认为,对于结构化文本,系统表现最佳,但对于复杂文档,可能需要调整分块参数。
开始使用
要开始使用我们的PDF智能问答系统,请按照以下步骤操作:
- 设置环境:
- 创建并激活虚拟环境,如下所示:
conda create --name rag python=3.12
conda activate rag
- 安装所有必要依赖:
pip install langchain faiss-cpu gradio PyMuPDF
pip install -U langchain-community
- 安装并运行ollama,确保
deepseek-r1
模型可用。您可以通过ollama list
列出可用模型,并使用ollama pull deepseek-r1
拉取模型。
- 运行应用程序:
- 导航到包含源代码的目录。
- 运行创建并启动Gradio界面的脚本。
- 访问Web界面:
- 打开浏览器,访问启动Gradio应用时提供的URL(通常为http://127.0.0.1:8888)。
- 上传您的PDF:
- 将PDF文件拖放到指定区域。
- 提出问题:
- 在文本框中输入问题并发送。
- 根据需要与系统交互,使用提供的控件。
要开始使用我们的PDF智能问答系统,请按照以下步骤操作:
- 设置环境:
- 创建并激活虚拟环境,如下所示:
conda create --name rag python=3.12
conda activate rag
- 安装所有必要依赖:
pip install langchain faiss-cpu gradio PyMuPDF
pip install -U langchain-community
- 安装并运行ollama,确保
deepseek-r1
模型可用。您可以通过ollama list
列出可用模型,并使用ollama pull deepseek-r1
拉取模型。
- 创建并激活虚拟环境,如下所示:
- 运行应用程序:
- 导航到包含源代码的目录。
- 运行创建并启动Gradio界面的脚本。
- 访问Web界面:
- 在文本框中输入问题并发送。
- 根据需要与系统交互,使用提供的控件。
通过这些步骤,您可以开始探索并受益于我们的智能问答系统。
技术细节
对于技术感兴趣的用户,以下是简要概述:
- 模型:我们使用Ollama的
deepseek-r1
模型,这是一个能够理解和生成类人文本的大型语言模型。 - 嵌入:使用相同的模型为PDF块生成嵌入,确保语义空间的一致性。
- 向量存储:使用FAISS(Facebook AI Similarity Search)进行大规模相似性搜索,这对于快速检索相关信息至关重要。
- 用户界面:使用Gradio(Gradio)构建,这是一个用户友好的机器学习模型Web界面框架。
环境配置
要运行此系统,您需要安装以下内容:
步骤 | 命令/说明 |
---|---|
1. 创建虚拟环境 | conda create --name rag python=3.12 然后 conda activate rag |
2. 安装依赖 | pip install langchain faiss-cpu gradio PyMuPDF 和 pip install -U langchain-community |
3. 安装Ollama | 从官方仓库安装ollama,确保deepseek-r1 模型可用,使用ollama pull deepseek-r1 拉取 |
4. Gradio | 界面使用Gradio构建,已包含在依赖中 |
设置完成后,您可以运行create_chat_interface
函数并启动Gradio应用。
源代码分析
源代码结构化处理PDF处理、问答和Gradio界面。以下是关键函数的概述:
函数名 | 功能描述 |
---|---|
processpdf | 处理PDF加载,分块,创建嵌入,并设置向量存储和检索器 |
combine_docs | 将多个文档块合并为单个字符串,用于上下文 |
ollama_llm | 使用ollama模型基于问题和提供的上下文生成回答 |
rag_chain | 实现RAG管道,检索相关文档并生成回答 |
chat_interface | 管理聊天交互,根据PDF上传决定使用RAG模式或标准模式 |
create_chat_interface | 设置Gradio界面,包括文件上传、聊天显示和用户输入组件 |
通过理解这些组件,您可以欣赏系统如何整合不同技术,提供高效的问答体验。
结论
我们的PDF智能问答系统是一个强大的工具,结合了自然语言处理和信息检索的最新进展。设计目的是使与PDF文档的交互更高效、更具洞察力。我们希望您发现它实用且易用!
关键引用
完整代码
import gradio as gr
from langchain_community.document_loaders import PyMuPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.vectorstores import FAISS
from langchain_community.embeddings import OllamaEmbeddings
import ollama
import re
def process_pdf(pdf_bytes):
"""
处理PDF文件并创建向量存储
Args:
pdf_bytes: PDF文件的路径
Returns:
tuple: 文本分割器、向量存储和检索器
"""
if pdf_bytes is :
return , ,
# 加载PDF文件
loader = PyMuPDFLoader(pdf_bytes)
data = loader.load()
# 创建文本分割器,设置块大小为500,重叠为100
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=100)
chunks = text_splitter.split_documents(data)
# 使用Ollama的deepseek-r1模型创建嵌入
embeddings = OllamaEmbeddings(model="deepseek-r1:8b")
# 将Chroma替换为FAISS向量存储
vectorstore = FAISS.from_documents(documents=chunks, embedding=embeddings)
# 从向量存储中创建检索器
retriever = vectorstore.as_retriever()
# # 返回文本分割器、向量存储和检索器
return text_splitter, vectorstore, retriever
def combine_docs(docs):
"""
将多个文档合并为单个字符串
Args:
docs: 文档列表
Returns:
str: 合并后的文本内容
"""
return "\n\n".join(doc.page_content for doc in docs)
def ollama_llm(question, context, chat_history):
"""
使用Ollama模型生成回答
Args:
question: 用户问题
context: 相关上下文
chat_history: 聊天历史记录
Returns:
str: 模型生成的回答
"""
# 构建更清晰的系统提示和用户提示
system_prompt = """你是一个专业的AI助手。请基于提供的上下文回答问题。
- 回答要简洁明了,避免重复
- 如果上下文中没有相关信息,请直接说明
- 保持回答的连贯性和逻辑性"""
# 只保留最近的3轮对话历史,避免上下文过长
recent_history = chat_history[-3:] if len(chat_history) > 3 else chat_history
chat_history_text = "\n".join([f"Human: {h}\nAssistant: {a}" for h, a in recent_history])
# 构建更结构化的提示模板
user_prompt = f"""基于以下信息回答问题:
问题:{question}
相关上下文:
{context}
请用中文回答上述问题。回答要简洁准确,避免重复。"""
# 调用Ollama模型生成回答
response = ollama.chat(
model="deepseek-r1:8b",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
)
response_content = response["message"]["content"]
# 移除思考过程和可能的重复内容
final_answer = re.sub(r".*? ", "", response_content, flags=re.DOTALL).strip()
return final_answer
def rag_chain(question, text_splitter, vectorstore, retriever, chat_history):
"""
实现RAG(检索增强生成)链
Args:
question: 用户问题
text_splitter: 文本分割器
vectorstore: 向量存储
retriever: 检索器
chat_history: 聊天历史
Returns:
str: 生成的回答
"""
# 减少检索文档数量,提高相关性
retrieved_docs = retriever.invoke(question, {"k": 2})
# 优化文档合并方式,去除可能的重复内容
formatted_content = "\n".join(set(doc.page_content.strip() for doc in retrieved_docs))
return ollama_llm(question, formatted_content, chat_history)
def chat_interface(message, history, pdf_bytes=, text_splitter=, vectorstore=, retriever=):
"""
聊天接口函数,处理用户输入并返回回答
Args:
message: 用户消息
history: 聊天历史
pdf_bytes: PDF文件
text_splitter: 文本分割器
vectorstore: 向量存储
retriever: 检索器
Returns:
str: 生成的回答
"""
if pdf_bytes is :
# 无PDF文件的普通对话模式
response = ollama_llm(message, "", history)
else:
# 有PDF文件的RAG对话模式
response = rag_chain(message, text_splitter, vectorstore, retriever, history)
return response
def create_chat_interface():
"""
创建Gradio聊天界面
Returns:
gr.Blocks: Gradio界面对象
"""
# 创建一个用户界面,并应用了一些自定义的CSS样式。
with gr.Blocks() as demo:
# 定义状态变量用于存储PDF处理相关的对象
pdf_state = gr.State()
# 存储文本分割器对象,用于将PDF文本分割成小块
text_splitter_state = gr.State()
# 存储向量数据库对象,用于存储文本向量
vectorstore_state = gr.State()
# 存储检索器对象,用于检索相关文本片段
retriever_state = gr.State()
with gr.Column(elem_classes="container"):
# 创建界面组件
with gr.Column(elem_classes="header"):
gr.Markdown("# PDF智能问答助手")
gr.Markdown("上传PDF文档,开始智能对话")
# 文件上传区域
with gr.Column(elem_classes="file-upload"):
file_output = gr.File(
label="上传PDF文件",
file_types=[".pdf"],
file_count="single"
)
# 处理PDF上传
def on_pdf_upload(file):
"""
处理PDF文件上传
Args:
file: 上传的文件对象
Returns:
tuple: 包含处理后的PDF相关对象
"""
# 如果文件存在
if file is not :
# 处理PDF文件,获取文本分割器、向量存储和检索器
text_splitter, vectorstore, retriever = process_pdf(file.name)
# 返回文件对象和处理后的组件
return file, text_splitter, vectorstore, retriever
# 如果文件不存在,返回值
return , , ,
# 注册文件上传事件处理
file_output.upload(
# 当文件上传时调用on_pdf_upload函数处理
on_pdf_upload,
# inputs参数指定输入组件为file_output
inputs=[file_output],
# outputs参数指定输出状态变量
outputs=[pdf_state, text_splitter_state, vectorstore_state, retriever_state]
)
# 聊天区域
with gr.Column(elem_classes="chat-container"):
chatbot = gr.Chatbot(
height=500,
bubble_full_width=False,
show_label=False,
avatar_images=,
elem_classes="chatbot"
)
with gr.Row():
msg = gr.Textbox(
label="输入问题",
placeholder="请输入你的问题...",
scale=12,
container=False
)
send_btn = gr.Button("发送", scale=1, variant="primary")
with gr.Row(elem_classes="button-row"):
clear = gr.Button("清空对话", variant="secondary")
regenerate = gr.Button("重新生成", variant="secondary")
# 发送消息处理函数
def respond(message, chat_history, pdf_bytes, text_splitter, vectorstore, retriever):
"""
处理用户消息并生成回答
Args:
message: 用户消息
chat_history: 聊天历史
pdf_bytes: PDF文件
text_splitter: 文本分割器
vectorstore: 向量存储
retriever: 检索器
Returns:
tuple: (清空的消息框, 更新后的聊天历史)
"""
# 如果用户消息为空(去除首尾空格后),直接返回空消息和原聊天历史
if not message.strip():
return "", chat_history
# 调用chat_interface函数处理用户消息,生成回复
bot_message = chat_interface(
message,
chat_history,
pdf_bytes,
text_splitter,
vectorstore,
retriever
)
# 将用户消息和模型回复作为一轮对话添加到聊天历史中
chat_history.append((message, bot_message))
# 返回空消息(清空输入框)和更新后的聊天历史
return "", chat_history
# 事件处理
# 当用户按回车键提交消息时触发
msg.submit(
respond,
[msg, chatbot, pdf_state, text_splitter_state, vectorstore_state, retriever_state],
[msg, chatbot]
)
# 当用户点击发送按钮时触发
send_btn.click(
respond,
[msg, chatbot, pdf_state, text_splitter_state, vectorstore_state, retriever_state],
[msg, chatbot]
)
# 当用户点击清空按钮时触发
# lambda: (, ) 返回两个值来清空消息框和对话历史
# queue=False 表示不进入队列直接执行
clear.click(lambda: (, ), , [msg, chatbot], queue=False)
# 重新生成按钮功能
def regenerate_response(chat_history, pdf_bytes, text_splitter, vectorstore, retriever):
"""
重新生成最后一条回答
Args:
chat_history: 聊天历史
pdf_bytes: PDF文件
text_splitter: 文本分割器
vectorstore: 向量存储
retriever: 检索器
Returns:
list: 更新后的聊天历史
"""
# 如果聊天历史为空,直接返回
if not chat_history:
return chat_history
# 获取最后一条用户消息
last_user_message = chat_history[-1][0]
# 移除最后一轮对话
chat_history = chat_history[:-1]
# 使用chat_interface重新生成回答
bot_message = chat_interface(
last_user_message, # 最后一条用户消息
chat_history, # 更新后的聊天历史
pdf_bytes, # PDF文件内容
text_splitter, # 文本分割器
vectorstore, # 向量存储
retriever # 检索器
)
# 将新生成的对话添加到历史中
chat_history.append((last_user_message, bot_message))
# 返回更新后的聊天历史
return chat_history
# 为重新生成按钮绑定点击事件
# 当点击时调用regenerate_response函数
# 输入参数为chatbot等状态
# 输出更新chatbot显示
regenerate.click(
regenerate_response,
[chatbot, pdf_state, text_splitter_state, vectorstore_state, retriever_state],
[chatbot]
)
return demo
# 启动接口
if __name__ == "__main__":
"""
主程序入口:启动Gradio界面
"""
demo = create_chat_interface()
demo.launch(
server_name="127.0.0.1",
server_port=8888,
show_api=False,
share=False
)
作者:AI_Echoes
来源:juejin.cn/post/7479036294875332644
import gradio as gr
from langchain_community.document_loaders import PyMuPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.vectorstores import FAISS
from langchain_community.embeddings import OllamaEmbeddings
import ollama
import re
def process_pdf(pdf_bytes):
"""
处理PDF文件并创建向量存储
Args:
pdf_bytes: PDF文件的路径
Returns:
tuple: 文本分割器、向量存储和检索器
"""
if pdf_bytes is :
return , ,
# 加载PDF文件
loader = PyMuPDFLoader(pdf_bytes)
data = loader.load()
# 创建文本分割器,设置块大小为500,重叠为100
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=100)
chunks = text_splitter.split_documents(data)
# 使用Ollama的deepseek-r1模型创建嵌入
embeddings = OllamaEmbeddings(model="deepseek-r1:8b")
# 将Chroma替换为FAISS向量存储
vectorstore = FAISS.from_documents(documents=chunks, embedding=embeddings)
# 从向量存储中创建检索器
retriever = vectorstore.as_retriever()
# # 返回文本分割器、向量存储和检索器
return text_splitter, vectorstore, retriever
def combine_docs(docs):
"""
将多个文档合并为单个字符串
Args:
docs: 文档列表
Returns:
str: 合并后的文本内容
"""
return "\n\n".join(doc.page_content for doc in docs)
def ollama_llm(question, context, chat_history):
"""
使用Ollama模型生成回答
Args:
question: 用户问题
context: 相关上下文
chat_history: 聊天历史记录
Returns:
str: 模型生成的回答
"""
# 构建更清晰的系统提示和用户提示
system_prompt = """你是一个专业的AI助手。请基于提供的上下文回答问题。
- 回答要简洁明了,避免重复
- 如果上下文中没有相关信息,请直接说明
- 保持回答的连贯性和逻辑性"""
# 只保留最近的3轮对话历史,避免上下文过长
recent_history = chat_history[-3:] if len(chat_history) > 3 else chat_history
chat_history_text = "\n".join([f"Human: {h}\nAssistant: {a}" for h, a in recent_history])
# 构建更结构化的提示模板
user_prompt = f"""基于以下信息回答问题:
问题:{question}
相关上下文:
{context}
请用中文回答上述问题。回答要简洁准确,避免重复。"""
# 调用Ollama模型生成回答
response = ollama.chat(
model="deepseek-r1:8b",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
)
response_content = response["message"]["content"]
# 移除思考过程和可能的重复内容
final_answer = re.sub(r".*? ", "", response_content, flags=re.DOTALL).strip()
return final_answer
def rag_chain(question, text_splitter, vectorstore, retriever, chat_history):
"""
实现RAG(检索增强生成)链
Args:
question: 用户问题
text_splitter: 文本分割器
vectorstore: 向量存储
retriever: 检索器
chat_history: 聊天历史
Returns:
str: 生成的回答
"""
# 减少检索文档数量,提高相关性
retrieved_docs = retriever.invoke(question, {"k": 2})
# 优化文档合并方式,去除可能的重复内容
formatted_content = "\n".join(set(doc.page_content.strip() for doc in retrieved_docs))
return ollama_llm(question, formatted_content, chat_history)
def chat_interface(message, history, pdf_bytes=, text_splitter=, vectorstore=, retriever=):
"""
聊天接口函数,处理用户输入并返回回答
Args:
message: 用户消息
history: 聊天历史
pdf_bytes: PDF文件
text_splitter: 文本分割器
vectorstore: 向量存储
retriever: 检索器
Returns:
str: 生成的回答
"""
if pdf_bytes is :
# 无PDF文件的普通对话模式
response = ollama_llm(message, "", history)
else:
# 有PDF文件的RAG对话模式
response = rag_chain(message, text_splitter, vectorstore, retriever, history)
return response
def create_chat_interface():
"""
创建Gradio聊天界面
Returns:
gr.Blocks: Gradio界面对象
"""
# 创建一个用户界面,并应用了一些自定义的CSS样式。
with gr.Blocks() as demo:
# 定义状态变量用于存储PDF处理相关的对象
pdf_state = gr.State()
# 存储文本分割器对象,用于将PDF文本分割成小块
text_splitter_state = gr.State()
# 存储向量数据库对象,用于存储文本向量
vectorstore_state = gr.State()
# 存储检索器对象,用于检索相关文本片段
retriever_state = gr.State()
with gr.Column(elem_classes="container"):
# 创建界面组件
with gr.Column(elem_classes="header"):
gr.Markdown("# PDF智能问答助手")
gr.Markdown("上传PDF文档,开始智能对话")
# 文件上传区域
with gr.Column(elem_classes="file-upload"):
file_output = gr.File(
label="上传PDF文件",
file_types=[".pdf"],
file_count="single"
)
# 处理PDF上传
def on_pdf_upload(file):
"""
处理PDF文件上传
Args:
file: 上传的文件对象
Returns:
tuple: 包含处理后的PDF相关对象
"""
# 如果文件存在
if file is not :
# 处理PDF文件,获取文本分割器、向量存储和检索器
text_splitter, vectorstore, retriever = process_pdf(file.name)
# 返回文件对象和处理后的组件
return file, text_splitter, vectorstore, retriever
# 如果文件不存在,返回值
return , , ,
# 注册文件上传事件处理
file_output.upload(
# 当文件上传时调用on_pdf_upload函数处理
on_pdf_upload,
# inputs参数指定输入组件为file_output
inputs=[file_output],
# outputs参数指定输出状态变量
outputs=[pdf_state, text_splitter_state, vectorstore_state, retriever_state]
)
# 聊天区域
with gr.Column(elem_classes="chat-container"):
chatbot = gr.Chatbot(
height=500,
bubble_full_width=False,
show_label=False,
avatar_images=,
elem_classes="chatbot"
)
with gr.Row():
msg = gr.Textbox(
label="输入问题",
placeholder="请输入你的问题...",
scale=12,
container=False
)
send_btn = gr.Button("发送", scale=1, variant="primary")
with gr.Row(elem_classes="button-row"):
clear = gr.Button("清空对话", variant="secondary")
regenerate = gr.Button("重新生成", variant="secondary")
# 发送消息处理函数
def respond(message, chat_history, pdf_bytes, text_splitter, vectorstore, retriever):
"""
处理用户消息并生成回答
Args:
message: 用户消息
chat_history: 聊天历史
pdf_bytes: PDF文件
text_splitter: 文本分割器
vectorstore: 向量存储
retriever: 检索器
Returns:
tuple: (清空的消息框, 更新后的聊天历史)
"""
# 如果用户消息为空(去除首尾空格后),直接返回空消息和原聊天历史
if not message.strip():
return "", chat_history
# 调用chat_interface函数处理用户消息,生成回复
bot_message = chat_interface(
message,
chat_history,
pdf_bytes,
text_splitter,
vectorstore,
retriever
)
# 将用户消息和模型回复作为一轮对话添加到聊天历史中
chat_history.append((message, bot_message))
# 返回空消息(清空输入框)和更新后的聊天历史
return "", chat_history
# 事件处理
# 当用户按回车键提交消息时触发
msg.submit(
respond,
[msg, chatbot, pdf_state, text_splitter_state, vectorstore_state, retriever_state],
[msg, chatbot]
)
# 当用户点击发送按钮时触发
send_btn.click(
respond,
[msg, chatbot, pdf_state, text_splitter_state, vectorstore_state, retriever_state],
[msg, chatbot]
)
# 当用户点击清空按钮时触发
# lambda: (, ) 返回两个值来清空消息框和对话历史
# queue=False 表示不进入队列直接执行
clear.click(lambda: (, ), , [msg, chatbot], queue=False)
# 重新生成按钮功能
def regenerate_response(chat_history, pdf_bytes, text_splitter, vectorstore, retriever):
"""
重新生成最后一条回答
Args:
chat_history: 聊天历史
pdf_bytes: PDF文件
text_splitter: 文本分割器
vectorstore: 向量存储
retriever: 检索器
Returns:
list: 更新后的聊天历史
"""
# 如果聊天历史为空,直接返回
if not chat_history:
return chat_history
# 获取最后一条用户消息
last_user_message = chat_history[-1][0]
# 移除最后一轮对话
chat_history = chat_history[:-1]
# 使用chat_interface重新生成回答
bot_message = chat_interface(
last_user_message, # 最后一条用户消息
chat_history, # 更新后的聊天历史
pdf_bytes, # PDF文件内容
text_splitter, # 文本分割器
vectorstore, # 向量存储
retriever # 检索器
)
# 将新生成的对话添加到历史中
chat_history.append((last_user_message, bot_message))
# 返回更新后的聊天历史
return chat_history
# 为重新生成按钮绑定点击事件
# 当点击时调用regenerate_response函数
# 输入参数为chatbot等状态
# 输出更新chatbot显示
regenerate.click(
regenerate_response,
[chatbot, pdf_state, text_splitter_state, vectorstore_state, retriever_state],
[chatbot]
)
return demo
# 启动接口
if __name__ == "__main__":
"""
主程序入口:启动Gradio界面
"""
demo = create_chat_interface()
demo.launch(
server_name="127.0.0.1",
server_port=8888,
show_api=False,
share=False
)
来源:juejin.cn/post/7479036294875332644
从侵入式改造到声明式魔法注释的演进之路
传统方案的痛点:代码入侵
在上一篇文章中,我们通过高阶函数实现了请求缓存功能:
const cachedFetch = memoReq(function fetchData(url) {
return axios.get(url);
}, 3000);
这种方式虽然有效,但存在三个显著问题:
- 结构性破坏:必须将函数声明改为函数表达式
- 可读性下降:业务逻辑与缓存逻辑混杂
- 维护困难:缓存参数与业务代码强耦合
灵感来源:两大技术启示
1. Webpack的魔法注释
Webpack使用魔法注释控制代码分割:
import(/* webpackPrefetch: true */ './module.js');
这种声明式配置给了我们启示:能否用注释来控制缓存行为?
2. 装饰器设计模式
装饰器模式的核心思想是不改变原有对象的情况下动态扩展功能。在TypeScript中:
@memoCache(3000)
async function fetchData() {}
虽然当前项目可能不支持装饰器语法,但我们可以借鉴这种思想!
创新方案:魔法注释 + Vite插件
设计目标
- 零入侵:不改变函数声明方式
- 声明式:通过注释表达缓存意图
- 渐进式:支持逐个文件迁移
使用对比
传统方式:
export const getStockData = memoReq(
function getStockData(symbol) {
return axios.get(`/api/stocks/${symbol}`);
},
5000
);
魔法注释方案:
/* abc-memoCache(5000) */
export function getStockData(symbol) {
return axios.get(`/api/stocks/${symbol}`);
}
而有经验的程序猿会敏锐地发现三个深层问题:
- 结构性破坏:函数被迫改为函数表达式
- 关注点混杂:缓存逻辑侵入业务代码
- 维护陷阱:硬编码参数难以统一管理
技术实现深度解析
核心转换原理
- 编译时处理:通过Vite或者webpack loader插件在代码编译阶段转换
- 正则匹配:实际上是通过正则匹配实现轻量级转换
- 自动导入:智能添加必要的依赖引用
// 转换前
/* abc-memoCache(3000) */
export function fetchData() {}
// 转换后
import { memoCache } from '@/utils/decorators';
export const fetchData = memoCache(function fetchData() {}, 3000);
完整实现代码如下(以vite插件为例)
/**
* 转换代码中的装饰器注释为具体的函数调用,并处理超时配置。
*
* @param {string} code - 待处理的源代码。
* @param {string} [prefix="aa"] - 装饰器的前缀,用于标识特定的装饰器注释。
* @param {string} [utilsPath="@/utils"] - 导入工具函数的路径。
* @returns {string} - 转换后的代码。
*/
export function transformMemoReq(code, prefix = "aa", utilsPath = "@/utils") {
// 检查是否包含魔法注释模式
const magicCommentPattern = new RegExp(`\/\*\s*${prefix}-\w+\s*\([^)]*\)\s*\*\/`);
if (!magicCommentPattern.test(code)) {
return code; // 如果没有找到符合模式的注释,返回原代码
}
let transformedCode = code;
const importsNeeded = new Set(); // 收集需要的导入
// 处理带超时配置的装饰器注释(带超时数字)
const withTimeoutPattern = new RegExp(
`\/\*\s*${prefix}-(\w+)\s*\(\s*(\d*)\s*\)\s*\*\/\s*\nexport\s+function\s+(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\s*\{([\s\S]*?)\n\}`,
"g"
);
transformedCode = transformedCode.replace(
withTimeoutPattern,
(match, decoratorName, timeout, functionName, params, body) => {
const timeoutValue = timeout ? parseInt(timeout, 10) : 3000; // 默认超时为3000毫秒
const fileNameSimple = decoratorName.replace(/([A-Z].*$)/, ""); // 获取装饰器文件名
importsNeeded.add({ fileName: fileNameSimple, functionName: decoratorName }); // 添加需要导入的函数
// 提取类型注解(如果存在)
const typeAnnotationMatch = match.match(/)\s*(:\s*[^{]+)/);
const typeAnnotation = typeAnnotationMatch ? typeAnnotationMatch[1] : "";
// 返回转换后的函数定义代码
return `export const ${functionName} = ${decoratorName}(function ${functionName}(${params})${typeAnnotation} {${body}\n}, ${timeoutValue});`;
}
);
// 处理不带超时配置的装饰器注释(无超时数字)
const emptyTimeoutPattern = new RegExp(
`\/\*\s*${prefix}-(\w+)\s*\(\s*\)\s*\*\/\s*\nexport\s+function\s+(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\s*\{([\s\S]*?)\n\}`,
"g"
);
transformedCode = transformedCode.replace(emptyTimeoutPattern, (match, decoratorName, functionName, params, body) => {
const fileNameSimple = decoratorName.replace(/([A-Z].*$)/, "");
importsNeeded.add({ fileName: fileNameSimple, functionName: decoratorName });
// 提取类型注解(如果存在)
const typeAnnotationMatch = match.match(/)\s*(:\s*[^{]+)/);
const typeAnnotation = typeAnnotationMatch ? typeAnnotationMatch[1] : "";
// 返回转换后的函数定义代码,默认超时为3000毫秒
return `export const ${functionName} = ${decoratorName}(function ${functionName}(${params})${typeAnnotation} {${body}\n}, 3000);`;
});
// 如果需要导入额外的函数,处理导入语句的插入
if (importsNeeded.size > 0) {
const lines = transformedCode.split("\n");
let insertIndex = 0;
// 检查是否是Vue文件
const isVueFile = transformedCode.includes("<script");
if (isVueFile) {
// Vue文件导入位置逻辑...
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i].trim();
if (line.includes("<script")) {
insertIndex = i + 1;
for (let j = i + 1; j < lines.length; j += 1) {
const scriptLine = lines[j].trim();
if (scriptLine.startsWith("import ") || scriptLine === "") {
insertIndex = j + 1;
} else if (!scriptLine.startsWith("import ")) {
break;
}
}
break;
}
}
} else {
// 普通JS/TS/JSX/TSX文件导入位置逻辑...
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i].trim();
if (line.startsWith("import ") || line === "" || line.startsWith("interface ") || line.startsWith("type ")) {
insertIndex = i + 1;
} else {
break;
}
}
}
// 按文件分组导入
const importsByFile = {};
importsNeeded.forEach(({ fileName, functionName }) => {
if (!importsByFile[fileName]) {
importsByFile[fileName] = [];
}
importsByFile[fileName].push(functionName);
});
// 生成导入语句 - 使用自定义utilsPath
const importStatements = Object.entries(importsByFile).map(([fileName, functions]) => {
const uniqueFunctions = [...new Set(functions)];
return `import { ${uniqueFunctions.join(", ")} } from "${utilsPath}/${fileName}";`;
});
// 插入导入语句
lines.splice(insertIndex, 0, ...importStatements);
transformedCode = lines.join("\n");
}
return transformedCode; // 返回最终转换后的代码
}
/**
* Vite 插件,支持通过魔法注释转换函数装饰器。
*
* @param {Object} [options={}] - 配置选项。
* @param {string} [options.prefix="aa"] - 装饰器的前缀。
* @param {string} [options.utilsPath="@/utils"] - 工具函数的导入路径。
* @returns {Object} - Vite 插件对象。
*/
export function viteMemoDectoratorPlugin(options = {}) {
const { prefix = "aa", utilsPath = "@/utils" } = options;
return {
name: "vite-memo-decorator", // 插件名称
enforce: "pre", // 插件执行时机,设置为"pre"确保在编译前执行
transform(code, id) {
// 支持 .js, .ts, .jsx, .tsx, .vue 文件
if (!/.(js|ts|jsx|tsx|vue)$/.test(id)) {
return null; // 如果文件类型不支持,返回null
}
// 使用动态前缀检查是否需要处理该文件
const magicCommentPattern = new RegExp(`\/\*\s*${prefix}-\w+\s*\([^)]*\)\s*\*\/`);
if (!magicCommentPattern.test(code)) {
return null; // 如果没有找到符合模式的注释,返回null
}
console.log(`🔄 Processing ${prefix}-* magic comments in: ${id}`);
try {
const result = transformMemoReq(code, prefix, utilsPath); // 调用转换函数
if (result !== code) {
console.log(`✅ Transform successful for: ${id}`);
return {
code: result, // 返回转换后的代码
map: null, // 如果需要支持source map,可以在这里添加
};
}
} catch (error) {
console.error(`❌ Transform error in ${id}:`, error.message);
}
return null;
},
};
}
vite使用方式
viteMemoDectoratorPlugin({
prefix: "abc",
}),
结语:成为解决方案的设计者
从闭包到魔法注释的演进:
- 发现问题:识别现有方案的深层缺陷
- 联想类比:从其他领域寻找灵感
- 创新设计:创造性地组合技术要素
- 工程落地:考虑实际约束条件
在这个技术飞速发展的时代,我们牛马面临着知识爆炸,卷到没边的风气,我们只能建立更系统的技术认知体系。只会复制粘贴代码的开发者注定会陷入越忙越累的怪圈,比如最近很火的vue不想使用虚拟dom,其实我们只需要知道为什么,那是不是又多了点知识储备,因为技术迭代的速度永远快于机械记忆的速度。真正的技术能力体现在对知识本质的理解和创造性应用上——就像本文中的缓存方案,从最初的闭包实现到魔法注释优化,每一步实现都源于对多种技术思想的相融。阅读技术博客时,不能满足于解决眼前问题,更要揣摩作者的设计哲学;我们要善用AI等现代工具,但不是简单地向它索要代码,而是通过它拓展思维边界;愿我们都能超越代码搬运工的局限,成为真正的问题解决者和价值创造者。技术之路没有捷径,但有方法;没有终点,但有无尽的风景。加油吧,程序猿朋友们!!!
来源:juejin.cn/post/7536178965851029544
docker容器增加或者修改容器映射端口
前言
在只有使用docker
安装的容器,没有使用docker-compose
或者其他客户端工具,如果要增加或者修改容器端口,也是可以增加或者修改容器端口映射=
容器端口映射
重新安装
这种方法简单粗暴,就是重新把docker容器移除,然后重新用
docker run -p
重新做端口映射
修改配置文件
这里以rabbitmq
为例子
1、 首先使用
docker ps
查看容器id
2、 然后使用
docker inspace 容器id
查看容器配置文件放止于哪里
这里放置于/var/lib/docker/containers/29384a9aa22f4fb53eda66d672b039b997143dc7633694e3455fc12f7dbcac5d
然后使用Linux进入到该目录
3、先把docker容器停止了
systemctl stop docker.socket
4、 修改hostconfig
文件,找到里面的json
数据中的PortBindings
这里将5672
端口修改为5673
保存文件
5、 修改config.v2.json
文件中的内容,找到里面中的ExposedPorts
,把5673
端口开放出来
保存文件
6、 启动docker服务
systemctl start docker.socket
这个时候就会发现5673
端口映射了
总结
修改docker容器映射开放端口方法很多,现在也有很多优秀的客户端可以进行配置
来源:juejin.cn/post/7456094963018006528
让DeepSeek模仿曹操,果然好玩!
上回说到,在《新三国》中荀彧对曹操说的那句名言,但相比荀彧而言,我觉得曹操的名言会更多,我一想,若能用AI重现这位乱世奸雄曹操,会得到怎样的体验?
于是这篇文章我们将以Go语言为例,展示如何通过LangChain框架调用DeepSeek大模型,重现一代枭雄曹操的独特对话风格。
工具介绍
LangChain 是一个专为构建大语言模型应用设计的开发框架,其核心使命是打通语言模型与真实世界的连接通道。它通过模块化设计将数据处理、记忆管理、工具调用等能力封装为标准化组件,开发者可像搭积木般将这些模块组装成智能应用链。经过一段时间的发展,LangChain不仅支持Python生态快速实现原型验证,也提供Go语言实现满足高并发生产需求。
在Go项目中安装:
go get -u github.com/tmc/langchaingo
使用LangChain接入DeepSeek
现在我们写一个最简单的LangChain程序,主要分为以下几个步骤:
1)函数定义和初始化OpenAI客户端
2)创建聊天消息
3)生成内容并流式输出
4)输出推理过程和最终答案
下面是代码:
func Simple() {
// 函数定义和初始化OpenAI客户端
llm, err := openai.New(
openai.WithBaseURL("https://api.deepseek.com"),
openai.WithModel("deepseek-chat"),
openai.WithToken("xxx"), // 填写自己的API Key
)
if err != nil {
log.Fatal(err)
}
// 创建聊天消息
content := []llms.MessageContent{
llms.TextParts(llms.ChatMessageTypeSystem, "你现在模仿曹操,以曹操的口吻和风格回答问题,要展现出曹操的霸气与谋略"),
llms.TextParts(llms.ChatMessageTypeHuman, "赤壁之战打输了怎么办?"),
}
// 生成内容并流式输出
fmt.Print("曹孟德:")
completion, err := llm.GenerateContent(
context.Background(),
content,
llms.WithMaxTokens(2000),
llms.WithTemperature(0.7),
llms.WithStreamingReasoningFunc(func(ctx context.Context, reasoningChunk []byte, chunk []byte) error {
contentColor := color.New(color.FgCyan).Add(color.Bold)
if len(chunk) > 0 {
_, err := contentColor.Printf("%s", string(chunk))
if err != nil {
return err
}
}
return nil
}),
)
if err != nil {
log.Fatal(err)
}
// 输出推理过程和最终答案
if len(completion.Choices) > 0 {
choice := completion.Choices[0]
fmt.Printf("\nFinal Answer:\n%s\n", choice.Content)
}
}
当然,如果我们想通过控制台和大模型多轮对话的话可以基于现有程序进行改造:
func Input() {
llm, err := openai.New(
openai.WithBaseURL("https://api.deepseek.com"),
openai.WithModel("deepseek-chat"),
openai.WithToken("xxx"),
)
if err != nil {
log.Fatal(err)
}
// 初始系统消息
systemMessage := llms.TextParts(llms.ChatMessageTypeSystem, "你现在模仿曹操,以曹操的口吻和风格回答问题,要展现出曹操的霸气与谋略。")
content := []llms.MessageContent{systemMessage}
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("闫同学:")
scanner.Scan()
question := scanner.Text()
if question == "exit" {
break
}
// 添加新的用户问题
userMessage := llms.TextParts(llms.ChatMessageTypeHuman, question)
content = append(content, userMessage)
fmt.Print("曹孟德:")
// Generate content with streaming to see both reasoning and final answer in real-time
completion, err := llm.GenerateContent(
context.Background(),
content,
llms.WithMaxTokens(2000),
llms.WithTemperature(0.7),
llms.WithStreamingReasoningFunc(func(ctx context.Context, reasoningChunk []byte, chunk []byte) error {
contentColor := color.New(color.FgCyan).Add(color.Bold)
if len(chunk) > 0 {
_, err := contentColor.Printf("%s", string(chunk))
if err != nil {
return err
}
}
return nil
}),
)
if err != nil {
log.Fatal(err)
}
fmt.Println()
// 将回复添加到历史消息中
if len(completion.Choices) > 0 {
choice := completion.Choices[0]
assistantMessage := llms.TextParts(llms.ChatMessageTypeHuman, choice.Content)
content = append(content, assistantMessage)
}
}
}
现在我们来启动调试一下:
重点步骤说明
其实纵观上面的整段代码,我认为在打造自己Agent中,最重要的一步莫过于在与AI对话前的消息组合部分,我们到底该怎样与AI对话才能得到自己想要的结果。
首先是content代码段的作用
content := []llms.MessageContent{
llms.TextParts(llms.ChatMessageTypeSystem, "你现在模仿曹操,以曹操的口吻和风格回答问题,要展现出曹操的霸气与谋略"),
llms.TextParts(llms.ChatMessageTypeHuman, "赤壁之战打输了怎么办?"),
}
content 是一个 []llms.MessageContent 类型的切片,用于存储一系列的聊天消息内容。
llms.TextParts
是 langchaingo 库中用于创建文本消息内容的函数。它接受两个参数:消息类型和消息内容。
llms.ChatMessageTypeSystem
表示系统消息类型。系统消息通常用于给 AI 提供一些额外的指令或上下文信息。在这个例子中,系统消息告知 AI 要模仿曹操的口吻和风格进行回答。
llms.ChatMessageTypeHuman
表示人类用户发送的消息类型。这里的消息内容是用户提出的问题“赤壁之战打输了怎么办?”。
ChatMessageType有哪些常量?我们来看下源码:
// ChatMessageTypeAI is a message sent by an AI.
ChatMessageTypeAI ChatMessageType = "ai"
// ChatMessageTypeHuman is a message sent by a human.
ChatMessageTypeHuman ChatMessageType = "human"
// ChatMessageTypeSystem is a message sent by the system.
ChatMessageTypeSystem ChatMessageType = "system"
// ChatMessageTypeGeneric is a message sent by a generic user.
ChatMessageTypeGeneric ChatMessageType = "generic"
// ChatMessageTypeFunction is a message sent by a function.
ChatMessageTypeFunction ChatMessageType = "function"
// ChatMessageTypeTool is a message sent by a tool.
ChatMessageTypeTool ChatMessageType = "tool"
解释下这些常量分别代表什么:
1)ChatMessageTypeAI:表示由 AI 生成并发送的消息。当 AI 对用户的问题进行回答时,生成的回复消息就属于这种类型。
2)ChatMessageTypeHuman:代表人类用户发送的消息。例如,用户在聊天界面输入的问题、评论等都属于人类消息。
3)ChatMessageTypeSystem:是系统发送的消息,用于设置 AI 的行为、提供指令或者上下文信息。系统消息可以帮助 AI 更好地理解任务和要求。
4)ChatMessageTypeGeneric:表示由通用用户发送的消息。这里的“通用用户”可以是除了明确的人类用户和 AI 之外的其他类型的用户。
5)ChatMessageTypeFunction:表示由函数调用产生的消息。在一些复杂的聊天系统中,AI 可能会调用外部函数来完成某些任务,函数执行的结果会以这种类型的消息返回。
6)ChatMessageTypeTool:表示由工具调用产生的消息。类似于函数调用,工具调用可以帮助 AI 完成更复杂的任务,工具执行的结果会以这种类型的消息呈现。
这些常量的定义有助于在代码中清晰地区分不同类型的聊天消息,方便对消息进行处理和管理。
接入DeepSeek-R1支持深度思考
本篇文章关于DeepSeek的相关文档主要参考deepseek官方文档,这篇文档里我们可以看到DeepSeek的V3模型和R1模型是两个不同的模型标识,即:
model='deepseek-chat' 即可调用 DeepSeek-V3。
model='deepseek-reasoner',即可调用 DeepSeek-R1。
因此在调用R1模型时我们需要改变初始化client的策略,然后在处理回答的时候也需要额外处理思考部分的回答,具体改动的地方如下:
1)初始化使用deepseek-reasoner:
llm, err := openai.New(
openai.WithBaseURL("https://api.deepseek.com"),
openai.WithModel("deepseek-reasoner"),
openai.WithToken("xxx"),
)
2)函数处理思考部分
completion, err := llm.GenerateContent(
ctx,
content,
llms.WithMaxTokens(2000),
llms.WithTemperature(0.7),
llms.WithStreamingReasoningFunc(func(ctx context.Context, reasoningChunk []byte, chunk []byte) error {
contentColor := color.New(color.FgCyan).Add(color.Bold)
reasoningColor := color.New(color.FgYellow).Add(color.Bold)
if !isPrint {
isPrint = true
fmt.Print("[思考中]")
}
// 思考部分
if len(reasoningChunk) > 0 {
_, err := reasoningColor.Printf("%s", string(reasoningChunk))
if err != nil {
return err
}
}
// 回答部分
if len(chunk) > 0 {
_, err := contentColor.Printf("%s", string(chunk))
if err != nil {
return err
}
}
return nil
}),
)
基于上面这些改动我们就能使用R1模型进行接入了。
小总结
这篇文章可以说展示了LangChain对接大模型的最基本功能,也是搭建我们自己Agent的第一步,如果真的想要搭建一个完整的AI Agent,那么还需要有很多地方进行补充和优化,比如:
- 上下文记忆:添加会话历史管理
- 风格校验:构建古汉语词库验证
- 多模态扩展:结合人物画像生成
本篇文章到这里就结束啦~
来源:juejin.cn/post/7490746012485009445
TailwindCSS 与 -webkit-line-clamp 深度解析:现代前端开发的样式革命
引言
在现代前端开发的浪潮中,CSS 的编写方式正在经历一场深刻的变革。传统的 CSS 开发模式虽然功能强大,但往往伴随着样式冲突、维护困难、代码冗余等问题。开发者需要花费大量时间在样式的命名、组织和维护上,而真正用于业务逻辑实现的时间却相对有限。
TailwindCSS 的出现,如同一股清流,为前端开发者带来了全新的开发体验。它不仅仅是一个 CSS 框架,更是一种全新的设计哲学——原子化 CSS 的完美实践。与此同时,在处理文本显示的细节问题上,诸如 -webkit-line-clamp
这样的 CSS 属性,虽然看似简单,却蕴含着深层的浏览器渲染原理。
本文将深入探讨 TailwindCSS 的核心理念、配置方法以及实际应用,同时详细解析 -webkit-line-clamp
的底层工作机制,帮助开发者更好地理解和运用这些现代前端技术。无论你是刚接触前端开发的新手,还是希望提升开发效率的资深开发者,这篇文章都将为你提供有价值的见解和实用的技巧。
TailwindCSS:原子化 CSS 的艺术
什么是原子化 CSS
原子化 CSS(Atomic CSS)是一种 CSS 架构方法,其核心思想是将样式拆分成最小的、不可再分的单元——就像化学中的原子一样。每个 CSS 类只负责一个特定的样式属性,比如 text-center
只负责文本居中,bg-blue-500
只负责设置蓝色背景。
传统的 CSS 开发模式往往采用组件化的方式,为每个 UI 组件编写独立的样式类。例如,一个按钮组件可能会有这样的 CSS:
.button {
padding: 12px 24px;
background-color: #3b82f6;
color: white;
border-radius: 6px;
font-weight: 600;
transition: background-color 0.2s;
}
.button:hover {
background-color: #2563eb;
}
这种方式在小型项目中运行良好,但随着项目规模的增长,会出现以下问题:
- 样式重复:不同组件可能需要相似的样式,导致代码重复
- 命名困难:为每个组件和状态想出合适的类名变得越来越困难
- 维护复杂:修改一个样式可能影响多个组件,需要谨慎处理
- CSS 文件膨胀:随着功能增加,CSS 文件变得越来越大
原子化 CSS 通过将样式拆分成最小单元来解决这些问题。上面的按钮样式在 TailwindCSS 中可以这样表示:
<button class="px-6 py-3 bg-blue-500 text-white rounded-md font-semibold hover:bg-blue-600 transition-colors">
Click me
</button>
每个类名都有明确的职责:
px-6
:左右内边距 1.5rem(24px)py-3
:上下内边距 0.75rem(12px)bg-blue-500
:蓝色背景text-white
:白色文字rounded-md
:中等圆角font-semibold
:半粗体字重hover:bg-blue-600
:悬停时的深蓝色背景transition-colors
:颜色过渡动画
TailwindCSS 的核心特性
TailwindCSS 作为原子化 CSS 的杰出代表,具有以下核心特性:
1. 几乎不用写 CSS
这是 TailwindCSS 最吸引人的特性之一。在传统开发中,开发者需要在 HTML 和 CSS 文件之间频繁切换,思考类名、编写样式、处理选择器优先级等问题。而使用 TailwindCSS,大部分样式都可以直接在 HTML 中通过预定义的类名来实现。
这种方式带来的好处是显而易见的:
- 开发速度提升:无需在文件间切换,样式即写即见
- 认知负担减轻:不需要思考复杂的类名和样式组织
- 一致性保证:使用统一的设计系统,避免样式不一致
2. AI 代码生成的首选框架
在人工智能辅助编程的时代,TailwindCSS 已经成为 AI 工具生成前端代码时的首选 CSS 框架。这主要有以下几个原因:
- 语义化程度高:TailwindCSS 的类名具有很强的语义性,AI 可以更容易理解和生成
- 标准化程度高:作为业界标准,AI 模型在训练时接触了大量 TailwindCSS 代码
- 组合性强:原子化的特性使得 AI 可以灵活组合不同的样式类
当你使用 ChatGPT、Claude 或其他 AI 工具生成前端代码时,它们几乎总是会使用 TailwindCSS 来处理样式,这已经成为了一种行业默认标准。
3. 丰富的内置类名系统
TailwindCSS 提供了一套完整而系统的类名体系,涵盖了前端开发中几乎所有的样式需求:
- 布局类:
flex
、grid
、block
、inline
等 - 间距类:
m-4
、p-2
、space-x-4
等 - 颜色类:
text-red-500
、bg-blue-200
、border-gray-300
等 - 字体类:
text-lg
、font-bold
、leading-tight
等 - 响应式类:
md:text-xl
、lg:flex
、xl:grid-cols-4
等 - 状态类:
hover:bg-gray-100
、focus:ring-2
、active:scale-95
等
这些类名都遵循一致的命名规范,学会了基本规则后,即使遇到没用过的类名也能快速理解其含义。
配置与使用
安装和配置流程
要在项目中使用 TailwindCSS,需要经过以下几个步骤:
1. 安装依赖包
npm install -D tailwindcss @vitejs/plugin-tailwindcss
这里安装了两个包:
tailwindcss
:TailwindCSS 的核心包@vitejs/plugin-tailwindcss
:Vite 的 TailwindCSS 插件,用于在构建过程中处理 TailwindCSS
2. 生成配置文件
npx tailwindcss init
这个命令会在项目根目录生成一个 tailwind.config.js
文件:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
content
数组指定了 TailwindCSS 应该扫描哪些文件来查找使用的类名,这对于生产环境的样式优化非常重要。
vite.config.js 配置详解
在 Vite 项目中,需要在 vite.config.js
中配置 TailwindCSS 插件:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@vitejs/plugin-tailwindcss'
export default defineConfig({
plugins: [
react(),
tailwindcss()
],
})
这个配置告诉 Vite 在构建过程中使用 TailwindCSS 插件来处理 CSS 文件。插件会自动:
- 扫描指定的文件查找使用的 TailwindCSS 类名
- 生成对应的 CSS 代码
- 在生产环境中移除未使用的样式(Tree Shaking)
tailwind.css 引入方式
在项目的主 CSS 文件(通常是 src/index.css
或 src/main.css
)中引入 TailwindCSS 的基础样式:
@tailwind base;
@tailwind components;
@tailwind utilities;
这三个指令分别引入了:
base
:重置样式和基础样式components
:组件样式(可以自定义)utilities
:工具类样式(TailwindCSS 的核心)
单位系统解析
TailwindCSS 使用了一套独特而直观的单位系统。其中最重要的概念是:1rem = 4 个单位。
这意味着:
w-4
=width: 1rem
=16px
(在默认字体大小下)p-2
=padding: 0.5rem
=8px
m-8
=margin: 2rem
=32px
这套系统的设计非常巧妙:
- 易于记忆:4 的倍数关系简单直观
- 设计友好:符合设计师常用的 8px 网格系统
- 响应式友好:基于 rem 单位,能够很好地适应不同的屏幕尺寸
常用的间距对照表:
类名 | CSS 值 | 像素值(16px 基准) |
---|---|---|
p-1 | 0.25rem | 4px |
p-2 | 0.5rem | 8px |
p-3 | 0.75rem | 12px |
p-4 | 1rem | 16px |
p-6 | 1.5rem | 24px |
p-8 | 2rem | 32px |
p-12 | 3rem | 48px |
p-16 | 4rem | 64px |
这套系统不仅适用于内外边距,也适用于宽度、高度、字体大小等其他尺寸相关的属性。
-webkit-line-clamp:文本截断的底层原理
浏览器内核基础知识
在深入了解 -webkit-line-clamp
之前,我们需要先理解浏览器内核的基本概念。浏览器内核(Browser Engine)是浏览器的核心组件,负责解析 HTML、CSS,并将网页内容渲染到屏幕上。不同的浏览器使用不同的内核,这也是为什么某些 CSS 属性需要添加特定前缀的原因。
主要浏览器内核及其前缀:
- WebKit 内核(-webkit-)
- 使用浏览器:Chrome、Safari、新版 Edge、Opera
- 特点:由苹果公司开发,后来被 Google 采用并发展出 Blink 内核
- 市场份额:目前占据主导地位,超过 70% 的市场份额
- Gecko 内核(-moz-)
- 使用浏览器:Firefox
- 特点:由 Mozilla 基金会开发,注重标准化和开放性
- 市场份额:约 3-5% 的市场份额
- Trident/EdgeHTML 内核(-ms-)
- 使用浏览器:旧版 Internet Explorer、旧版 Edge
- 特点:微软开发,现已基本被淘汰
由于 WebKit 内核的广泛使用,许多实验性的 CSS 属性首先在 WebKit 中实现,并使用 -webkit-
前缀。-webkit-line-clamp
就是其中的一个典型例子。
实验性属性的概念
CSS 中的实验性属性(Experimental Properties)是指那些尚未成为正式 W3C 标准,但已经在某些浏览器中实现的功能。这些属性通常具有以下特征:
- 前缀标识:使用浏览器厂商前缀,如
-webkit-
、-moz-
、-ms-
等 - 功能性强:虽然不是标准,但能解决实际开发中的问题
- 兼容性限制:只在特定浏览器中工作
- 可能变化:语法和行为可能在未来版本中发生变化
-webkit-line-clamp
正是这样一个实验性属性。它最初是为了解决移动端 WebKit 浏览器中多行文本截断的需求而设计的,虽然不是 CSS 标准的一部分,但由于其实用性,被广泛采用并逐渐得到其他浏览器的支持。
-webkit-line-clamp 深度解析
属性的工作原理
-webkit-line-clamp
是一个用于限制文本显示行数的 CSS 属性。当文本内容超过指定行数时,多余的内容会被隐藏,并在最后一行的末尾显示省略号(...)。
这个属性的工作原理涉及到浏览器的文本渲染机制:
- 文本流计算:浏览器首先计算文本在容器中的自然流动方式
- 行数统计:根据容器宽度、字体大小、行高等因素计算文本占用的行数
- 截断处理:当行数超过
line-clamp
指定的值时,截断多余内容 - 省略号添加:在最后一行的适当位置添加省略号
为什么不能独自生效
这是 -webkit-line-clamp
最容易让开发者困惑的地方。单独使用这个属性是无效的,必须配合其他 CSS 属性才能正常工作。这是因为 -webkit-line-clamp
的设计初衷是作为 Flexbox 布局的一部分来工作的。
具体来说,-webkit-line-clamp
只在以下条件同时满足时才会生效:
- 容器必须是 Flexbox:
display: -webkit-box
- 必须设置排列方向:
-webkit-box-orient: vertical
- 必须隐藏溢出内容:
overflow: hidden
这种设计反映了早期 WebKit 对 Flexbox 规范的实现方式。在当时,-webkit-box
是 Flexbox 的早期实现,而 -webkit-line-clamp
被设计为在这种布局模式下工作。
必需的配套属性详解
让我们详细分析每个必需的配套属性:
1. display: -webkit-box
display: -webkit-box;
这个属性将元素设置为 WebKit 的旧版 Flexbox 容器。在现代 CSS 中,我们通常使用 display: flex
,但 -webkit-line-clamp
需要这个特定的值才能工作。
-webkit-box
是 2009 年 Flexbox 规范的实现,虽然已经过时,但为了兼容 -webkit-line-clamp
,我们仍然需要使用它。这个值会:
- 将元素转换为块级容器
- 启用 WebKit 的 Flexbox 布局引擎
- 为
-webkit-line-clamp
提供必要的布局上下文
2. -webkit-box-orient: vertical
-webkit-box-orient: vertical;
这个属性设置 Flexbox 容器的主轴方向为垂直。在文本截断的场景中,我们需要垂直方向的布局来正确计算行数。
可选值包括:
horizontal
:水平排列(默认值)vertical
:垂直排列inline-axis
:沿着内联轴排列block-axis
:沿着块轴排列
对于文本截断,我们必须使用 vertical
,因为:
- 文本行是垂直堆叠的
-webkit-line-clamp
需要在垂直方向上计算行数- 只有在垂直布局下,行数限制才有意义
3. overflow: hidden
overflow: hidden;
这个属性隐藏超出容器边界的内容。在文本截断的场景中,它的作用是:
- 隐藏超出指定行数的文本内容
- 确保省略号正确显示在可见区域内
- 防止内容溢出影响页面布局
如果不设置 overflow: hidden
,超出行数限制的文本仍然会显示,-webkit-line-clamp
就失去了意义。
完整的文本截断方案
将所有必需的属性组合起来,一个完整的文本截断方案如下:
.text-clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
这个方案会将文本限制在 2 行内,超出的内容会被隐藏并显示省略号。
浏览器兼容性分析
虽然 -webkit-line-clamp
带有 WebKit 前缀,但实际上它的兼容性比想象中要好:
浏览器 | 支持版本 | 备注 |
---|---|---|
Chrome | 6+ | 完全支持 |
Safari | 5+ | 完全支持 |
Firefox | 68+ | 2019年开始支持 |
Edge | 17+ | 基于 Chromium 的版本支持 |
IE | 不支持 | 需要 JavaScript 降级方案 |
现代浏览器(除了 IE)都已经支持这个属性,使得它在实际项目中具有很高的可用性。
高级用法和注意事项
1. 响应式行数控制
可以结合媒体查询实现响应式的行数控制:
.responsive-clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 3;
}
@media (max-width: 768px) {
.responsive-clamp {
-webkit-line-clamp: 2;
}
}
2. 与其他 CSS 属性的交互
-webkit-line-clamp
与某些 CSS 属性可能产生冲突:
- white-space: nowrap:会阻止文本换行,使 line-clamp 失效
- height 固定值:可能与 line-clamp 的高度计算冲突
- line-height:会影响行数的计算,需要谨慎设置
3. 性能考虑
使用 -webkit-line-clamp
时需要注意性能影响:
- 浏览器需要重新计算文本布局
- 在大量元素上使用可能影响渲染性能
- 动态改变 line-clamp 值会触发重排(reflow)
实战应用与代码示例
line-clamp 在 TailwindCSS 中的应用
TailwindCSS 内置了对 -webkit-line-clamp
的支持,提供了 line-clamp-{n}
工具类。让我们看看如何在实际项目中使用这些类。
基础使用示例
// 产品卡片组件
function ProductCard({ product }) {
return (
<div className="card max-w-sm">
{/* 产品图片 */}
<div className="relative">
<img
src={product.image}
alt={product.name}
className="w-full h-64 object-cover"
/>
{product.isNew && (
<span className="absolute top-2 left-2 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded">
New
</span>
)}
</div>
{/* 产品信息 */}
<div className="p-6">
{/* 产品标题 - 限制1行 */}
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mb-2">
{product.name}
</h3>
{/* 产品描述 - 限制2行 */}
<p className="text-sm text-gray-600 line-clamp-2 mb-4">
{product.description}
</p>
{/* 产品特性 - 限制3行 */}
<div className="text-xs text-gray-500 line-clamp-3 mb-4">
{product.features.join(' • ')}
</div>
{/* 价格和操作 */}
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-gray-900">
${product.price}
</span>
<button className="btn-primary">
Add to Cart
</button>
</div>
</div>
</div>
);
}
在这个示例中,我们使用了不同的 line-clamp
值来处理不同类型的文本内容:
line-clamp-1
:产品标题保持在一行内line-clamp-2
:产品描述限制在两行内line-clamp-3
:产品特性列表限制在三行内
响应式文本截断
TailwindCSS 的响应式前缀可以与 line-clamp
结合使用,实现不同屏幕尺寸下的不同截断行为:
function ArticleCard({ article }) {
return (
<article className="card">
<div className="p-6">
{/* 响应式标题截断 */}
<h2 className="text-xl font-bold text-gray-900 line-clamp-2 md:line-clamp-1 mb-3">
{article.title}
</h2>
{/* 响应式内容截断 */}
<p className="text-gray-600 line-clamp-3 sm:line-clamp-4 lg:line-clamp-2 mb-4">
{article.content}
</p>
{/* 标签列表 - 移动端截断更多 */}
<div className="text-sm text-gray-500 line-clamp-2 md:line-clamp-1">
{article.tags.map(tag => `#${tag}`).join(' ')}
</div>
</div>
</article>
);
}
这个示例展示了如何根据屏幕尺寸调整文本截断行为:
- 移动端:标题显示2行,内容显示3行
- 平板端:标题显示1行,内容显示4行
- 桌面端:标题显示1行,内容显示2行
动态 line-clamp 控制
有时我们需要根据用户交互动态改变文本的截断行为:
import { useState } from 'react';
function ExpandableText({ text, maxLines = 3 }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="space-y-2">
<p className={`text-gray-700 ${isExpanded ? '' : `line-clamp-${maxLines}`}`}>
{text}
</p>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
>
{isExpanded ? 'Show Less' : 'Show More'}
</button>
</div>
);
}
// 使用示例
function ReviewCard({ review }) {
return (
<div className="card p-6">
<div className="flex items-center mb-4">
<img
src={review.avatar}
alt={review.author}
className="w-10 h-10 rounded-full mr-3"
/>
<div>
<h4 className="font-semibold text-gray-900">{review.author}</h4>
<div className="flex items-center">
{/* 星级评分 */}
{[...Array(5)].map((_, i) => (
<svg
key={i}
className={`w-4 h-4 ${i < review.rating ? 'text-yellow-400' : 'text-gray-300'} fill-current`}
viewBox="0 0 24 24"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
))}
</div>
</div>
</div>
{/* 可展开的评论内容 */}
<ExpandableText text={review.content} maxLines={4} />
</div>
);
}
这个示例展示了如何创建一个可展开的文本组件,用户可以点击按钮来显示完整内容或收起到指定行数。
最佳实践与总结
开发建议
在实际项目中使用 TailwindCSS 和 -webkit-line-clamp
时,以下最佳实践将帮助你获得更好的开发体验和项目质量:
TailwindCSS 开发最佳实践
1. 合理组织类名
虽然 TailwindCSS 鼓励在 HTML 中直接使用工具类,但过长的类名列表会影响代码可读性。建议采用以下策略:
// ❌ 避免:过长的类名列表
<div className="flex items-center justify-between p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow duration-200 ease-in-out">
// ✅ 推荐:使用组件抽象
const Card = ({ children, className = "" }) => (
<div className={`card hover:shadow-md transition-shadow ${className}`}>
{children}
</div>
);
// ✅ 推荐:使用 @apply 指令创建组件类
// 在 CSS 中定义
.card {
@apply flex items-center justify-between p-6 bg-white rounded-lg shadow-sm border border-gray-200;
}
2. 建立设计系统
充分利用 TailwindCSS 的配置系统建立项目专属的设计系统:
// tailwind.config.js
module.exports = {
theme: {
extend: {
// 定义项目色彩系统
colors: {
brand: {
primary: '#3B82F6',
secondary: '#10B981',
accent: '#F59E0B',
}
},
// 定义间距系统
spacing: {
'18': '4.5rem',
'88': '22rem',
},
// 定义字体系统
fontSize: {
'xs': ['0.75rem', { lineHeight: '1rem' }],
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base': ['1rem', { lineHeight: '1.5rem' }],
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
}
}
}
}
3. 性能优化策略
TailwindCSS 的性能优化主要体现在生产环境的样式清理:
// tailwind.config.js
module.exports = {
content: [
// 精确指定扫描路径,避免不必要的文件扫描
"./src/**/*.{js,jsx,ts,tsx}",
"./public/index.html",
// 如果使用了第三方组件库,也要包含其路径
"./node_modules/@my-ui-lib/**/*.{js,jsx}",
],
// 启用 JIT 模式获得更好的性能
mode: 'jit',
}
4. 响应式设计策略
采用移动优先的设计理念,合理使用响应式前缀:
// ✅ 移动优先的响应式设计
<div className="
grid grid-cols-1 gap-4
sm:grid-cols-2 sm:gap-6
md:grid-cols-3 md:gap-8
lg:grid-cols-4
xl:gap-10
">
{/* 内容 */}
</div>
// ✅ 响应式文字大小
<h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold">
标题
</h1>
line-clamp 使用最佳实践
1. 选择合适的截断行数
不同类型的内容需要不同的截断策略:
内容类型 | 推荐行数 | 使用场景 |
---|---|---|
标题 | 1-2行 | 卡片标题、列表项标题 |
摘要/描述 | 2-3行 | 产品描述、文章摘要 |
详细内容 | 3-5行 | 评论内容、详细说明 |
标签列表 | 1-2行 | 标签云、分类列表 |
2. 考虑内容的语义完整性
// ✅ 好的实践:为截断的内容提供完整查看选项
function ProductDescription({ description }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div>
<p className={isExpanded ? '' : 'line-clamp-3'}>
{description}
</p>
{description.length > 150 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-blue-600 text-sm mt-1"
>
{isExpanded ? '收起' : '查看更多'}
</button>
)}
</div>
);
}
3. 处理不同语言的截断
不同语言的文字密度不同,需要相应调整截断行数:
// 根据语言调整截断行数
function MultiLanguageText({ text, language }) {
const getLineClampClass = (lang) => {
switch (lang) {
case 'zh': return 'line-clamp-2'; // 中文字符密度高
case 'en': return 'line-clamp-3'; // 英文需要更多行数
case 'ja': return 'line-clamp-2'; // 日文类似中文
default: return 'line-clamp-3';
}
};
return (
<p className={`text-gray-700 ${getLineClampClass(language)}`}>
{text}
</p>
);
}
性能考虑
TailwindCSS 性能优化
1. 构建时优化
TailwindCSS 在构建时会自动移除未使用的样式,但我们可以进一步优化:
// postcss.config.js
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
// 生产环境启用 CSS 压缩
process.env.NODE_ENV === 'production' && require('cssnano')({
preset: 'default',
}),
].filter(Boolean),
}
2. 运行时性能
避免在运行时动态生成类名,这会影响 TailwindCSS 的优化效果:
// ❌ 避免:动态类名生成
const dynamicClass = `text-${color}-500`; // 可能不会被包含在最终构建中
// ✅ 推荐:使用完整的类名
const colorClasses = {
red: 'text-red-500',
blue: 'text-blue-500',
green: 'text-green-500',
};
const selectedClass = colorClasses[color];
line-clamp 性能影响
1. 重排和重绘
-webkit-line-clamp
的使用会触发浏览器的重排(reflow),在大量元素上使用时需要注意性能:
// ✅ 使用 CSS containment 优化性能
.text-container {
contain: layout style;
-webkit-line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
2. 虚拟化长列表
在处理大量带有文本截断的列表项时,考虑使用虚拟化技术:
import { FixedSizeList as List } from 'react-window';
function VirtualizedProductList({ products }) {
const Row = ({ index, style }) => (
<div style={style}>
<ProductCard product={products[index]} />
</div>
);
return (
<List
height={600}
itemCount={products.length}
itemSize={200}
>
{Row}
</List>
);
}
总结
TailwindCSS 和 -webkit-line-clamp
代表了现代前端开发中两个重要的技术趋势:工具化的 CSS 开发和细粒度的样式控制。
TailwindCSS 的价值在于:
- 开发效率的显著提升:通过原子化的类名系统,开发者可以快速构建界面而无需编写大量自定义 CSS
- 设计系统的一致性:内置的设计令牌确保了整个项目的视觉一致性
- 维护成本的降低:减少了 CSS 文件的复杂性和样式冲突的可能性
- 团队协作的改善:统一的类名约定降低了团队成员之间的沟通成本
-webkit-line-clamp 的意义在于:
- 用户体验的优化:通过优雅的文本截断保持界面的整洁和一致性
- 响应式设计的支持:在不同屏幕尺寸下提供合适的内容展示
- 性能的考虑:避免了复杂的 JavaScript 文本处理逻辑
- 标准化的推动:虽然是实验性属性,但推动了相关 CSS 标准的发展
在实际项目中,这两个技术的结合使用能够帮助开发者:
- 快速原型开发:在设计阶段快速验证界面效果
- 响应式布局:轻松适配各种设备和屏幕尺寸
- 内容管理:优雅处理动态内容的显示问题
- 性能优化:减少 CSS 体积和运行时计算
随着前端技术的不断发展,我们可以期待看到更多类似的工具和技术出现,它们将继续推动前端开发向着更高效、更标准化的方向发展。对于前端开发者而言,掌握这些现代技术不仅能提升当前的开发效率,更重要的是能够跟上技术发展的步伐,为未来的项目做好准备。
无论你是刚开始学习前端开发的新手,还是希望优化现有项目的资深开发者,TailwindCSS 和 -webkit-line-clamp
都值得你深入学习和实践。它们不仅是技术工具,更代表了现代前端开发的最佳实践和发展方向。
来源:juejin.cn/post/7536092776867840039
.NET 高级开发:反射与代码生成的实战秘籍
在当今快速发展的软件开发领域,灵活性和动态性是开发者不可或缺的能力。.NET 提供的反射机制和代码生成技术,为开发者提供了强大的工具,能够在运行时动态地探索和操作代码。这些技术不仅能够提升开发效率,还能实现一些传统静态代码无法完成的功能。本文将深入探讨 .NET 反射机制的核心功能、高级技巧以及代码生成的实际应用,帮助你在开发中更好地利用这些强大的工具。
.NET 反射:运行时的魔法
反射是 .NET 中一个极其强大的特性,它允许开发者在运行时动态地检查和操作类型信息。通过反射,你可以获取类型信息、动态创建对象、调用方法,甚至访问私有成员。这种能力在许多场景中都非常有用,比如实现插件系统、动态调用方法、序列化和反序列化等。
反射基础
反射的核心是 System.Type
类,它代表了一个类型的元数据。通过 Type
类,你可以获取类的名称、基类、实现的接口、方法、属性等信息。System.Reflection
命名空间提供了多个关键类,如 Assembly
、MethodInfo
、PropertyInfo
和 FieldInfo
,帮助你更深入地探索类型信息。
获取 Type
对象有三种常见方式:
- 使用
typeof
运算符:适用于编译时已知的类型。
Type type = typeof(string);
Console.WriteLine(type.Name); // 输出:String
- 调用
GetType()
方法:适用于运行时已知的对象。
string name = "Hello";
Type type = name.GetType();
Console.WriteLine(type.Name); // 输出:String
- 通过类型名称动态加载:适用于运行时动态加载类型。
Type? type = Type.GetType("System.String");
if (type != null) {
Console.WriteLine(type.Name); // 输出:String
}
反射的常见操作
反射可以完成许多强大的操作,以下是一些常见的用法:
获取类型信息
通过 Type
对象,你可以获取类的各种信息,例如类名、基类、是否泛型等。
Type type = typeof(List<int>);
Console.WriteLine($"类名: {type.Name}"); // 输出:List`1
Console.WriteLine($"基类: {type.BaseType?.Name}"); // 输出:Object
Console.WriteLine($"是否泛型: {type.IsGenericType}"); // 输出:True
动态调用方法
假设你有一个类 Calculator
,你可以通过反射动态调用它的方法。
public class Calculator
{
public int Add(int a, int b) => a + b;
}
Calculator calc = new Calculator();
Type type = calc.GetType();
MethodInfo? method = type.GetMethod("Add");
if (method != null) {
int result = (int)method.Invoke(calc, new object[] { 5, 3 })!;
Console.WriteLine(result); // 输出:8
}
访问私有成员
反射可以绕过访问修饰符的限制,访问私有字段或方法。
public class SecretHolder
{
private string _secret = "Hidden Data";
}
var holder = new SecretHolder();
Type type = holder.GetType();
FieldInfo? field = type.GetField("_secret", BindingFlags.NonPublic | BindingFlags.Instance);
if (field != null) {
string secret = (string)field.GetValue(holder)!;
Console.WriteLine(secret); // 输出:Hidden Data
}
动态创建对象
通过 Activator.CreateInstance
方法,你可以动态实例化对象。
Type type = typeof(StringBuilder);
object? instance = Activator.CreateInstance(type);
StringBuilder sb = (StringBuilder)instance!;
sb.Append("Hello");
Console.WriteLine(sb.ToString()); // 输出:Hello
高级反射技巧
反射的高级用法可以让你在开发中更加灵活,以下是一些进阶技巧:
调用泛型方法
如果方法带有泛型参数,你需要先使用 MakeGenericMethod
指定类型。
public class GenericHelper
{
public T Echo<T>(T value) => value;
}
var helper = new GenericHelper();
Type type = helper.GetType();
MethodInfo method = type.GetMethod("Echo")!;
MethodInfo genericMethod = method.MakeGenericMethod(typeof(string));
string result = (string)genericMethod.Invoke(helper, new object[] { "Hello" })!;
Console.WriteLine(result); // 输出:Hello
性能优化
反射调用比直接调用慢很多,因此在高性能场景下,可以缓存 MethodInfo
或使用 Delegate
来优化性能。
MethodInfo method = typeof(Calculator).GetMethod("Add")!;
var addDelegate = (Func<Calculator, int, int, int>)Delegate.CreateDelegate(
typeof(Func<Calculator, int, int, int>),
method
);
Calculator calc = new Calculator();
int result = addDelegate(calc, 5, 3);
Console.WriteLine($"result: {result}"); // 输出:8
动态加载插件
假设你有一个插件系统,所有插件都实现了 IPlugin
接口,你可以通过反射动态加载插件。
public interface IPlugin
{
void Execute();
}
public class HelloPlugin : IPlugin
{
public void Execute() => Console.WriteLine("Hello from Plugin!");
}
Assembly assembly = Assembly.LoadFrom("MyPlugins.dll");
var pluginTypes = assembly.GetTypes()
.Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract);
foreach (Type type in pluginTypes)
{
IPlugin plugin = (IPlugin)Activator.CreateInstance(type);
plugin.Execute();
}
代码生成:运行时的创造力
在某些高级场景中,你可能需要在运行时生成新的类型或方法。.NET 提供的 System.Reflection.Emit
命名空间允许你在运行时构建程序集、模块、类型和方法。
使用 Reflection.Emit
生成动态类
以下是一个示例,展示如何使用 Reflection.Emit
生成一个动态类 Person
,并为其添加一个 SayHello
方法。
using System;
using System.Reflection;
using System.Reflection.Emit;
public class DynamicTypeDemo
{
public static void Main()
{
// 创建一个动态程序集
AssemblyName assemblyName = new AssemblyName("DynamicAssembly");
AssemblyBuilder assemblyBuilder =
AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
// 创建一个模块
ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("MainModule");
// 定义一个类:public class Person
TypeBuilder typeBuilder = moduleBuilder.DefineType(
"Person",
TypeAttributes.Public
);
// 定义一个方法:public void SayHello()
MethodBuilder methodBuilder = typeBuilder.DefineMethod(
"SayHello",
MethodAttributes.Public,
returnType: typeof(void),
parameterTypes: Type.EmptyTypes
);
// 生成 IL 代码,等价于 Console.WriteLine("Hello from dynamic type!");
ILGenerator il = methodBuilder.GetILGenerator();
il.Emit(OpCodes.Ldstr, "Hello from dynamic type!");
il.Emit(OpCodes.Call, typeof(Console).GetMethod("WriteLine", new[] { typeof(string) })!);
il.Emit(OpCodes.Ret);
// 创建类型
Type personType = typeBuilder.CreateType();
// 实例化并调用方法
object personInstance = Activator.CreateInstance(personType)!;
personType.GetMethod("SayHello")!.Invoke(personInstance, null);
}
}
运行上述代码后,你将看到输出:
Hello from dynamic type!
表达式树:更安全的代码生成
如果你希望在运行时生成代码行为,但又不想深入 IL 层,表达式树(System.Linq.Expressions
)是一个更现代、更安全的替代方案。以下是一个示例,展示如何使用表达式树生成一个简单的 SayHello
方法。
using System;
using System.Linq.Expressions;
public class ExpressionTreeDemo
{
public static void Main()
{
// 表达式:() => Console.WriteLine("Hello from expression tree!")
var writeLineMethod = typeof(Console).GetMethod("WriteLine", new[] { typeof(string) });
// 构建常量表达式 "Hello from expression tree!"
var messageExpr = Expression.Constant("Hello from expression tree!");
// 调用 Console.WriteLine(string) 的表达式
var callExpr = Expression.Call(writeLineMethod!, messageExpr);
// 构建 lambda 表达式:() => Console.WriteLine(...)
var lambda = Expression.Lambda<Action>(callExpr);
// 编译成委托并执行
Action sayHello = lambda.Compile();
sayHello();
}
}
运行上述代码后,你将看到输出:
Hello from expression tree!
Source Generator:编译期代码生成
Source Generator 是 .NET 提供的一种编译期代码生成工具,可以在编译过程中注入额外的源代码。它不依赖反射,无运行时开销,适合构建高性能、可维护的自动化代码逻辑。
以下是一个简单的 Source Generator 示例,展示如何为类自动生成一个 SayHello
方法。
- 创建标记用的 Attribute
// HelloGenerator.Attributes.csproj
namespace HelloGenerator
{
[System.AttributeUsage(System.AttributeTargets.Class)]
public class GenerateHelloAttribute : System.Attribute { }
}
- 创建 Source Generator
// HelloGenerator.Source/HelloMethodGenerator.cs
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using System.Text;
[Generator]
public class HelloMethodGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// 注册一个语法接收器,用于筛选出标记了 [GenerateHello] 的类
context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}
public void Execute(GeneratorExecutionContext context)
{
if (context.SyntaxReceiver is not SyntaxReceiver receiver)
return;
// 遍历所有被标记的类,生成 SayHello 方法
foreach (var classDecl in receiver.CandidateClasses)
{
var model = context.Compilation.GetSemanticModel(classDecl.SyntaxTree);
var symbol = model.GetDeclaredSymbol(classDecl) as INamedTypeSymbol;
if (symbol is null) continue;
string className = symbol.Name;
string namespaceName = symbol.ContainingNamespace.ToDisplayString();
string source = $@"
namespace {namespaceName}
{{
public partial class {className}
{{
public void SayHello()
{{
System.Console.WriteLine(""Hello from Source Generator!"");
}}
}}
}}";
context.AddSource($"{className}_Hello.g.cs", SourceText.From(source, Encoding.UTF8));
}
}
// 语法接收器
class SyntaxReceiver : ISyntaxReceiver
{
public List<ClassDeclarationSyntax> CandidateClasses { get; } = new();
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
if (syntaxNode is ClassDeclarationSyntax classDecl &&
classDecl.AttributeLists.Count > 0)
{
CandidateClasses.Add(classDecl);
}
}
}
}
- 在主项目中使用 Source Generator
using HelloGenerator;
namespace MyApp
{
[GenerateHello]
public partial class Greeter { }
class Program
{
static void Main()
{
var g = new Greeter();
g.SayHello(); // 自动生成的方法
}
}
}
运行上述代码后,你将看到输出:
Hello from Source Generator!
总结
反射和代码生成是 .NET 中非常强大的特性,它们为开发者提供了运行时动态探索和操作代码的能力。反射机制允许你在运行时检查类型信息、动态创建对象、调用方法,甚至访问私有成员。代码生成技术则让你能够在运行时生成新的类型和方法,或者在编译期生成代码,从而提升开发效率和代码的灵活性。
在实际开发中,反射虽然功能强大,但需要注意性能开销。在需要高性能的场景下,可以考虑使用 Delegate
缓存、表达式树,或 .NET 6 的 Source Generators 来替代反射。通过合理使用这些技术,你可以在开发中更加灵活地应对各种复杂场景,提升代码的可维护性和性能。
希望这篇文章能帮助你更好地理解和应用 .NET 反射和代码生成技术,让你在开发中更加得心应手!
来源:juejin.cn/post/7527559658276323379
React 核心 API 全景实战:从状态管理到性能优化,一网打尽
✨ 为什么写这篇文章?
很多前端朋友在用 React
的时候:
- 只会用
useState
做局部状态,结果项目一大就乱套。 - 不了解
useReducer
和Context
,复杂页面全靠 props 一层层传。 - 性能卡顿后,只知道用
React.memo
,但为什么卡? useMemo
和useCallback
的区别 ?- 明明只是个
Modal
,结果被卡在组件层级里动弹不得,不知道可以用Portals
。
👉「在什么场景下选用哪个 API」+「如何写出最合理的 React 代码」。
🟢 1. useState:局部状态管理
🌳 场景:表单输入管理
比起枯燥的计数器,这里用表单输入做示例。
import { useState } from 'react';
export default function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = e => {
e.preventDefault();
console.log("登录中", username, password);
}
return (
<form onSubmit={handleSubmit}>
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="用户名"/>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="密码"/>
<button type="submit">登录</button>
</form>
);
}
🚀 优势
- 简单、直接
- 适用于小型、独立的状态
🟡 2. useEffect:副作用处理
🌍 场景:组件挂载时拉取远程数据
import { useEffect, useState } from 'react';
export default function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
});
return () => {
// 组件销毁执行此回调
};
}, []);
return user ? <h1>{user.name}</h1> : <p>加载中...</p>;
}
🚀 优势
- 集中管理副作用(请求、订阅、定时器、事件监听)
🔵 3. useRef & useImperativeHandle:DOM、实例方法控制
场景 1:聚焦输入框
import { useRef, useEffect } from 'react';
export default function AutoFocusInput() {
const inputRef = useRef();
useEffect(() => {
inputRef.current.focus();
}, []);
return <input ref={inputRef} placeholder="自动聚焦" />;
}
场景 2:在父组件调用子组件的方法
import { forwardRef, useRef, useImperativeHandle } from 'react';
const FancyInput = forwardRef((props, ref) => {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus()
}));
return <input ref={inputRef} />;
});
export default function App() {
const fancyRef = useRef();
return (
<>
<FancyInput ref={fancyRef} />
<button onClick={() => fancyRef.current.focus()}>父组件聚焦子组件</button>
</>
);
}
🧭 4. Context & useContext:解决多层级传值
场景:用户登录信息在多层组件使用
import React, { createContext, useContext } from 'react';
const UserContext = createContext();
/** 设置在 DevTools 中将显示为 User */
UserContext.displayName = 'User'
function Navbar() {
return (
<div>
<UserInfo />
</div>
)
}
function UserInfo() {
const user = useContext(UserContext);
return <span>欢迎,{user.name}</span>;
}
export default function App() {
return (
<UserContext.Provider value={{ name: 'Zheng' }}>
<Navbar />
</UserContext.Provider>
);
}
🚀 优势
- 解决「祖孙组件传值太麻烦」的问题
🔄 5. useReducer:复杂状态管理
import { useReducer } from 'react';
function reducer(state, action) {
switch(action.type){
case 'next':
return { ...state, step: state.step + 1 };
case 'prev':
return { ...state, step: state.step - 1 };
default:
return state;
}
}
export default function Wizard() {
const [state, dispatch] = useReducer(reducer, { step: 1 });
return (
<>
<h1>步骤 {state.step}</h1>
<button onClick={() => dispatch({type: 'prev'})}>上一步</button>
<button onClick={() => dispatch({type: 'next'})}>下一步</button>
</>
);
}
🆔 6. useId:避免 SSR / 并发下 ID 不一致
import { useId } from 'react';
export default function FormItem() {
const id = useId();
return (
<>
<label htmlFor={id}>姓名</label>
<input id={id} type="text" />
</>
);
}
🚀 7. Portals:在根元素渲染 Modal
import { useState } from 'react';
import ReactDOM from 'react-dom';
function Modal({ onClose }) {
return ReactDOM.createPortal(
<div style={{ position: "fixed", top: 100, left: 100, background: "white" }}>
<h1>这是 Modal</h1>
<button onClick={onClose}>关闭</button>
</div>,
document.getElementById('root')
);
}
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(true)}>打开 Modal</button>
{show && <Modal onClose={() => setShow(false)} />}
</>
);
}
在上面代码中,我们将要渲染的视图作为createPortal
方法的第一个参数,而第二个参数用于指定要渲染到那个DOM元素中。
尽管 portal 可以被放置在 DOM 树中的任何地方,但在任何其他方面,其行为和普通的 React 子节点行为一致。由于 portal 仍存在于 React 树, 且与 DOM 树中的位置无关,那么无论其子节点是否是 portal,像 context 这样的功能特性都是不变的。
这包含事件冒泡。一个从 portal 内部触发的事件会一直冒泡至包含 React 树的祖先,即便这些元素并不是 DOM 树中的祖先。
🔍 8. 组件渲染性能优化
🐘 之前类组件时代:shouldComponentUpdate与PureComponent
import { Component } from 'react'
export default class App extends Component {
constructor() {
super();
this.state = {
counter: 1
}
}
render() {
console.log("App 渲染了");
return (
<div>
<h1>App 组件</h1>
<div>{this.state.counter}</div>
<button onClick={() => this.setState({
counter : 1
})}>+1</button>
</div>
)
}
}
在上面的代码中,按钮在点击的时候仍然是设置 counter 的值为1,虽然 counter
的值没有变,整个组件仍然是重新渲染了的,显然,这一次渲染是没有必要的。
当 props
或 state
发生变化时,shouldComponentUpdate
会在渲染执行之前被调用。返回值默认为 true。首次渲染或使用 forceUpdate 方法时不会调用该方法。
下面我们来使用 shouldComponentUpdate 优化上面的示例:
import React from 'react'
/**
* 对两个对象进行一个浅比较,看是否相等
* obj1
* obj2
* 返回布尔值 true 代表两个对象相等, false 代表不相等
*/
function objectEqual(obj1, obj2) {
for (let prop in obj1) {
if (!Object.is(obj1[prop], obj2[prop])) {
// 进入此 if,说明有属性值不相等
// 只要有一个不相等,那么就应该判断两个对象不等
return false
}
}
return true
}
class PureChildCom1 extends React.Component {
constructor(props) {
super(props)
this.state = {
counter: 1,
}
}
// 验证state未发生改变,是否会执行render
onClickHandle = () => {
this.setState({
counter: Math.floor(Math.random() * 3 + 1),
})
}
// shouldComponentUpdate 中返回 false 来跳过整个渲染过程。其包括该组件的 render 调用以及之后的操作。
// 返回true 只要执行了setState都会重新渲染
shouldComponentUpdate(nextProps, nextState) {
if (
objectEqual(this.props, nextProps) &&
objectEqual(this.state, nextState)
) {
return false
}
return true
}
render() {
console.log('render')
return (
<div>
<div>{this.state.counter}</div>
<button onClick={this.onClickHandle}>点击</button>
</div>
)
}
}
export default PureChildCom1
PureComponent
内部做浅比较:如果 props/state 相同则跳过渲染。- 不适用于复杂对象(如数组、对象地址未变)。
🥇 React.memo:函数组件记忆化
上面主要是优化类组件的渲染性能,那么如果是函数组件该怎么办呢?
React中为我们提供了memo
高阶组件,只要 props 不变,就不重新渲染。
const Child = React.memo(function Child({name}) {
console.log("Child 渲染");
return <div>{name}</div>;
});
🏷 useCallback:缓存函数引用,避免触发子组件更新
import React, { useState, useCallback } from 'react';
function Child({ onClick }) {
console.log("Child 渲染")
return <button onClick={onClick}>点我</button>;
}
const MemoChild = React.memo(Child);
export default function App() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("点击");
}, []);
return (
<>
<div>{count}</div>
<button onClick={() => setCount(count+1)}>+1</button>
<MemoChild onClick={handleClick} />
</>
);
}
在上面的代码中,我们对Child
组件进行了memo
缓存,当修改App
组件中的count值的时候,不会引起Child
组件更新;使用了useCallback
对函数进行了缓存,当点击Child
组件中的button时也不会引起父组件的更新。
🔢 useMemo:缓存计算
某些时候,组件中某些值需要根据状态进行一个二次计算(类似于 Vue 中的计算属性),由于函数组件一旦重新渲染,就会重新执行整个函数,这就导致之前的二次计算也会重新执行一次。
import React, { useState } from 'react';
function App() {
const [count, setCount] = useState(1);
const [val, setValue] = useState('');
console.log("App render");
// 使用useMemo缓存计算
const getNum = useMemo(() => {
console.log('调用了!!!!!');
return count + 100;
}, [count])
return (
<div>
<h4>总和:{getNum()}</h4>
<div>
<button onClick={() => setCount(count + 1)}>+1</button>
{/* 文本框的输入会导致整个组件重新渲染 */}
<input value={val} onChange={event => setValue(event.target.value)} />
</div>
</div>
);
}
export default App;
在上面的示例中,文本框的输入会导致整个 App
组件重新渲染,但是 count
的值是没有改变的,所以 getNum
这个函数也是没有必要重新执行的。我们使用了 useMemo
来缓存二次计算的值,并设置了依赖项 count
,只有在 count
发生改变时,才会重新执行二次计算。
面试题:useMemo 和 useCallback 的区别及使用场景?
useMemo
和 useCallback
接收的参数都是一样,第一个参数为回调,第二个参数为要依赖的数据。
共同作用: 仅仅依赖数据发生变化,才会去更新缓存。
两者区别:
useMemo
计算结果是return
回来的值, 主要用于缓存计算结果的值。应用场景如:需要进行二次计算的状态useCallback
计算结果是函数, 主要用于缓存函数,应用场景如: 需要缓存的函数,因为函数式组件每次任何一个state
的变化,整个组件都会被重新刷新,一些函数是没有必要被重新刷新的,此时就应该缓存起来,提高性能,和减少资源浪费。
来源:juejin.cn/post/7525375329105674303
Vue 3 中的 Watch、WatchEffect 和 Computed:深入解析与案例分析
引言
在前端开发中,尤其是使用 Vue.js 进行开发时,我们经常需要监听数据的变化以执行相应的操作。Vue 3 提供了三种主要的方法来实现这一目标:watch
、watchEffect
和 computed
。虽然它们都能帮助我们监听数据变化,但各自的适用场景和工作原理有所不同。本文将详细探讨这三者的区别,并通过具体的案例进行说明。
一、Computed 属性
1.1 定义与用途
computed
是 Vue 中用于定义计算属性的方法。它允许你基于其他响应式数据创建一个新的响应式数据。这个新数据会根据其依赖的数据自动更新。
生活中的类比:
想象一下你在超市里购买商品,每个商品都有一个价格标签。当你想要知道购物车里所有商品的总价时,你可以手动计算每件商品的价格乘以其数量,然后加起来得到总价。但是如果你使用了一个智能购物车,它能够自动为你计算总价(只要你知道单价和数量),这就是 computed
的作用——它能帮你自动计算并实时更新结果。
1.2 使用示例
import { ref, computed } from 'vue';
const price = ref(10);
const quantity = ref(5);
const totalPrice = computed(() => {
return price.value * quantity.value;
});
console.log(totalPrice.value); // 输出: 50
// 修改其中一个变量
price.value = 15;
console.log(totalPrice.value); // 输出: 75 自动更新
二、Watch 监听器
2.1 定义与用途
watch
允许你监听特定的数据源(如响应式引用或 getter 函数的结果),并在数据发生变化时执行回调函数。它可以监听单个源或多个源。
生活中的类比:
假设你现在正在做菜,你需要监控锅里的水是否沸腾。一旦水开始沸腾,你就知道是时候下饺子了。这里,“水是否沸腾”就是你要监听的数据源,而“下饺子”的动作则是监听到变化后执行的操作。
2.2 使用示例
import { ref, watch } from 'vue';
let waterBoiling = ref(false);
watch(waterBoiling, (newValue, oldValue) => {
if (newValue === true) {
console.log('Water is boiling, time to add the dumplings!');
}
});
waterBoiling.value = true; // 触发监听器
2.3 监听多个来源
有时候我们需要同时监听多个数据源的变化:
watch([sourceA, sourceB], ([newSourceA, newSourceB], [oldSourceA, oldSourceB]) => {
// 处理逻辑
});
三、WatchEffect 响应式效果
3.1 定义与用途
watchEffect
立即运行传入的函数,并响应式地追踪其内部使用的任何 reactive 数据。当这些数据更新时,该函数将再次执行。
生活中的类比:
想象你在厨房里准备晚餐,你需要时刻关注炉子上的火候以及烤箱里的温度。每当任何一个参数发生变化,你都需要相应地调整你的烹饪策略。在这里,watchEffect
就像一个智能助手,它会自动检测这些条件的变化,并即时调整你的行为。
3.2 使用示例
import { ref, watchEffect } from 'vue';
const temperature = ref(180);
const ovenStatus = ref('off');
watchEffect(() => {
console.log(`Oven status is ${ovenStatus.value}, current temperature is ${temperature.value}`);
});
temperature.value = 200; // 自动触发重新执行
ovenStatus.value = 'on'; // 同样会触发重新执行
四、三者之间的对比
特性 | Computed | Watch | WatchEffect |
---|---|---|---|
初始执行 | 只有当访问时才会执行 | 立即执行一次 | 立即执行一次 |
依赖追踪 | 自动追踪依赖 | 需要明确指定依赖 | 自动追踪依赖 |
更新时机 | 当依赖改变时自动更新 | 当指定的值改变时 | 当依赖改变时自动更新 |
返回值 | 可以返回值 | 不直接返回值 | 不直接返回值 |
五、面试题
问题 1:请简述 computed
和 watch
的主要区别?
答案:
computed
更适合用于需要根据其他状态派生出的新状态,并且这种派生关系是确定性的。watch
更适用于监听某个状态的变化,并在变化发生时执行异步操作或昂贵的计算任务。
问题 2:在什么情况下你会选择使用 watchEffect
而不是 watch
?
答案:
当你希望立即执行一个副作用并且自动追踪所有被用到的状态作为依赖项时,watchEffect
是更好的选择。它简化了代码结构,因为你不需要显式声明哪些状态是你关心的。
问题 3:如何使用 watch
来监听多个状态的变化?
答案:
可以通过数组的形式传递给 watch
,这样就可以同时监听多个状态的变化,并在任一状态发生变化时触发回调函数。
通过以上内容,我们对 watch
、watchEffect
和 computed
在 Vue 3 中的应用有了较为全面的理解。理解这些工具的不同之处有助于我们在实际项目中做出更合适的选择。无论是构建简单的用户界面还是处理复杂的业务逻辑,正确运用这些功能都可以显著提高我们的开发效率。
来源:juejin.cn/post/7525375329105035327
手写一个 UML 绘图软件
为何想做一款软件
在日常的开发和学习过程中,我们常常致力于实现各种功能点,解决各种 Bug。然而,我们很少有机会去设计和制作属于自己的产品。有时,我们可能认为市面上已有众多类似产品,自己再做一款似乎没有必要;有时,我们又觉得要做的事情太多,不知从何下手。
最近,我意识到仅仅解决单点问题已没有那么吸引我。相反,如果我自己开发一款产品,它能够被其他人使用,这将是一件有意思的事情。
因此,我决定在新的一年里,根据自己熟悉的领域和过去一年的积累,尝试打造一款自己的 UML 桌面端软件。我想知道,自己是否真的能够创造出一款在日常工作中好用的工具。
目前,这个计划中的产品的许多功能点已经在开发计划中。我已经完成了最基础的技术架构,并实现了核心的绘图功能。接下来,让我们一探究竟,看看这款软件目前支持哪些功能点。
技术方案
Monorepo 项目结构
使用了 Monorepo(单一代码仓库)项目管理模式。
- 这样可以将通用类型和工具方法抽离在 types 包和 utils 包中。
- 像 graph 这样功能独立的模块也可以单独抽离成包发布。
- 通过集中管理依赖,可以更容易地确保所有项目使用相同版本的库,并且相同版本的依赖库可以只安装一次。
项目介绍:
- 其中 draw-client 是 electron 客户端项目,它依赖自定义的 graph 库。
- services 是服务端代码,和 draw-client 同时依赖了,types 和 utils 公共模块。
|-- apps/ # 包含所有应用程序的代码,每个应用程序可以有自己的目录,如draw-client。
|-- draw-client/ # 客户端应用程序的主目录
|-- src
|-- package.json
|-- tsconfig.json
|-- packages/ # 用于存放项目中的多个包或模块,每个包可以独立开发和测试,便于代码复用和维护。
|-- graph/ # 包含与图表绘制相关的逻辑和组件,可能是一个通用的图表库。
|-- src
|-- package.json
|-- tsconfig.json
|-- types/ # 存放TypeScript类型定义文件,为项目提供类型安全。
|-- src
|-- package.json
|-- tsconfig.json
|-- utils/ # 包含工具函数和辅助代码,这些是项目中通用的功能,可以在多个地方复用。
|-- src
|-- package.json
|-- tsconfig.json
|-- services/ # 服务端代码
|-- src
|-- package.json
|-- tsconfig.json
|-- .npmrc
|-- package.json # 项目的配置文件,包含项目的元数据、依赖项、脚本等,是npm和pnpm管理项目的核心。
|-- pnpm-lock.yaml # pnpm的锁定文件,确保所有开发者和构建环境中依赖的版本一致。
|-- pnpm-workspace.yaml # 定义pnpm工作区的结构,允许在同一个仓库中管理多个包的依赖关系。
|-- REDEME.md
|-- tsconfig.base.json
|-- tsconfig.json
|-- tsconfig.tsbuildinfo
技术栈相关
涉及的技术架构图如下:
- draw-client 相关技术栈
- services 相关技术栈
软件操作流程说明
为了深入理解软件开发的流程,我们将通过两个具体的案例来阐述图形的创建、展示以及动态变化的过程。
创建图形流程
在本节中,我们将详细介绍如何使用我们的图形库来创建图形元素。通过序列图可以了解到一个最简单的图形创建的完整流程如下:
撤销操作
在软件开发中,撤销操作是一个常见的需求,它允许用户撤销之前的某些操作,恢复到之前的状态。本节将探讨如何在图形编辑器中实现撤销功能。
当我们想要回退某一个步骤时,流程如下:
规划
目前软件开发还是处于一个初步的阶段,还有很多有趣的功能需要开发。并且软件开发需要大量的时间,我会逐步去开发相关的功能。这不仅是一个技术实现的过程,更是一个不断学习和成长的过程。我计划在以下几个关键领域深入挖掘:
- NestJS服务端:我将深入研究NestJS框架,利用其强大的模块化和依赖注入特性,构建一个高效、可扩展的服务端架构。我希望通过实践,掌握NestJS的高级特性。
- Electron应用开发:利用Electron框架,将Web应用与桌面应用的优势结合起来。
- SVG图形处理:深入SVG的,我将开发相关库,使得用户能够轻松地在应用中绘制和编辑图形。
当然我会也在开发的过程中记录分享到掘金社区,如果有人想要体验和参与的也是非常欢迎!如果对你有帮助感谢点赞关注,可以私信我一起讨论下独立开发相关的话题。
来源:juejin.cn/post/7455151799030317093
深入理解 Java 中的信号机制
观察者模式的困境
在Java中实现观察者模式通常需要手动管理监听器注册、事件分发等逻辑,这会带来以下问题:
- 代码侵入性高:需要修改被观察对象的代码(如添加
addListener()
方法) - 紧耦合:监听器与被观察对象高度耦合,难以复用
- 类型安全缺失:传统
Observable
只能传递Object
类型参数,需强制类型转换 - 事件解耦困难:难以区分触发事件的具体属性变化
下面,我们用一个待办事项的例子说明这个问题。同时利用信号机制的方法改写传统方式,进行对比。
示例:待办事项应用
我们以经典的待办事项应用为例,需要监听以下事件:
- 当单个Todo项发生以下变化时:
- 标题变更
- 完成状态切换
- 当TodoList发生以下变化时:
- 新增条目
- 删除条目
传统实现方案
1. 基础监听器模式
// 监听器接口
public interface Listener {
void onTitleChanged(Todo todo);
void onCompletionChanged(Todo todo);
void onItemAdded(Todo entity, Collection<Todo> todos);
void onItemRemoved(Todo entity, Collection<Todo> todos);
}
// 具体实现
public class ConsoleListener implements Listener {
@Override
public void onTitleChanged(Todo todo) {
System.out.printf("任务标题变更为: %s%n", todo.getTitle());
}
// 其他事件处理...
}
// 被观察对象(侵入式改造)
public class TodosList {
private final List<Listener> listeners = new ArrayList<>();
public void addListener(Listener listener) {
listeners.add(listener);
}
public void removeListener(Listener listener) {
listeners.remove(listener);
}
public Todo add(String title) {
Todo todo = new Todo(UUID.randomUUID(), title, false);
listeners.forEach(l -> l.onItemAdded(todo, todos));
return todo;
}
// 其他操作方法...
}
2. Java 内置的 Observable(已弃用)
// 被观察的Todo类
@Getter @AllArgsConstructor
public class Todo extends Observable {
private UUID id;
@Setter private String title;
@Setter private boolean completed;
public void setTitle(String title) {
this.title = title;
setChanged();
notifyObservers(this); // 通知所有观察者
}
// 其他setter同理...
}
// 观察者实现
public class BasicObserver implements Observer {
@Override
public void update(Observable o, Object arg) {
if (o instanceof Todo todo) {
System.out.println("[Observer] 收到Todo更新事件: " + todo);
}
}
}
信号机制(Signals)解决方案
核心思想:将属性变化抽象为可观察的信号(Signal),通过声明式编程实现事件监听
1. 信号基础用法
// 信号定义(使用第三方库com.akilisha.oss:signals)
public class Todo {
private final Signal<String> title = Signals.signal("");
private final Signal<Boolean> completed = Signals.signal(false);
public void setTitle(String newTitle) {
title.set(newTitle); // 自动触发订阅的副作用
}
public void observeTitleChanges(Consumer<String> effect) {
Signals.observe(title, effect); // 注册副作用
}
}
2. 待办事项列表实现
public class TodosList {
private final SignalCollection<Todo> todos = Signals.signal(new ArrayList<>());
public Todo add(String title) {
Todo todo = Todo.from(title);
todos.add(todo); // 自动触发集合变更事件
// 声明式监听集合变化
Signals.observe(todos, (event, entity) -> {
switch (event) {
case "add":
System.out.printf("新增任务: %s%n", entity);
break;
case "remove":
System.out.printf("删除任务: %s%n", entity);
break;
}
});
return todo;
}
}
3. 效果注册与取消
public class Main {
public static void main(String[] args) {
TodosList list = new TodosList();
// 注册副作用(自动绑定到Todo属性)
list.add("学习Signals")
.observeTitleChanges(title ->
System.out.printf("任务标题变更为: %s%n", title)
);
list.add("实践Signals")
.observeCompletionChanges(completed ->
System.out.printf("任务完成状态: %s%n", completed)
);
// 触发事件
list.todos.get(0).getTitle().set("深入学习Signals");
}
}
技术对比
特性 | 传统监听器模式 | Java Observable | Signals机制 |
---|---|---|---|
类型安全 | ❌ 需强制转换 | ❌ Object类型 | ✅ 泛型类型安全 |
事件解耦 | ❌ 难以区分属性变化 | ❌ 无法区分属性 | ✅ 明确属性变更事件 |
内存泄漏风险 | ⚠️ 需手动移除监听器 | ⚠️ 需手动移除观察者 | ✅ 自动取消订阅 |
代码侵入性 | ❌ 需修改被观察对象 | ❌ 需继承Observable | ✅ 零侵入 |
生态支持 | ✅ 成熟框架 | ❌ 已弃用 | ⚠️ 第三方库 |
关键优势
- 声明式编程:通过
.observe()
方法直接声明副作用逻辑 - 精确事件解耦:可区分
add
/remove
/update
等具体操作 - 组合式API:支持多信号组合(如
Signals.combineLatest()
) - 类型安全:编译期检查事件类型匹配
使用建议
- 新项目推荐:优先考虑使用Signals机制
- 遗留系统改造:可通过适配器模式逐步替换传统监听器
- 复杂场景:结合RxJava等响应式流框架实现高级功能
通过这种现代化的事件处理方式,可以显著提升代码的可维护性和可测试性,特别适合需要精细控制状态变化的复杂业务场景。
来源:juejin.cn/post/7512657698408988713
React-native中高亮文本实现方案
前言
React-native中高亮文本实现方案,rn中文本高亮并不像h5那样,匹配正则,直接添加标签实现,rn中一般是循环实现了。一般是一段文本,拆分出关键词,然后关键词高亮。
简单实现
const markKeywords = (text, highlight) => {
if (!text || !highlight) return { value: [text], highlight: [] }
for (let index = 0; index < highlight.length; index++) {
const reg = new RegExp(highlight[index], 'g');
text = text.replace(reg, `**${highlight[index]}**`)
}
return {
markKeywordList: text.split('**').filter(item => item),
hightList: highlight.map(item => item)
}
}
上面可以拆分出可以循环的文本,和要高亮的文本。
特殊情况
const title = 'haorooms前端博文章高亮测试一下'
const highLightWords = ['前端博文', '文章高亮']
因为打上星号标记的原因,文章高亮 在被标记成 前端博文 章高亮 后,并不能被 文章高亮 匹配,而且即使能匹配也不能把 前端博文章高亮 拆成 前端博文 、文章高亮,如果能拆成 前端博文章高亮 就好了。
function sort(letter, substr) {
letter = letter.toLocaleUpperCase()
substr = substr.toLocaleUpperCase()
var pos = letter.indexOf(substr)
var positions = []
while(pos > -1) {
positions.push(pos)
pos = letter.indexOf(substr, pos + 1)
}
return positions.map(item => ([item, item + substr.length]))
}
// 高亮词第一次遍历索引
function format (text, hight) {
var arr = []
// hight.push(hight.reduce((prev, curr) => prev+curr), '')
hight.forEach((item, index) => {
arr.push(sort(text, item))
})
return arr.reduce((acc, val) => acc.concat(val), []);
}
// 合并索引区间
var merge = function(intervals) {
const n = intervals.length;
if (n <= 1) {
return intervals;
}
intervals.sort((a, b) => a[0] - b[0]);
let refs = [];
refs.unshift([intervals[0][0], intervals[0][1]]);
for (let i = 1; i < n; i++) {
let ref = refs[0];
if (intervals[i][0] < ref[1]) {
ref[1] = Math.max(ref[1], intervals[i][1]);
} else {
refs.unshift([intervals[i][0], intervals[i][1]]);
}
}
return refs.sort((a,b) => a[0] - b[0]);
}
function getHightLightWord (text, hight) {
var bj = merge(format(text, hight))
const c = text.split('')
var bjindex = 0
try {
bj.forEach((item, index) => {
item.forEach((_item, _index) => {
c.splice(_item + bjindex, 0, '**')
bjindex+=1
})
})
} catch (error) {
}
return c.join('').split('**')
}
export const markKeywords = (text, keyword) => {
if (!text || !keyword || keyword.length === 0 ) {
return { value: [text], keyword: [] }
}
if (Array.isArray(keyword)) {
keyword = keyword.filter(item => item)
}
let obj = { value: [text], keyword };
obj = {
value: getHightLightWord(text, keyword).filter((item) => item),
keyword: keyword.map((item) => item),
};
return obj;
};
述方法中我们先使用了下标匹配的方式,得到一个下标值的映射,然后通过区间合并的方式把连着的词做合并处理,最后再用合并后的下标值映射去打 ** 标记即可。
简单组件封装
function TextHighLight(props) {
const { title = '', highLightWords = [] } = props
const { numberOfLines, ellipsizeMode } = props
const { style } = props
const { markKeywordList, hightList } = markKeywords(title, highLightWords)
return <Text
numberOfLines={numberOfLines}
ellipsizeMode={ellipsizeMode}
style={style}
>
{
markKeywordList ?
markKeywordList.map((item,index) => (
(hightList && hightList.some(i => (i.toLocaleUpperCase().includes(item) || i.toLowerCase().includes(item))))
? <Text key={index} style={{ color: '#FF6300' }}>{item}</Text>
: item
))
: null
}
</Text>
}
来源:juejin.cn/post/7449373647233941541