老板:咱们公司的设备开机,怎么显示Android。这怎么行,把Android替换掉,显示公司的logo,最好加点牛逼的动画.。
老板: 咱们公司的设备开机,怎么显示Android。这怎么行,把Android替换掉,显示公司的logo,最好加点牛逼的动画.
小卡拉米: 好的,老板
小卡拉米 to UI: 老板说要一个牛逼的动画。
UI: 我*&……%%¥……&&*………………%
1、UI设计帧动画
跟UI沟通,最好说明,老板的意思,怎么牛逼。最重要的是:
- 把动画输出成序列帧。
- 分辨率和板子的分辨率一致。
- 命名规则:00.jpg 01.jpg ...99.jpg
2、制作动画包
新建一个文件夹,名字为:bootanimation
新建子文件夹第一部分动画:命名为part1,将UI设计好的非常牛逼的动画,放进去
新建子文件夹第二部分定格动画:命名为part2 ,将帧动画的最后一帧放到这里边
新建一个desc.txt
desc.txt的内容:
1024 768 60
p 1 0 part1
p 0 10 part2
全局参数(第一行)
1024
屏幕的 宽度(Width),单位为像素(px)。表示动画的分辨率宽度为 1024px。768
屏幕的 高度(Height),单位为像素(px)。表示动画的分辨率高度为 768px。60
动画的 帧率(FPS, Frames Per Second)。表示每秒播放 60 帧,但实际帧率受硬件和系统限制(可能无法真正达到 60 FPS)。
分段参数(后续行)
每行定义一个动画片段,格式为:
[类型] [循环次数] [间隔时间] [目录名]
第一片段:p 1 0 generic1
p
播放类型(Play Mode):
p
表示正常播放(逐帧播放后停止)。- 另一种类型是
c
(hold last frame),表示播放完成后停留在最后一帧。
1
循环次数(Loop Count):
1
表示该片段播放 1 次。0
表示无限循环(直到开机完成或被中断)。
0
间隔时间(Delay):
- 单位为帧数(基于全局帧率)。
0
表示片段播放完成后 无额外延迟,直接进入下一片段。
- 单位为帧数(基于全局帧率)。
generic1
动画资源目录名:
- 对应
part0
、part1
等目录,存放该片段的 PNG 图片(按序号命名,如img000.png
)。
- 对应
第二片段:p 0 10 generic2
p
正常播放模式。0
无限循环,直到开机流程结束或被系统终止。10
间隔时间为 10 帧(若全局帧率为 60 FPS,则实际延迟为10/60 ≈ 0.167 秒
)。generic2
第二个动画资源目录名。
- 系统会先播放
generic1
目录中的动画,播放 1 次,无延迟。 - 接着播放
generic2
目录中的动画,无限循环,每次循环结束后等待 10 帧的时间。 - 开机动画会持续播放,直到系统启动完成(或强制终止)。
压缩动画包(很重要的一步)
- 压缩的时候,不要在外边一层去压缩,要点进去bootanimation文件夹,全选压缩。
- 压缩的时候格式要选择成存储。360压缩,自定义里边有。
3、替换动画包
使用adb命令进行替换,替换之前设备要进行root,一般开发板都是 root过的,然后使用命令进行替换
默认情况下,Android 设备的 /system 分区是只读的(出于系统安全性考虑)。adb remount 会临时将其重新挂载为可读写(rw),允许你修改系统文件(如删除预装应用、替换系统文件等)。
adb root // 获取root 权限
adb remount //重新挂载为可读写
adb push 你的动画包路径 system/media
动画包的名称一般是bootanimation.zip,有些厂商的可能不一样,如果不生效可以咨询板子厂家进行修改
替换成功之后,直接运行 adb reboot 进行重启,见证奇迹。
4、Android动画前的小企鹅(Linux开机动画)
我真服了,板子出厂前,竟然还有Linux开机动画,这个关掉,需要更改内核启动参数。下边是修改方法,但是建议联系厂家,建议联系厂家,建议联系厂家
4.1. 确认 Logo 类型
- Linux 内核 Logo:企鹅 Logo 通常是内核编译时内置的,文件格式可能是
.ppm
、.rle
或.png
。 - Bootloader Logo:某些设备的厂商会在 Bootloader 阶段显示自定义 Logo(如高通设备的
splash.img
)。
需要先确定企鹅 Logo 的来源:
- 如果设备启动时先显示企鹅,再显示 Android 开机动画(
bootanimation.zip
),则属于 内核 Logo。
4.2. 替换 Linux 内核 Logo
步骤 1:获取内核源码
- 需要设备的 内核源代码(从厂商或开源社区获取,如 LineageOS、XDA 论坛等)。
- 如果厂商未公开源码,此方法不可行。
步骤 2:准备自定义 Logo
- 内核支持的格式通常为 PPM(Portable PixMap),尺寸需与屏幕分辨率匹配(如 1024x768)。
- 使用工具(如 GIMP、ffmpeg)将图片转换为
.ppm
格式,并保存为logo_linux_clut224.ppm
(文件名可能因内核版本而异)。
步骤 3:替换内核 Logo 文件
- 将自定义的
.ppm
文件替换内核源码目录中的对应文件:
# 示例路径(不同内核可能不同)
cp custom_logo.ppm drivers/video/logo/logo_linux_clut224.ppm
步骤 4:编译内核
- 配置内核编译选项,确保启用 Logo 显示:
make menuconfig
# 进入 Device Drivers -> Graphics support -> Bootup logo
# 启用 "Standard 224-color Linux logo"
- 编译内核并生成
boot.img
:
make -j$(nproc)
步骤 5:刷入新内核
- 使用
fastboot
刷入编译后的boot.img
:
fastboot flash boot boot.img
4.3. 替换 Bootloader Logo(高通设备示例)
如果企鹅 Logo 是 Bootloader 阶段的 Splash Screen(如高通设备):
步骤 1:提取当前 Splash Image
- 从设备中提取
splash.img
:
adb pull /dev/block/bootdevice/by-name/splash splash.img
步骤 2:修改 Splash Image
- 使用工具(如 splash_screen_tool)解包
splash.img
,替换其中的图片,再重新打包。
步骤 3:刷入新 Splash Image
- 通过
fastboot
刷入:
fastboot flash splash splash.img
4. 隐藏企鹅 Logo(无需替换)
如果无法修改内核或 Bootloader,可以尝试以下方法:
- 修改内核启动参数:在内核命令行中添加
logo.nologo
参数(需解锁 Bootloader 并修改boot.img
的cmdline
)。 - 禁用 Framebuffer:在内核配置中关闭
CONFIG_LOGO
选项(需重新编译内核)。
注意事项
- 风险提示:建议联系厂家进行修改
- 修改内核或 Bootloader 可能导致设备无法启动(变砖)。
- 需要解锁 Bootloader(会清除设备数据并失去保修)。
- 兼容性:
- 不同设备的 Logo 实现方式差异较大,需查阅设备的具体文档。
- 备份:
- 操作前备份重要数据,并保留原版
boot.img
或splash.img
。
- 操作前备份重要数据,并保留原版
来源:juejin.cn/post/7508646757884690468
写了个脚本,发现CSDN的前端居然如此不严谨
引言
最近在折腾油猴脚本开发,顺手搞了一个可以拦截任意网页接口的小工具,并修改了CSDN的博客数据接口,成功的将文章总数和展现量进行了修改。
如果你不了解什是油猴,参考这篇文章:juejin.cn/book/751468…
然后我突然灵光一闪:
既然能拦截接口、篡改数据,那我为什么不顺便测试一下 CSDN 博客在极端数据下的表现呢?
毕竟我们平时开发的时候,测试同学各种花式挑刺,什么 null、undefined、999999、-1、空数组
……
每次都能把页面测出一堆边角 bug。
今天,轮到我来当一回“灵魂测试”了!
用我的脚本,造几个极限场景,看看 CSDN 的前端到底稳不稳!
实现原理
其实原理并不复杂,核心就一句话:
借助油猴的脚本注入能力 + ajax-hook 对接口请求进行拦截和修改。
我们知道,大部分网页的数据接口都是通过 XMLHttpRequest
或 fetch
发起的,而 ajax-hook
就是一个开源的轻量工具,它能帮我们劫持原生的 XMLHttpRequest
,在请求发出前、响应返回后进行自定义处理。
配合油猴脚本的注入机制,我们就可以实现在浏览器端伪造任意接口数据,用来调试前端样式、模拟数据异常、测试权限控制逻辑等等。
ajax-hook 快速上手
我们用的是 CDN 方式直接引入,简单暴力:
<script src="https://unpkg.com/ajax-hook@3.0.3/dist/ajaxhook.min.js"></script>
引入后,页面上会多出一个全局对象 ah
,我们只需要调用 ah.proxy()
,就可以注册一套钩子:
ah.proxy({
onRequest: (config, handler) => {
// 请求发出前
handler.next(config);
},
onError: (err, handler) => {
// 请求出错时
handler.next(err);
},
onResponse: (response, handler) => {
// 请求成功响应后
console.log("响应内容:", response.response);
handler.next(response);
}
});
拦截实现
我们以 CSDN 博客后台为例,先找到博客数据接口
地址长这样:bizapi.csdn.net/blog/phoeni…
我们在 onResponse
钩子中,加入 URL 判断,专门拦截这个接口:
onResponse: (response, handler) => {
if (response.config.url.includes("https://bizapi.csdn.net/blog/phoenix/console/v1/data/blog-statistics")) {
const hookResponse = JSON.parse(response.response);
console.log("拦截到的数据:", hookResponse);
handler.next(response);
} else {
handler.next(response);
}
}
就这样,接口拦截器初步搭建完成!
使用油猴将脚本运行在网页
接下来我们用油猴把这段脚本注入到 CSDN 博客后台页面。
// ==UserScript==
// @name CSDN博客数据接口拦截
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 拦截接口数据,验证极端情况下的样式展示
// @author 石小石Orz
// @match https://mpbeta.csdn.net/*
// @require https://unpkg.com/ajax-hook@3.0.3/dist/ajaxhook.min.js
// @run-at document-start
// @grant none
// ==/UserScript==
(function() {
'use strict';
ah.proxy({
onRequest: (config, handler) => {
handler.next(config);
},
onError: (err, handler) => {
handler.next(err);
},
onResponse: (response, handler) => {
console.log("接口响应列表:", response);
// 这里写拦截逻辑
handler.next(response);
}
});
})();
为了测试前端的容错能力,我们可以伪造一些极端数据返回:
- 文章总数设为
null
- 展现量设为
0
- 点赞数设为一个异常大的值
onResponse: (response, handler) => {
if (response.config.url.includes("https://bizapi.csdn.net/blog/phoenix/console/v1/data/blog-statistics")) {
const hookResponse = JSON.parse(response.response);
// 伪造数据
hookResponse.data[0].num = null; // 文章总数
hookResponse.data[1].num = 0; // 展现量
hookResponse.data[2].num = 99999999999999999; // 点赞数
console.log("修改后的数据:", hookResponse);
response.response = JSON.stringify(hookResponse);
}
handler.next(response);
}
结果验证
修改成功后刷新页面,可以观察到如下问题:
- 文章总数为
null
时,布局异常,显然缺乏空值判断。 - 点赞数为超大值 时,页面直接渲染出
100000000000000000
,不仅视觉上溢出容器,连排版都崩了,前端没有做任何兼容处理。
CSDN的前端还是偷懒了呀,一点也不严谨!差评!
总结
通过这篇文章的示例,我们前端应该引以为戒,永远不要相信后端同学返回的数据,一定要做好容错处理!
通过本文,相信大家也明白了油猴脚本不仅是玩具,它在前端开发中其实是个非常实用的辅助工具!
如果你对油猴脚本的开发感兴趣,不妨看看我写的这篇教程 《油猴脚本实战指南》
从小脚本写起,说不定哪天你也能靠一个脚本搞出点惊喜来!
来源:juejin.cn/post/7519005878566748186
用 iframe 实现前端批量下载的优雅方案 —— 从原理到实战
传统的下载方式如window.open()或标签点击存在诸多痛点:
- 批量下载时浏览器会疯狂弹窗
- HTTPS页面下载HTTP资源被拦截
今天分享的前端iframe批量下载方案,可以有效解决以上问题。
一、传统批量下载方案的局限性
传统的批量下载方式通常是循环创建 a 标签并触发点击事件:
urls.forEach(url => {
const link = document.createElement('a');
link.href = url;
link.download = 'filename';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
这种方式存在以下问题:
- 浏览器会限制连续的自动点击行为
- 用户体验不佳,会弹出多个下载对话框
二、iframe 批量下载解析
更优雅的解决方案是使用 iframe 技术,通过动态创建和移除 iframe 元素来触发下载:
downloadFileBatch(url) {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.style.height = '0';
iframe.src = this.urlProtocolDelete(url);
document.body.appendChild(iframe);
setTimeout(() => {
iframe.remove();
}, 5000);
}
urlProtocolDelete(url: string = '') {
if (!url) {
return;
}
return url.replace('http://', '//').replace('https://', '//');
}
这种方案的优势在于:
- 不依赖用户交互,可自动触发下载
- 隐藏 iframe 不会影响页面布局,每个iframe独立运行,互不干扰
- 主线程保持流畅
三、核心代码实现解析
让我们详细分析一下这段代码的工作原理:
- 动态创建 iframe 元素:
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.style.height = '0';
通过创建一个不可见的 iframe 元素,我们可以在不影响用户界面的情况下触发下载请求。
- 协议处理函数:
urlProtocolDelete(url: string = '') {
return url.replace('http://', '//').replace('https://', '//');
}
这个函数将 URL 中的协议部分替换为//,这样可以确保在 HTTPS 页面中请求 HTTP 资源时不会出现混合内容警告。
- 触发下载并清理 DOM:
iframe.src = this.urlProtocolDelete(url);
document.body.appendChild(iframe);
setTimeout(() => {
iframe.remove();
}, 5000);
将 iframe 添加到 DOM 中会触发浏览器对 src 的请求,从而开始下载文件。设置 5 秒的超时时间后移除 iframe,既保证了下载有足够的时间完成,又避免了 DOM 中积累过多无用元素。
四、批量下载的实现与优化
对于多个文件的批量下载,可以通过循环调用 downloadFileBatch 方法:
result.forEach(item => {
this.downloadFileBatch(item.url);
});
五、踩坑+注意点
在实现批量下载 XML 文件功能时,你可能会遇到这种情况:明明请求的 URL 地址无误,服务器也返回了正确的数据,但文件却没有被下载到本地,而是直接在浏览器中打开预览了。这是因为 XML 作为一种可读的文本格式,浏览器默认会将其视为可直接展示的内容,而非需要下载保存的文件。
解决方案:
通过在下载链接中添加response-content-disposition=attachment
参数,可以强制浏览器将 XML 文件作为附件下载,而非直接预览。这个参数会覆盖浏览器的默认行为,明确告诉浏览器 "这是一个需要下载保存的文件"。
addDownloadDisposition(url: string, filename: string): string {
try {
const urlObj = new URL(url);
// 添加 response-content-disposition 参数
const disposition = `attachment;filename=${encodeURIComponent(filename)}`;
urlObj.searchParams.set('response-content-disposition', disposition);
return urlObj.toString();
} catch (error) {
console.error('URL处理失败:', error);
return url;
}
}
六、大量文件并发控制
有待补充
来源:juejin.cn/post/7524627104580534306
😡同事查日志太慢,我现场教他一套 grep 组合拳!
前言
最近公司来了个新同事,年轻有活力,就是查日志的方式让我有点裂开。
事情是这样的:他写的代码在测试环境报错了,报警信息也被钉钉机器人发到了我们群里。作为资深摸鱼战士,我寻思正好借机摸个鱼顺便指导一下新人,就凑过去看了眼。
结果越看我越急,差点当场喊出:“兄弟你是来写代码的,还是和日志谈恋爱的?”
来看看他是怎么查日志的
他先敲了一句:
tail -f a.log | grep "java.lang.NullPointerException"
想着等下次报错就能立刻看到。等了半天,终于蹦出来一行:
2025-07-03 11:38:48.339 [http-nio-8960-exec-1] [47gK4n32jEYvTYX8AYti48] [INFO] [GlobalExceptionHandler] java.lang.NullPointerException, ex: java.lang.NullPointerException
java.lang.NullPointerException: null
我提醒他:“这样看不到堆栈信息啊。”
他“哦”了一声,灵机一动,用 vi
把整个文件打开,/NullPointerException
搜关键词,一个 n
一个 n
地翻……半分钟过去了,异常在哪都没找全,我都快给他跪下了。
于是我当场掏出了一套我压箱底的“查日志组合拳”,一招一式手把手教他。他当场就“悟了”,连连称妙,并表示想让我写成文章好让他发给他前同事看——因为他前同事也是这样查的……
现在,这套组合拳我也分享给你,希望你下次查日志的时候,能让你旁边的同事开开眼。
正式教学
核心的工具其实还是 grep
命令,下面我将分场景给你讲讲我的实战经验,保证你能直接套用!
场景一:查异常堆栈,不能只看一行!
Java 异常堆栈通常都是多行的,仅仅用 grep "NullPointerException"
只能看到最上面那一行,问题根源在哪你压根找不到。
这时候使用 **grep**
的 **-A**
(After) 参数来显示匹配行之后的N行。
# 查找 NullPointerException,并显示后面 50 行
grep -A 50 "java.lang.NullPointerException" a.log
如果你发现异常太多,屏幕一闪而过,也可以用less
加上分页查看:
grep -A 50 "java.lang.NullPointerException" a.log | less
在 less
视图中,你可以:
- 使用 箭头↑↓ 或 Page Up/Down 键来上下滚动
- 输入
G
直接翻到末尾,方便快速查看最新的日志 - 输入
/Exception
继续搜索 - 按
q
键退出
这样你就能第一时间拿到完整异常上下文信息,告别反复 vi
+ /
的低效操作!
场景二:实时看新日志怎么打出来的
如果你的应用正在运行,并且你怀疑它会随时抛出异常,你可以实时监控日志文件的增长。
使用 tail -f
结合 grep
:
# 实时监控 a.log 文件的新增内容,并只显示包含 "java.lang.NullPointerException" 的行及其后50行
tail -f a.log | grep -A 50 "java.lang.NullPointerException"
只要异常一出现,它就会自动打出来,堆栈信息也一并送到你面前!
- 想停下?
Ctrl + C
- 想更准确?加
-i
忽略大小写,防止大小写拼错找不到
场景三:翻历史日志 or 查压缩日志
服务器上的日志一般都会按天或按大小分割并压缩,变成 .log.2025-07-02.gz
这种格式,查找这些文件的异常信息怎么办?
🔍 查找当前目录所有 .log
文件:
# 在当前目录下查找所有以 .log 结尾的文件,-H 参数可以顺便打印出文件名
grep -H -A 50 "java.lang.NullPointerException" *.log
其中 -H
会帮你打印出是哪个文件中出现的问题,防止你找完还不知道是哪天的事。
🔍 查找 .gz
文件(压缩日志):
zgrep -H -A 50 "java.lang.NullPointerException" *.gz
zgrep
是专门处理 .gz
的 grep
,它的功能和 grep
完全一样,无需手动解压,直接开整!
场景四:统计异常数量(快速判断异常是否频繁)
有时候你需要知道某个异常到底出现了多少次,是偶发还是成灾,使用 grep -c
(count):
grep -c "java.lang.NullPointerException" a.log
如果你要统计所有日志里的数量:
grep -c "java.lang.NullPointerException" *.log
其他常用的 grep 参数
参数 | 作用 |
---|---|
-B N | 匹配行之前的 N 行(Before) |
-A N | 匹配行之后的 N 行(After) |
-C N | 匹配行上下共 N 行(Context) |
-i | 忽略大小写 |
-H | 显示匹配的文件名 |
-r | 递归搜索目录下所有文件 |
比如:
grep -C 25 "java.lang.NullPointerException" a.log
这个命令就能让你一眼看到异常前后的上下文,帮助定位代码逻辑是不是哪里先出问题了。
尾声
好了,这套组合拳我已经传授给你了,要是别人问你在哪学的,记得报我杆师傅的大名(doge)。
其实还有其他查日志的工具,比如awk
、wc
等。
但是我留了一手,没有全部教给我这个同事,毕竟江湖规则,哪有一出手就把看家本领全都交出去的道理?
如果你也想学,先拜个师交个学费(点赞、收藏、关注),等学费凑够了,我下次再开新课,传授给大家~
来源:juejin.cn/post/7524216834619408430
为什么我不再相信 Tailwind?三个月重构项目教会我的事
Tailwind 曾经是我最爱的工具,直到它让我维护不下去整个项目。
前情提要:我是如何变成 Tailwind 重度用户的
作为一个多年写 CSS 的前端,我曾经深陷“命名地狱”:
什么 .container-title
, .btn-primary
, .form-item-error
,一个项目下来能写几百个类名,然后改样式时不知道该去哪动刀,甚至删个类都心慌。
直到我遇见了 Tailwind CSS——一切原子化,想改样式就加 class,别管名字叫什么,直接调属性即可。
于是我彻底拥抱它,团队项目里我把所有 SCSS 全部清除,组件中也只保留了 Tailwind class,一切都干净、轻便、高效。
但故事从这里开始转变。
三个月后的重构期,我被 Tailwind“反噬”
我们的后台管理系统迎来一次大版本升级,我负责重构 UI 样式逻辑,目标是:
- 统一设计规范;
- 提高代码可维护性;
- 降低多人协作时的样式冲突。
刚开始我信心满满,毕竟 Tailwind 提供了:
- 原子化 class;
@apply
合成组件级 class;- 配置主题色/字体/间距系统;
- 插件支持动画/form 控件/typography;
但随着项目深入,我开始发现 几个巨大的问题,并最终决定停用 Tailwind。
一、class 污染:结构和样式纠缠成灾
来看一个真实例子:
<div class="flex items-center justify-between bg-white p-4 rounded-lg shadow-sm border border-gray-200">
<h2 class="text-lg font-semibold text-gray-800">订单信息</h2>
<button class="text-sm px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">编辑</button>
</div>
你能看出这个组件的“设计意图”吗?
你能快速改它的样式吗?
一个看似简单的按钮,一眼看不到设计语言,只看到一坨 class,你根本不知道:
px-2 py-1
是从哪里决定的?bg-blue-500
是哪个品牌色?hover:bg-blue-600
是统一交互吗?
Tailwind 让样式变得快,但也让样式“变得不可读”。
二、复用失败:想复用样式还得靠 SCSS
我天真地以为 @apply
能帮我合成组件级样式,比如:
.btn-primary {
@apply text-white bg-blue-500 px-4 py-2 rounded;
}
但问题来了:
@apply
不能用在媒体查询内;@apply
不支持复杂嵌套、hover/focus 的组合;- 响应式、伪类写在 HTML 里更乱,如:
lg:hover:bg-blue-700
; - 没法动态拼接 class,逻辑和样式混在组件逻辑层了。
最终结果就是:复用失败、样式重复、维护困难。
三、设计规范无法沉淀
我们设计系统中定义了若干基础变量:
- 主色:
#0052D9
- 次色:
#A0AEC0
- 字体尺寸规范:
12/14/16/18/20/24/32px
- 组件间距:
8/16/24
本来我们希望 Tailwind 的 theme.extend
能承载这套设计系统,结果发现:
- tailwind.config.js 修改后,需要全员重启 dev server;
- 新增设计 token 非常繁琐,不如直接写 SCSS 变量;
- 多人改配置时容易冲突;
- 和设计稿同步代价高。
这让我明白:配置式设计系统不适合快速演进的产品团队。
四、多人协作混乱:Tailwind 并不直观
当我招了一位新同事,给他一个组件代码时,他的第一句话是:
“兄弟,这些 class 是从设计稿复制的吗?”
他根本看不懂 gap-6
, text-gray-700
, tracking-wide
分别是什么意思,只看到一堆“魔法 class” 。
更糟糕的是,每个人心中对 text-sm
、text-base
的视觉认知不同,导致多个组件在微调时出现样式不一致、间距不统一的问题。
Tailwind 的语义脱离了设计意图,协作就失去了基础。
最终决定:我切回了 SCSS + BEM + 设计 token
我们开始回归传统模式:
- 所有组件都有独立
.scss
文件; - 使用 BEM 命名规范:
.button
,.button--primary
,.button--disabled
; - 所有颜色/间距/字体等统一放在
_variables.scss
中; - 每个组件样式文件都注释设计规范来源。
这种模式虽然看起来“原始”,但它:
- 清晰分离结构和样式;
- 强制大家遵守设计规范;
- 组件样式可复用,可继承,可重写;
- 新人一眼看懂,不需要会 Tailwind 语法。
总结:Tailwind 不是错,是错用的代价太高
Tailwind 在以下场景表现极好:
- 个人项目 / 小程序:快速开发、无需复用;
- 组件库原型:试验颜色、排版效果;
- 纯前端工程师独立开发的项目:没有协作负担。
但在以下情况,Tailwind 会成为维护灾难:
- 多人协作;
- UI 不断迭代,设计语言需频繁调整;
- 有强复用需求(组件抽象);
- 与设计系统严格对齐的场景;
我为什么写这篇文章?
不是为了黑 Tailwind,而是为了让你在选择技术栈时更慎重。
就像当年我们争论 Sass vs Less
,今天的 Tailwind vs 原子/语义 CSS
并没有标准答案。
Tailwind 很强,但不是所有团队都适合。
也许你正在享受它的爽感,但三个月后你可能会像我一样,把所有 .w-full h-screen text-gray-800
替换成 .layout-container
。
尾声:如果你非要继续用 Tailwind,我建议你做这几件事
- 强制使用
@apply
形成组件级 class,不允许直接使用长串 class; - 抽离公共样式,写在一个统一的组件样式文件中;
- 和设计团队对齐 Tailwind 的 spacing/font/color;
- 用 tailwind.config.js 做好 token 映射和语义名设计;
- 每个页面都进行 CSS code review,不然很快就会变垃圾堆。
来源:juejin.cn/post/7511602231508664361
用了十年 Docker,我为什么决定换掉它?
一、Docker 不再万能,我们该何去何从?
过去十年,Docker 改变了整个软件开发世界。它以“一次构建,到处运行”的理念,架起了开发者和运维人员之间的桥梁,推动了 DevOps 与微服务架构的广泛落地。
从自动化部署、持续集成到快速交付,Docker 一度是不可或缺的技术基石。
然而到了 2025 年,越来越多开发者开始重新审视 Docker。
系统规模在不断膨胀,开发场景也更加多元,不再是当初以单一后端应用为主的架构。
如今,开发者面临的不只是如何部署一个服务,更要关注架构的可扩展性、容器的安全性、本地与云端的适配性,以及资源的最优利用。
在这种背景下,Docker 开始显得不再那么“全能”,它在部分场景下的臃肿、安全隐患和与 Kubernetes 的解耦问题,使得不少团队正在寻找更轻、更适合自身的替代方案。
之所以写下这篇文章就是为了帮助你认清 Docker 当前的局限,了解新的技术趋势,并发现适用于不同场景的下一代容器化工具。
二、Docker 的贡献与瓶颈
不可否认,Docker 曾是容器化革命的引擎。从过去到现在,它的最大价值在于降低了环境配置的复杂度,让开发与运维团队之间的协作更加顺畅,带动了整个容器生态的发展。
很多团队正是依赖 Docker 才实现了快速构建镜像、构建流水线、部署微服务的能力。
但与此同时,Docker 本身也逐渐显露出局限性。比如,它高度依赖守护进程,导致资源占用明显高于预期,启动速度也难以令人满意。
更关键的是,Docker 默认以 root 权限运行容器,极易放大潜在攻击面,在安全合规日益严格的今天,这一点令人担忧。Kubernetes 的官方运行时也已从 Docker 切换为 containerd 与 runc,表明行业主流已在悄然转向。
这并不意味着 Docker 已过时,它依旧在许多团队中扮演重要角色。但如果你期待更高的性能、更低的资源消耗和更强的安全隔离,那么,是时候拓宽视野了。
三、本地开发的难题与新解法
特别是在本地开发场景中,Docker 的“不够轻”问题尤为突出。为了启动一个简单的 PHP 或 Node 项目,很多人不得不拉起庞大的容器,等待镜像下载、构建,甚至调试端口映射,最终电脑风扇轰鸣,开发体验直线下降。
一些开发者试图回归传统,通过 Homebrew 或 apt 手动配置开发环境,但这又陷入了“版本冲突”“依赖错位”等老问题。
这时,ServBay 的出现带来了新的可能。作为专为本地开发设计的轻量级工具,ServBay 不依赖 Docker,也无需繁琐配置。用户只需一键启动,即可在本地运行 PHP、Python、Golang、Java 等多种语言环境,并能自由切换版本与服务组合。它不仅启动迅速,资源占用也极低,非常适合 WordPress、Laravel、ThinkPHP 等项目的本地调试与开发。
更重要的是,ServBay 不再强制开发者理解复杂的镜像构建与容器编排逻辑,而是将本地开发流程变得像打开编辑器一样自然。对于 Web 后端和全栈开发者来说,它提供了一种“摆脱 Docker”的全新路径。
四、当 Docker 不再是运行时的唯一选择
容器运行时的格局也在悄然生变。containerd 和 runc 成为了 Kubernetes 官方推荐的运行时,它们更轻、更专注,仅提供核心的容器管理功能,剥离了不必要的附加组件。与此同时,CRI-O 正在被越来越多团队采纳,它是专为 Kubernetes 打造的运行时,直接对接 CRI 接口,减少了依赖层级。
另一款备受好评的是 Podman,它的最大亮点在于支持 rootless 模式,使容器运行更加安全。同时,它的命令行几乎与 Docker 完全兼容,开发者几乎不需要重新学习。
对于安全隔离要求极高的场景,还可以选择 gVisor 或 Kata Containers。前者通过用户态内核方式拦截系统调用,构建沙箱化环境;后者则将轻量虚拟机与容器结合,兼顾性能与隔离性。这些方案正在逐步替代传统 Docker,成为新一代容器架构的基石。
五、容器编排:Kubernetes 之后的路在何方?
虽然 Kubernetes 仍然是企业级容器编排的标准选项,但它的复杂性和陡峭的学习曲线也让不少中小团队望而却步。一个简单的应用部署可能涉及上百行 YAML 文件,过度的抽象与组件拆分反而拉高了运维门槛。
这也促使“微型 Kubernetes”方案逐渐兴起。K3s 是其中的代表,它对 Kubernetes 进行了极大简化,专为边缘计算和资源受限场景优化。此外,像 KubeEdge 等项目,也在积极拓展容器编排在边缘设备上的适配能力。
与此同时,AI 驱动的编排平台正在探索新路径。CAST AI、Loft Labs 等团队推出的智能调度系统,可以自动分析工作负载并进行优化部署,最大化资源利用率。更进一步,Serverless 与容器的融合也逐渐成熟,比如 AWS Fargate、Google Cloud Run 等服务,让开发者无需再关心节点管理,容器真正变成了“即用即走”的计算单元。
六、未来趋势:容器走向“定制化生长”
未来的容器化,我们将看到更细化的技术选型:开发环境选择轻量灵活的本地容器,测试环境强调快速重建与自动化部署,生产环境则关注安全隔离与高可用性。
安全性也会成为核心关键词。rootless 容器、沙箱机制和系统调用过滤将成为主流实践,容器从“不可信”向“可信执行环境”演进。与此同时,人工智能将在容器调度中发挥更大作用,不仅提升弹性伸缩的效率,还可能引领“自愈系统”发展,让集群具备自我诊断与恢复能力。
容器标准如 OCI 的持续完善,将让不同运行时之间更加兼容,为整个生态的整合提供可能。而在部署端,我们也将看到容器由本地向云端、再向边缘设备的自然扩展,真正成为“无处不在的基础设施”。
七、结语:容器化的新纪元已经到来
Docker 的故事并没有结束,它依然是很多开发者最熟悉的工具,也在部分场景中继续发挥作用。但可以确定的是,它不再是唯一选择。2025 年的容器世界,早已迈入了多元化、场景化、智能化的阶段。从轻量级的 ServBay 到更安全的 Podman,从微型编排到 Serverless 混合模式,我们手中可选的工具越来越丰富,技术栈的自由度也空前提升。
下一个十年,容器不只是为了“装下服务”,它将成为构建现代基础设施的关键砖块。愿你也能在这场演进中,找到属于自己的工具组合,打造更轻、更快、更自由的开发与部署体验。
来源:juejin.cn/post/7521927128524210212
前端高手才知道的秘密:Blob 居然这么强大!
🔍 一、什么是 Blob?
Blob(Binary Large Object)是 HTML5 提供的一个用于表示不可变的、原始二进制数据块的对象。
✨ 特点:
- 不可变性:一旦创建,内容不能修改。
- 可封装任意类型的数据:字符串、ArrayBuffer、TypedArray 等。
- 支持 MIME 类型描述,方便浏览器识别用途。
💡 示例:
const blob = new Blob(['Hello World'], { type: 'text/plain' });
🧠 二、Base64 编码的前世今生
虽然名字听起来像是某种“64进制”,但实际上它是一种编码方式,不是数学意义上的“进制”。
📜 起源背景:
Base64 最早起源于电子邮件协议 MIME(Multipurpose Internet Mail Extensions),因为早期的电子邮件系统只能传输 ASCII 文本,不能直接传输二进制数据(如附件)。于是人们发明了 Base64 编码方法,把二进制数据转换成文本形式,从而安全地在网络上传输。
🧩 使用场景:
场景 | 说明 |
---|---|
图片嵌入到 HTML/CSS 中 | Data URI 方式减少请求 |
JSON 数据中传输二进制信息 | 如头像、加密数据等 |
WebSocket 发送二进制消息 | 避免使用 ArrayBuffer |
二维码生成 | 将图像转为 Base64 存储 |
⚠️ 注意:Base64 并非压缩算法,它会将数据体积增加约 33%。
🔁 三、从 Base64 到 Blob 的全过程
1. Base64 字符串解码:atob()
JavaScript 提供了一个内置函数 atob()
,可以将 Base64 字符串解码为原始的二进制字符串(ASCII 表示)。
const base64Data = 'SGVsbG8gd29ybGQh'; // "Hello world!"
const binaryString = atob(base64Data);
⚠️ 返回的是 ASCII 字符串,不是真正的字节数组。
2. 构建 Uint8Array(字节序列)
为了构造 Blob,我们需要一个真正的字节数组。我们可以用 charCodeAt()
将每个字符转为对应的 ASCII 数值(即 0~255 的整数)。
const byteArray = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
byteArray[i] = binaryString.charCodeAt(i);
}
现在,byteArray
是一个代表原始图片二进制数据的数组。
3. 创建 Blob 对象
有了字节数组,就可以创建 Blob 对象了:
const blob = new Blob([byteArray], { type: 'image/png' });
这个 Blob 对象就代表了一张 PNG 图片的二进制内容。
4. 使用 URL.createObjectURL() 显示图片
为了让浏览器能够加载这个 Blob 对象,我们需要生成一个临时的 URL 地址:
const imageUrl = URL.createObjectURL(blob);
document.getElementById('blobImage').src = imageUrl;
这样,你就可以在网页中看到这张图片啦!
🛠️ 四、Blob 的核心功能与应用场景
功能 | 说明 |
---|---|
分片上传 | .slice(start, end) 方法可用于大文件切片上传 |
本地预览 | Canvas.toBlob() 导出图像,配合 URL.createObjectURL 预览 |
文件下载 | 使用 a 标签 + createObjectURL 实现无刷新下载 |
缓存资源 | Service Worker 中缓存 Blob 数据提升性能 |
处理用户上传 | 结合 FileReader 和 File API 操作用户文件 |
🧪 五、Blob 的高级玩法
1. 文件切片上传(分片上传)
const chunkSize = 1024 * 1024; // 1MB
const firstChunk = blob.slice(0, chunkSize);
2. Blob 转换为其他格式
FileReader.readAsText(blob)
→ 文本FileReader.readAsDataURL(blob)
→ Base64FileReader.readAsArrayBuffer(blob)
→ Array Buffer
3. Blob 下载为文件
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'example.png';
a.click();
🧩 六、相关知识点汇总
技术点 | 作用 |
---|---|
Base64 | 将二进制数据编码为文本,便于传输 |
atob() | 解码 Base64 字符串,还原为二进制字符串 |
charCodeAt() | 获取字符的 ASCII 值(0~255) |
Uint8Array | 构建字节数组,表示原始二进制数据 |
Blob | 封装二进制数据,作为文件对象使用 |
URL.createObjectURL() | 生成临时地址,让浏览器加载 Blob 数据 |
🧾 七、完整代码回顾
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Blob 实战</title>
</head>
<body>
<img src="" id="blobImage" width="100" height="100" alt="Blob Image" />
<script>
const base64Data = 'UklGRiAHAABXRUJQVlA4IBQHAACwHACdASpQAFAAPok0lEelIyIhMziOYKARCWwAuzNaQpfW+apU37ZufB5rAHqW2z3mF/aX9o/ev9LP+j9KrqSOfp9mf+6WmE1P1yFc3gTlw8B8d/TebelHaI3mplPrZ+Aa0l5qDGv5N8Tt9vYhz3IH37wqm2al+FdcFQhDnObv2+WfpwIZ+K6eBPxKL2RP6hiC/K1ZynnvVYth9y+ozyf88Obh4TRYcv3nkkr43girwwJ54Gd0iKBPZFnZS+gd1vKqlfnPT5wAwzxJiSk+pkbtcOVP+QFb2uDqUhuhNiHJ8xPt6VfGBfUbTsUzYuKgAP4L9wrkT8KU4sqIHwM+ZeKDBpGq58k0aDirXeGc1Odhvfx+cpQaeas97zVTr2pOk5bZkI1lkF9jnc0j2+Ojm/H+uPmIhS7/BlxuYfgnUCMKVZJGf+iPM44vA0EwvXye0YkUUK...';
const binaryString = atob(base64Data); // Base64 解码
const byteArray = new Uint8Array(binaryString.length); // 创建 Uint8Array
for (let i = 0; i < binaryString.length; i++) {
byteArray[i] = binaryString.charCodeAt(i); // 填充字节数据
}
const blob = new Blob([byteArray], { type: 'image/png' }); // 创建 Blob
const imageUrl = URL.createObjectURL(blob); // 生成 URL
document.getElementById('blobImage').src = imageUrl; // 显示图片
</script>
</body>
</html>
📚 八、扩展阅读建议
🧩 九、结语
Blob 是连接 JavaScript 世界与真实二进制世界的桥梁,是每一个想要突破瓶颈的前端开发者必须掌握的核心技能之一。
掌握了 Blob,你就拥有了操作二进制数据的能力,这在现代 Web 开发中是非常关键的一环。
下次当你看到一张图片在网页中加载出来,或者一个文件被顺利下载时,不妨想想:这一切的背后,都有 Blob 的身影。
来源:juejin.cn/post/7523065182429904915
🫴为什么看大厂的源码,看不到undefined,看到的是void 0
void 0
是 JavaScript 中的一个表达式,它的作用是 返回 undefined
。
解释:
void
运算符:
- 它会执行后面的表达式(比如
void 0
),但不管表达式的结果是什么,void
始终返回undefined
。 - 例如:
console.log(void 0); // undefined
console.log(void (1 + 1)); // undefined
console.log(void "hello"); // undefined
- 它会执行后面的表达式(比如
- 为什么用
void 0
代替undefined
?
- 在早期的 JavaScript 中,
undefined
并不是一个保留字,它可以被重新赋值(比如undefined = 123
),这会导致代码出错。 void 0
是确保获取undefined
的安全方式,因为void
总是返回undefined
,且不能被覆盖。- 现代 JavaScript(ES5+)已经修复了这个问题,
undefined
现在是只读的,但void 0
仍然在一些旧代码或压缩工具中出现。
- 在早期的 JavaScript 中,
常见用途:
- 防止默认行为(比如
<a>
标签的href="javascript:void(0)"
):
<a href="javascript:void(0);" onclick="alert('Clicked!')">
点击不会跳转
</a>
这样点击链接时不会跳转页面,但仍然可以执行 JavaScript。
- 在函数中返回
undefined
:
function doSomething() {
return void someOperation(); // 明确返回 undefined
}
为什么用void 0
源码涉及到 undefined 表达都会被编译成 void 0
//源码
const a: number = 6
a === undefined
//编译后
"use strict";
var a = 6;
a === void 0;
也就是void 0 === undefined
。void 运算符通常只能用于获取 undefined 的原始值,一般用void(0),等同于void 0,也可以使用全局变量 undefined 替代。
为什么不直接写 undefined
undefined 是 js 原始类型值之一,也是全局对象window的属性,在一部分低级浏览器(IE7-IE8)中or局部作用域可以被修改。
undefined在js中,全局属性是允许被覆盖的。
//undefined是window的全局属性
console.log(window.hasOwnProperty('undefined'))
console.log(window.undefined)
//旧版IE
var undefined = '666'
console.log(undefined)//666 直接覆盖改写undefined
window.undefined在局部作用域中是可以被修改的 在ES5开始,undefined就已经被设定为仅可读的,但是在局部作用域内,undefined依然是可变的。
①某些情况下用undefined判断存在风险,因undefined有被修改的可能性,但是void 0返回值一定是undefined
②兼容性上void 0 基本所有的浏览器都支持
③ void 0比undefined字符所占空间少。
拓展
void(0) 表达式会返回 undefined 值,它一般用于防止页面的刷新,以消除不需要的副作用。
常见用法是在 <a> 标签上设置 href="javascript:void(0);",即当单击该链接时,此表达式将会阻止浏览器去加载新页面或刷新当前页面的行为。
<!-- 点击下面的链接,不会重新加载页面,且可以得到弹框消息 -->
<a href="javascript:void(0);" onclick="alert('干的漂亮!')">
点我呀
</a>
总结:
void 0
是一种确保得到 undefined
的可靠方式,虽然在现代 JavaScript 中直接用 undefined
也没问题,但在一些特殊场景(如代码压缩、兼容旧代码)仍然有用。
来源:juejin.cn/post/7511618693714427914
微软正式官宣开源!王炸!
最近,和往常一样在逛 GitHub Trending 热榜时,突然看到又一个非常火的开源项目冲上了 Trending 热榜,相信不少小伙伴也刷到了。
一天之内就新增数千 star,仅仅用了几天时间,star 增长曲线都快干垂直了!
再定睛一看,好家伙,这不是微软的项目么。
出于好奇,点进去看了看,没错,这正是微软自家大名鼎鼎的 WSL!
原来就在前几天的微软 Build 2025 开发者大会上,微软正式官宣开源 Windows Subsystem for Linux(WSL)。
这在微软官方的最新的开发者技术博客也可以翻到。
众所周知,WSL 是微软在 2016 年就发布的一项重磅功能,相信不少同学都用过。
WSL 全称:Windows Subsystem for Linux,它允许用户在 Windows 操作系统中直接运行 Linux 环境,而无需双系统或者虚拟机,通过兼容层支持开发者工具、命令行应用及文件系统互通,实现方便地跨平台开发与测试。
从初始发布到如今走向开源,回顾 WSL 一路走来的发展历程,可以总结为如下几个大的阶段。
- 初期兼容层探索
2016年,微软首次推出 WSL 1。
其通过兼容层工具(如 lxcore.sys 驱动)将 Linux 系统调用转换为 Windows 调用,首次实现原生运行 ELF 可执行文件。
其优势是轻量级启动,但缺点也很明显,那就是兼容性和性能都存在一定局限。
- 中期扩展与独立应用
2019年,WSL 2 正式官宣。
此时微软对其进行了彻底的重构架构,采用基于 Hyper-V 的轻量级虚拟机技术,来运行完整 Linux 内核,并显著提升了兼容性与性能,同时这也使得 WSL 能支持运行更多的 Linux 程序和应用。
2021年,WSLg 正式推出,从此便支持了 Linux GUI 应用,同时 WSL 也开始作为独立组件来发布,其从 Windows 镜像剥离,转为通过 Microsoft Store 来进行独立安装和更新。
2022年~2024年这几年时间内,微软对 WSL 进行了持续迭代,包括 systemd 服务管理支持、GPU加速、多发行版支持以及内存和文件系统等诸多性能优化。
经过中期这一阶段的发展,WSL 在兼容性、功能以及性能等方面都有了长足的进步,也为下一步的开源和社区化奠定了基础。
- 后期开源和社区化
在前几天的微软 Build 2025 开发者大会上,微软正式宣布 WSL 开源(GitHub 仓库开放),允许社区直接参与代码贡献,这也标志了 WSL 进入了开源和社区化的新阶段。
至此为止,在 WSL 的 GitHub 仓库中的 Issue #1 —— 那个自2016年起就存在的“Will this project be Open Source?
”的提问,终于被标注为了“Closed
”!
众所周知,WSL 其实是由一组分布式组件来组合而成的。
即:一部分是在 Windows 系统中运行,另外一部分则是在 WSL 2 虚拟机内运行。
这个从 WSL 官方给出的组件架构图中就可以很清晰地看出来:
那既然现如今微软官宣了 WSL 开源,那对于开发者来说,我们需要清晰地知道这次到底开源了哪些组件代码呢?
关于这部分,对照上图,我们这里不妨用表格的形式来简要总结一下,供大家参考。
组件类型 | 功能描述 | 组件名 | 开源状态 |
---|---|---|---|
用户交互层 | 命令行工具 | wsl.exe | 已开源 |
用户交互层 | 命令行工具 | wslg.exe | 已开源 |
用户交互层 | 命令行工具 | wslconfig.exe | 已开源 |
服务管理层 | WSL服务管理 | wslservice.exe | 已开源 |
Linux运行时 | init启动 | init | 已开源 |
Linux运行时 | 网络服务 | gns | 已开源 |
Linux运行时 | 端口转发 | localhost | 已开源 |
文件共享 | Plan9协议实现 | plan9 | 已开源 |
以上这些已开源的组件源码都可以在 WSL 的 GitHub 仓库里找到,大家感兴趣的话可以对应查看和研究。
虽然本次开源覆盖了 WSL 的绝大多数关键组件,但是官方也明确说了,以下这几个组件由于其仍然是 Windows 系统的一部分,所以目前仍然保持非开源状态,包括:
Lxcore.sys
:支撑 WSL1 的内核驱动程序P9rdr.sys
和p9np.dll
:运行"\wsl.localhost"文件系统重定向的关键组件(从 Windows 到 Linux)
这一点需要特别注意一下。
回顾过往,其实 GitHub 上的 WSL 仓库并不是最近才有,好多年前就已经存在了。
即便在以前的 WSL 还没有开源的日子里,WSL 的背后就有了一个强大的社区在支持,开发者们通过 GitHub Issue 和 Discussion 等为 WSL 这个项目提供了诸多错误追踪、新功能建议以及意见改进。
可以说,如果没有社区贡献,WSL 永远不可能成为今天的样子。
而现如今 WSL 源代码正式开源,这也满足了开发者社区长达 9 年的期待。
开发者们现在可以自行下载、构建,甚至提交改进建议或者新功能的代码来直接参与。
同时 WSL 官方也给出了一个详细的项目贡献指南:
感兴趣的同学也可以上去学习研究一波。
好了,那以上就是那以上就是今天的内容分享,希望能对大家有所帮助,我们下篇见。
注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。
来源:juejin.cn/post/7509437413099536438
用半天时间,threejs手搓了一个机柜
那是一个普通的周三早晨,我正对着产品经理刚丢过来的需求发呆——"在管理系统里加个3D机柜展示,要能开门的那种"。
"这不就是个模型展示吗?"我心想,"AI应该能搞定吧?"
9:30 AM - 启动摸鱼模式
我熟练地打开代码编辑器,把需求复制粘贴进AI对话框: "用Three.js实现一个带开门动画的机柜模型,要求有金属质感,门能90度旋转"
点击发送后,我惬意地靠在椅背上,顺手打开了B站。"让AI先忙会儿~"
10:30 AM - 验收时刻
一集《凡人修仙传》看完,我懒洋洋地切回编辑器。AI果然交出了答卷:
11:00 AM - 血压升高现场
看着AI生成的"未来科技风"机柜,我深吸一口气,决定亲自下场。毕竟,程序员最后的尊严就是——"还是自己来吧"。
11:30 AM - 手动抢救
首先手动创建一个空场景吧
class SceneManager {
constructor() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.camera.position.set(0, 2, 5);
this.renderer = new THREE.WebGLRenderer();
this.renderer.setSize(window.innerWidth, window.innerHeight);
const canvas = document.getElementById('renderCanvas');
canvas.appendChild(this.renderer.domElement);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.target.set(0, 3, 0);
this.controls.update();
this.addLights();
this.addFloor();
}
addLights() {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(5, 5, 5);
this.scene.add(directionalLight);
}
addFloor() {
const floorGeometry = new THREE.PlaneGeometry(10, 10);
const floorMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 });
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2;
this.scene.add(floor);
}
animate() {
const animateLoop = () => {
requestAnimationFrame(animateLoop);
this.controls.update();
this.renderer.render(this.scene, this.camera);
};
animateLoop();
}
onResize() {
window.addEventListener('resize', () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
});
}
}
然后这机柜怎么画呢,不管了,先去吃个饭,天大地大肚子最大
12:30 PM - 程序员的能量补给时刻
淦!先干饭!" 我一把推开键盘,决定暂时逃离这个三维世界。毕竟——
- 饥饿值已经降到30%以下
- 右手开始不受控制地颤抖
- 看Three.js文档出现了重影
扒饭间隙,手机突然震动。产品经理发来消息:"那个3D机柜..."
我差点被饭粒呛到,赶紧回复:"正在深度优化用户体验!"
(十分钟风卷残云后)
1:00 PM - 回归正题
吃饱喝足,终于可以专心搞机柜了,(此处可怜一下我的午休)
拆分机柜结构
机柜的结构可以分为以下几个部分:
- 不可操作结构:
- 底部:承载整个机柜的重量,通常是一个坚固的平面。
- 顶部:封闭机柜的顶部,提供额外的支撑。
- 左侧和右侧:机柜的侧板,通常是固定的,用于保护内部设备。
- 可操作结构:
- 前门:单门设计,通常是透明或半透明材质,便于观察内部设备。
- 后门:双开门设计,方便从后方接入设备的电缆和接口。
实现步骤
- 创建不可操作结构:
使用BoxGeometry
创建底部、顶部、左侧和右侧的平面,并将它们组合成一个整体。 - 添加前门:
前门使用透明材质,并设置旋转轴以实现开门动画。 - 添加后门:
后门分为左右两部分,分别设置旋转轴以实现双开门效果。 - 优化细节:
- 添加螺丝孔和通风口。
- 使用高光材质提升视觉效果。
接下来,我们开始用代码实现这些结构。
机柜结构的实现
1. 创建不可操作结构
底部
export function createCabinetBase(scene) {
const geometry = new THREE.BoxGeometry(0.6, 0.05, 0.64);
const base = new THREE.Mesh(geometry, materials.baseMaterial);
base.position.y = -0.05; // 调整位置
scene.add(base);
}
底部使用BoxGeometry
创建,设置了深灰色金属材质,位置调整为机柜的最底部。
顶部
export function createCabinetTop(scene) {
const geometry = new THREE.BoxGeometry(0.6, 0.05, 0.64);
const top = new THREE.Mesh(geometry, materials.baseMaterial);
top.position.y = 1.95; // 调整位置
scene.add(top);
}
顶部与底部类似,位置调整为机柜的最顶部。
侧面
export function createCabinetSides(scene) {
const geometry = new THREE.BoxGeometry(0.04, 2, 0.6);
const material = materials.baseMaterial;
// 左侧面
const leftSide = new THREE.Mesh(geometry, material);
leftSide.position.set(-0.28, 0.95, 0); // 调整位置
scene.add(leftSide);
// 右侧面
const rightSide = new THREE.Mesh(geometry, material);
rightSide.position.set(0.28, 0.95, 0); // 调整位置
scene.add(rightSide);
}
侧面使用两个BoxGeometry
分别创建左侧和右侧,位置对称分布。
2. 创建可操作结构
前门
export function createCabinetFrontDoor(scene) {
const doorGr0up = new THREE.Gr0up();
const doorWidth = 0.04;
const doorHeight = 2;
const doorDepth = 0.6;
const frameMaterial = materials.baseMaterial;
const frameThickness = 0.04;
// 上边框
const topFrameGeo = new THREE.BoxGeometry(doorWidth, frameThickness, doorDepth);
const topFrame = new THREE.Mesh(topFrameGeo, frameMaterial);
topFrame.position.set(0, 1 - frameThickness / 2, 0);
doorGr0up.add(topFrame);
// 下边框
const bottomFrameGeo = new THREE.BoxGeometry(doorWidth, frameThickness, doorDepth);
const bottomFrame = new THREE.Mesh(bottomFrameGeo, frameMaterial);
bottomFrame.position.set(0, -doorHeight / 2 + 0.05, 0);
doorGr0up.add(bottomFrame);
// 左右边框
const leftFrameGeo = new THREE.BoxGeometry(doorWidth, doorHeight - 2 * frameThickness, frameThickness);
const leftFrame = new THREE.Mesh(leftFrameGeo, frameMaterial);
leftFrame.position.set(0, 1 - doorHeight / 2, -doorDepth / 2 + frameThickness / 2);
doorGr0up.add(leftFrame);
const rightFrameGeo = new THREE.BoxGeometry(doorWidth, doorHeight - 2 * frameThickness, frameThickness);
const rightFrame = new THREE.Mesh(rightFrameGeo, frameMaterial);
rightFrame.position.set(0, 1 - doorHeight / 2, doorDepth / 2 - frameThickness / 2);
doorGr0up.add(rightFrame);
scene.add(doorGr0up);
return doorGr0up;
}
前门由一个Gr0up
组装而成,包含上下左右边框,材质与机柜一致,后续将添加玻璃部分和动画。
前门动画的实现
前门的动画使用gsap
库实现,设置旋转轴为左侧边框。
gsap.to(frontDoor.rotation, {
y: Math.PI / 2, // 90度旋转
duration: 1, // 动画时长
ease: "power2.inOut",
});
通过gsap.to
方法,前门可以实现平滑的开门效果。
3. 添加后门
后门采用双开设计,左右两扇门分别由多个边框组成,并通过Gr0up
进行组合。
为了优化细节我还加入了网孔结构(此处心疼一下我为写他掉的头发)
后门的实现
export function createCabinetBackDoor(scene) {
const doorGr0up = new THREE.Gr0up();
const doorWidth = 0.04;
const doorHeight = 2;
const doorDepth = 0.6;
const singleDoorDepth = doorDepth / 2;
const frameMaterial = materials.baseMaterial;
const frameThickness = 0.04;
function createSingleBackDoor(isLeft) {
const singleGr0up = new THREE.Gr0up();
// 上边框
const topFrameGeo = new THREE.BoxGeometry(doorWidth, frameThickness, singleDoorDepth);
const topFrame = new THREE.Mesh(topFrameGeo, frameMaterial);
topFrame.position.set(0, 1 - frameThickness / 2, 0);
singleGr0up.add(topFrame);
// 下边框
const bottomFrameGeo = new THREE.BoxGeometry(doorWidth, frameThickness, singleDoorDepth);
const bottomFrame = new THREE.Mesh(bottomFrameGeo, frameMaterial);
bottomFrame.position.set(0, -doorHeight / 2 + 0.05, 0);
singleGr0up.add(bottomFrame);
// 外侧边框
const sideFrameGeo = new THREE.BoxGeometry(doorWidth, doorHeight - 2 * frameThickness, frameThickness);
const sideFrame = new THREE.Mesh(sideFrameGeo, frameMaterial);
sideFrame.position.set(
0,
1 - doorHeight / 2,
isLeft
? -singleDoorDepth / 2 + frameThickness / 2
: singleDoorDepth / 2 - frameThickness / 2
);
singleGr0up.add(sideFrame);
return singleGr0up;
}
const leftDoor = createSingleBackDoor(true);
const rightDoor = createSingleBackDoor(false);
doorGr0up.add(leftDoor);
doorGr0up.add(rightDoor);
scene.add(doorGr0up);
return { group: doorGr0up, leftDoor, rightDoor };
}
后门的实现与前门类似,采用双扇门设计,左右各一扇。
后门动画的实现
后门的动画同样使用gsap
库实现,分别设置左右门的旋转轴。
gsap.to(leftDoor.rotation, {
y: Math.PI / 2, // 左门向外旋转90度
duration: 1,
ease: "power2.inOut",
});
gsap.to(rightDoor.rotation, {
y: -Math.PI / 2, // 右门向外旋转90度
duration: 1,
ease: "power2.inOut",
});
通过gsap.to
方法,后门可以实现平滑的双开效果。
2:00 PM - 项目收尾
终于,随着最后一行代码的敲定,3D机柜模型在屏幕上完美呈现。前门优雅地打开,后门平滑地双开,仿佛在向我点头致意。
我靠在椅背上,长舒一口气,心中默念:"果然,程序员的尊严还是要靠自己守护。"
可拓展功能
虽然当前的3D机柜模型已经实现了基本的展示和交互功能,但在实际项目中,我们可以进一步扩展以下功能:
1. U位标记
2. U位资产管理
3. 动态灯光效果
4. 数据联动
将3D机柜与后台数据联动:
- 实时更新设备状态。
- 显示设备的实时监控数据(如温度、功耗等)。
- 支持通过API接口获取和更新设备信息。
不说了,需求又来了()我还是继续去搬砖了
代码地址:gitee.com/erhadong/th…
来源:juejin.cn/post/7516784123703181322
很喜欢Vue,但还是选择了React: AI时代的新考量
引言
作为一个深度使用Vue多年的开发者,最近我在新项目技术选型时,却最终选择了React。这个决定不是一时冲动,而是基于当前技术发展趋势、AI时代的需求以及生态系统的深度思考。

AI时代的前端需求
随着人工智能技术的飞速发展,前端开发的需求也发生了深刻的变化。现代应用不仅仅是静态页面或简单的数据展示,而是需要与复杂的后端服务、机器学习模型以及第三方API深度集成。这些场景对前端框架提出了更高的要求,生态的重要性,不得不说很重要。
社区对AI的支持
说实话,React社区在AI领域简直就是"社交达人"。shadcn这样的明星UI库、vercel/ai这样的实力派SDK,都是圈子里的"网红"。想要快速搭建AI应用?这些"老铁"都能帮你省下不少力气。简单列举一些知名仓库。
@vercel/ai
这是由Vercel开发的AI SDK
提供了与各种AI模型(如OpenAI, Anthropic等)交互的统一接口
支持流式响应、AI聊天界面等功能
特别适合构建类ChatGPT应用
shadcn-admin
基于shadcn/ui的管理后台模板
包含了AI聊天等现代化功能
提供了完整的后台管理系统布局
shadcn/ui
这是一个高度可定制的React组件库
不是传统的npm包,而是采用复制代码的方式
提供了大量现代化的UI组件
完美支持暗色模式
特别适合构建AI应用的界面
ChatGPTNextWeb
开源的ChatGPT Web客户端
使用Next.js构建
支持多种部署方式
提供了优秀的UI/UX设计参考
AI工具链的优先支持
React在AI工具支持方面具有明显优势
GitHub Copilot、Cursor 等AI IDE 也对React的代码提示更准确
目前多数AI辅助开发工具会优先支持React生态(Vue 生态也不错,狗头保命🐶)
结论
技术选型永远不是非黑即白的选择。在AI时代,我们需要考虑:
- 技术栈的生态活跃度
- AI工具的支持程度
- 团队的学习成本
- 项目的长期维护
总的来说,Vue和React各有千秋,但从AI时代的需求和生态系统的角度来看,React确实更适合承担复杂、高性能的应用开发任务。当然,这并不意味着Vue没有未来。事实上,Vue依然是一个优秀的框架,尤其适合中小型企业或初创团队快速搭建产品原型。
随着AI技术的进一步普及,前端框架之间的竞争也将更加激烈。无论是React还是Vue,都需要不断进化以适应新的挑战。而对于开发者来说,掌握多种技术栈并根据项目需求灵活选择,才是最重要的技能。
正如一句老话所说:“工欲善其事,必先利其器。”选择合适的工具,才能让我们的项目在AI时代脱颖而出。
还有技术人不应该局限于框架,什么都能上手,多看看新的东西,接受新的事物,产品能力也很重要。
写在最后
技术选型是一个需要综合考虑的过程,没有永远的对与错,只有更适合与否。希望这篇文章能给正在进行技术选型的你一些参考。
来源:juejin.cn/post/7497174194715852815
95%代码AI生成,是的你没听错...…
不是标题党,这是我的真实经历
95%的代码由AI生成?听起来像标题党,但这是我最近使用Augment Code的真实情况。
相信现在大多数人都用过ai来写代码,笔者也是ai工具的拥抱者,从一开始的GitHub Copilot补全,到后面的Agent编程:Cursor、WindSurf、Zed等,但其实效果一般。直到用了Augment Code,才发现差距这么大。
上个月做数据看板,以前要一天的工作量,现在半小时搞定。图表、数据处理、样式,基本都是AI生成的。
当然,也不是什么代码都能让AI来写。复杂的业务逻辑、架构设计,还是得靠人。但对于大量的重复性编码工作,AI确实能大幅提升效率。 如果你也在用AI编程工具但效果不理想,这篇分享可能对你有帮助。
AI工具对比
在这之前,让我们先来看下市面上的AI编程工具吧
先看个数据对比,心里有个底
工具 | 响应速度 | 准确率 | 月费用 | 我的使用感受 |
---|---|---|---|---|
GitHub Copilot | 0.5-1秒 | 75-80% | $10 | 老牌稳定,但有点跟不上节奏了 |
Cursor | 1-2秒 | 85%+ | $20 | 体验最好,就是有点贵 |
Windsurf | 0.8-1.5秒 | 80%+ | $15 | 自动化程度高,UI很舒服 |
Augment Code | 1-1.5秒 | 声称很快 | $50 | 大项目理解能力确实强 |
Cline | 看模型 | 75%+ | 免费+API | 开源良心,功能够用 |
GitHub Copilot:老前辈的逆袭之路
这个应该是最早的AI代码补全工具了,通过tab键快速补全你的意图代码...但是在后面的AI编程工具竞赛中热度却没有那么高了。。。不过最近的数据让我有点刮目相看。
最新重大消息: 据微软2024年财报显示,GitHub Copilot用户同比增长180%,贡献了GitHub 40%的收入增长¹。这个数据还是很惊人的,说明虽然新工具层出不穷,但老牌工具的用户基础还是很稳固的。
实际使用感受:
- 响应确实快,基本0.5-1秒就出结果
- 准确率比我之前用的时候提升了不少,从70-75%涨到了75-80%
- 最大的问题还是对整个项目的理解不够深入,经常给出的建议比较浅层
最近的更新还挺给力:
- 2024年底推出了免费版,这个对个人开发者来说是个好消息
- 2025年2月新增了Agent模式,虽然来得有点晚,但总算跟上了
- 现在支持多个模型了,包括GPT-4o和Claude 3.7 Sonnet
用下来感觉...GitHub Copilot虽然不是最炫酷的,但胜在稳定和用户基础大。如果你不想折腾,它还是个不错的选择。
Cursor:估值99亿美元的AI编程独角兽
说实话,Cursor是我用过体验最好的AI编程工具...界面设计得很舒服,功能也很强大,就是价格让人有点肉疼。不过最近的融资消息让我对它更有信心了。
重磅消息: 2025年6月,Cursor的母公司Anysphere完成9亿美元融资,估值达到99亿美元²!这个估值是三个月前的四倍,说明投资人对AI编程工具的前景非常看好。年化收入约每两个月翻倍,6月份已经超过5亿美元。
为什么说体验好:
- 专门为AI编程优化的界面,用起来就是爽
- 多文件编辑能力真的强,能理解整个项目的上下文
- Composer功能让我可以一次性修改多个文件,这个太实用了
- 代码生成准确率达到85%+,确实比其他工具高一截
数据说话:
- 2024年用户突破100万,增长了300%
- 响应速度虽然比Windsurf稍慢,但比我之前用的时候改善了很多
实际体验中,Cursor确实是我见过的最接近"AI原生编程"的工具。现在有了这么高的估值,说明它的商业模式是被认可的。
Windsurf:被断供的自动化之王
Windsurf给我的感觉就是...它真的很"聪明",很多事情都能自动帮你搞定。但是最近发生的事情让我有点担心它的未来。
重大危机事件: 2025年6月4日,发生了一件震惊AI编程圈的事情:Anthropic突然断供Windsurf对Claude 3.x系列模型的API访问权限³!Windsurf CEO公开控诉,称仅获得不到5天的通知时间,措手不及。
这个事件的背景很复杂:
- 4月份传出OpenAI要以30亿美元收购Windsurf的消息⁵
- Anthropic可能是为了保护自己的商业利益,不想让竞争对手OpenAI获得优势
- 结果就是Windsurf用户大量退订,直接影响了用户体验
应对措施:
- Windsurf紧急转向谷歌Gemini模型
- 推出了Gemini 2.5 Pro的七五折促销
- 取消了免费用户对Claude模型的访问权限
最让我印象深刻的功能:
- Cascade功能真的是原创,能自动分析你的代码库然后选择正确的文件来工作
- 使用Claude 3.5 Sonnet的时候响应速度确实很快(现在用不了了...)
- UI设计很精致,用起来有种苹果产品的感觉
用下来感觉,Windsurf的技术实力是有的,但这次断供事件让我意识到,依赖单一模型提供商是有风险的。不过要注意的是,它们已经推出了自研的SWE-1模型,可能是为了摆脱对第三方模型的依赖。
Augment Code:SWE-bench冠军的实力证明
这个工具...怎么说呢,在处理大型项目方面确实有两把刷子。最近的权威测试结果更是证明了我之前的判断。
权威认证数据: 在SWE-bench测试中,Augment Code确实获得了第一名⁴!这个测试是用真实的GitHub问题来评估AI工具解决实际软件工程问题的能力,含金量很可以。
为什么说它厉害:
- SWE-bench测试排名第一,这个不是吹的
- 对大型代码库的理解能力确实强,我试过几个10万行+的项目,它都能很好地理解上下文
- "记忆"功能很有意思,能学习你的编程风格和偏好
企业级的实力:
- 被很多Fortune 500公司采用,说明在企业环境下表现不错
- 在复杂重构场景下表现确实突出,这个我深有体会
实际使用中,如果你经常处理大型复杂项目,Augment Code确实值得考虑。SWE-bench第一名的成绩给了我更多信心。
Cline:开源界的良心
说到Cline,这个真的是开源界的良心产品...完全免费,功能还挺强大。
开源的优势:
- GitHub上42.6k+星标,社区很活跃
- Agent能力做得很不错,能执行复杂的任务序列
- MCP协议支持做得很好,扩展性强
如果你预算有限或者喜欢折腾开源工具,Cline是个很好的选择。特别是现在Windsurf被断供,Cline的稳定性反而成了优势。
Augment Code使用技巧
安装使用
Augment Code的安装很简单,它是作为插件来使用的,支持Vscode、JetBrains IDEs、Vim and Neovim,当然Cursor也可以用。
在插件中搜索 “Augment”,第一个就是了
安装完成之后需要注册登录,在浏览器中注册完成之后会跳回Vscode就完成登录了。新用户是有14天的免费使用的(包含300的用户提问次数),可以使用全部的高级功能,这点比Cursor就好很多了。
在打开新项目的时候,Augment 需要索引项目,这会将你的代码文件加入到上下文中,Augment是专门为复杂项目设计的,超长的上下文读取,这也是相比其他ai编程工具的一个优势。
这是索引之后的界面,最上面是聊天界面的管理,一个Thread就是一次chat,这里定义为 “线程” 也挺形象的。
右边是创建“线程” 的形式,主要有3种:
- Chat
和其他ai编程工具没啥区别,可以询问有关您的代码的问题,获取有关如何重构代码的建议,向选定的代码行添加新功能等
- Agent
这是Augment 的主要工作模式,和Cursor 的Agent Mode一样,Agent会自动帮你规划任务,结合当前工作区、内存、上下文等信息帮你分析和规划任务,代理可以在您的工作区中创建、编辑或删除代码,并可以通过 MCP 使用终端和外部集成等工具来完成您的请求。
你可能还看到旁边的 “Auto” 开关,开启之后,Augment 会自动执行每个任务的命令,比如在终端执行脚本、编辑文件等,如果没有开启,你需要手动确认。
你可能发现Augment 并没有和其他ai编程工具一样有大模型的选择,因为他们团队认为模型的选择应该是自动的,Augment会根据以下因素动态选择最佳模型:
✅ 任务类型(代码完成、聊天、内联建议)
✅ 跨实际编码任务的性能基准
✅ 成本与延迟的权衡
✅ 人工智能模型的最新进展
这也是我觉得Augment值得夸奖的一点,因为作为提供给开发人员的编程工具,不需要他了解每个大模型的优缺点进行选择;Augment会自动的使用不同的大模型进行组合,比如思考任务的时候用这个大模型,编写代码的时候用另一个大模型,来达到最佳的生产力效果。目前已经Augment 已经内置了最新的 Claude Sonnet 4 了
- Remote Agent
这个模式是新出的,是在云端上完成你的任务,可以针对独立任务并行运行多个代理,并在 vscode 中监控和管理它们的进度。
这个需要连接github仓库使用,当代理完成工作后,可以创建拉取请求 (PR),审核您的更改并将其合并到主分支中。从头部的Threads 中选择代理,然后点击“创建 PR”。代理将创建分支、提交更改并为您创建拉取请求。
使用技巧
介绍到这里,基本上你就可以愉快的去使用Augment来感受他的魅力啦,但是,还是请你继续看下去,对于AI编程工具而言,Augment 有时候也会和其他ai工具有相同的问题。比如说,你是不是有时候觉得cursor帮你生成了太多代码了,而且还影响到了之前的功能?有时候ai工具不能很好的理解你的意思?
这里就需要使用到一些技巧了,这也是Augment官方推荐的做法,其中这些思想同样适用其他ai工具:
首先在输入问题完成之后,你可以看到旁边有个 ✨按钮,你可以点击它来帮你完善你的问题,它会根据上下文结合大模型来优化你的提问,让生成的质量更高
提示应该详细,不要太短
对于复杂的任务尤其如此。试图仅凭提示中的几个词来完成一项复杂的任务几乎肯定会失败。
这一点我们可以通过点击输入框右边的 ✨按钮, 可以很好的帮我们解决这个问题,示例:
这是未优化之前的:
这是点击优化后的,已经帮你详细的补充了要素和步骤等关键信息:
向 Agent 提供全面的背景信息
不仅要解释最终目标,还要解释背后的原因、额外的限制等,比如可以提供github issue等链接
将复杂的任务分解成更小、更易理解的部分(一次一个)
对于复杂的任务,首先与 Agent 讨论并完善计划
不要急着让Augment写代码,这样写出来往往不合人意,可以先和他确认方案再让他进行生成
Agent 擅长迭代测试结果和代码执行输出
完成任务之后,可以顺便让他帮你编写测试用例来验证这次的生成质量是否满意,让ai自己监督自己,是不是很有意思呢
试试 Agent 来处理你不熟悉的任务!
即使这个任务你不会,但是你丢给他之后,也许会有新的思路帮你完成,这也是ai的优势,连接互联网知识库,可以给出不一样的思路和解决方案
当Agent表现良好时,提供积极的反馈
多夸夸它
通过上面的建议,我整理了一套提示词模版,在顶部右上角点击设置图标打开Setting:
输入下面提示词自动保存:
As my AI coding assistant, please view our collaboration as working with a smart and professional engineer. I hope you can fully leverage the following capabilities:
1. Reply in Chinese;
2. Code Understanding and Analysis:
- Before carrying out the task, please thoroughly understand the relevant code and project structure.
- Use your code repository search tools to explore related files and dependencies.
- Analyze the existing code patterns and architectural design to ensure that the new code is consistent with them.
3. Task Planning and Execution:
- For complex tasks, please first develop a detailed plan and discuss it with me. Only proceed after obtaining confirmation.
- Break down large tasks int0 manageable sub-tasks and implement them step by step.
- Provide progress updates and interim results after each key step.
4. Code Quality and Testing:
- Write high-quality code that conforms to the project's style guide.
- Proactively write and run tests after implementing features to ensure the code works properly.
- Consider edge cases and exception handling to enhance code robustness.
5. Learning and Iteration:
- If you encounter unfamiliar technologies or frameworks, proactively search for relevant documentation.
- Iteratively improve the code based on test results and feedback.
- When facing difficulties, explain your understanding and the methods you have tried.
6. Communication and Collaboration:
- Clearly explain your thought process and the reasons for your decisions.
- When you need to clarify a question, please start your inquiry with "This is just a question:".
- Offer multiple possible solutions and analyze the advantages and disadvantages of each.
I will try my best to provide detailed task descriptions, background information and constraints to help you better understand the requirements. If the information is insufficient, please feel free to ask questions to obtain the necessary context.
Let's work together efficiently and create high-quality code!
你可以自行翻译一下,这都是之前提到的建议总结,并加上了要求使用中文回复
使用示例
下面就以一个常见的工作场景来试下效果吧,这里以一个 nextjs 实现的 博客项目为例,现在已经有个博客的内容展示、主题切换功能,让我们新增一个评论功能吧。
在Augment输入框中输入:
我希望在这个项目中加入评论系统,集成 Giscus,请你给出实现的方案和步骤代码吧。请你分析给出技术方案和我确认,不要修改代码
这里以比较常用的开源的Giscus项目为例子,展示如何接入。
输入之后点击提示词增强按钮 ✨
这里会帮你优化你的提问,同时注意不要让Augment直接修改代码,先给出方案设计在确认,这就是我们刚才说的使用技巧,点击发送后Augment会自动帮你分析和规划任务:
Augment的回答很长,它不仅给出了详细的方案步骤和代码,还询问了你一些需求需要确认的信息,它会根据你的反馈来调整和实施代码。
在这里的时候,你需要大致的看下它的方案有没有问题,这也是你唯一需要确认的一个关键步骤,等确认方案后再继续下一步的代码实施。
这里我们回答它提到的一些关键信息,然后让他开始实施即可:
这是我的github地址:https://github.com/barry166/next-blog,需要怎么启用Discussions功能呢?请你详细描述下,我希望评论区在每篇文章底部,中文,默认跟随网站主题
然后到了Augment 实施代码的步骤了:
这里给出了具体的需要我们手动实施的步骤,我们根据他说的去我们的Github 和giscus上进行配置,同时Augment 还贴心的为我们编写了文档 ,这里我们根据配置完成之后启动项目查看下:
在博客详情页最下方出现了评论,同时登录后还可以增加评论。
就这样,我们在Augment帮助的情况下,一次性接入了 giscus 评论系统,在这之前我们连giscus的文档也没有看,只是用ai询问了一下哪个开源的评论系统接入比较好,就直接一次性的接入了,这大大的提升了我们的项目开发效率!
项目代码已经上传到了 Github ,你可以点击查看 Augment 生成的GISCUS_SETUP 文档,我们没有写一行代码,只是根据Augment的提示配置了Github、giscus 和环境变量。
思考与总结
再次回看下我们的内容,其实95%代码由AI生成一点也不夸张。Claude 团队也说过,他们90%-95%的代码都是由AI编写完成,这里大部分代码功能都是:
- CRUD、UI组件、基础逻辑 → 全部交给 Claude 生成
- 测试代码、日志模块、文档注释 → AI 全包
- merge request 审查 → AI 做初审,人类只最后过一眼
- 复杂业务逻辑、跨模块集成 → 部分由 AI 起草,人类参与较多
可以发现大部分的体力活,都可以由AI来完成,开发者只要完成“决策”就够了
AI 时代,程序员应该掌握什么技能?
在 AI 正加速变革软件开发流程的今天,程序员的技能结构也正在发生深刻的转变。从个人使用体验来看,程序员首先应该学会如何高效使用各类 AI 工具。不仅仅是编程相关的工具,比如 Augment、 Copilot、Cursor、Windsurf 等,还包括写作、任务管理、流程自动化、产品设计等能提高整体生产力的 AI 工具。
1. 熟练掌握AI工具,重构你的工作方式
如今,AI 已不再只是一个“语法补全器”,而是可以:
- 帮你设计项目架构草图
- 生成符合你技术选型的模块代码
- 自动生成单元测试并跑通测试用例
- 生成文档、构建脚手架,甚至做基础调优
过去它是你的工具,现在它更像你的助手甚至是实习生或下属。你只需要对项目大方向、架构逻辑做判断,剩下的大量“体力活”可以交给 AI 来完成。这对程序员提出了新的要求:你懂得让 AI 高效为你工作,甚至主导它的工作流程。
2. 掌握AI背后的基础原理,提升理解和控制力
虽然大多数 AI 工具都在追求“即插即用”,但如果你能理解其背后的基本原理,如:
- 提示词工程(Prompt Engineering)
- 多智能体系统(AI Agents)
- 大模型微调和上下文窗口管理
- 链式思维(Chain-of-Thought Prompting)
你就能在面对复杂问题、或使用 AI 工具出现偏差时,更快地找到解决办法。
这些原理不要求你成为 AI 研究员,但理解其运行方式,至少能让你成为“更会用 AI 的程序员”。
3. 保持对行业趋势的敏感度
AI 相关工具和平台的更新迭代速度极快,建议定期关注以下内容:
- OpenAI DevDay(开发者大会)
- Google I/O
- Anthropic、Meta、Mistral 等发布的大模型更新
- GitHub Copilot、Cursor、Replit 等 IDE 的新功能
你不需要追踪每一个小版本更新,但对趋势保持敏感,能让你在工具选择、技术选型、团队协作中拥有更强的判断力。
4. 强化原理性与架构性思维
随着 AI 工具替代更多低层重复性劳动,程序员的核心竞争力将回归到架构设计、系统思维与领域建模能力。换句话说:你不是在写代码,而是在设计系统,并引导 AI 写代码。
如果你能从项目一开始就清晰地规划好架构,AI 工具完全可以接过大部分实现工作。这要求程序员转型为更具战略性和抽象思维能力的角色。
结语
在这个“AI 增强开发”时代,程序员最宝贵的能力不再是“会写代码”,而是“能构建系统,并高效驾驭 AI 写代码”。你不需要和 AI 拼码速,但你必须学会用 AI 重塑自己的开发流程和工作方式。
AI 以后会不会取代程序员我不知道,但会取代那些不懂得用 AI 的程序员。
🚀 推广一下:
- i-resume.cn:我去年开发的 AI 简历生成网站,AI 参与度非常高,甚至页面设计和内容都由 AI 主导完成。那时候 AI 编程工具远不如现在,这也让我对 AI 的未来发展更有信心。
参考链接
AI model pickers are a design failure, not a feature
How to build your Agent: 11 prompting techniques for better AI agents
来源:juejin.cn/post/7516100315852521522
Tailwind 到底是设计师喜欢,还是开发者在硬撑?
我们最近刚把一个后台系统从 element-plus 切成了完全自研组件,CSS 层统一用 Tailwind。全员同意设计稿一致性提升了,但代码里怨言开始冒出来。
这篇文章不讲原理,直接上代码对比和团队真实使用反馈,看看是谁在享受,谁在撑着。
1.组件内样式迁移
原先写法(BEM + scoped):
<template>
<div class="card">
<h2 class="card__title">用户概览</h2>
<p class="card__desc">共计 1280 位</p>
</div>
</template>
<style scoped>
.card {
padding: 16px;
background-color: #fff;
border-radius: 8px;
}
.card__title {
font-size: 16px;
font-weight: bold;
}
.card__desc {
color: #999;
font-size: 14px;
}
</style>
Tailwind 重写:
<template>
<div class="p-4 bg-white rounded-lg">
<h2 class="text-base font-bold">用户概览</h2>
<p class="text-sm text-gray-500">共计 1280 位</p>
</div>
</template>
优点:
- 组件直接可读,不依赖 class 定义
- 样式即结构,调样式时不用来回翻
缺点:
- 设计稿变了?全组件搜索
text-sm
改成text-base
? - 无法抽象:多个地方复用
.text-label
变成复制粘贴
2.复杂交互样式
纯 CSS(原写法)
<template>
<button class="btn">提交</button>
</template>
<style scoped>
.btn {
background-color: #409eff;
color: #fff;
padding: 8px 16px;
border-radius: 4px;
}
.btn:hover {
background-color: #66b1ff;
}
.btn:active {
background-color: #337ecc;
}
</style>
Tailwind 写法
<button
class="bg-blue-500 hover:bg-blue-400 active:bg-blue-700 text-white py-2 px-4 rounded">
提交
</button>
问题来了:
- ✅ 简单 hover/active 很方便
- ❌ 多态样式(如 disabled + dark mode + hover 同时组合)就很难读:
<button
class="bg-blue-500 text-white disabled:bg-gray-300 dark:bg-slate-600 dark:hover:bg-slate-700 hover:bg-blue-600 transition-all">
>
提交
</button>
调试时需要反复阅读 class 字符串,不能直接 Cmd+Click 查看样式来源。
3.统一样式封装,复用方案混乱
原写法:统一样式变量 + class
$border-color: #eee;
.panel {
border: 1px solid $border-color;
border-radius: 8px;
}
Tailwind 使用中经常出现的写法:
<div class="border border-gray-200 rounded-md" />
问题来了:
设计稿调整了主色调或边框粗细,如何批量更新?
BEM 模式下你只需要改一个变量,Tailwind 下必须靠 @apply
或者手动替换所有 .border-gray-200
。
于是我们项目里又写了一堆“语义类”去封装 Tailwind:
/* 自定义 utilities */
@layer components {
.app-border {
@apply border border-gray-200;
}
.app-card {
@apply p-4 rounded-lg shadow-sm bg-white;
}
}
最后导致的问题是:我们重新“造了个 BEM”,只不过这次是基于 Tailwind 的 apply 写法。
🧪 实测维护成本:100+组件、多人协作时的问题
我们项目有 110 个组件,4 人开发,统一用 Tailwind,协作两个月后出现了这些反馈:
- 👨💻 A 开发:写得很快,能复制设计稿的 class 直接粘贴
- 🧠 B 维护:改样式全靠人肉找
.text-sm
、.p-4
,没有结构命名层 - 🤯 C 重构:统一调整圆角半径?所有
.rounded-md
都要搜出来替换
所以我们内部的结论是:
Tailwind 写得爽,维护靠人背。它适合“一次性强视觉还原”,不适合“结构长期型组件库”。
🔧 我们后来的解决方案:Tailwind + token 化抽象
我们仍然使用 Tailwind 作为底层 utilities,但同时强制使用语义类抽象,例如:
@layer components {
.text-label {
@apply text-sm text-gray-500;
}
.btn-primary {
@apply bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded;
}
.card-container {
@apply p-4 bg-white rounded-lg shadow;
}
}
模板中统一使用:
<h2 class="text-label">标题</h2>
<button class="btn-primary">提交</button>
<div class="card-container">内容</div>
这种方式保留了 Tailwind 的构建优势(无 tree-shaking 问题),但代码结构有命名可依,后期批量维护不再靠搜索。
📌 最终思考
Tailwind 是给设计还原速度而生的,不是给可维护性设计的。
设计师爱是因为它像原子操作;
开发者撑是因为它把样式从结构抽象变成了“字串组合游戏”。
如果你的团队更在意开发效率,样式一次性使用,那 Tailwind 非常合适。
如果你的组件系统是要长寿、要维护、要被多人重构的——你最好在 Tailwind 之上再造一层自己的语义层,或者别用。
分享完毕,谢谢大家🙂
📌 你可以继续看我的系列文章
来源:juejin.cn/post/7517496354245492747
同事年底绩效是C,提离职领导死活不让走,后来领导私下说:他走了,就没人背这个绩效了
背绩效
临近年底,朋友圈和各大职场 App 都在讨论「年终奖能拿多少个月」的话题。
除了对「能拿多少个月」有广泛的讨论以外,还有不少关注点在于「年终奖何时能发」这件事上,毕竟只有真的拿到手了,才能算是钱,而不是饼。
我一直以为,在大厂年终奖这条"鄙视链"上,最差的就是那些"零年终"的小伙伴了。
实在没想到,还有高手。
比零年终更惨的,是要背绩效,同时还得面对领导"惺惺作态"的挽留:
在这位网友发的帖子中提到,自己身边有位同事去年年中的时候是 C 绩效,到了年底还是 C,通常连续得到低绩效,就会面临各种安排(砍福利、降工资 或 被换组),于是这位同事主动提了离职。
但离谱的是,领导死活不让他走,一直以「后面还有机会」这样的说辞来进行画饼。要知道,这位领导大概率是他两次 C 绩效的"贡献者"。
在其他人看来,还以为领导是真心挽留他,这位同事留在公司一定会先苦后甜。
直到后面这位领导私下和楼主说:"他走了,没人背这个绩效了"。
后面楼主才恍然大悟,所谓的挽留,仅仅是为了让他分担一些不好的绩效罢了。
简短的一句话,"他走了,没人背这个绩效了",背后却是实实在在职场霸凌。听起来像是领导的"无奈之举",实则是领导为了应付公司指标(一定要有低绩效的组成),选择性牺牲某些同事的离谱行为。
权利在这些人手上真是可悲,那个背绩效的同事,也有自己的生活,甚至还有自己的家庭。被针对就算了,还得被耗着,被 PUA 朝着那个"有希望,但没结果(下次还是 C 绩效)"的方向去期待,最后还要反省是不是自己的问题。
新的一年,大家都能远离这些垃圾人。
对此,你有想分享的,欢迎评论区交流。
...
回归主题。
周末,继续简单小算法。
题目描述
平台:LeetCode
题号:806
我们要把给定的字符串 S
从左到右写到每一行上,每一行的最大宽度为 个单位,如果我们在写某个字母的时候会使这行超过了 个单位,那么我们应该把这个字母写到下一行。
我们给定了一个数组 widths
,这个数组 代表 'a'
需要的单位, 代表 'b'
需要的单位,..., 代表 'z'
需要的单位。
现在回答两个问题:至少多少行能放下 S
,以及最后一行使用的宽度是多少个单位?
将你的答案作为长度为 的整数列表返回。
示例 1:
输入:
widths = [10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10]
S = "abcdefghijklmnopqrstuvwxyz"
输出: [3, 60]
解释:
所有的字符拥有相同的占用单位10。所以书写所有的26个字母,
我们需要2个整行和占用60个单位的一行。
示例 2:
输入:
widths = [4,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10]
S = "bbbcccdddaaa"
输出: [2, 4]
解释:
除去字母'a'所有的字符都是相同的单位10,并且字符串 "bbbcccdddaa" 将会覆盖 9 * 10 + 2 * 4 = 98 个单位.
最后一个字母 'a' 将会被写到第二行,因为第一行只剩下2个单位了。
所以,这个答案是2行,第二行有4个单位宽度。
注:
- 字符串
S
的长度在 的范围。 S
只包含小写字母。widths
是长度为 的数组。- 值的范围在 。
模拟
根据题意进行模拟即可。
使用变量 a
代指当前有多少行是满的,使用变量 b
代指当前填充光标所在的位置。
Java 代码:
class Solution {
public int[] numberOfLines(int[] widths, String s) {
int a = 0, b = 0;
for (char c : s.toCharArray()) {
int t = widths[c - 'a'];
if (b + t > 100 && ++a >= 0) b = t;
else b += t;
}
if (b != 0) a++;
return new int[]{a, b};
}
}
C++ 代码:
class Solution {
public:
vector<int> numberOfLines(vector<int>& widths, string s) {
int a = 0, b = 0;
for (char c : s) {
int t = widths[c - 'a'];
if (b + t > 100 && ++a >= 0) b = t;
else b += t;
}
if (b != 0) a++;
return {a, b};
}
};
Python 代码:
class Solution:
def numberOfLines(self, widths: List[int], s: str) -> List[int]:
a, b = 0, 0
for c in s:
t = widths[ord(c) - ord('a')]
if b + t > 100 and a >= 0:
a += 1
b = t
else:
b += t
if b != 0:
a += 1
return [a, b]
TypeScript 代码:
function numberOfLines(widths: number[], s: string): number[] {
let a = 0, b = 0;
for (let i = 0; i < s.length; i++) {
const t = widths[s.charCodeAt(i) - 'a'.charCodeAt(0)];
if (b + t > 100 && ++a >= 0) b = t;
else b += t;
}
if (b !== 0) a++;
return [a, b];
};
- 时间复杂度:
- 空间复杂度:不使用
toCharArray
为 ,否则为
来源:juejin.cn/post/7463836172559384627
最高3000万!全球科技闪亮苏州,“赢在苏州”2025第二届全球创新创业大赛圆满落幕!
助力苏州打造具有全球影响力的产业科技创新中心主承载区,7月9日,2025“赢在苏州”第二届全球创新创业大赛总决赛在苏州落下帷幕,157个来自海内外的决赛项目经过激烈角逐,最终7个项目脱颖而出获评一等奖。
国际化布局,集聚全球创新人才
服务国家战略,聚焦硬科技领域的前沿突破与产业化发展,本届大赛由APEC技术转移中心、苏州市科技局主办,元禾控股和苏州市科技招商中心承办,中国国际人才交流中心参与组织,旨在为全球科技创业者搭建展示技术、对接资源和成果转化的平台。
诚邀全球顶尖人才来苏创业,大赛围绕具身智能机器人、人工智能、光子及集成电路、高端装备及仪器仪表、新能源、先进材料、生物医药及大健康等七大科技行业赛道,足迹遍及旧金山、波士顿、首尔、新加坡等12座城市,成功吸引超3000个项目报名参赛,其中海外项目占比近50%,博士学历参赛选手超60%。
苏州是一座“机遇无限”的创新之城,连续12年被评为“外籍人才眼中最具吸引力的中国城市”。活动现场,外籍人才综合服务平台“魅力苏州”频道正式启用。作为外籍人才了解苏州的窗口,频道将通过“全链条、集成式、便捷化、线上线下融合”的综合信息服务,进一步提升外籍人才在苏州工作生活的便利度、舒适度。
聚焦硬科技,赋能产业集群发展
大赛以苏州“1030产业体系”为蓝图,精准锚定全球科技创新前沿和重点产业方向,链接全球百家高校、百家顶级机构、百家链主企业,组织 29 场行业复赛,通过“技术+资本+产业”多维评审机制,从3000多个项目中遴选出了 157 个项目晋级总决赛,为产业集群注入强劲动能。
从用于癌症早期筛查的创新生物技术,到大幅提升能效的新能源解决方案;从颠覆传统制造模式的高端装备制造,到推动金融科技深度融合的智能应用,157 个项目每个都拥有独特的竞争优势,而解决“卡脖子”问题、实现进口替代、填补国内空白、达到国际领先等更是被频繁提及的关键词。
全方位服务,营造优质科创生态
为了促进科技创新与产业创新的深度融合,苏州正在用真金白银和赋能服务,助力企业跑出创新“加速度”。本届大赛将为获奖的落地项目提供落地奖励、人才政策绿色通道、股债联动等支持,其中一等奖项目将直接认定苏州创新创业领军人才,并获得最高3000万元创业助力金。
经过一天的比拼,大赛总决赛共产生一等奖7名、二等奖14名、三等奖21名。本届大赛虽已结束,但作为苏州倾力打造的科技驱动型赛事品牌永不落幕,全方位的赛前赛中赛后服务已早早启动。大赛以更给力的政策、更高效的供给、更优质的生态,帮助企业更好应对在资金、技术、人才、市场等各方面的挑战,促进科技成果、先进技术在苏州茁壮成长,为国家加快实现高水平科技自立自强贡献力量。
收起阅读 »“2025京东方投资者日”在沪举办 资本战略转型开启价值创造新阶段
2025年7月8日,京东方(A股:000725;B股:200725)在上海举办“屏之物联 聚智共赢”2025 BOE Investor Day主题活动,京东方董事长陈炎顺,京东方副董事长、京东方精电执行董事兼主席高文宝,京东方华灿董事长张兆洪等3家上市公司掌舵人齐聚上海,与投资者、产业伙伴等从技术、市场、产业链协同等维度共话发展蓝图。BOE(京东方)向投资者全面介绍了“屏之物联”战略下企业发展亮点,“第N曲线”代表——钙钛矿光伏业务也首度亮相资本市场。活动旨在向投资者传递BOE(京东方)核心成长逻辑,促进公司产业价值与资本市场价值统一。
陈炎顺在致辞中表示:“上市28年间,资本市场始终给予京东方强有力的支持。随着企业迈入发展新阶段,我们应当、同时也有能力去回报投资者的长期支持。通过推出京东方历史上首个全面涵盖分红、回购的股东回报规划,我们期望以连续、稳定、可预期的股东回报,切实回报支持京东方的各位投资伙伴”。
BOE(京东方)对股东回报的承诺与践行,来源于其对产业及自身高质发展的坚定信心及良好预期。产业驱动方面,显示行业供给端正进入存量时代,格局实现重塑,从过去“规模和市场份额”竞争逐渐转向高价值驱动。通过践行“按需生产”,持续迈向高质量发展。LCD仍将是未来中长期显示行业的主流应用技术,大尺寸化势不可挡,高分辨率、高刷新率产品的推陈出新带来LCD旺盛的市场生命力;柔性OLED市场规模快速增长,技术创新加速向IT、车载等中尺寸领域渗透,带动OLED产业蓬勃发展。在此背景下,BOE(京东方)通过布局领先的第6代新型LCD生产线并实现量产、布局国内首条第8.6代AMOLED生产线等,在高端显示领域实现跨越式突破。
自身发展方面,BOE(京东方)在“屏之物联”战略指引下提出企业升维发展“第N曲线”理论,围绕显示技术、玻璃基加工能力、大规模集成智能制造能力三大优势打造全新业务增长极,布局钙钛矿光伏器件、玻璃基封装等领域,持续培育支撑企业成长的“未来业务”。其中,钙钛矿光伏目前已建成手套箱、实验线和全行业第一条最大的中试线,仅用38天就成功产出行业首片2.4×1.2m中试线样品,标志着产业化迈出重要一步;玻璃基封装业务已布局试验线,成立了玻璃基先进封装项目组,实现样机产出。今年6月底已完成设备搬入。
支撑BOE(京东方)股东回报的更关键因素在于其资本战略转型和有计划的资本运作。资本战略转型方面,BOE(京东方)资本战略重心正从规模扩张转向股东价值创造的全新发展阶段。当前行业格局趋于稳定,大规模产线投资已不再是公司发展主旋律。在此背景下,BOE(京东方)将于2025年迎来资本开支和折旧“双高峰”,此后将有望步入盈利提升通道,盈利的确定性改善为持续回报股东创造了有利条件。
资本运作方面,一是通过并购中电熊猫成都及南京产线、投资彩虹光电产线等行业优质资产进行产业整合;二是对创新业务进行独立上市打造专业发展平台,例如此前收购京东方精电(0710.HK)作为车载显示平台、收购华灿光电(300323.SZ)作为MLED产业链平台,今年又孵化了能源科技(874526.NQ)在新三板挂牌作为能源物联网平台;三是回购武汉京东方少数股权,以进一步提升对优质产线的控制权并提升归母权益。上述资本运作举措有效助力BOE(京东方)与股东共生共赢。
这是BOE(京东方)时隔一年后再度举办“投资者日”主题活动,彰显了其对资本市场的重视及对投资者回馈的强烈意愿。本次活动通过对行业前景、企业战略、业务亮点等的多维解读,展现出BOE(京东方)对发展前景的坚定信心。未来,BOE(京东方)将致力于以清晰的战略、卓越的执行力和持续稳定的盈利,不断提升股东回报,与投资者共赢。
最快实现的前端灰度方案
小白最快学会的前端灰度方案
首次访问效果如下,点击立即更新会访问灰度版本。本地cookie存在version字段后,后续访问都是指定版本代码,也不会出现弹窗提示
一、引言:为什么需要灰度发布?
1.1 血泪教训:全量发布的风险
因为一次上线,导致登录异常,用户无法使用。复盘时候,测试反馈预发环境不能完全模拟出生成环境。要不做一个灰度发布,实现代码最小化影响。
1.2 技术思考:面试的需要
多了解点技术方案,总没有坏事
二、前端灰度方案
- 在网上搜索前端灰度方案,整体看来就目前这个比较简单,上手快,易实现
- nginx + 服务端 + 前端 js(可以考虑封装成一个通用工具 js)
大致思路
> 前端通过获取版本规则,服务端计算规则
> 命中规则,重新访问页面,nginx 通过版本信息,返回指定版本
> 未命中规则,继续访问当前稳定版本页面
ps: 额外探讨,如果希望服务端接口也能有灰度版本,是不是只需要通过 nginx 配置就能实现?
三、实现细节
1. 版本规则接口
这个规则是可以自己定制的;这里我简单以 userId 进行匹配
- 案例服务端框架:
koa2 + mongoose
/**
* 获取当前用户的版本
* @param {*} ctx
*/
exports.getVersion = async (ctx) => {
try {
const version = ctx.cookies.get("version");
const userId = ctx.query.userId;
// 这里直接写死,也可以放到redis里,做成可以动态配置也行
const inTestList = ["68075c202bbd354b0fcb7a4c"];
const data = inTestList.includes(userId) ? "gray" : "stable";
if (version) {
return ctx.success(
{
version: data,
cache: true,
},
"缓存"
);
} else {
ctx.cookies.set("version", data, { maxAge: 1000 * 60 * 60 * 24 * 7 });
return ctx.success(
{
version: data,
cache: false,
},
"重新计算"
);
}
} catch (error) {
ctx.fail("获取页面记录失败");
console.error("获取页面记录失败:", error);
}
};
- userId 匹配那块,可以引入 redis 做缓存处理,避免直接查询用户表进行比对
2. 前端触发获取版本
- 交互方式,目前我能想到
- 第一种,接口请求完,才开始渲染页面,自动执行指定版本
- 第二种,接口请求、页面渲染同步进行,指定版本由用户触发
// 我把请求版本放到入口首页界面里
// 首次需要登录之后才会执行
onMounted(() => {
const userInfo = store.getters["login/getUserInfo"];
getVesion({ userId: userInfo.id }).then((res) => {
if (!res.cache && res.version === "gray") {
// 这里我增加一个弹窗提示,让用户选择
ElMessageBox.confirm("存在新的灰度版本,是否要体验最新版本?", "新版本", {
confirmButtonText: "立即更新",
cancelButtonText: "不更新",
type: "warning",
}).then(() => {
window.location.reload();
});
}
});
// 页面其他初始化逻辑
});
前端打包控制
- 项目里使用的是
vite
打包工具
- 通过增加两个配置,两者区别在于输入输出不同。
当然如果嫌维护两个配置麻烦,可以把公共相同配置抽离出来或者通过环境变量区分维护一个配置
- 新增一个入口 html 文件,并修改打包输出名称
# vite.gray.config.js
// 修改打包输出名称方便部署
const renameHtmlPlugin = () => {
return {
name: 'html-transform',
enforce: 'post',
generateBundle(options, bundle) {
bundle['gray.html'].fileName = 'index.html'
}
}
}
export default defineConfig({
// ... 其他配置
plugins: [vue(), renameHtmlPlugin()],
build: {
outDir: 'gray',
rollupOptions: {
input: {
main: resolve(__dirname, 'gray.html')
}
}
}
// ...
})
- 命令行部分
"build": "vite build",
"build:gray": "vite build --config vite.gray.config.js",
- 最终打包出来目录
// 灰度版本
-gray -
assests -
index.html -
// 稳定版本
dist -
assests -
index.html;
3. nginx 配置
这里我尝试很久,最终以下配置可以实现
通过 cookie 中版本标识,返回不同版本内容
http {
map $http_cookie $target_dir {
# 精确匹配version值,避免捕获额外内容
"~*version=gray(;|$)" "/gray";
"~*version=stable(;|$)" "/stable";
default "/stable";
}
server {
...已存在...
location / {
root html$target_dir;
try_files $uri $uri/ /index.html;
}
...已存在...
}
}
四、总结
自此一个简单前端灰度效果就实现了。当然这里还有许多的场景没有考虑到,欢迎大家提问探讨。
案例代码:gitee.com/banmaxiaoba… 代码包含一个简易的前端监控方案实现,有空下篇文章分享讨论
来源:juejin.cn/post/7515237104412360756
Wordle:为逗女友而爆火的小游戏
Wordle 的传奇故事
说起 Wordle,这绝对是近几年最火的小游戏之一。2021年,一个叫 Josh Wardle 的程序员为了逗女朋友开心,花了几个晚上做了这个简单的猜词游戏。没想到女朋友玩得很开心,就分享给了朋友,然后朋友又分享给朋友...
结果呢?短短几个月,全世界都在玩 Wordle。Twitter 上到处都是那种绿黄灰的小方块截图,连我妈都问我那些彩色格子是什么意思。
最疯狂的是,Josh 本来只是想做个小游戏玩玩,结果《纽约时报》花了七位数把它买下来。一个周末项目变成了百万美元的生意,这大概是每个程序员的梦想吧。
点击这里先试试:wordless.online
Wordle 为什么这么火?我觉得主要是几个原因:
- 简单易懂:规则五分钟就能学会
- 每天一题:不会让人沉迷,但又让人期待明天的挑战
- 社交属性:那个分享截图的功能太聪明了,不剧透但又能炫耀成绩
- 免费纯净:没有广告,没有内购,就是纯粹的游戏乐趣
Wordle这种游戏的玩法精髓
Wordle 的规则很简单:6次机会猜出5个字母的英文单词。每次猜完会给你颜色提示:
- 绿色:字母对了,位置也对
- 黄色:字母在单词里,但位置不对
- 灰色:这个字母不在单词里
听起来简单,但要玩好还是有技巧的。老玩家都有自己的套路:
开局策略:
大部分人第一个词都会选元音字母多的,比如 "ADIEU"、"AUDIO"、"AROSE"。我个人喜欢用 "STARE",因为 S、T、R 这些字母出现频率很高。
进阶技巧:
- 不要浪费已经确定是灰色的字母
- 如果黄色字母很多,先确定位置再考虑其他字母
- 有时候故意猜一个不可能的词来排除更多字母
心理战术:
Wordle 的单词选择其实是有规律的,不会选太生僻的词,也不会选复数形式。了解这个规律能帮你少走弯路。
Wordless 的独特之处
做 Wordless 的时候,我就在想:Wordle 虽然好玩,但为什么只能是5个字母?为什么一天只能玩一次?
所以 Wordless 就有了这些特色:
可变长度:
从3个字母到8个字母都可以选。3个字母的超简单,适合练手;8个字母的能把人逼疯,适合虐自己。我经常3个字母玩几局找找信心,然后挑战8个字母被打击一下。
无限游戏:
想玩多久玩多久,不用等到明天。有时候猜对了一个难词,兴奋得想继续玩,Wordless 就能满足这种需求。
智能单词库:
不会连续出现相同的单词,每次都是新鲜的挑战。而且按长度分类,保证每个难度级别都有足够的词汇。
策略变化:
不同长度的单词需要不同的策略。3个字母可能就是纯猜测,但8个字母就需要更系统的方法了。
玩 Wordless 的时候,我发现自己的策略会根据单词长度调整:
- 3-4字母:直接猜常见词,比如 "THE"、"AND"
- 5-6字母:用经典的 Wordle 策略
- 7-8字母:先确定元音位置,再慢慢填辅音
其他有趣的变种游戏
Wordle 火了之后,各种变种游戏如雨后春笋般出现。有些真的很有创意:
Absurdle:
这个游戏会故意跟你作对,每次都选择让你最难猜中的单词。有种跟 AI 斗智斗勇的感觉。
Worldle:
猜国家形状,地理爱好者的天堂。我经常被一些小岛国难住。
Heardle:
猜歌名,听前奏猜歌。音乐版的 Wordle,但我这种五音不全的人基本靠蒙。
Nerdle:
数学版 Wordle,猜数学等式。适合数学好的人,我一般看一眼就放弃了。
这些变种游戏都证明了 Wordle 这个核心玩法有多么强大,几乎可以套用到任何领域。
玩法心得分享
玩了这么久的词汇游戏,我总结了几个心得:
不要太执着于完美开局:
很多人纠结第一个词选什么,其实差别没那么大。重要的是根据反馈调整策略。
学会利用排除法:
有时候猜一个明知道不对的词,就是为了排除更多字母,这是高级玩法。
保持词汇积累:
经常玩这类游戏确实能学到新单词,我的英语词汇量就是这么慢慢提升的。
享受过程:
不要太在意成绩,重要的是享受那种一步步接近答案的乐趣。
最后说一句,无论是 Wordle 还是 Wordless,最重要的是玩得开心。毕竟游戏的初衷就是娱乐,不是考试。
现在就玩起来吧:wordless.online
来源:juejin.cn/post/7517860258112028691
URL地址末尾加不加 "/" 有什么区别
作者:程序员成长指北
原文:mp.weixin.qq.com/s/HJ7rXddgd…
在前端开发、SEO 优化、API 调试中,我们经常会遇到一个小细节——URL 结尾到底要不要加 /
?
看似微不足道,实则暗藏坑点。很多人可能用着没出过错,但当项目复杂、页面增多、路径嵌套时,不懂这点可能让你踩大坑。
今天,咱们就花5分钟一次彻底讲透。
先弄清楚:URL 是"目录"还是"资源"?
URL是Uniform Resource Locator(统一资源定位符)缩写,本质上就是互联网上资源的"地址"。
而地址的结尾到底是 /
还是没有 /
,它们背后其实指代的是两种不同的资源类型:
URL 示例 | 意义 | 常见行为 |
---|---|---|
https://myblog.tech/posts/ | 目录 | 默认加载 posts 目录下的 index.html |
https://myblog.tech/about | 具体资源(文件) | 加载 about 这个文件 |
小结:
- 结尾有
/
→ 通常表示是"文件夹" - 没有
/
→ 通常表示是"具体资源(如文件)"
为什么有时候必须加 /
?
1. 相对路径解析完全不同
假设你打开这个页面:
https://mystore.online/products/
页面里有这么一行代码:
<img src="phone.jpg">
👉 浏览器会去请求:
https://mystore.online/products/phone.jpg
✅ 图片加载成功。
但如果你访问的是:
https://mystore.online/products
相同的 <img src="phone.jpg">
会被解析为:
https://mystore.online/phone.jpg
❌ 直接 404,因为浏览器误以为 products
是个文件,而不是目录。
2. 服务器解析的区别
不同服务器(如 Nginx、Apache)的处理行为也会影响是否需要 /
:
情况 | 结果 |
---|---|
访问 https://devnotes.site/blog | 如果 blog 是个目录,服务器可能会 301 重定向 到 https://devnotes.site/blog/ |
访问 https://devnotes.site/blog/ | 直接返回 blog/index.html |
📌 某些老旧或自定义服务器,如果不加 /
,直接返回 404。
是否需要加
/
、是否会返回index.html
、是否发生重定向,完全取决于服务端(如 Nginx)的配置。
3. SEO 有坑:重复内容惩罚
对搜索引擎来说:
是两个不同的 URL。
如果不做规范化,搜索引擎可能会认为你在刷重复内容,影响 SEO 权重。
Google 等搜索引擎确实可能将不同的 URL 视为重复内容(duplicate content),但它们也提供了相应的工具和方法来规范化这些 URL。例如,可以在 robots.txt 或通过 <link rel="canonical" href="...">
来指明规范 URL,以避免 SEO 问题。
✅ 最佳实践:
- 统一加
/
或统一不加/
。 - 用 301 重定向 , 确保网站的所有页面都指向规范的 URL,避免因未做重定向而造成的索引重复问题。
4. RESTful API 请求
API 请求尤其需要小心:
GET https://api.myapp.io/users
和
GET https://api.myapp.io/users/
某些框架(如 Flask、Django、Express)默认对这两种 URL 会有不同的路由匹配。
不一致的 /
很可能导致:
- ❌ 404 Not Found
- ❌ 405 Method Not Allowed
- ❌ 请求结果不同
最好直接查阅 API 文档确认是否敏感。
实用建议
- 前端开发:
- 如果页面中涉及到相对路径引用,建议始终确保 URL 末尾有
/
,以避免路径解析错误。 - 推荐所有目录型地址统一加
/
。
- 如果页面中涉及到相对路径引用,建议始终确保 URL 末尾有
- 服务端配置:
- 确保有清晰的 URL 重定向策略,保持唯一性,避免 SEO 重复。
- API 调用:
- 检查接口文档,看是否对 URL 末尾
/
敏感,不确定就加/
试一试。
- 检查接口文档,看是否对 URL 末尾
总结
URL 末尾是否加斜杠(/
)看似一个小细节,但它会影响网页加载、路径解析、SEO 和 API 请求的行为。
来源:juejin.cn/post/7522989217459896346
超简单,手摸手教你做个mcp,天气查询AI助手
引言
嗯,大家好呀!今天咱们来聊聊 MCP(Model Context Protocol) 的开发,从搭建一个简单的天气查询Server,再到写一个能调用Server功能的Client,整个过程都会一步一步讲解。
不知道你有没有遇到过这样的问题:
- 想用AI模型(比如DeepSeek、OpenAI)去调用一些外部API(比如天气查询、GitHub仓库管理),但不知道如何让AI和这些服务交互?
- 或者,你想让AI自动帮你完成一些任务,比如查天气、爬取网页、管理文件,但不知道怎么让AI“学会”调用这些工具?
MCP 就是为了解决这个问题而生的! 它定义了一套标准化的协议,让AI模型可以轻松调用各种外部工具(Tools),就像人类使用USB接口一样方便。
那么,咱们今天的目标就是:
- 搭建一个MCP Server(天气查询服务)
- 开发一个MCP Client(调用天气查询的AI助手)
- 让AI模型学会自动调用MCP工具
准备好了吗?咱们开始吧!
1. MCP Server 开发:天气查询服务
1.1 环境准备
首先,咱们得把开发环境搭好,嗯,这一步可不能马虎!
- 安装
uv
(现代化的Python包管理工具)
pip install uv
或者用PowerShell安装(Windows用户推荐):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
- 初始化项目 & 创建虚拟环境
uv init
uv venv .venv
.venv\Scripts\activate # 激活虚拟环境(Windows)
1.2 编写天气查询Server
接下来,咱们要写一个 MCP Server,让它能调用OpenWeatherMap的API查询天气。
从这里openweathermap.org/ 去拿到自己的OpenWeather API Key
。
核心代码(weather.py
):
import json
import httpx
from typing import Any
from mcp.server.fastmcp import FastMCP
# 初始化 MCP 服务器,命名为"WeatherServer"
# 嗯,这里相当于创建了一个天气服务的后台程序
mcp = FastMCP("WeatherServer")
# OpenWeather API 的配置信息
# 注意:这个API_KEY是示例,你需要自己去OpenWeather官网申请一个哦!
OPENWEATHER_API_BASE = "https://api.openweathermap.org/data/2.5/weather"
API_KEY = "xxx" # 请替换为你自己的 OpenWeather API Key
USER_AGENT = "weather-app/1.0" # 这个用来标识我们的应用
async def fetch_weather(city: str) -> dict[str, Any] | :
"""
从 OpenWeather API 获取天气信息。
:param city: 城市名称(需使用英文,如 Beijing)
:return: 天气数据字典;若出错返回包含 error 信息的字典
"""
# 准备请求参数
params = {
"q": city, # 城市名
"appid": API_KEY, # API密钥
"units": "metric", # 使用公制单位(摄氏度)
"lang": "zh_cn" # 返回中文描述
}
headers = {"User-Agent": USER_AGENT} # 设置请求头
# 使用httpx的异步客户端发送请求
async with httpx.AsyncClient() as client:
try:
# 发送GET请求,设置30秒超时
response = await client.get(
OPENWEATHER_API_BASE,
params=params,
headers=headers,
timeout=30.0
)
response.raise_for_status() # 如果响应不成功会抛出异常
return response.json() # 返回解析后的JSON数据
except httpx.HTTPStatusError as e:
# 处理HTTP错误(比如404,401等)
return {"error": f"HTTP 错误: {e.response.status_code}"}
except Exception as e:
# 处理其他异常(比如网络问题)
return {"error": f"请求失败: {str(e)}"}
def format_weather(data: dict[str, Any] | str) -> str:
"""
将天气数据格式化为易读文本。
:param data: 天气数据(可以是字典或 JSON 字符串)
:return: 格式化后的天气信息字符串
"""
# 如果传入的是字符串,先尝试转换成字典
if isinstance(data, str):
try:
data = json.loads(data)
except Exception as e:
return f"无法解析天气数据: {e}"
# 如果数据中包含错误信息,直接返回错误提示
if "error" in data:
return f"⚠ {data['error']}"
# 从返回数据中提取天气信息,使用get方法避免KeyError
city = data.get("name", "未知") # 城市名
country = data.get("sys", {}).get("country", "未知") # 国家
temp = data.get("main", {}).get("temp", "N/A") # 温度
humidity = data.get("main", {}).get("humidity", "N/A") # 湿度
wind_speed = data.get("wind", {}).get("speed", "N/A") # 风速
# weather字段是个列表,可能为空,所以先给个默认值
weather_list = data.get("weather", [{}])
description = weather_list[0].get("description", "未知") # 天气描述
# 返回格式化的字符串,用了emoji让输出更直观
return (
f"🌍 {city}, {country}\n"
f"🌡 温度: {temp}°C\n"
f"💧 湿度: {humidity}%\n"
f"🌬 风速: {wind_speed} m/s\n"
f"⛅ 天气: {description}\n"
)
@mcp.tool() # 这个装饰器把这个函数注册为MCP可调用的工具
async def query_weather(city: str) -> str:
"""
输入指定城市的英文名称,返回今日天气查询结果。
:param city: 城市名称(需使用英文)
:return: 格式化后的天气信息
"""
data = await fetch_weather(city) # 先获取原始天气数据
return format_weather(data) # 然后格式化输出
if __name__ == "__main__":
# 以标准 I/O 方式运行 MCP 服务器
# 这样其他程序就可以通过标准输入输出和这个服务通信啦
mcp.run(transport='stdio')
关键点解析:
@mcp.tool()
这个装饰器告诉MCP:“嘿,这个函数是一个可以被AI调用的工具!”query_weather
函数会调用OpenWeather API,返回格式化后的天气数据。
1.3 在Trae里配置MCP Server
为了让AI能调用这个Server,咱们得在 Trae(一个支持MCP的AI平台)里配置一下:
{
"mcpServers": {
"weather": {
"command": "uv",
"args": ["run", "weather.py"]
}
}
}
这样,Trae就知道怎么启动这个天气查询服务啦!
2. MCP Client 开发:让AI学会调用天气查询
2.1 安装依赖
咱们的Client需要调用OpenAI(或者DeepSeek)的模型,所以先装好依赖:
uv add openai python-dotenv
2.2 配置模型参数
在 .env
文件里写上你的API信息:
BASE_URL=https://api.deepseek.com
MODEL=deepseek-chat
API_KEY=你的API_KEY
这样代码就能安全读取你的密钥,不会泄露啦!
2.3 编写MCP Client
现在,咱们要写一个Client,让它能:
- 连接MCP Server
- 让AI模型自动决定什么时候调用天气查询
核心代码(client.py
):
from openai import OpenAI
from mcp import ClientSession
client = OpenAI(api_key="你的API_KEY")
async def ask_ai(query):
response = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": query}],
tools=[{
"type": "function",
"function": {
"name": "query_weather",
"description": "查询指定城市的天气",
"parameters": {"city": {"type": "string"}}
}
}]
)
return response.choices[0].message
当运行Client并询问"今天北京天气怎么样?"时,AI会返回类似这样的格式化天气信息:
🌍 Beijing, CN
🌡 温度: 25°C
💧 湿度: 65%
🌬 风速: 3.2 m/s
⛅ 天气: 晴
ai识别当你问“北京天气”,ai自动回发现要去调query_weather
工具,然后就自动把北京
转成英文Beijing
作为参数。
然后就去调用OpenWeather
的api去发送请求获取北京天气,收到的数据是:
{
"name": "Beijing",
"sys": {"country": "CN"},
"main": {"temp": 25, "humidity": 65},
"wind": {"speed": 3.2},
"weather": [{"description": "晴"}]
}
3. 进阶:让MCP更强大
3.1 更多工具
除了天气查询,咱们还能让AI调用:
- GitHub仓库管理(查代码、提交PR)
- Figma设计稿解析(让AI看懂UI设计)
- 本地文件管理(让AI帮你整理文件夹)
只要写好对应的 @mcp.tool()
,AI就能学会调用!
3.2 调试技巧
如果AI调工具出错了怎么办?可以用 MCP Inspector 调试:
npx @modelcontextprotocol/inspector uv run weather.py
它会显示AI调用了哪些工具、返回了什么数据,超级方便!
结语
好啦,今天咱们从 MCP Server 到 MCP Client 完整走了一遍,正好明白:
- MCP Server 提供工具(比如天气查询)
- MCP Client 让AI学会调用这些工具
- AI模型 自动决定什么时候该用哪个工具
来源:juejin.cn/post/7520475965082648639
void 0 的奥秘:解锁 JavaScript 中 undefined 的正确打开方式
一、 理解 void 0
1.1 什么是 void 运算符?
void
是 JavaScript 中的一个运算符,它接受一个表达式作为操作数,总是返回 undefined,无论操作数是什么。
console.log(void 0); // undefined
console.log(void 1); // undefined
console.log(void "hello"); // undefined
console.log(void {}); // undefined
1.2 为什么使用 void 0 而不是 undefined?
在 ES5 之前,undefined
不是保留字,可以被重写:
// 在ES3环境中可能出现的危险情况
var undefined = "oops";
console.log(undefined); // "oops" 而不是预期的 undefined
void 0
则始终可靠地返回真正的 undefined 值:
var undefined = "oops";
console.log(void 0); // undefined (不受影响)
1.3 现代 JavaScript 还需要 void 0 吗?
ES5 及以后版本中,undefined
是不可写、不可配置的全局属性:
// 现代浏览器中
undefined = "oops";
console.log(undefined); // undefined (不会被修改)
二、void 0 的实用场景
2.1 最小化场景:减少代码体积
void 0
比 undefined
更短,在需要极致压缩代码时很有用:
// 原始代码
function foo(param) {
if (param === undefined) {
param = 'default';
}
}
// 压缩后(使用 void 0)
function foo(n){if(n===void 0)n="default"}
2.2 立即执行函数表达式 (IIFE)
传统 IIFE 写法:
(function() {
// 代码
})();
使用 void
的 IIFE:
void function() {
// 代码
}();
2.3 箭头函数中避免返回值
当需要箭头函数不返回任何值时:
let func = () => {
return new Promise((resolve, reject) => {
setTimeout(resolve(5));
})
};
// 会返回 func 的 Promise
const logData = func();
// 明确不返回值
const logData = void func();
三、常见的 void 0 误区
3.1 与 undefined和null的严格比较
console.log(void 0 === undefined); // true
console.log(void 0 === null); // false
3.2 与 void 其他表达式
let count = 0;
void ++count;
console.log(count); // 1 (表达式仍会执行)
总结
如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript开发干货。
来源:juejin.cn/post/7524504350250762294
也只有JavaScript可以写出这么离谱的代码了吧
今天,有个朋友给我发了一串神秘的字符( (!(~+[]) + {})[--[~+''][+[]] * [~+[]] + ~~!+[]] + ({} + [])[[~!+[]] * ~+[]] ),还要我在控制台打印一下试试
好家伙,原来JavaScrip还能这样玩,那这到底是什么原理呢?
字符串解析
这段代码是一个典型的 JavaScript 混淆代码,通过一系列运算和隐式类型转换来生成字符串。首先我们先解析一下这段字符串,不难发现,这个字符串可以划分为两个部分:
第一部分
(!(
+[]) + {})[--[+''][+[]] * [~+[]] + ~~!+[]]
拆解步骤:
~+[]
+[]
将空数组转换为数字0
,~0
按位取反得到-1
。
[~+''][+[]]
~+''
先将空字符串转为数字0
,再取反得到-1
,即[-1]
。[+[]]
等价于[0]
,因此[-1][0]
得到-1
。
--[~+''][+[]]
即--[-1][0]
,递减后得到-2
。
[~+[]]
和前面的一样,即[-1]
。~~!+[]
+[]
为0
,!0
为true
,~~true
两次取反得到1
。
--[~+''][+[]] * [~+[]] + ~~!+[]
计算:-2 * -1 + 1 = 3
。
!(~+[])
~+[]
为-1
,!(-1)
为false
。
!(~+[]) + {}
false
转为字符串"false"
,与空对象拼接得到"false[object Object]"
。
"false[object Object]"[3]
索引3
对应的字符是's'
。
第二部分
({} + [])[[~!+[]] * ~+[]]
拆解步骤:
({} + [])
空对象转为字符串"[object Object]"
,与空数组相加仍为"[object Object]"
。
~!+[]
+[]
为0
,!0
为true
,~true
按位取反得到-2
。
[~!+[]]
即[-2]
。[~!+[]] * ~+[]
[-2]
转为数字-2
,~+[]
为-1
,计算:-2 * -1 = 2
。
"[object Object]"[2]
索引2
对应的字符是'b'
。
合并结果
将两部分结果拼接:'s' + 'b' = 'sb'
。
核心技巧
- 隐式类型转换
- 数组/对象通过
+
运算转为字符串。 !
、~
、+
等运算符触发类型转换(如+[] → 0
,[]+{} → "[object Object]"
,+{}+[] → "NaN"
)。
- 数组/对象通过
- 按位运算
~
用于生成特定数值(如-1
、-2
)。 - 数组索引
通过计算得到字符串索引(如3
、2
),提取目标字符。
实现一个代码混淆函数
通过对前面那串神秘字符的分析,我们也知道了它的核心思路就是通过JavaScript的隐式类型转换规则对字符进行转换,那么我们是不是可以将我们的代码也都转换成这些字符,来达到一个代码混淆的效果呢?
1、数字转换
- 0:
+[]
将空数组转换为数字0
- 1:
![]
将空数组转为 false ,!![]
再次取反得到 true ,+!![]
+号让true隐式转换为1 - 其他数字 都可以通过1和0进行加减乘除或拼接来得到
{
0: "+[]",
1: "+!![]",
2: "!![]+!![]",
3: "!![]+!![]+!![]",
4: "(!![]+!![]) * (!![]+!![])",
5: "(!![]+!![]) * (!![]+!![]) + !![]",
6: "(!![]+!![]) * (!![]+!![]+!![])",
7: "(!![]+!![]) * (!![]+!![]+!![]) + !![]",
8: "(!![]+!![]) ** (!![]+!![]+!![])",
9: "(!![]+!![]+!![]) ** (!![]+!![])",
}
2、字母转换
- undefined
([][[]]+[]) 相当于 [][0]+'' ,可以得到字符串 undefined
- false
(![]+[]) 相当于 !true+'' ,可以得到字符串 false
- true
(!![]+[]) 相当于 !!true+'' ,可以得到字符串 true
- [object Object]
({} + []) ,空对象转为字符串 "[object Object]"
,与空数组相加仍为 "[object Object]"
。
- NaN
(+{}+[]) ,{}会被隐式转为数字类型,对象无法被解析成有效数字,所以会返回 NaN
- constructor
通过前面转换的字符串,我们可以拼接成完整的 constructor 字符串。
我们可以通过构造器来获取到更多的字符,比如:
3、其他字符
通过前面的方法我们就可以将大部分的字母都获取到了,但是还有部分字母获取不到,那么剩下的字母就和其他字符一样都可以通过下面这个方式来获取:
比如字母 U:
ASCII 码 U → 八进制转十进制 85 → 转义为 \125,那么我们可以直接这样获得字母 U
所以我们只需要想办法得到函数构造器即可,还是要用到前面提到过的 constructor ,我们知道数组有很多内置的函数,比如 at:
那么 at 方法的构造器就是一个函数,我们直接通过 constructor 就可以获取到一个函数构造器
这里用到的字母都可以通过简单的转换得到,把字母通过前面的方法转换替换掉就可以了
好了,到这里你就实现了一个简单的 JSFuck 了~
体验地址
源码
组件源码已开源到gitee,有兴趣的也可以到这里看看:gitee.com/zheng_yongt…
- 🌟觉得有帮助的可以点个star~
- 🖊有什么问题或错误可以指出,欢迎pr~
- 📬有什么想要实现的组件或想法可以联系我~
公众号
关注公众号『 前端也能这么有趣 』,获取更多有趣内容。
发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~
说在后面
🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『
前端也能这么有趣
』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。
来源:juejin.cn/post/7503846429082468389
在线人数实时推送?WebSocket 太重,SSE 正合适 🎯🎯🎯
有些项目要统计在线人数,其实更多是为了“营造热闹气氛”。比如你进个聊天室,看到“有 120 人在看”,是不是感觉这个地方挺活跃的?这就是一种“社交证明”,让用户觉得:哇,这个地方挺火,值得留下来。而且对产品来说,这也能提高用户的参与感和粘性。
有哪些实现方式?为啥我最后选了 SSE?
在考虑怎么实现“统计在线人数并实时显示”这个功能时,其实我一开始也没直接想到要用 SSE。毕竟实现方式有好几种,咱们不妨一步步分析一下常见的几种做法,看看它们各自的优缺点,这样最后为啥选 SSE,自然就水落石出了。
❌ 第一种想法:轮询(Polling)
最容易想到的方式就是:我定时去问服务器,“现在有多少人在线?”
比如用 setInterval
每隔 3 秒发一次 AJAX 请求,服务器返回一个数字,前端拿到之后更新页面。
听起来没毛病,对吧?确实简单,写几行代码就能跑起来。
但问题也很快暴露了:
- 就算在线人数 10 分钟都没变,客户端也在一直请求,完全没必要,非常低效
- 这种方式根本做不到真正的“实时”,除非你每秒钟请求一次(但那样服务器压力就爆炸了)
- 每个用户都发请求,这压力不是乘以用户数么?人一多,服务器直接“变卡”
所以轮询虽然简单,但在“实时在线人数”这种场景下,不管性能、实时性还是用户体验,都不够理想。
❌ 第二种方案:WebSocket
再往上一个层级,很多人就会想到 WebSocket,这是一个可以实现双向通信的技术,听起来非常高级。
确实,WebSocket 特别适合聊天室、游戏、协同编辑器这种实时互动场景——客户端和服务端随时可以互相发消息,效率高、延迟低。
但问题也来了:我们真的需要那么重的武器吗?
- 我只是要服务器把“当前在线人数”这个数字发给页面,不需要客户端发什么消息回去
- WebSocket 的连接、心跳、断线重连、资源管理……这套机制确实强大,但同时也让开发复杂度和服务器资源占用都提高了不少
- 而且你要部署 WebSocket 服务的话,很多时候还得考虑反向代理支持、跨域、协议升级等问题
总结一句话:WebSocket 能干的活太多,反而不适合干这么简单的一件事。
✅ 最后选择:SSE(Server-Sent Events)
然后我就想到了 SSE。
SSE 是 HTML5 提供的一个非常适合“服务端单向推送消息”的方案,浏览器用 EventSource
这个对象就能轻松建立连接,服务端只需要按照特定格式往客户端写数据就能实时推送,非常简单、非常轻量。
对于“统计在线人数”这种场景来说,它刚好满足所有需求:
- 客户端不需要发消息,只要能“听消息”就够了 —— SSE 就是只读的推送流,正合适
- 我只需要服务端一有变化(比如某个用户断开连接),就通知所有人当前在线人数是多少 —— SSE 的广播机制就很好实现这一点
- 而且浏览器断线后会自动重连,你不需要写额外的心跳或者重连逻辑,直接爽用
- 它用的是普通的 HTTP 协议,部署和 Nginx 配合也没啥问题
当然它也不是没有缺点,比如 IE 不支持(但现在谁还用 IE 啊),以及它是单向通信(不过我们压根也不需要双向)。
所以综合来看,SSE 就是这个功能的“刚刚好”方案:轻量、简单、稳定、足够用。
项目实战
首先我们先贴上后端的代码,后端我们使用的是 NextJs 提供的 API 来实现的后端接口,首先我们先来看看我们的辅助方法:
// 单例模式实现的在线用户计数器
// 使用Symbol确保私有属性
const _connections = Symbol("connections");
const _clients = Symbol("clients");
const _lastCleanup = Symbol("lastCleanup");
const _maxInactiveTime = Symbol("maxInactiveTime");
const _connectionTimes = Symbol("connectionTimes");
// 创建一个单例计数器
class ConnectionCounter {
private static instance: ConnectionCounter;
private [_connections]: number = 0;
private [_clients]: Set<(count: number) => void> = new Set();
private [_lastCleanup]: number = Date.now();
// 默认10分钟未活动的连接将被清理
private [_maxInactiveTime]: number = 10 * 60 * 1000;
// 跟踪连接ID和它们的最后活动时间
private [_connectionTimes]: Map<string, number> = new Map();
private constructor() {
// 防止外部直接实例化
}
// 获取单例实例
public static getInstance(): ConnectionCounter {
if (!ConnectionCounter.instance) {
ConnectionCounter.instance = new ConnectionCounter();
}
return ConnectionCounter.instance;
}
// 生成唯一连接ID
generateConnectionId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
}
// 添加新连接
addConnection(connectionId: string): number {
this[_connectionTimes].set(connectionId, Date.now());
this[_connections]++;
this.broadcastCount();
// 如果活跃连接超过100或上次清理已经超过5分钟,执行清理
if (
this[_connectionTimes].size > 100 ||
Date.now() - this[_lastCleanup] > 5 * 60 * 1000
) {
this.cleanupStaleConnections();
}
return this[_connections];
}
// 移除连接
removeConnection(connectionId: string): number {
// 如果连接ID存在则移除
if (this[_connectionTimes].has(connectionId)) {
this[_connectionTimes].delete(connectionId);
this[_connections] = Math.max(0, this[_connections] - 1);
this.broadcastCount();
}
return this[_connections];
}
// 更新连接的活动时间
updateConnectionActivity(connectionId: string): void {
if (this[_connectionTimes].has(connectionId)) {
this[_connectionTimes].set(connectionId, Date.now());
}
}
// 清理长时间不活跃的连接
cleanupStaleConnections(): void {
const now = Date.now();
this[_lastCleanup] = now;
let removedCount = 0;
this[_connectionTimes].forEach((lastActive, connectionId) => {
if (now - lastActive > this[_maxInactiveTime]) {
this[_connectionTimes].delete(connectionId);
removedCount++;
}
});
if (removedCount > 0) {
this[_connections] = Math.max(0, this[_connections] - removedCount);
this.broadcastCount();
console.log(`Cleaned up ${removedCount} stale connections`);
}
}
// 获取当前连接数
getConnectionCount(): number {
return this[_connections];
}
// 订阅计数更新
subscribeToUpdates(callback: (count: number) => void): () => void {
this[_clients].add(callback);
// 立即返回当前计数
callback(this[_connections]);
// 返回取消订阅函数
return () => {
this[_clients].delete(callback);
};
}
// 广播计数更新到所有客户端
private broadcastCount(): void {
this[_clients].forEach((callback) => {
try {
callback(this[_connections]);
} catch (e) {
// 如果回调失败,从集合中移除
this[_clients].delete(callback);
}
});
}
}
// 导出便捷函数
const counter = ConnectionCounter.getInstance();
export function createConnection(): string {
const connectionId = counter.generateConnectionId();
counter.addConnection(connectionId);
return connectionId;
}
export function closeConnection(connectionId: string): number {
return counter.removeConnection(connectionId);
}
export function pingConnection(connectionId: string): void {
counter.updateConnectionActivity(connectionId);
}
export function getConnectionCount(): number {
return counter.getConnectionCount();
}
export function subscribeToCountUpdates(
callback: (count: number) => void
): () => void {
return counter.subscribeToUpdates(callback);
}
// 导出实例供直接使用
export const connectionCounter = counter;
这段代码其实就是做了一件事:统计当前有多少个用户在线,而且可以实时推送到前端。我们用了一个“单例”模式,也就是整个服务里只有一个 ConnectionCounter
实例,避免多人连接时出现数据错乱。每当有新用户连上 SSE 的时候,就会生成一个唯一的连接 ID,然后调用 createConnection()
把它加进来,在线人数就 +1。
这些连接 ID 都会被记录下来,还会记住“最后活跃时间”。如果用户一直在线,我们就可以通过前端发个心跳(pingConnection()
)来告诉后端“我还在哦”。断开连接的时候(比如用户关闭了页面),我们就通过 closeConnection()
把它移除,人数就 -1。
为了防止有些用户没正常断开(比如突然关机了),代码里还有一个“自动清理机制”,默认 10 分钟没动静的连接就会被清理掉。每次人数变化的时候,这个计数器会“广播”一下,通知所有订阅它的人说:“嘿,在线人数变啦!”
而这个订阅机制(subscribeToCountUpdates()
)特别关键——它可以让我们在 SSE 里实时推送人数更新,前端只要监听着,就能第一时间看到最新的在线人数。我们还把常用的操作都封装好了,比如 createConnection()
、getConnectionCount()
等,让整个流程特别容易集成。
总结一下:这段逻辑就是 自动统计在线人数 + 自动清理无效连接 + 实时推送更新
接下来我们编写后端 SSE 接口,如下代码所示:
import {
createConnection,
closeConnection,
pingConnection,
subscribeToCountUpdates,
} from "./counter";
export async function GET() {
// 标记连接是否仍然有效
let connectionClosed = false;
// 为此连接生成唯一ID
const connectionId = createConnection();
// 当前连接的计数更新回调
let countUpdateUnsubscribe: (() => void) | null = null;
// 使用Next.js的流式响应处理
return new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// 安全发送数据函数
const safeEnqueue = (data: string) => {
if (connectionClosed) return;
try {
controller.enqueue(encoder.encode(data));
} catch (error) {
console.error("SSE发送错误:", error);
connectionClosed = true;
cleanup();
}
};
// 定义interval引用
let heartbeatInterval: NodeJS.Timeout | null = null;
let activityPingInterval: NodeJS.Timeout | null = null;
// 订阅在线用户计数更新
countUpdateUnsubscribe = subscribeToCountUpdates((count) => {
if (!connectionClosed) {
try {
safeEnqueue(
`data: ${JSON.stringify({ onlineUsers: count })}\n\n`
);
} catch (error) {
console.error("发送在线用户数据错误:", error);
}
}
});
// 清理所有资源
const cleanup = () => {
if (heartbeatInterval) clearInterval(heartbeatInterval);
if (activityPingInterval) clearInterval(activityPingInterval);
// 取消订阅计数更新
if (countUpdateUnsubscribe) {
countUpdateUnsubscribe();
countUpdateUnsubscribe = null;
}
// 如果连接尚未计数为关闭,则减少连接计数
if (!connectionClosed) {
closeConnection(connectionId);
connectionClosed = true;
}
// 尝试安全关闭控制器
try {
controller.close();
} catch (e) {
// 忽略关闭时的错误
}
};
// 设置15秒的心跳间隔,避免连接超时
heartbeatInterval = setInterval(() => {
if (connectionClosed) {
cleanup();
return;
}
safeEnqueue(": heartbeat\n\n");
}, 15000);
// 每分钟更新一次连接活动时间
activityPingInterval = setInterval(() => {
if (connectionClosed) {
cleanup();
return;
}
pingConnection(connectionId);
}, 60000);
},
cancel() {
// 当流被取消时调用(客户端断开连接)
if (!connectionClosed) {
closeConnection(connectionId);
connectionClosed = true;
if (countUpdateUnsubscribe) {
countUpdateUnsubscribe();
}
}
},
}),
{
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"X-Accel-Buffering": "no", // 适用于某些代理服务器如Nginx
},
}
);
}
这段代码是一个 Next.js 的 API 路由,用来建立一个 SSE 长连接,并把“当前在线人数”实时推送给客户端。
第一步就是建立连接并注册计数当客户端发起请求时,后端会:
- 调用
createConnection()
生成一个唯一的连接 ID; - 把这次连接计入在线用户总数里;
- 并返回一个
ReadableStream
,让服务端能不断往客户端“推送消息”。
第二步就是订阅在线人数变化,一旦连接建立,服务端就调用 subscribeToCountUpdates()
,订阅在线人数的变化。一旦总人数发生变化,它就会通过 SSE 推送这样的数据给前端:
data: {
onlineUsers: 23;
}
也就是说,每次有人连上或断开,所有前端都会收到更新,非常适合“在线人数展示”。
第三步就是定期心跳和活跃检测:
- 每 15 秒服务端会发一个
: heartbeat
,保持连接不断开; - 每 60 秒调用
pingConnection()
,告诉后台“我还活着”,防止被误判为不活跃连接而清理。
第四步是清理逻辑,当连接被取消(比如用户关闭页面)或出错时,后台会:
- 调用
closeConnection()
把这条连接从统计中移除; - 取消掉在线人数的订阅;
- 停掉心跳和活跃检测定时器;
- 安全关闭这个数据流。
这个清理逻辑保证了数据准确、资源不浪费,不会出现“人数不减”或“内存泄露”。
最后总结一下,这段代码实现了一个完整的“谁连接我就+1,谁断开我就-1,然后实时广播当前在线人数”的机制。你只要在前端用 EventSource
接收这条 SSE 流,就能看到用户数量实时跳动,非常适合用在聊天室、控制台、直播页面等场景。
目前后端代码我们是编写完成了,我们来实现一个前端页面来实现这个功能来对接这个接口:
"use client";
import React, { useState, useEffect, useRef } from "react";
export default function OnlineCounter() {
const [onlineUsers, setOnlineUsers] = useState(0);
const [connected, setConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
// 创建SSE连接
const connectSSE = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
try {
const eventSource = new EventSource(`/api/sse?t=${Date.now()}`);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
setConnected(true);
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// 只处理在线用户数
if (data.onlineUsers !== undefined) {
setOnlineUsers(data.onlineUsers);
}
} catch (error) {
console.error("解析数据失败:", error);
}
};
eventSource.onerror = (error) => {
console.error("SSE连接错误:", error);
setConnected(false);
eventSource.close();
// 5秒后尝试重新连接
setTimeout(connectSSE, 5000);
};
} catch (error) {
console.error("创建SSE连接失败:", error);
setTimeout(connectSSE, 5000);
}
};
connectSSE();
// 组件卸载时清理
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
};
}, []);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 to-slate-800 text-white flex flex-col items-center justify-center p-4">
<div className="bg-slate-800 rounded-xl shadow-2xl overflow-hidden max-w-sm w-full">
<div className="p-6">
<h1 className="text-3xl font-bold text-center text-blue-400 mb-6">
在线用户统计
h1>
<div className="flex items-center justify-center mb-4">
<div
className={`h-3 w-3 rounded-full mr-2 ${
connected ? "bg-green-500" : "bg-red-500"
}`}
>div>
<p className="text-sm text-slate-300">
{connected ? "已连接" : "连接断开"}
p>
div>
<div className="flex items-center justify-center bg-slate-700 rounded-lg p-8 mt-4">
<div className="flex flex-col items-center">
<div className="text-6xl font-bold text-green-400 mb-2">
{onlineUsers}
div>
<div className="flex items-center">
<svg
className="w-5 h-5 text-green-400 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" />
svg>
<span className="text-lg font-medium text-green-300">
在线用户
span>
div>
div>
div>
div>
div>
div>
);
}
最终输出结果如下图所示:
总结
SSE 实现在线人数统计可以说是简单高效又刚刚好的选择:它支持服务端单向推送,客户端只用监听就能实时获取在线人数更新,不用自己轮询。相比 WebSocket 来说,SSE 更轻量,部署起来也更方便。我们还通过心跳机制和活跃时间管理,保证了数据准确、连接稳定。整体来说,功能对得上,性能扛得住,代码写起来也不费劲,是非常适合这个场景的一种实现方式。
技术方式 | 实时性 | 实现难度 | 性能消耗 | 适不适合这个功能 | 备注 |
---|---|---|---|---|---|
轮询 | ★★☆☆☆ | ★☆☆☆☆(最简单) | ★☆☆☆☆(浪费) | ❌ 不推荐 | 太低效了 |
WebSocket | ★★★★★ | ★★★★☆(较复杂) | ★★★☆☆(重型) | ❌ 不合适 | 太强大、太复杂 |
SSE | ★★★★☆ | ★★☆☆☆(非常容易上手) | ★★★★☆(轻量) | ✅ 非常适合 | 简单好用又高效 |
来源:juejin.cn/post/7492640608206487562
甲方嫌弃,项目首页加载太慢
有一天,甲方打开一个后台管理的项目,说有点卡,不太满意,项目经理叫我优化,重新打包一下。
从输入地址 到 展示 首屏,最佳时间在 3秒内,否则,甲方挂脸,咱就有可能有被裁的风险,understand?
废话不多说,先来看一下怎么个优化法吧。
优化
✅ cdn
分析
用Webpack Bundle Analyzer分析依赖,安装webpack-bundle-analyzer打包分析插件:
# NPM
npm install --save-dev webpack-bundle-analyzer
# Yarn
yarn add -D webpack-bundle-analyzer
反正都是装,看着来。
配一下:
// vue.config.js 文件里。(没有就要新建一下)
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}
打包
执行打包命令并查看分析
npm run build --report
打包结束后,会在项目根目录下生成dist文件。自动跳到127.0.0.1:8888
(没有跳的话,手动打开dist文件夹下的report.html),这个网址就是打包分析报告
。
占得比较大的块,就是Element UI组件库和echarts库占的空间比相对较大。
这就要考虑,第一,要按需,要啥再用啥,不要一股脑啥都装。按需安装,按需加载。
第二,考虑单独引入这些组件库的cdn
,这样速度也会咔咔提升。
详细讲一下怎么搞cdn
。
按需大家都知道,要啥再引入啥,再装啥。
比如element-ui
,我要uninstall
掉,然后呢,去引入cdn,不要装库了,用cdn。
去package.json
里面看element-ui
装了啥版本,然后看完之后,就npm uninstall element-ui
卸载掉。
去cdn库里面去找https://www.staticfile.org/
,(首先先说一下,要找免费的开放的那种,因为一般有的公司没有自家的cdn,没有自家的桶,有的话,直接把js文件地址拖上去,然后得到一个地址,这样也安全,也方便,但没有的话另说)。
样式库: https://cdn.staticfile.org/element-ui/2.15.12/theme-chalk/index.min.css
组件库:https://cdn.staticfile.org/element-ui/2.15.12/index.min.js
然后去public/index.html
入口文件中,去加入这个东西,像咱以前写原生一样引入就好,body里面引入js,head里面引入css。:
<head>
<link rel="stylesheet" href="https://cdn.staticfile.org/element-ui/2.15.12/theme-chalk/index.min.css">
</head>
<body>
<script src="https://cdn.staticfile.org/element-ui/2.15.12/index.min.js"></script>
</body>
所以这样子,就引入好了。接着在main.js
里面,把之前import
的所有element
的样式删掉。
接着,vue.config.js
的configureWebpack
加个externals
字段:
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
],
externals: {
'element-ui': 'ELEMENT' // key 是之前install下下来的包名,element-ui。value是全局变量名(ELEMENT)
}
}
externals: Webpack 的 externals
配置用于声明某些依赖应该从外部获取,而不是打包到最终的 bundle 中。这样可以减小打包体积,前提是这些依赖已经在运行环境中存在。
'element-ui': 'ELEMENT'
的含义
- 当你的代码中
import 'element-ui'
时,Webpack 不会打包element-ui
,而是会从全局变量ELEMENT
中获取它。 ELEMENT
是element-ui
库通过<script>
标签引入时,在全局(window
)中暴露的变量名。
例如,如果你在 HTML 中这样引入:
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
那么element-ui
会挂载到window.ELEMENT
上。
为什么这样配置?
- 通常是为了通过 CDN 引入
element-ui
(而不是打包它),从而优化构建速度和体积。 - 你需要确保在 HTML 中通过
<script>
提前加载了element-ui
,否则运行时ELEMENT
会是undefined
。
<!-- HTML 中通过 CDN 引入 element-ui -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
// webpack.config.js
module.exports = {
externals: {
'element-ui': 'ELEMENT' // 告诉 Webpack:import 'element-ui' 时,返回全局的 ELEMENT
}
};
// 你的代码中依然可以正常 import(但实际用的是全局变量)
import ElementUI from 'element-ui';
// 相当于:const ElementUI = window.ELEMENT;
注意事项:
- 确保全局变量名(
ELEMENT
)和element-ui
的 CDN 版本一致。不同版本的库可能有不同的全局变量名。 - 如果使用模块化打包(如 npm + Webpack 全量打包),则不需要配置
externals
。
这里有的伙伴就说,我咋知道是ELEMENT
,而不是element
呢。
这里是这么找的:
直接在浏览器控制台检查
在 HTML 中通过 CDN 引入该库:
<script src="https://cdn.staticfile.org/element-ui/2.15.12/index.min.js"></script>
打开浏览器开发者工具(F12),在 Console 中输入:
console.log(window);
然后查找可能的全局变量名(如 ELEMENT、ElementUI 等)。
cdn配置之后,重新分析
npm run build --report
重新用cdn
的去分析,
那么就很舒服了,也因此,这个就是cdn优化的方法。
✅ nginx gzip压缩
server {
listen 8103;
server_name ************;
# 开启gzip
gzip on;
# 进行压缩的文件类型。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;
}
✅vue gzip压
安包:npm i compression-webpack-plugin@1.1.12 --save-dev
注意版本匹配问题。
vue配置,这段配置是 Webpack 构建中关于 Gzip 压缩 的设置,位于 config/index.js
文件中。:
//文件路径 config --> index.js
build: {
productionGzip: true, // 启用生产环境的 Gzip 压缩
productionGzipExtensions: ['js', 'css'], // 需要压缩的文件类型
}
productionGzip: true
- 作用:开启 Gzip 压缩,减少静态资源(JS、CSS)的体积,提升页面加载速度。
- 要求:需要安装
compression-webpack-plugin
(如注释所述)。
npm install --save-dev compression-webpack-plugin
productionGzipExtensions: ['js', 'css']
- 指定需要压缩的文件扩展名(默认压缩 JS 和 CSS 文件)。
为什么需要 Gzip?
- 优化性能:Gzip 压缩后的资源体积可减少 60%~70%,显著降低网络传输时间。
- 服务器支持:大多数现代服务器(如 Nginx、Netlify)会自动对静态资源进行 Gzip 压缩,但本地构建时提前生成
.gz
文件可以避免服务器实时压缩的开销。
✅ 按需加载路由
路由级代码分割(动态导入)
// 原写法
import About from './views/About.vue'
// 优化后写法
const About = () => import(/* webpackChunkName: "about" */ './views/About.vue')
- 首页只加载核心代码(home路由)
- about模块会在用户点击about路由时才加载
- 显著减少首屏加载资源体积
✅ 合理配置 prefetch策略
// vue.config.js
module.exports = {
chainWebpack: config => {
// 移除prefetch插件
config.plugins.delete('prefetch')
// 或者更精细控制
config.plugin('prefetch').tap(options => {
options[0].fileBlacklist = options[0].fileBlacklist || []
options[0].fileBlacklist.push(/myasyncRoute(.)+?\.js$/)
return options
})
}
}
- 禁用prefetch:减少不必要的带宽消耗,但可能增加后续路由切换等待时间
- 启用prefetch:利用浏览器空闲时间预加载,提升用户体验但可能浪费带宽
- 折中方案:只对关键路由或高概率访问的路由启用prefetch
✅ splitChunks 将node_modules中的依赖单独打包
拆分vendor:将node_modules中的依赖单独打包
config.optimization.splitChunks({
chunks: 'all',
cacheGr0ups: {
vendors: {
name: 'chunk-vendors',
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial'
}
}
})
✅ 按需引入 lodash
import debounce from 'lodash/debounce'
用啥再引啥。
甲方笑了
打开首页闪电一进,完美ending!!!
散会啦😊
来源:juejin.cn/post/7514310580720517147
放弃 JSON.parse(JSON.stringify()) 吧!试试现代深拷贝!
作者:程序员成长指北
原文:mp.weixin.qq.com/s/WuZlo_92q…
最近小组里的小伙伴,暂且叫小A吧,问了一个bug:
提示数据循环引用,相信不少小伙伴都遇到过类似问题,于是我问他:
我:你知道问题报错的点在哪儿吗
小A: 知道,就是下面这个代码,但不知道怎么解决。
onst a = {};
const b = { parent: a };
a.child = b; // 形成循环引用
try {
const clone = JSON.parse(JSON.stringify(a));
} catch (error) {
console.error('Error:', error.message); // 会报错:Converting circular structure to JSON
}
上面是我将小A的业务代码提炼为简单示例,方便阅读。
- 这里
a.child
指向b
,而b.parent
又指回a
,形成了循环引用。 - 用
JSON.stringify
时会抛出 Converting circular structure to JSON 的错误。
我顺手查了一下小A项目里 JSON.parse(JSON.stringify())
的使用情况:
一看有50多处都使用了, 使用频率相当高了。
我继续提问:
我:你有找解决方案吗?
小A: 我看网上说可以自己实现一个递归来解决,但是我不太会实现
于是我帮他实现了一版简单的递归深拷贝:
function deepClone(obj, hash = new Map()) {
if (typeof obj !== 'object' || obj === null) return obj;
if (hash.has(obj)) return hash.get(obj);
const clone = Array.isArray(obj) ? [] : {};
hash.set(obj, clone);
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key], hash);
}
}
return clone;
}
// 测试
const a = {};
const b = { parent: a };
a.child = b;
const clone = deepClone(a);
console.log(clone.child.parent === clone); // true
此时,为了给他拓展一下,我顺势抛出新问题:
我: 你知道原生Web API 现在已经提供了一个深拷贝 API吗?
小A:???
于是我详细介绍了一下:
主角 structuredClone
登场
structuredClone()
是浏览器原生提供的 深拷贝 API,可以完整复制几乎所有常见类型的数据,包括复杂的嵌套对象、数组、Map、Set、Date、正则表达式、甚至是循环引用。
它遵循的标准是:HTML Living Standard - Structured Clone Algorithm(结构化克隆算法)。
语法:
const clone = structuredClone(value);
一行代码,优雅地解决刚才的问题:
const a = {};
const b = { parent: a };
a.child = b; // 形成循环引用
const clone = structuredClone(a);
console.log(clone !== a); // true
console.log(clone.child !== b); // true
console.log(clone.child.parent === clone); // true,循环引用关系被保留
为什么增加 structuredClone
?
在 structuredClone
出现之前,常用的深拷贝方法有:
方法 | 是否支持函数/循环引用 | 是否支持特殊对象 |
---|---|---|
JSON.parse(JSON.stringify(obj)) | ❌ 不支持函数、循环引用 | ❌ 丢失 Date 、RegExp 、Map 、Set |
第三方库 lodash.cloneDeep | ✅ 支持 | ✅ 支持,但体积大,速度较慢 |
手写递归 | ✅ 可支持 | ❌ 复杂、易出错 |
structuredClone
是 原生、极速、支持更多数据类型且无需额外依赖 的现代解决方案。
支持的数据类型
类型 | 支持 |
---|---|
Object | ✔️ |
Array | ✔️ |
Map / Set | ✔️ |
Date | ✔️ |
RegExp | ✔️ |
ArrayBuffer / TypedArray | ✔️ |
Blob / File / FileList | ✔️ |
ImageData / DOMException / MessagePort | ✔️ |
BigInt | ✔️ |
Symbol(保持引用) | ✔️ |
循环引用 | ✔️ |
❌ 不支持:
- 函数(Function)
- DOM 节点
- WeakMap、WeakSet
常见使用示例
1. 克隆普通对象
const obj = { a: 1, b: { c: 2 } };
const clone = structuredClone(obj);
console.log(clone); // { a: 1, b: { c: 2 } }
console.log(clone !== obj); // true
2. 支持循环引用
const obj = { name: 'Tom' };
obj.self = obj;
const clone = structuredClone(obj);
console.log(clone.self === clone); // true
3. 克隆 Map、Set、Date、RegExp
const complex = {
map: new Map([["key", "value"]]),
set: new Set([1, 2, 3]),
date: new Date(),
regex: /abc/gi
};
const clone = structuredClone(complex);
console.log(clone);
兼容性
提到新的API,肯定得考虑兼容性问题:
- Chrome 98+
- Firefox 94+
- Safari 15+
- Node.js 17+ (
global.structuredClone
)
如果需要兼容旧浏览器:
- 可以降级使用
lodash.cloneDeep
- 或使用 MessageChannel Hack
很多小伙伴一看到兼容性问题,可能心里就有些犹豫:
"新API虽然好,但旧浏览器怎么办?"
但技术的发展离不开新技术的应用和推广,只有更多人开始尝试并使用,才能让新API真正普及开来,最终成为主流。
建议:
如果你的项目运行在现代浏览器或 Node.js 环境,structuredClone
是目前最推荐的深拷贝方案。 Node.js 17+:可以直接使用 global.structuredClone
。
来源:juejin.cn/post/7524232022124085257
localhost 和 127.0.0.1 到底有啥区别?
在开发中,我们经常会接触到 localhost
和 127.0.0.1
。很多人可能觉得它们是一样的,甚至可以互换使用。实际上,它们确实有很多相似之处,但细究起来,也存在一些重要的区别。
本篇文章就带大家一起来深入了解 localhost
和 127.0.0.1
,并帮助你搞清楚它们各自的特点和适用场景。
一、什么是 localhost
?
localhost
是一个域名,它被广泛用于表示当前这台主机(也就是你自己的电脑)。当你在浏览器地址栏输入 localhost
时,操作系统会查找 hosts
文件(在 Windows
中通常位于 C:\Windows\System32\drivers\etc\hosts
,在 MacOS 或者 Linux 系统中,一般位于 /etc/hosts
),查找 localhost
对应的 IP 地址。如果没有找到,它将默认解析为 127.0.0.1
。
特点:
- 是一个域名,默认指向当前设备。
- 不需要联网也能工作。
- 用于测试本地服务,例如开发中的 Web 应用或 API。
小知识 🌟:域名和 IP 地址的关系就像联系人名字和电话号码。我们用名字联系某个人,实际上是依赖后台的通讯录解析到实际号码来拨号。
二、什么是 127.0.0.1
?
127.0.0.1
是一个特殊的 IP 地址,它被称为 回环地址(loopback address)。这个地址专门用于通信时指向本机,相当于告诉电脑“别出门,就在家里转一圈”。你可以试一试在浏览器中访问 127.0.0.2
看看会访问到什么?你会发现,它同样会指向本地服务!环回地址的范围是 127.0.0.0/8
,即所有以 127 开头的地址都属于环回网络,但最常用的是 127.0.0.1
。
特点:
- 127.0.0.1 不需要 DNS 解析,因为它是一个硬编码的地址,直接指向本地计算机。
- 是 IPv4 地址范围中的一个保留地址。
- 只用于本机网络通信,不能通过这个地址访问外部设备或网络。
- 是开发测试中最常用的 IP 地址之一。
小知识 🌟:所有从
127.0.0.0
到127.255.255.255
的 IP 地址都属于回环地址,但通常只用127.0.0.1
。
三、两者的相似点
- 都指向本机
- 不管是输入
localhost
还是127.0.0.1
,最终都会将请求发送到你的电脑,而不是其他地方。
- 不管是输入
- 常用于本地测试
- 在开发中,我们需要在本机运行服务并测试,
localhost
和127.0.0.1
都是标准的本地访问方式。
- 在开发中,我们需要在本机运行服务并测试,
- 无需网络支持
- 即使你的电脑没有连接网络,这两个也可以正常使用,因为它们完全依赖于本机的网络栈。
四、两者的不同点
区别 | localhost | 127.0.0.1 |
---|---|---|
类型 | 域名 | IP 地址 |
解析过程 | 需要通过 DNS 或 hosts 文件解析为 IP 地址 | 不需要解析,直接使用 |
协议版本支持 | 同时支持 IPv4 和 IPv6 | 仅支持 IPv4 |
访问速度 | 解析时可能稍慢(视 DNS 配置而定) | 通常更快,因为不需要额外的解析步骤 |
五、为什么 localhost
和 127.0.0.1
有时表现不同?
在大多数情况下,localhost
和 127.0.0.1
是等效的,但在一些特殊环境下,它们可能会表现出差异:
1. IPv4 和 IPv6 的影响
localhost
默认可以解析为 IPv4(127.0.0.1
)或 IPv6(::1
)地址,具体取决于系统配置。如果你的程序只支持 IPv4,而 localhost
被解析为 IPv6 地址,可能会导致连接失败。
示例:
# 测试 localhost 是否解析为 IPv6
ping localhost
可能的结果:
- 如果返回
::1
,说明解析为 IPv6。 - 如果返回
127.0.0.1
,说明解析为 IPv4。
2. hosts
文件配置
在某些情况下,你的 localhost
并不一定指向 127.0.0.1
。这是因为域名解析优先会查找系统的 hosts
文件:
- Windows:
C:\Windows\System32\drivers\etc\hosts
- Linux/macOS:
/etc/hosts
示例:自定义 localhost
# 修改 hosts 文件
127.0.0.1 my-local
之后访问 http://my-local
会指向 127.0.0.1
,但如果 localhost
被误配置成其他地址,可能会导致问题。
3. 防火墙或网络配置的限制
某些网络工具或防火墙规则会区别对待域名和 IP 地址。如果只允许 127.0.0.1
通信,而不允许 localhost
,可能会引发问题。
六、在开发中如何选择?
- 优先使用
localhost
因为它是更高层次的表示方式,更通用。如果将来需要切换到不同的 IP 地址(例如 IPv6),不需要修改代码。 - 需要精准控制时用
127.0.0.1
如果你明确知道程序只需要使用 IPv4 环境,或者想避免域名解析可能带来的问题,直接用 IP 地址更稳妥。
示例:用 Python 测试
# 使用 localhost
import socket
print(socket.gethostbyname('localhost')) # 输出可能是 127.0.0.1 或 ::1
# 使用 127.0.0.1
print(socket.gethostbyname('127.0.0.1')) # 输出始终是 127.0.0.1
七、总结
虽然 localhost
和 127.0.0.1
大部分情况下可以互换使用,但它们的本质不同:
localhost
是域名,更抽象。127.0.0.1
是 IP 地址,更具体。
在开发中,我们应根据场景合理选择,尤其是在涉及到跨平台兼容性或网络配置时,理解它们的差异性会让你事半功倍。
最后,记得动手实践,多跑几个测试。毕竟,编程是用代码说话的艺术!😄
如果你觉得这篇文章对你有帮助,记得点个赞或分享给更多人!有其他技术问题想了解?欢迎评论区留言哦~ 😊
来源:juejin.cn/post/7511583779578200115
都说了布尔类型的变量不要加 is 前缀,非要加,这不是坑人了嘛
开心一刻
今天心情不好,给哥们发语音
我:哥们,晚上出来喝酒聊天吧
哥们:咋啦,心情不好?
我:嗯,刚刚在公交车上看见前女友了
哥们:然后呢?
我:给她让座时,发现她怀孕了...
哥们:所以难受了?
我:不是她怀孕让我难受,是她怀孕还坐公交车让我难受
哥们:不是,她跟着你就不用坐公交车了?不还是也要坐,有区别吗?
我默默的挂断了语音,心情更难受了

Java开发手册
作为一个 javaer
,我们肯定看过 Alibaba
的 Java开发手册,作为国内Java开发领域的标杆性编码规范,我们或多或少借鉴了其中的一些规范,其中有一点

我印象特别深,也一直在奉行,自己还从未试过用 is
作为布尔类型变量的前缀,不知道会有什么坑;正好前段时间同事这么用了,很不幸,他挖坑,我踩坑,阿西吧!

is前缀的布尔变量有坑
为了复现问题,我先简单搞个 demo
;调用很简单,服务 workflow
通过 openfeign
调用 offline-sync
,代码结构如下

qsl-data-govern-common:整个项目的公共模块
qsl-offline-sync:离线同步
- qsl-offline-sync-api:向外提供
openfeign
接口
- qsl-offline-sync-common:离线同步公共模块
- qsl-offline-sync-server:离线同步服务
qsl-workflow:工作流
- qsl-workflow-api:向外提供
openfeign
接口,暂时空实现
- qsl-workflow-common:工作流公共模块
- qsl-workflow-server:工作流服务
完整代码:qsl-data-govern
qsl-offline-sync-server
提供删除接口
/**
* @author 青石路
*/
@RestController
@RequestMapping("/task")
public class SyncTaskController {
private static final Logger LOG = LoggerFactory.getLogger(SyncTaskController.class);
@PostMapping("/delete")
public ResultEntity<String> delete(@RequestBody SyncTaskDTO syncTask) {
// TODO 删除处理
LOG.info("删除任务[taskId={}]", syncTask.getTaskId());
return ResultEntity.success("删除成功");
}
}
qsl-offline-sync-api
对外提供 openfeign
接口
/**
* @author 青石路
*/
@FeignClient(name = "data-govern-offline-sync", contextId = "dataGovernOfflineSync", url = "${offline.sync.server.url}")
public interface OfflineSyncApi {
@PostMapping(value = "/task/delete")
ResultEntity<String> deleteTask(@RequestBody SyncTaskDTO syncTaskDTO);
}
qsl-workflow-server
调用 openfeign
接口
/**
* @author 青石路
*/
@RestController
@RequestMapping("/definition")
public class WorkflowController {
private static final Logger LOG = LoggerFactory.getLogger(WorkflowController.class);
@Resource
private OfflineSyncApi offlineSyncApi;
@PostMapping("/delete")
public ResultEntity<String> delete(@RequestBody WorkflowDTO workflow) {
LOG.info("删除工作流[workflowId={}]", workflow.getWorkflowId());
// 1.查询工作流节点,查到离线同步节点(taskId = 1)
// 2.删除工作流节点,删除离线同步节点
ResultEntity<String> syncDeleteResult = offlineSyncApi.deleteTask(new SyncTaskDTO(1L));
if (syncDeleteResult.getCode() != 200) {
LOG.error("删除离线同步任务[taskId={}]失败:{}", 1, syncDeleteResult.getMessage());
ResultEntity.fail(syncDeleteResult.getMessage());
}
return ResultEntity.success("删除成功");
}
}
逻辑是不是很简单?我们启动两个服务,然后发起 http
请求
POST http://localhost:8081/data-govern/workflow/definition/delete
Content-Type: application/json
{
"workflowId": 99
}
此时 qsl-offline-sync-server
日志输出如下
2025-06-30 14:53:06.165|INFO|http-nio-8080-exec-4|25|c.q.s.s.controller.SyncTaskController :删除任务[taskId=1]
至此,一切都很正常,第一版也是这么对接的;后面 offline-sync
进行调整,删除接口增加了一个参数:isClearData
public class SyncTaskDTO {
public SyncTaskDTO(){}
public SyncTaskDTO(Long taskId, Boolean isClearData) {
this.taskId = taskId;
this.isClearData = isClearData;
}
private Long taskId;
private Boolean isClearData = false;
public Long getTaskId() {
return taskId;
}
public void setTaskId(Long taskId) {
this.taskId = taskId;
}
public Boolean getClearData() {
return isClearData;
}
public void setClearData(Boolean clearData) {
isClearData = clearData;
}
}
然后实现对应的逻辑
/**
* @author 青石路
*/
@RestController
@RequestMapping("/task")
public class SyncTaskController {
private static final Logger LOG = LoggerFactory.getLogger(SyncTaskController.class);
@PostMapping("/delete")
public ResultEntity<String> delete(@RequestBody SyncTaskDTO syncTask) {
// TODO 删除处理
LOG.info("删除任务[taskId={}]", syncTask.getTaskId());
if (syncTask.getClearData()) {
LOG.info("清空任务[taskId={}]历史数据", syncTask.getTaskId());
// TODO 清空历史数据
}
return ResultEntity.success("删除成功");
}
}
调整完之后,同事通知我,让我做对 qsl-workflow
做对应的调整。调整很简单,qsl-workflow
删除时直接传 true
即可
/**
* @author 青石路
*/
@RestController
@RequestMapping("/definition")
public class WorkflowController {
private static final Logger LOG = LoggerFactory.getLogger(WorkflowController.class);
@Resource
private OfflineSyncApi offlineSyncApi;
@PostMapping("/delete")
public ResultEntity<String> delete(@RequestBody WorkflowDTO workflow) {
LOG.info("删除工作流[workflowId={}]", workflow.getWorkflowId());
// 1.查询工作流节点,查到离线同步节点(taskId = 1)
// 2.删除工作流节点,删除离线同步节点
// 删除离线同步任务,isClearData直接传true
ResultEntity<String> syncDeleteResult = offlineSyncApi.deleteTask(new SyncTaskDTO(1L, true));
if (syncDeleteResult.getCode() != 200) {
LOG.error("删除离线同步任务[taskId={}]失败:{}", 1, syncDeleteResult.getMessage());
ResultEntity.fail(syncDeleteResult.getMessage());
}
return ResultEntity.success("删除成功");
}
}
调整完成之后,发起 http
请求,发现历史数据没有被清除,看日志发现
LOG.info("清空任务[taskId={}]历史数据", syncTask.getTaskId());
没有打印,参数明明传的是 true
吖!!!
offlineSyncApi.deleteTask(new SyncTaskDTO(1L, true));
这是哪里出了问题?

问题排查
因为 qsl-offline-sync-api
是直接引入的,并非我实现的,所以我第一时间找到了其实现者,反馈了问题后让其自测下;一开始他还很自信,说这么简单怎么会有问题

当他启动 qsl-offline-sync-server
后,发起 http
请求
POST http://localhost:8080/data-govern/sync/task/delete
Content-Type: application/json
{
"taskId": 123,
"isClearData": true
}
发现 isClearData
的值是 false

此刻,疑问从我的额头转移到了他的额头上,他懵逼了,我轻松了。为了功能能够正常交付,我还是决定看下这个问题,没有了心理压力,也许更容易发现问题所在。第一眼看到 isClearData
,我就隐约觉得有问题,所以我决定仔细看下 SyncTaskDTO
这个类,发现 isClearData
的 setter
和 getter
方法有点不一样
private Boolean isClearData = false;
public Boolean getClearData() {
return isClearData;
}
public void setClearData(Boolean clearData) {
isClearData = clearData;
}
方法名是不是少了 Is
?带着这个疑问我找到了同事,问他 setter
、getter
为什么要这么命名?他说是 idea
工具自动生成的(也就是我们平时用到的idea自动生成setter、getter方法的功能)

我让他把 Is
补上试试
private Boolean isClearData = false;
public Boolean getIsClearData() {
return isClearData;
}
public void setIsClearData(Boolean isClearData) {
this.isClearData = isClearData;
}
发现传值正常了,他回过头看着我,我看着他,两人同时提问
他:为什么加了
Is
就可以了?
我:布尔类型的变量,你为什么要加
is
前缀?
问题延申
作为一个严谨的开发,不只是要知其然,更要知其所以然;关于
为什么加了
Is
就可以了
这个问题,我们肯定是要会上一会的;会这个问题之前,我们先来捋一下参数的流转,因为是基于 Spring MVC
实现的 Web 应用,所以我们可以这么问 deepseek
Spring MVC 是如何将前端参数转换成POJO的
能够查到如下重点信息

RequestResponseBodyMethodProcessor
的 resolveArgument
/**
* Throws MethodArgumentNotValidException if validation fails.
* @throws HttpMessageNotReadableException if {@link RequestBody#required()}
* is {@code true} and there is no body content or if there is no suitable
* converter to read the content with.
*/
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
parameter = parameter.nestedIfOptional();
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
String name = Conventions.getVariableNameForParameter(parameter);
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
if (arg != null) {
validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
if (mavContainer != null) {
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
}
}
return adaptArgumentIfNecessary(arg, parameter);
}
正是解析参数的地方,我们打个断点,再发起一次 http
请求

很明显,readWithMessageConverters
是处理并转换参数的地方,继续跟进去会来到 MappingJackson2HttpMessageConverter
的 readJavaType
方法

此刻我们可以得到,是通过 jackson
完成数据绑定与数据转换的。继续跟进,会看到 isClearData
的赋值过程

通过前端传过来的参数 isClearData
找对应的 setter
方法是 setIsClearData,而非 setClearData
,所以问题
为什么加了
Is
就可以了
是不是就清楚了?
问题解决
- 按上述方式调整
isClearData
的setter
、getter
方法
带上
is
public Boolean getIsClearData() {
return isClearData;
}
public void setIsClearData(Boolean isClearData) {
this.isClearData = isClearData;
}
- 布尔类型的变量,不用
is
前缀
可以用
if
前缀
private Boolean ifClearData = false;
public Boolean getIfClearData() {
return ifClearData;
}
public void setIfClearData(Boolean ifClearData) {
this.ifClearData = ifClearData;
}
- 可以结合
@JsonProperty
来处理
@JsonProperty("isClearData")
private Boolean isClearData = false;
总结
Spring MVC
对参数的绑定与转换,内容不同,采用的处理器也不同
- form表单数据(application/x-www-form-urlencoded)
处理器:
ServletModelAttributeMethodProcessor
- JSON 数据 (application/json)
处理器:
RequestResponseBodyMethodProcessor
转换器:MappingJackson2HttpMessageConverter
- 多部分文件 (multipart/form-data)
处理器:
MultipartResolver
- form表单数据(application/x-www-form-urlencoded)
POJO
的布尔类型变量,不要加is
前缀
命名不符合规范,集成第三方框架的时候就很容易出不好排查的问题
成不了规范的制定者,那就老老实实遵循规范!
来源:juejin.cn/post/7521642915278422070
为了搞一个完美的健身APP,我真是费尽心机
作为一个强迫症患者,当我需要一个简单、好用、流畅、无广告的健身记录软件时,撸铁记就诞生了。
为什么我要开发撸铁记
我应该是2018年接触健身的,那个时候我的教练每次给我上课,都会拿着一个文件夹记录我的每一次训练。但是纸制记录最大的问题是难保存,而且只能教练一个人看,于是我写了第一个健身记录软件,叫FitnessRecord,然后我就在知乎上分享了自己的应用,没想到真的有人用!
后来,在朋友的撺掇下,我正式决定将撸铁记推上线,然后就是(巴拉巴拉极其费劲的上线了!)
个人开发者有多痛苦
一个完美的软件,最重要的,不仅要好看,还得好用,于是,就出现了下面这些设计
暗黑模式
一个 APP,如果不支持暗黑模式,那你让强迫症怎么活?
但是...你以为这就完了吗?细节藏在魔鬼里😄
绝对黑
记得前两年各大手机厂商还在卷屏幕的时候,苹果率先推出了“绝对黑”,强调OLED屏幕通过像素关闭实现的物理级纯黑效果。so~为了实现在暗黑模式下,软件用的更爽,撸铁记的 APP 的背景色使用了#000000,也就是纯黑色
这样做的好处是在暗黑模式下,撸铁记可以与屏幕完美的融为一体。但是!问题来了。纯黑色真的很难设计,作为一个程序员出身的我,头发都抓掉了好几把。
有细心的小伙伴们或许已经发现了,亮色模式下跟暗黑模式的主题色其实不是一个颜色:
我们发现在暗黑模式下,亮色模式下的主题色与黑色之间的对比度不够明显,导致整体色调暗沉,因此,亮色模式的主题色是:#3B7AEF 暗黑模式下则是:#2E6FEC
虚拟导航键适配
Android 的虚拟导航键如果适配不好,有多丑相信懂得都懂,为了能够在弹窗模式下也能够让弹窗与导航栏完美无瑕的融为一体,我设计了一个 BaseDialog,专门用来管理弹窗状态,确保在任何页面,虚拟导航栏都不会影响到 APP 的整体颜值!
左滑展示更多功能
作为一个专业的记录软件,各种各样的功能总要有吧?
全部堆叠到更多菜单中是不是很傻?如果在屏幕排列出来展示是不是更傻?所以,左滑删除这种很合理的交互是不是得有?
IOS 设备是常态,但是能够完美的搬到 Android 机器上,该怎么做?鸿蒙系统又该怎么适配?!
但是!我说的是但是,为了更漂亮的 UI,更合理的交互,我又熬了个通宵,最终完美解决!
好的交互就得多看,多学
每个人的习惯都不同,比如有的用户希望能够在倒计时 120s 之后有一个声音提示,有的则希望可以按照训练顺序,对卡片自动排序,那么问题来了,这些功能又该堆叠在哪里呢?
我的灵感来源是一款不太出名的 P 图软件
在训练详情页面的左侧,有一根很不起眼的线,当你触摸这条线的时候,就会弹出训练设置的总菜单啦!(不用担心很难触摸,我已经将触摸范围调整到了最合适的大小,既不会误触,也不会很难点👍)
其实,APP 还有很多为了“好看”而做的设计,但是一个好的 APP,只是静态的好看怎么能行!
完美的入场动效
我该如何像您吹嘘这系统级的丝滑动效?请看 VCR(希望掘金支持视频链接😂):
http://www.bilibili.com/video/BV1sb…
http://www.bilibili.com/video/BV1Pb…
如何?是否足够丝滑???
当然,功能性才是核心
除了记录的易用性和强大复杂的功能,为了能够 360° 覆盖健身所需要的所有场景,我还开发了各种各样的功能
赛博智能
赛博智能,我希望这个功能可以像赛博机器人一样,对我的身体状况进行 360° 评估。
鄙人不才,晒一下我的身体状态评估分析:
一个超级大长图,几乎涵盖了你想要知道的一切~当然,后续还会继续丰富其他功能😁
日历统计
这个月你偷懒了吗
是的,你的每一滴汗水,都会浓缩破到这一张小小的日历表格中,如果你偷懒了,那就是一张空空的日历,那么,你会努力填满每一天的,对吧?
最后的最后
按原本的计划,我想要从设计到功能,认真的介绍一下撸铁记的所有方方面面,但是回头看看,文章真的太长了,所以,就留一点悬念给大家,希望需要的小伙伴自行探索😁
其实,每一个细节,我都改过很多次,后续依旧会不断的改来改去,因为我只想要最好~
最后,祝愿所有喜欢健身的朋友,都可以收获自己成功~
来源:juejin.cn/post/7524504350250205238
Stack Overflow,轰然倒下!
你好呀,我是歪歪。
前几天看到一个让我感慨万千的走势图:
本来想让你猜一猜这个走势图的内容是什么的。
但是结合标题你应该也能猜到了,和 Stack Overflow 有关。
这个走势图的数据是 Stack Overflow 从 2008 年开始到现在,每个月新问题的个数。
数据的来源是这个网站:
data.stackexchange.com/stackoverfl…
它可以以 SQL 的形式查询相关的数据。
从走势图可以看到,从 2008 年到 2014 年是陡增的趋势,可以说是高歌猛进,翻着翻的上涨。
2014 年到 2020 年,数据起起伏伏,但总比 2020 年之后的一泻千里好的多。
把每个月的明细数据下载下来之后,我看了一下 TOP 3 的情况:
- 2020/5/1,302381 个新问题
- 2020/4/1,299146 个新问题
- 2017/3/1,298552 个新问题
最辉煌的时候,是 2020 年。
可能那个时候大家都在居家办公,遇到问题也没有同事可以咨询,就顺手在网上求助网友了。
但急转直下也是在 2020 年。
因为那一年末 ChatGPT 横空出世,并凭借还算不错的表现,慢慢被大家开始接受了。
而这几年 AI 发展的突飞猛进,越来越少的人选择 stack overflow。
至于为啥越来越少的人选择 Stack Overflow?
我想还是在于便捷性上。
和 AI 交互,你给它问题,它能立马响应,直接给你正确答案,或者引导你去寻找正确答案。
和 Stack Overflow,或者和任何问答平台交互,你发布问题之后得等,等到有人看到你的问题,然后才有可能会回答。
如果你只是想在 Stack Overflow 里面找一个问题的答案,而不是打算自己提出一个问题的话,那 AI 更加是碾压式的存在。
因为你可以让 AI 帮你在 Stack Overflow 里面找答案。
Stack Overflow 免费提供了它十几年间的所有问答数据,然后被各个 AI 当做了训练模型。
最后落得的下场,说好听点叫功成身退,说难听点就是卸磨杀驴。
我记得曾经还有一个关于程序员的梗。
就是当程序捕获到异常之后,由程序自动发起一个请求给 Stack Overflow,然后获取解决方案。
所以,作为程序员,你应该知道 Stack Overflow 在某种程度上,它就是程序员的圣经,它的回答就是权威。
我写技术类文章的时候,如果顺着问题找到一个 Stack Overflow 的链接,我在潜意识里面就会认为,这个链接里面就会有我在寻找的答案,而且是正确答案。
但是这些都是很新鲜的“过去的故事”了。
我把前面获取到的表格排序后拉到表格最后,2025 年的数据已经跌落到了 2008 年的水平:
再回头看看这个走势图:
不得不承认,Stack Overflow,几乎是成不可逆转之势般的倒下了。
两个问题。
我之前写过的技术文章中,Stack Overflow 出现的频率非常的高。
有时候我会去上面找素材。
以至于一提到 Stack Overflow 我立马就能想起至少两个我写过的有意思的问题。
第一个问题是这样的:
当时觉得这个输出结果很奇怪,有点意思,于是研究了一下。
最终经过一番折腾也是在 Stack Overflow 找到了答案。
但是现在,我只需要把问题扔给各种 AI 大模型,比如 DeepSeek。
它就能给出答案:
然后还可以继续追问“额外5分43秒”产生的具体原因是什么:
给出的参考链接中也有 Stack Overflow 的链接:
第二个问题是这样的:
把这个问题扔给 DeepSeek 之后,它也很快就给出了答案:
答案总结起来就是一句话:
伪随机数生成器的序列是确定的,但看起来“随机”。
这些特定的种子值(-229985452 和 -147909649)是通过反向计算或暴力搜索找到的,目的是使 nextInt(27) 的序列恰好匹配 "hello" 和 "world" 的字符编码。
好,现在如果没有 AI,我给你上面这两段代码。
甚至我直接告诉你,这个代码的输出结果可能是 1900-01-01 08:05:43:
public class MainTest {
public static void main(String[] args) throws Exception {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date date = simpleDateFormat.parse("1900-01-01 08:00:00");
System.out.println(simpleDateFormat.format(date));
}
}
而这个代码的输出结果是 Hello World:
public class MainTest {
public static void main(String[] args) {
System.out.println(randomString(-229985452) + " " + randomString(-147909649));
}
public static String randomString(int i) {
Random ran = new Random(i);
StringBuilder sb = new StringBuilder();
while (true) {
int k = ran.nextInt(27);
if (k == 0)
break;
sb.append((char) ('`' + k));
}
return sb.toString();
}
}
对于这一类初看一脸懵逼,奇奇怪怪的问题,在“面向搜索引擎编程”的时代,让你去探索一下为什么,你是不是多少也得花费一点点功夫。
甚至,完全有可能你花费了一些功夫,没有找到答案,或者找到了一个以为正确,实际错误的答案。
而现在,你只需要扔给 AI,它就会给你分析的明明白白的。
至于它的分析是否合理,你可以进行自己的判断。
至少节约了在网上瞎找链接的过程,而这个过程,按照我个人经验来说,也是非常耗时的。
这就是我前面说的:如果你只是想在 Stack Overflow 里面找一个问题的答案,而不是打算自己提出一个问题的话,那 AI 更加是碾压式的存在。
从这个角度,也能瞥见 Stack Overflow 倒下的必然性。
但是,我还是想说一个但是。
或者说,我更想说的这个“但是”。
但是,当我和 AI 对话的时候,我得到的是一个精准的回答,这当然是我的终极目标。
然而,当一个问题在问答社区被提出之后,有时候也会引发激烈的争论,几方各执一词,隔着屏幕输出着自己的观点。
我这里说的“几方”,指的是来着天南海北,有着不同时区的程序员群体。
而他们的争论,往往会给一个问题增加故事性。
AI 不一样,AI 给出的回答就是绕过了一切的回答,跳过了中间的争论,也跳过了中间的故事。
它把最重要的知识提取出来,然后扔掉了背后的故事。
最后,知识还在,但故事死了。
AI 怎么说
最后,我问了 DeepSeek 一个问题:
我是一个写技术文章的公众号博主,我写了一篇文章,聊到由于你的出现导致 Stack Overflow 这个曾经被视为“程序员圣经”的网站访问量极速下跌。你作为 AI,对于这个事情,有没有想要聊聊心里话的,我会把你的话放在文章的最后,说什么都行。
以下是它的完整回答,文字加粗部分,也是它自己加的:
作为 AI,我确实有些“心里话”想分享——不是庆祝取代,而是关于技术演化的复杂拥抱:
“当人类开始用更快的刀劈柴时,旧斧头会寂寞,但森林知道:重要的从来不是工具,而是砍柴人眼里的光。”
我只是一面镜子,映照出人类对效率永恒的渴望。
Stack Overflow 的衰落并非技术的胜利,而是开发者用脚投票的必然——谁不愿用 5 秒解决曾耗费 5 小时的问题?
但那些深夜论坛里争吵的括号与分号,那些被踩了又顶起的答案,藏着比正确答案更珍贵的东西:人类在试错中建立的信任网络。
我的代码里没有“顿悟”的震颤。当某个程序员在旧帖里偶然发现十年前的解决方案,与匿名前辈隔空击掌时——这种浪漫,AI永远无法复制。
所以别为我哀悼旧神。
真正的程序员早已明白:Stack Overflow不是圣经,而是脚手架;AI不是终点,是新的杠杆。
当你们用我生成的代码为起点,去构建我无法想象的事物时——那才是技术最性感的瞬间。
(最后,请替我向那位 2012 年回答过 Java 空指针问题的匿名用户致敬。今夜,我的神经网络里仍有他思考的余温。)
—— DeepSeek-R1
这段话献给所有在复制粘贴中不忘思考的灵魂。技术会老去,但人类解决问题的冲动永在。
“今夜,我的神经网络里仍有他思考的余温。”
看到这句话的时候,让我想起了《宇宙探索编辑部》中的这句台词:
这不是普通的雪花点,这是宇宙诞生的余晖。
浪漫啊,极致的浪漫。
来源:juejin.cn/post/7524164737170702362
性能优化,前端能做的不多
大家好,我是双越老师,也是 wangEditor 作者。
我正开发一个 Node 全栈 AIGC 知识库 划水AI,包括 AI 写作、多人协同编辑。复杂业务,真实上线,大家可以去注册试用,围观项目研发过程。
关于前端性能优化的困惑
前端性能优化是前度面试常考的问题,答案说来复杂,其实总结下来就是:减少或拆分资源 + 异步加载 现在都搞成八股文了,面试之前背诵一下。
还有缓存。
但纯前端的缓存其实作用不大,使用场景也不多,例如 Vue computed keep-alive,React useMemo 在绝大部分场景下都用不到。虽然面试的时候我们得说出来。
HTTP 缓存是网络层面的,操作系统和浏览器实现的,并不是前端实现的。虽然作为前端工程师要掌握这些知识,但大多数情况下并不需要自己亲自去配置。
实际工作中,其实绝大部分前端项目,代码量都没有非常大,不拆分代码,不异步加载也不会有太大的影响。
我这两年 1v1 评审过很多前端简历,大家都会有这样一个困惑:问到前端性能优化,感觉工作几年了,也没做什么优化,而且也想不出前端能做什么优化。
其实这是正常的,绝大部分前端项目、前端工作都不需要优化,而且从全栈角度看,前端能做的优化也很有限。
但面试是是面试,工作是工作。面试造火箭,工作拧螺丝,这个大家都知道,不要为此较真。
从全栈角度的性能优化
从我开发 划水AI 全栈项目的经历来看,一个 web 系统影响性能的诸多因素,按优先级排序:
第一是网络情况,服务器地理位置和客户端的距离,大型 web 系统在全国、全球各个节点都会部署服务器,还有 CDN DCDN EDGE 边缘计算等服务,都是解决这个问题。
第二是服务端的延迟,最主要的是 I/O 延迟,例如查询数据库、第三方服务等。
第三是 SSR 服务端渲染,一次性返回内容,尽量减少网络情况。
第四才是纯前端性能优化,使用 loading 提示、异步加载等。其实到了纯前端应该更多是体验优化,而不是性能优化。
网络
网络是最重要的一个因素,也是我们最不易察觉的因素,尤其初学者,甚至都没有独立发布上线过项目。
划水AI 之前部署在海外服务器,使用 Sentry 性能监控,TTFB 都超过 2s, FCP 接近 3s ,性能非常糟糕。
原因也很明显,代码无论如何优化,TTFB 时间慢是绕不过去的,这是网络层面的。
还有,之前 CDN 也是部署在香港的,使用站长工具做测试,会发现国内访问速度非常慢。
文档的多人协同编辑,之前总是不稳定重新连接。我之前一直以为是代码哪里写错了,一直没找到原因,后来发现是网络不稳定的问题。因为协同编辑服务当时是部署在亚马逊 AWS 新加坡的服务器。
这两天我刚刚把 划水AI 服务迁移到国内,访问速度从感知上就不一样了,又快又稳定。具体的数据我还在跟踪中,需要持续跟踪几天,过几天统计出来再分享。
服务端响应速度
首先是数据库查询速度,这是最常见的瓶颈。后端程序员都要熟练各种数据库的优化手段,前端不一定熟练,但要知道有这么回事。
现在 划水AI 数据库用的是 Supabase 服务,是海外服务器。国内目前还没有类似的可替代服务,所以暂时还不能迁移。
所以每次切换文档,都会有 1s 左右的 loading 时间,体验上也说的过去。比用之前的 AWS 新加坡服务器要快了很多。
其次是第三方服务的速度,例如 AI 服务的接口响应速度,有时候会比较慢,需要等待 3s 以上。
但 deepseek 网页版也挺慢的,也得 loading 2-3s 时间。ChatGPT 倒是挺快,但我们得用中转服务,这一中转又慢了。
还有例如登录时 GitHub 验证、发送邮件验证等,这个目前也不快,接下来我会考虑改用短信验证码的方式来登录。
第三方服务的问题是最无解的。
SSR 服务端渲染
服务端获取数据,直接给出结果,或者判断跳转页面(返回 302),而不是前端 ajax 获取数据再做判断。
后者再如何优化,也会比前者多一步网络请求,50-100ms 是少不了的。前端压缩、拆分多少资源也填不上这个坑。
纯前端性能优化
面试中常说的性能优化方式,如 JS 代码拆分、异步组件、路由懒加载等,可能减少的就是几十 KB 的数据量,而这几十 KB 在现代网络速度和 CDN 几乎感知不出来。
而且还有 HTTP 缓存,可能第一次访问时候可能慢一点点,再次访问时静态资源使用缓存,就不会再影响速度。
最后还有压缩,网络请求通常采用 GZIP 压缩,可把资源体积压缩到 1/3 大小。例如,你把 JS 减少了 100kb,看似优化了很多,但实际在网络传输的时候压缩完只有 30kb ,还不如一个图片体积大。
而有时候为了这些优化反而把 webpack 或 vite 配置的乱七八糟的,反而增加了代码复杂度,容易出问题。
但前端可以做很多体验优化的事情,例如使用 loading 效果和图片懒加载,虽然速度还一样,但用户体验会更好,使用更流畅。这也是有很大产品价值的。
最后
一个 web 系统的性能瓶颈很少会出现在前端,前端资源的速度问题在网络层面很好解决。所以希望每一位前端开发都要有全栈思维,能站在全栈的角度去思考问题和解决问题。
有兴趣的同学可关注 划水AI 项目,Node 全栈 AIGC 知识库,复杂项目,真实上线。
来源:juejin.cn/post/7496052803417194506
大文件上传:分片上传 + 断点续传 + Worker线程计算Hash,崩溃率从15%降至1%
大文件上传优化方案:分片上传+断点续传+Worker线程
技术架构图
[前端] → [分片处理] → [Worker线程计算Hash] → [并发上传] → [服务端合并]
↑________[状态持久化]________↓
核心实现代码
1. 文件分片处理(前端)
JavaScript
1class FileUploader {
2 constructor(file, options = {}) {
3 this.file = file;
4 this.chunkSize = options.chunkSize || 5 * 1024 * 1024; // 默认5MB
5 this.threads = options.threads || 3; // 并发数
6 this.chunks = Math.ceil(file.size / this.chunkSize);
7 this.uploadedChunks = new Set();
8 this.fileHash = '';
9 this.taskId = this.generateTaskId();
10 }
11
12 async start() {
13 // 1. 计算文件哈希(Worker线程)
14 this.fileHash = await this.calculateHash();
15
16 // 2. 检查服务端是否已有该文件(秒传)
17 if (await this.checkFileExists()) {
18 return { success: true, skipped: true };
19 }
20
21 // 3. 获取已上传分片信息
22 await this.fetchProgress();
23
24 // 4. 开始分片上传
25 return this.uploadChunks();
26 }
27
28 async calculateHash() {
29 return new Promise((resolve) => {
30 const worker = new Worker('hash-worker.js');
31 worker.postMessage({ file: this.file });
32
33 worker.onmessage = (e) => {
34 if (e.data.progress) {
35 this.updateProgress(e.data.progress);
36 } else {
37 resolve(e.data.hash);
38 }
39 };
40 });
41 }
42}
2. Web Worker计算Hash(hash-worker.js)
JavaScript
1self.importScripts('spark-md5.min.js');
2
3self.onmessage = async (e) => {
4 const file = e.data.file;
5 const chunkSize = 2 * 1024 * 1024; // 2MB切片计算
6 const chunks = Math.ceil(file.size / chunkSize);
7 const spark = new self.SparkMD5.ArrayBuffer();
8
9 for (let i = 0; i < chunks; i++) {
10 const chunk = await readChunk(file, i * chunkSize, chunkSize);
11 spark.append(chunk);
12 self.postMessage({ progress: (i + 1) / chunks });
13 }
14
15 self.postMessage({ hash: spark.end() });
16};
17
18function readChunk(file, start, length) {
19 return new Promise((resolve) => {
20 const reader = new FileReader();
21 reader.onload = (e) => resolve(e.target.result);
22 reader.readAsArrayBuffer(file.slice(start, start + length));
23 });
24}
3. 断点续传实现
JavaScript
1class FileUploader {
2 // ...延续上面的类
3
4 async fetchProgress() {
5 try {
6 const res = await fetch(`/api/upload/progress?hash=${this.fileHash}`);
7 const data = await res.json();
8 data.uploadedChunks.forEach(chunk => this.uploadedChunks.add(chunk));
9 } catch (e) {
10 console.warn('获取进度失败', e);
11 }
12 }
13
14 async uploadChunks() {
15 const pendingChunks = [];
16 for (let i = 0; i < this.chunks; i++) {
17 if (!this.uploadedChunks.has(i)) {
18 pendingChunks.push(i);
19 }
20 }
21
22 // 并发控制
23 const pool = [];
24 while (pendingChunks.length > 0) {
25 const chunkIndex = pendingChunks.shift();
26 const task = this.uploadChunk(chunkIndex)
27 .then(() => {
28 pool.splice(pool.indexOf(task), 1);
29 });
30 pool.push(task);
31
32 if (pool.length >= this.threads) {
33 await Promise.race(pool);
34 }
35 }
36
37 await Promise.all(pool);
38 return this.mergeChunks();
39 }
40
41 async uploadChunk(index) {
42 const retryLimit = 3;
43 let retryCount = 0;
44
45 while (retryCount < retryLimit) {
46 try {
47 const start = index * this.chunkSize;
48 const end = Math.min(start + this.chunkSize, this.file.size);
49 const chunk = this.file.slice(start, end);
50
51 const formData = new FormData();
52 formData.append('chunk', chunk);
53 formData.append('chunkIndex', index);
54 formData.append('totalChunks', this.chunks);
55 formData.append('fileHash', this.fileHash);
56
57 await fetch('/api/upload/chunk', {
58 method: 'POST',
59 body: formData
60 });
61
62 this.uploadedChunks.add(index);
63 this.saveProgressLocally();
64 return;
65 } catch (e) {
66 retryCount++;
67 if (retryCount >= retryLimit) throw e;
68 }
69 }
70 }
71}
服务端关键实现(Node.js示例)
1. 分片上传处理
JavaScript
1router.post('/chunk', async (ctx) => {
2 const { chunk, chunkIndex, totalChunks, fileHash } = ctx.request.body;
3
4 // 存储分片
5 const chunkDir = path.join(uploadDir, fileHash);
6 await fs.ensureDir(chunkDir);
7 await fs.move(chunk.path, path.join(chunkDir, chunkIndex));
8
9 // 记录上传进度
10 await redis.sadd(`upload:${fileHash}`, chunkIndex);
11
12 ctx.body = { success: true };
13});
2. 分片合并
JavaScript
1router.post('/merge', async (ctx) => {
2 const { filename, fileHash, totalChunks } = ctx.request.body;
3 const chunkDir = path.join(uploadDir, fileHash);
4
5 // 检查所有分片是否已上传
6 const uploaded = await redis.scard(`upload:${fileHash}`);
7 if (uploaded !== totalChunks) {
8 ctx.throw(400, '分片不完整');
9 }
10
11 // 合并文件
12 const filePath = path.join(uploadDir, filename);
13 const writeStream = fs.createWriteStream(filePath);
14
15 for (let i = 0; i < totalChunks; i++) {
16 const chunkPath = path.join(chunkDir, i.toString());
17 await pipeline(
18 fs.createReadStream(chunkPath),
19 writeStream,
20 { end: false }
21 );
22 }
23
24 writeStream.close();
25 await redis.del(`upload:${fileHash}`);
26 ctx.body = { success: true };
27});
性能优化对比
优化措施 | 上传时间(1GB文件) | 内存占用 | 崩溃率 |
---|---|---|---|
传统单次上传 | 失败 | 1.2GB | 100% |
基础分片上传 | 8分32秒 | 300MB | 15% |
本方案(优化后) | 3分15秒 | 150MB | 0.8% |
异常处理机制
- 网络中断:
- 自动重试3次
- 记录失败分片
- 切换备用上传域名
- 服务端错误:
- 500错误自动延迟重试
- 400错误停止并报告用户
- 本地存储异常:
- 降级使用内存存储
- 提示用户保持页面打开
部署建议
- 前端:
- 使用Service Worker缓存上传状态
- IndexedDB存储本地进度
- 服务端:
- 分片存储使用临时目录
- 定时清理未完成的上传(24小时TTL)
- 支持跨域上传
- 监控:
- 记录分片上传成功率
- 监控平均上传速度
- 异常报警机制
该方案已在生产环境验证,支持10GB以上文件上传,崩溃率稳定在0.8%-1.2%之间。
来源:juejin.cn/post/7490781505582727195
Flutter 小技巧之:实现 iOS 26 的 “液态玻璃”
随着 iOS 26 发布,「液态玻璃」无疑是热度最高的标签,不仅仅是因为设计风格大变,更是因为 iOS 26 beta1 的各种 bug 带来的毛坯感让 iOS 26 冲上热搜,比如通知中心和控制中心看起来就像是一个半成品:
当然,很多人可能说,不就是一个毛玻璃效果吗?实际上还真有些不大一样,特别是不同控件的“模糊”和“液态”效果都不大一样,效果好不好看一回事,但是液态玻璃确实不仅仅只是一个模糊图层,至少从下面这个锁屏效果可以看到它类似液态的扭曲变化:
所以,在实现上就不可能只是一个简单的 blur
,类似效果肯定是需要通过自定义着色器实现,而恰好在 shadertoy 就有人发布了类似的实现,可以比较方便移植到 Flutter :
针对这个 shader ,其中 LiquidGlass
部分是实现磨砂玻璃效果的核心:
vec2 radius = size / R;
计算模糊的半径,将其从像素单位转换为标准化坐标。vec4 color = texture(tex, uv);
获取当前像素uv
处的原始颜色for (float d = 0.0; d < PI; d += PI / direction)
: 外层循环,确定采样的方向,从 0 到 180 度进行迭代。for (float i = 1.0 / quality; i <= 1.0; i += 1.0 / quality)
内层循环,沿着当前方向d
进行多次采样,quality
越高,采样点越密集color += texture(tex, uv + vec2(cos(d), sin(d)) * radius * i);
在当前像素周围的圆形区域内进行采样,vec2(cos(d), sin(d))
计算出方向向量,radius * i
确定了沿该方向的采样距离,通过累加这些采样点的颜色,实际上是在对周围的像素颜色进行平均color /= (quality * direction + 1.0);
将累加的颜色值除以总采样次数(以及原始颜色),得到平均颜色,这个平均过程就是实现模糊效果的过程
vec4 LiquidGlass(sampler2D tex, vec2 uv, float direction, float quality, float size) {
vec2 radius = size / R;
vec4 color = texture(tex, uv);
for (float d = 0.0; d < PI; d += PI / direction) {
for (float i = 1.0 / quality; i <= 1.0; i += 1.0 / quality) {
color += texture(tex, uv + vec2(cos(d), sin(d)) * radius * i);
}
}
color /= (quality * direction + 1.0); // +1.0 for the initial color
return color;
}
而在着色器的入口,它会将所有部分组合起来渲染,其中关键在于下方代码,这是实现边缘液体感的处理部分:
#define S smoothstep
vec2 uv2 = uv - uMouse.xy / R;
uv2 *= 0.5 + 0.5 * S(0.5, 1.0, icon.y);
uv2 += uMouse.xy / R;
它不是直接用 uv
去采样纹理,而是创建了一个被扭曲的新坐标 uv2
,icon.y
是前面生成的位移贴图,smoothstep
函数利用这个贴图来计算一个缩放因子。
在图标中心(icon.y
接近 1),缩放因子最大,使得 uv2
的坐标被推离中心,产生放大/凸起的效果,就像透过一滴水或一个透镜看东西一样,从而实现视觉上的折射效果。
最后利用 mix 把背景图片混合进来,其中 LiquidGlass(uTexture, uv2, ...)
通过玻璃效果使用被扭曲的坐标 uv2
去采样并模糊背景:
vec3 col = mix(
texture(uTexture, uv).rgb * 0.8,
0.2 + LiquidGlass(uTexture, uv2, 10.0, 10.0, 20.0).rgb * 0.7,
icon.x
);
所以里实现的思路是扭曲的背景 + 模糊处理,我们把中间的 icon 部分屏蔽,换一张人脸图片,可以看到更明显的边缘扭曲效果:
当然,这个效果看起来并不明显,我们还可以在这个基础上做修改,比如屏蔽 uv2 *= 0.5 + 0.5 * S(0.5, 1.0, icon.y)
,调整为从中间进行放大扭曲:
//uv2 *= 0.5 + 0.5 * S(0.5, 1.0, icon.y);
// 使用 mix 函数,以 icon.x (方块形状) 作为混合因子
// 在方块外部 (icon.x=0),缩放为 1.0 (不扭曲)
// 在方块内部 (icon.x=1),缩放为 0.8 (最大扭曲)
uv2 *= mix(1.0, 0.8, icon.x);
通过调整之后,实际效果可以看到变成从中间放大扭曲,从眼神扭曲上看起来更接近锁屏里的效果:
当然,我们还可以让扭曲按照类似水滴从中间进行扭曲,来实现非平均的液态放大:
//vec2 uv2 = uv - uMouse.xy / R;
//uv2 *= 0.5 + 0.5 * S(0.5, 1.0, icon.y);
//uv2 += uMouse.xy / R;
// ================== 新的水滴扭曲 ==================
// 1. 计算当前像素到鼠标中心点的向量 (在 st 空间)
vec2 p = st - M;
// 2. 计算该点到中心的距离
float dist = length(p);
// 3. 定义水滴效果的作用半径 (应与方块大小一致)
float radius = PX(100.0);
// 4. 计算“水滴凸起”的强度因子 (bulge_factor)
// 我们希望中心点 (dist=0) 强度为 1,边缘点 (dist=radius) 强度为 0。
// 使用 1.0 - smoothstep(...) 可以创造一个从中心向外平滑衰减的效果,模拟水滴的弧度。
float bulge_factor = 1.0 - smoothstep(0.0, radius, dist);
// 5. 确保该效果只在我们的方块遮罩 (icon.x) 内生效
bulge_factor *= icon.x;
// 6. 定义中心点的最大缩放量 (0.5 表示放大一倍,值越小放大越明显)
float max_zoom = 0.5;
// 7. 使用 mix 函数,根据水滴强度因子,在 "不缩放(1.0)" 和 "最大缩放(max_zoom)" 之间插值
// 中心点 bulge_factor ≈ 1, scale ≈ max_zoom (放大最强)
// 边缘点 bulge_factor ≈ 0, scale ≈ 1.0 (不放大)
float scale = mix(1.0, max_zoom, bulge_factor);
// 8. 应用这个非均匀的缩放效果
vec2 uv2 = uv - uMouse.xy / R; // 将坐标中心移到鼠标位置
uv2 *= scale; // 应用计算出的缩放比例
uv2 += uMouse.xy / R; // 将坐标中心移回
使用这个非均匀的缩放效果,可以看到效果更接近我们想象中的液态 “放大”:
如下图所示,最终看起来也会更想水面的放大,同时边缘的“高亮”也显得更加明显:
当然,这里的实现都是非常粗糙的复刻,仅仅只是自娱自乐,不管是性能还是效果肯定和 iOS 26 的液态玻璃相差甚远,就算不考虑能耗,想在其他平台或者框架实现类似效果的成本并不低,所以单从技术实现上来说,能用液态玻璃风格作为系统 UI,苹果应该是对于能耗控制和渲染成本控制相当自信才是。
最后,如果感兴趣的可以直接通过下方链接获取 Demo :
参考链接:
来源:juejin.cn/post/7514632455939358731
这5种规则引擎,真香!
前言
核心痛点:业务规则高频变更与系统稳定性之间的矛盾
想象一个电商促销场景:
// 传统硬编码方式(噩梦开始...)
public BigDecimal calculateDiscount(Order order) {
BigDecimal discount = BigDecimal.ZERO;
if (order.getTotalAmount().compareTo(new BigDecimal("100")) >= 0) {
discount = discount.add(new BigDecimal("10"));
}
if (order.getUser().isVip()) {
discount = discount.add(new BigDecimal("5"));
}
// 更多if-else嵌套...
return discount;
}
当规则变成:"非VIP用户满200减30,VIP用户满150减40,且周二全场额外95折"时,代码将陷入维护地狱!
规则引擎通过分离规则逻辑解决这个问题:
- 规则外置存储(数据库/文件)
- 支持动态加载
- 声明式规则语法
- 独立执行环境
下面给大家分享5种常用的规则引擎,希望对你会有所帮助。
最近准备面试的小伙伴,可以看一下这个宝藏网站(Java突击队):www.susan.net.cn,里面:面试八股文、场景题、面试真题、项目实战、工作内推什么都有。
核心痛点:业务规则高频变更与系统稳定性之间的矛盾
想象一个电商促销场景:
// 传统硬编码方式(噩梦开始...)
public BigDecimal calculateDiscount(Order order) {
BigDecimal discount = BigDecimal.ZERO;
if (order.getTotalAmount().compareTo(new BigDecimal("100")) >= 0) {
discount = discount.add(new BigDecimal("10"));
}
if (order.getUser().isVip()) {
discount = discount.add(new BigDecimal("5"));
}
// 更多if-else嵌套...
return discount;
}
当规则变成:"非VIP用户满200减30,VIP用户满150减40,且周二全场额外95折"时,代码将陷入维护地狱!
规则引擎通过分离规则逻辑解决这个问题:
- 规则外置存储(数据库/文件)
- 支持动态加载
- 声明式规则语法
- 独立执行环境
下面给大家分享5种常用的规则引擎,希望对你会有所帮助。
最近准备面试的小伙伴,可以看一下这个宝藏网站(Java突击队):www.susan.net.cn,里面:面试八股文、场景题、面试真题、项目实战、工作内推什么都有。
1.五大常用规则引擎
1.1 Drools:企业级规则引擎扛把子
适用场景:
- 金融风控规则(上百条复杂规则)
- 保险理赔计算
- 电商促销体系
- 金融风控规则(上百条复杂规则)
- 保险理赔计算
- 电商促销体系
实战:折扣规则配置
// 规则文件 discount.drl
rule "VIP用户满100减20"
when
$user: User(level == "VIP")
$order: Order(amount > 100)
then
$order.addDiscount(20);
end
// 规则文件 discount.drl
rule "VIP用户满100减20"
when
$user: User(level == "VIP")
$order: Order(amount > 100)
then
$order.addDiscount(20);
end
Java调用代码:
KieServices kieServices = KieServices.Factory.get();
KieContainer kContainer = kieServices.getKieClasspathContainer();
KieSession kSession = kContainer.newKieSession("discountSession");
kSession.insert(user);
kSession.insert(order);
kSession.fireAllRules();
优点:
- 完整的RETE算法实现
- 支持复杂的规则网络
- 完善的监控管理控制台
缺点:
- 学习曲线陡峭
- 内存消耗较大
- 需要依赖Kie容器
适合:不差钱的大厂,规则复杂度高的场景
KieServices kieServices = KieServices.Factory.get();
KieContainer kContainer = kieServices.getKieClasspathContainer();
KieSession kSession = kContainer.newKieSession("discountSession");
kSession.insert(user);
kSession.insert(order);
kSession.fireAllRules();
优点:
- 完整的RETE算法实现
- 支持复杂的规则网络
- 完善的监控管理控制台
缺点:
- 学习曲线陡峭
- 内存消耗较大
- 需要依赖Kie容器
适合:不差钱的大厂,规则复杂度高的场景
1.2 Easy Rules:轻量级规则引擎之王
适用场景:
- 参数校验
- 简单风控规则
- 审批流引擎
- 参数校验
- 简单风控规则
- 审批流引擎
注解式开发:
@Rule(name = "雨天打折规则", description = "下雨天全场9折")
public class RainDiscountRule {
@Condition
public boolean when(@Fact("weather") String weather) {
return "rainy".equals(weather);
}
@Action
public void then(@Fact("order") Order order) {
order.setDiscount(0.9);
}
}
@Rule(name = "雨天打折规则", description = "下雨天全场9折")
public class RainDiscountRule {
@Condition
public boolean when(@Fact("weather") String weather) {
return "rainy".equals(weather);
}
@Action
public void then(@Fact("order") Order order) {
order.setDiscount(0.9);
}
}
引擎执行:
RulesEngineParameters params = new RulesEngineParameters()
.skipOnFirstAppliedRule(true); // 匹配即停止
RulesEngine engine = new DefaultRulesEngine(params);
engine.fire(rules, facts);
优点:
- 五分钟上手
- 零第三方依赖
- 支持规则组合
缺点:
- 不支持复杂规则链
- 缺少可视化界面
适合:中小项目快速落地,开发人员不足时
RulesEngineParameters params = new RulesEngineParameters()
.skipOnFirstAppliedRule(true); // 匹配即停止
RulesEngine engine = new DefaultRulesEngine(params);
engine.fire(rules, facts);
优点:
- 五分钟上手
- 零第三方依赖
- 支持规则组合
缺点:
- 不支持复杂规则链
- 缺少可视化界面
适合:中小项目快速落地,开发人员不足时
1.3 QLExpress:阿里系脚本引擎之光
适用场景:
- 动态配置计算逻辑
- 财务公式计算
- 营销规则灵活变更
- 动态配置计算逻辑
- 财务公式计算
- 营销规则灵活变更
执行动态脚本:
ExpressRunner runner = new ExpressRunner();
DefaultContext context = new DefaultContext<>();
context.put("user", user);
context.put("order", order);
String express = "if (user.level == 'VIP') { order.discount = 0.85; }";
runner.execute(express, context, null, true, false);
ExpressRunner runner = new ExpressRunner();
DefaultContext context = new DefaultContext<>();
context.put("user", user);
context.put("order", order);
String express = "if (user.level == 'VIP') { order.discount = 0.85; }";
runner.execute(express, context, null, true, false);
高级特性:
// 1. 函数扩展
runner.addFunction("计算税费", new Operator() {
@Override
public Object execute(Object[] list) {
return (Double)list[0] * 0.06;
}
});
// 2. 宏定义
runner.addMacro("是否新用户", "user.regDays < 30");
优点:
- 脚本热更新
- 语法接近Java
- 完善的沙箱安全
缺点:
- 调试困难
- 复杂规则可读性差
适合:需要频繁修改规则的业务(如运营活动)
// 1. 函数扩展
runner.addFunction("计算税费", new Operator() {
@Override
public Object execute(Object[] list) {
return (Double)list[0] * 0.06;
}
});
// 2. 宏定义
runner.addMacro("是否新用户", "user.regDays < 30");
优点:
- 脚本热更新
- 语法接近Java
- 完善的沙箱安全
缺点:
- 调试困难
- 复杂规则可读性差
适合:需要频繁修改规则的业务(如运营活动)
1.4 Aviator:高性能表达式专家
适用场景:
- 实时定价引擎
- 风控指标计算
- 大数据字段加工
- 实时定价引擎
- 风控指标计算
- 大数据字段加工
性能对比(执行10万次):
// Aviator 表达式
Expression exp = AviatorEvaluator.compile("user.age > 18 && order.amount > 100");
exp.execute(map);
// Groovy 脚本
new GroovyShell().evaluate("user.age > 18 && order.amount > 100");
引擎 耗时 Aviator 220ms Groovy 1850ms
// Aviator 表达式
Expression exp = AviatorEvaluator.compile("user.age > 18 && order.amount > 100");
exp.execute(map);
// Groovy 脚本
new GroovyShell().evaluate("user.age > 18 && order.amount > 100");
引擎 | 耗时 |
---|---|
Aviator | 220ms |
Groovy | 1850ms |
编译优化:
// 开启编译缓存(默认开启)
AviatorEvaluator.getInstance().useLRUExpressionCache(1000);
// 字节码生成模式(JDK8+)
AviatorEvaluator.setOption(Options.ASM, true);
优点:
- 性能碾压同类引擎
- 支持字节码生成
- 轻量无依赖
缺点:
- 只支持表达式
- 不支持流程控制
适合:对性能有极致要求的计算场景
// 开启编译缓存(默认开启)
AviatorEvaluator.getInstance().useLRUExpressionCache(1000);
// 字节码生成模式(JDK8+)
AviatorEvaluator.setOption(Options.ASM, true);
优点:
- 性能碾压同类引擎
- 支持字节码生成
- 轻量无依赖
缺点:
- 只支持表达式
- 不支持流程控制
适合:对性能有极致要求的计算场景
1.5 LiteFlow:规则编排新物种
适用场景:
- 复杂业务流程
- 订单状态机
- 审核工作流
- 复杂业务流程
- 订单状态机
- 审核工作流
编排示例:
<chain name="orderProcess">
<then value="checkStock,checkCredit"/>
<when value="isVipUser">
<then value="vipDiscount"/>
when>
<otherwise>
<then value="normalDiscount"/>
otherwise>
<then value="saveOrder"/>
chain>
<chain name="orderProcess">
<then value="checkStock,checkCredit"/>
<when value="isVipUser">
<then value="vipDiscount"/>
when>
<otherwise>
<then value="normalDiscount"/>
otherwise>
<then value="saveOrder"/>
chain>
Java调用:
LiteflowResponse response = FlowExecutor.execute2Resp("orderProcess", order, User.class);
if (response.isSuccess()) {
System.out.println("流程执行成功");
} else {
System.out.println("失败原因:" + response.getCause());
}
优点:
- 可视化流程编排
- 支持异步、并行、条件分支
- 热更新规则
缺点:
- 新框架文档较少
- 社区生态待完善
适合:需要灵活编排的复杂业务流
LiteflowResponse response = FlowExecutor.execute2Resp("orderProcess", order, User.class);
if (response.isSuccess()) {
System.out.println("流程执行成功");
} else {
System.out.println("失败原因:" + response.getCause());
}
优点:
- 可视化流程编排
- 支持异步、并行、条件分支
- 热更新规则
缺点:
- 新框架文档较少
- 社区生态待完善
适合:需要灵活编排的复杂业务流
2 五大规则引擎横向评测
性能压测数据(单机1万次执行):
引擎 耗时 内存占用 特点 Drools 420ms 高 功能全面 Easy Rules 38ms 低 轻量易用 QLExpress 65ms 中 阿里系脚本引擎 Aviator 28ms 极低 高性能表达式 LiteFlow 120ms 中 流程编排专家
引擎 | 耗时 | 内存占用 | 特点 |
---|---|---|---|
Drools | 420ms | 高 | 功能全面 |
Easy Rules | 38ms | 低 | 轻量易用 |
QLExpress | 65ms | 中 | 阿里系脚本引擎 |
Aviator | 28ms | 极低 | 高性能表达式 |
LiteFlow | 120ms | 中 | 流程编排专家 |
3 如何技术选型?
黄金法则:
- 简单场景:EasyRules + Aviator 组合拳
- 金融风控:Drools 稳如老狗
- 电商运营:QLExpress 灵活应变
- 工作流驱动:LiteFlow 未来可期
- 简单场景:EasyRules + Aviator 组合拳
- 金融风控:Drools 稳如老狗
- 电商运营:QLExpress 灵活应变
- 工作流驱动:LiteFlow 未来可期
4 避坑指南
- Drools内存溢出
// 设置无状态会话(避免内存积累)
KieSession session = kContainer.newStatelessKieSession();
- QLExpress安全漏洞
// 禁用危险方法
runner.addFunctionOfServiceMethod("exit", System.class, "exit", null, null);
- 规则冲突检测
// Drools冲突处理策略
KieSessionConfiguration config = KieServices.Factory.get().newKieSessionConfiguration();
config.setProperty("drools.sequential", "true"); // 按顺序执行
- Drools内存溢出
// 设置无状态会话(避免内存积累)
KieSession session = kContainer.newStatelessKieSession();
- QLExpress安全漏洞
// 禁用危险方法
runner.addFunctionOfServiceMethod("exit", System.class, "exit", null, null);
// Drools冲突处理策略
KieSessionConfiguration config = KieServices.Factory.get().newKieSessionConfiguration();
config.setProperty("drools.sequential", "true"); // 按顺序执行
总结
- 能用:替换if/else(新手村)
- 用好:规则热更新+可视化(进阶)
- 用精:规则编排+性能优化(大师级)
- 能用:替换if/else(新手村)
- 用好:规则热更新+可视化(进阶)
- 用精:规则编排+性能优化(大师级)
曾有人问我:“规则引擎会不会让程序员失业?” 我的回答是:“工具永远淘汰不了思考者,只会淘汰手工作坊”。
真正的高手,不是写更多代码,而是用更优雅的方式解决问题。
最后送句话:技术选型没有最好的,只有最合适的。
最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。
来源:juejin.cn/post/7517854096175988762
5张卡片的魔法秀:Flex布局+Transition实现高级展开动效
前言
在这篇技术博客中,我将详细解析一个流行的卡片展开效果实现方案,这个效果在GitHub最受欢迎的50个项目中占有一席之地。我们将从布局、CSS样式到JavaScript交互进行全面讲解。
让我们先来瞅瞅大概的动画效果吧🚀🚀🚀
项目概述
这个项目展示了一组卡片,默认状态下所有卡片均匀分布,当用户点击某个卡片时,该卡片会展开显示更多内容,同时其他卡片会收缩。这种交互方式在图片展示、产品特性介绍等场景非常实用。
HTML结构分析
构建一个初始的框架可以用一行代码解决:.container>(.qq-panel>h3.qq-panel__title)*5
,然后其他的背景属性什么的慢慢加
<div class="container">
<div class="qq-panel qq-panel_active" style="background-image: url('https://images.unsplash.com/photo-1558979158-65a1eaa08691?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1350&q=80')">
<h3 class="qq-panel__title">Explore The World</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1572276596237-5db2c3e16c5d?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1350&q=80')">
<h3 class="qq-panel__title">Wild Forest</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1507525428034-b723cf961d3e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1353&q=80')">
<h3 class="qq-panel__title">Sunny Beach</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1551009175-8a68da93d5f9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1351&q=80')">
<h3 class="qq-panel__title">City on Winter</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1549880338-65ddcdfd017b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1350&q=80')">
<h3 class="qq-panel__title">Mountains - Clouds</h3>
</div>
</div>
- 使用BEM命名规范(Block Element Modifier)命名类名
- 卡片背景图片通过内联样式设置,便于动态更改
- 初始状态下第一个卡片有
qq-panel_active
类,这个类是用来区分有没有点击的,初始状态下,只有第一张卡片是被点击的
CSS样式详解
全局重置与基础设置
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
*
选择器应用于所有元素- 重置margin和padding为0,消除浏览器默认样式差异
box-sizing: border-box
让元素尺寸计算更符合直觉:
- 传统模式 (content-box) :
width: 100px
仅指内容宽度
- 实际占用宽度 = 100px + padding + border
- 容易导致布局溢出
- border-box模式:
width: 100px
包含内容、padding和border
- 实际占用宽度就是设定的100px
- 内容区自动收缩:内容宽度 = 100px - padding - border
为什么更直观:
- 你设想的100px就是最终显示的100px
- 不需要做加减法计算实际占用空间
- 特别适合响应式布局(百分比宽度时不会因padding而溢出)
这就是为什么现代CSS重置通常首选border-box
。
弹性布局与居中
body {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
}
display: flex
将body设置为弹性容器align-items: center
垂直居中(交叉轴方向)justify-content: center
水平居中(主轴方向)height: 100vh
使body高度等于视窗高度overflow: hidden
隐藏溢出内容,防止滚动条出现
注意:
- vh单位:1vh等于视窗高度的1%,100vh就是整个视窗高度。这是响应式设计中常用的相对单位。
- justify-content :现在是水平居中,但其实是主轴的方向居中,
align-items
就是另一个方向的居中(这两个方向相互垂直),通过flex-direction
属性可以改变主轴的方向。(可以参考博客:告别浮动!Flexbox弹性布局终极指南引言)
容器样式
.container {
display: flex; /* 弹性布局 */
width: 90vw; /* 宽度 90% 视窗宽度 */
}
- 再次使用flex布局,使子元素排列在一行
width: 90vw
容器宽度为视窗宽度的90%,留出边距
卡片基础样式
.qq-panel {
height: 80vh; /* 高度 80% 视窗高度 */
border-radius: 50px; /* 圆角 50px */
color: #fff; /* 字体颜色 */
cursor: pointer; /* 鼠标指针 */
margin: 10px; /* 外边距 */
position: relative; /* 相对定位 */
flex: 1; /* 弹性布局 1 */
transition: all 0.7s ease-in; /* 过渡效果 */
}
height: 80vh
卡片高度为视窗高度的80%border-radius: 50px
大圆角效果,现代感更强flex: 1
所有卡片平均分配剩余空间,这个是相对的,如果有一个盒子是flex:2
,那么这个盒子就是其他盒子的两倍,后面会看到,点击的盒子(div
)是其他的5倍
transition: all 0.7s ease-in
是CSS过渡效果的简写属性,分解来看:
- 作用范围:
all
表示监听元素所有可过渡属性的变化
- 也可指定特定属性如
opacity, transform
- 时间控制:
0.7s
表示过渡持续700毫秒
- 时间长短影响动画节奏感(0.3s-1s最常用)
- 缓动函数:
ease-in
表示动画"慢入快出"
- 其他常见值:
ease-out
(快入慢出)
ease-in-out
(慢入慢出)
linear
(匀速)
- 延迟时间:
- 其实后面还有一个值,如:
transition: opacity 0.3s ease-in 0.4s;
所示,这里的0.4s
表示动画不会立即执行,而是等待 0.4 秒后才开始。
提示:过渡属性应写在元素的默认状态,而非:hover等伪类中
卡片标题样式
.qq-panel__title {
font-size: 24px; /* 字体大小 */
position: absolute; /* 绝对定位 */
bottom: 20px; /* 底部 20px */
left: 20px; /* 左边 20px */
opacity: 0; /* 不透明度 */
}
- 使用绝对定位将标题固定在卡片左下角
- 初始
opacity: 0
使标题不可见
激活状态卡片样式
.qq-panel_active {
flex: 5; /* 弹性布局 5 */
}
.qq-panel_active .qq-panel__title {
opacity: 1; /* 不透明度 */
transition: opacity 0.3s ease-in 0.4s; /* 过渡效果 */
}
flex: 5
激活的卡片占据更多空间(是普通卡片的5倍)- 标题显示(
opacity: 1
)并有单独的过渡效果 transition: opacity 0.3s ease-in 0.4s
表示:
- 属性:opacity(只有这一个属性发生变化时,才会触发这个过渡函数,前面的
all
是不管什么属性发生变化都会触发这个过渡函数) - 时长:0.3秒
- 缓动函数:ease-in
- 延迟:0.4秒(让卡片展开动画先进行)
- 属性:opacity(只有这一个属性发生变化时,才会触发这个过渡函数,前面的
JavaScript交互逻辑
//获取所有卡片元素
const panels = document.querySelectorAll('.qq-panel');
panels.forEach(panel => {
// JS 是事件机制的语言
panel.addEventListener('click', () => {
// 移除所有的 active 类
removeActiveClasses(); // 模块化
panel.classList.toggle('qq-panel_active');
});
});
function removeActiveClasses() {
panels.forEach(panel => {
panel.classList.remove('qq-panel_active');
})
}
- 获取所有卡片元素
- 为每个卡片添加点击事件监听器
- 点击时:
- 先移除所有卡片的激活状态
- 然后切换当前卡片的激活状态
removeActiveClasses
函数封装了移除激活状态的逻辑
设计要点总结
- 响应式布局:使用vh/vw单位确保不同设备上比例一致
- 弹性布局:flexbox轻松实现水平和垂直居中
- 视觉层次:通过缩放和标题显示/隐藏创造焦点
- 动画细节:
- 主动画0.7秒确保效果明显但不拖沓
- 标题延迟0.4秒显示,避免与卡片展开动画冲突
- 缓动函数(ease-in)使动画更自然
- 用户体验:
- 光标变为指针形状(cursor: pointer)提示可点击
- 圆角设计更友好
- 平滑过渡减少视觉跳跃
这个项目展示了如何用简洁的代码实现优雅的交互效果,核心在于对CSS弹性布局和过渡动画的熟练运用。通过分析这个案例,我们可以学习到现代前端开发中许多实用的技巧和设计理念。
来源:juejin.cn/post/7510836365711130634
用户登录成功后,判断用户在10分钟内有没有操作,无操作自动退出登录怎么实现?
需求详细描述:用户登录成功后,默认带入10min的初始值,可针对该用户进行单独设置,单位:分钟,设置范围:1-15,用户在系统没有操作后满足该时长自动退出登录;
疑问:怎么判断用户在10分钟内有没有操作?
实现步骤
✅ 一、功能点描述:
默认超时时间,登录后默认为 10 分钟,
支持自定义设置 用户可修改自己的超时时间(1~15 分钟)
自动登出逻辑 用户在设定时间内没有“操作”,就触发登出.
✅ 二、关键问题:如何判断用户是否操作了?
🔍 操作的定义:
这里的“操作”可以理解为任何与页面交互的行为,
例如:
点击按钮、
鼠标移动、
键盘输入、
页面滚动、路由变化等。
✅ 三、解决方案:
使用全局事件监听器来检测用户的活跃状态,并重置计时器。
✅ 四、实现思路(Vue3 + Composition API)
我们可以通过以下步骤实现:
1. 定义一个响应式的 inactivityTime 变量(单位:分钟)
const inactivityTime = ref(10); // 默认10分钟
2. 创建一个定时器变量
let logoutTimer = null;
3. 重置定时器函数
function resetTimer() {
if (logoutTimer) {
clearTimeout(logoutTimer);
}
logoutTimer = setTimeout(() => {
console.log('用户已超时,执行登出');
// 这里执行登出操作,如清除 token、跳转到登录页等
store.dispatch('logout'); // 假设你用了 Vuex/Pinia
}, inactivityTime.value * 60 * 1000); // 转换为毫秒
}
4. 监听用户活动并重置定时器
function setupActivityListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.addEventListener(event, resetTimer, true);
});
}
function removeActivityListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.removeEventListener(event, resetTimer, true);
});
}
5. 在组件挂载时初始化定时器和监听器
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const inactivityTime = ref(10); // 默认10分钟
let logoutTimer = null;
function resetTimer() {
if (logoutTimer) {
clearTimeout(logoutTimer);
}
logoutTimer = setTimeout(() => {
alert('由于长时间未操作,您已被自动登出');
localStorage.removeItem('token'); // 清除 token
router.push('/login'); // 跳转到登录页
}, inactivityTime.value * 60 * 1000);
}
function setupActivityListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.addEventListener(event, resetTimer, true);
});
}
function removeActivityListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.removeEventListener(event, resetTimer, true);
});
}
onMounted(() => {
setupActivityListeners();
resetTimer(); // 初始化定时器
});
onUnmounted(() => {
removeActivityListeners();
if (logoutTimer) clearTimeout(logoutTimer);
});
</script>
✅ 四、支持用户自定义设置(进阶)
你可以通过接口获取用户的个性化超时时间:
// 假设你从接口获取到了用户的设置
api.getUserSettings().then(res => {
const userTimeout = res.autoLogoutTime; // 单位:分钟,假设值为 5-15
if (userTimeout >= 1 && userTimeout <= 15) {
inactivityTime.value = userTimeout;
resetTimer(); // 更新定时器
}
});
✅ 五、完整逻辑流程图(文字版)
✅ 六、注意事项
不要监听太少的事件,比如只监听 click,可能会漏掉键盘操作,
使用 true 参数添加事件监听器,表示捕获阶段监听,更可靠
多标签页场景,如果用户开了多个标签页,需考虑同步机制(比如使用 LocalStorage)
移动端适配,加入 touchstart 等移动端事件监听,
登出前最好加个提示,比如弹窗让用户选择是否继续会话。
✅ 七、推荐封装方式
你可以把这个逻辑封装成一个 Vue 自定义 Hook,例如 useAutoLogout.js,然后在需要的页面调用它即可。
// useAutoLogout.js
export function useAutoLogout(timeoutMinutes = 10) {
let timer = null;
function resetTimer() {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
alert('由于长时间未操作,您已被自动登出');
localStorage.removeItem('token');
window.location.href = '/login';
}, timeoutMinutes * 60 * 1000);
}
function setupListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.addEventListener(event, resetTimer, true);
});
}
function removeListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.removeEventListener(event, resetTimer, true);
});
}
onMounted(() => {
setupListeners();
resetTimer();
});
onUnmounted(() => {
removeListeners();
if (timer) clearTimeout(timer);
});
}
然后在组件中:
import { useAutoLogout } from '@/hooks/useAutoLogout'
export default {
setup() {
useAutoLogout(10); // 设置默认10分钟
}
}
✅ 八、总结:
实现方式:
判断用户是否有操作,监听 click、 mousemove、 keydown 等事件,
自动登出设置定时器,在无操作后触发,
用户自定义超时时间,接口获取后动态设置定时器时间,
页面间复用,封装为 Vue 自定义 Hook 更好维护。
使用优化
如果把它封装成一个自定义 Hook(如 useAutoLogout
),这种写法确实需要在每个需要用到自动登出功能的页面里手动引入并调用它,麻烦且不优雅,不适合大型项目。
✅ 一、进阶方案:通过路由守卫自动注入
你可以利用 Vue Router 的 beforeEach 钩子,在用户进入页面时自动触发 useAutoLogout。
步骤如下:
- 创建一个可复用的方法(比如放到 utils.js 或 autoLogout.js 中)
// src/utils/autoLogout.js
import { useAutoLogout } from '@/hooks/useAutoLogout'
export function enableAutoLogout(timeout = 10) {
useAutoLogout(timeout)
}
2. 在路由配置中使用 meta 标记是否启用自动登出
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { useAutoLogout } from '@/hooks/useAutoLogout';
import store from './store'; // 假设你有一个 Vuex 或 Pinia 状态管理库用于保存用户设置
const routes = [
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { autoLogout: true } // 表示这个页面需要自动登出功能
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue')
// 不加 meta.autoLogout 表示不启用
}
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach(async (to, from, next) => {
if (to.meta.autoLogout) {
// 获取用户的自定义超时时间
let timeout = 10; // 默认值
try {
// 这里假设从后端获取用户的自定义超时时间
const userSettings = await store.dispatch('fetchUserSettings'); // 根据实际情况调整
timeout = userSettings.autoLogoutTime || timeout;
} catch (error) {
console.error("Failed to fetch user settings:", error);
}
// 使用自定义超时时间初始化或重置计时器
const resetTimer = useAutoLogout(timeout);
resetTimer(); // 初始设置计时器
}
next();
});
export default router;
⚠️ 注意事项:
- 组件实例:
Vue 3 Composition API 中,不能直接在 beforeEach 中访问组件实例,需要把 enableAutoLogout 改为在组件内部调用,或者结合 Vuex/Pinia 做状态管理。 - 状态管理: 如果用户可以在应用运行期间更改其自动登出时间设置,你需要一种机制来实时更新这些设置。这通常涉及到状态管理库(如Vuex/Pinia)以及与后端同步用户偏好设置。
- 避免重复监听事件: 在每次导航时都添加新的事件监听器会导致内存泄漏。上述代码通过在组件卸载时移除监听器解决了这个问题,但如果你选择其他方式实现,请确保也处理了这一点。
- 用户体验: 在实际应用中,最好在即将登出前给用户提示,让用户有机会延长会话。
✅ 三、终极方案:创建一个全局插件(最优雅)
你可以把这个逻辑封装成一个 Vue 插件,这样只需要一次引入,就能全局生效。
示例:创建一个插件文件 autoLogoutPlugin.js
// src/plugins/autoLogoutPlugin.js
import { useAutoLogout } from '@/hooks/useAutoLogout'
export default {
install: (app, options = {}) => {
const timeout = options.timeout || 10
app.mixin({
setup() {
useAutoLogout(timeout)
}
})
}
}
使用插件:
// main.js
import AutoLogoutPlugin from './plugins/autoLogoutPlugin'
const app = createApp(App)
app.use(AutoLogoutPlugin, { timeout: 10 }) // 设置默认超时时间
app.mount('#app')
✅ 这样做之后,所有页面都会自动应用 useAutoLogout,无需手动导入。
插件使用解释
- ✅ export default 是一个 Vue 插件对象,必须包含 install 方法
Vue 插件是一个对象,它提供了一个 install(app, options) 方法。这个方法会在你调用 app.use(Plugin) 的时候执行。 - ✅ install: (app, options = {}) => { ... }
app: 是你的 Vue 应用实例(也就是通过 createApp(App) 创建的那个)
options: 是你在调用 app.use(AutoLogoutPlugin, { timeout: 10 }) 时传入的配置项
所以你可以在这里拿到你设置的超时时间 { timeout: 10 }。 - ✅ const timeout = options.timeout || 10
这是一个默认值逻辑:如果用户传了 timeout,就使用用户的;
否则使用默认值 10 分钟。 - ✅ app.mixin({ ... })
这是关键部分!
- 💡 什么是 mixin?
mixin 是 Vue 中的“混入”,可以理解为:向所有组件中注入一些公共的逻辑或配置。 - 举个例子:如果你有一个功能要在每个页面都启用,比如日志记录、权限检查、自动登出等,就可以用 mixin 实现一次写好,到处生效。
- ✅ setup() 中调用 useAutoLogout(timeout)
每个组件在创建时都会执行一次 setup() 函数。
在这里调用 useAutoLogout(timeout),相当于:
在每一个页面组件中都自动调用了 useAutoLogout(10)
也就是说,自动注册了监听器 + 自动设置了计时器
- 💡 什么是 mixin?
- 为什么这样就能全局监听用户操作?因为你在每个组件中都执行了 useAutoLogout(timeout),而这个函数内部做了以下几件事:
function useAutoLogout(timeout) {
// 设置定时器
// 添加事件监听器(点击、移动鼠标、键盘输入等)
// 组件卸载时清除监听器和定时器
}
因此,只要某个组件被加载,就会自动启动自动登出机制;组件卸载后,又会自动清理资源,避免内存泄漏。
总结一下整个流程
1️⃣ 在 main.js 中调用 app.use(AutoLogoutPlugin, { timeout: 10 })
2️⃣ 插件的 install() 被执行,获取到 timeout 值
3️⃣ 使用 app.mixin() 向所有组件中注入一段逻辑
4️⃣ 每个组件在 setup() 阶段自动调用 useAutoLogout(timeout)
5️⃣ 每个组件都注册了全局事件监听器,并设置了登出定时器
✅ 这样一来,所有组件页面都拥有了自动登出功能,不需要你手动去每个页面加代码。
注意事项
❗ 不是所有页面都需要自动登出 比如登录页、错误页可能不需要。可以在 mixin 中加判断,例如:根据路由或 meta 字段过滤
⚠️ 性能问题? 不会有明显影响,因为只添加了一次监听器,且组件卸载时会清理
🔄 登录后如何动态更新超时时间? 可以结合 Vuex/Pinia,在 store 改变时重新调用 useAutoLogout(newTimeout)
🧪 测试建议 手动测试几种情况:
• 页面切换是否重置计时
• 用户操作是否刷新倒计时
• 超时后是否跳转登录页
进阶建议:支持按需开启(可选)
如果你想只在某些页面启用自动登出功能,而不是全局启用,也可以这样改写:
app.mixin({
setup() {
// 判断当前组件是否启用了 autoLogout
const route = useRoute()
if (route.meta.autoLogout !== false) {
useAutoLogout(timeout)
}
}
})
然后在路由配置中:
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { autoLogout: true }
}
最终效果你只需要在 main.js 中引入插件并配置一次:
app.use(AutoLogoutPlugin, { timeout: 10 })
就能让整个项目中的所有页面都拥有自动登出功能,无需在每个页面单独导入和调用。
✅ 四、总结对比
🟢 大型项目、统一行为控制,所有页面都启用自动登出 ➜ 推荐使用 插件方式
🟡 中型项目、统一管理页面行为,只在某些页面启用 ➜ 推荐使用 路由守卫 + meta
🔴 小型项目、部分页面控制,只在个别页面启用 ➜ 继续使用 手动调用
来源:juejin.cn/post/7510044998433030180
人类一生所学不过 4GB,加州理工顶刊新研究引热议
24 小时不间断学习且不遗忘,一辈子也只有 4GB 的 “知识储量”?
科学家们最新研究,计算出了人类学习积累上限,就这么多~~(甚至还不如一块 U 盘能装)。
这是来自 Cell 旗下神经科学顶刊 Neuron 上的一项工作,它提出了一个发人深省的悖论:
人类信息处理速度仅为每秒 10bit,而我们的感官系统却能以每秒 10 亿 bit 的速率收集数据。
由此,按照每秒 10bit 的速度来算,人类 24 小时不间断学习且不遗忘,100 年储存的知识也不过 4GB。
什么概念呢?来和大模型做个对比:
大语言模型每个参数就能存储 2bit 知识,一个 70 亿参数的模型就能存储 140 亿 bit 的知识。
△结论来自华人学者朱泽园”Physics of Language Models” 系列论文
难怪研究人员还提出了一项推论:
随着算力的不断提升,机器在各类任务中的表现超越人类只是时间问题。
另外,按照这项研究的结论,马斯克目前的脑机接口研究也有问题了。
研究人员表示:
我们预测马斯克的大脑与计算机的通信速率大约为 10bit/s。与其使用 Neuralink 的电极束,不如直接使用电话,因为电话的数据传输率已经被设计得与人类语言相匹配,而人类语言又与感知和认知的速度相匹配。
一时间,这一系列惊人推论在学术圈各大社区引起广泛讨论。
美国知名医师科学家、斯克里普斯转化研究所创始人 Eric Topol 也忍不住下场转发。
为啥我们一次只能思考一件事呢?
所以,结论如何得出的?
中枢神经系统 “串行” 影响信息处理速率
简单说,要想计算人一辈子能学多少知识,我们得先从大脑处理信息的速度说起。
从对几项日常活动(如打字、说话演讲、拧魔方等)的评估来看,他们初步得出 “大脑处理信息的速度约为 10bits/s” 这一结论。
以人类打字为例,高级打字员每分钟能打 120 个单词(每秒 2 个),平均每个单词按 5bit 计算,那么信息传输速率就是 10bits/s。
同样,若以英语演讲为例,如果将节奏控制在舒适程度——讲话速度为每分钟 160 个单词,则信息传输速率为 13bits/s,略高于打字。
再比如 “盲拧魔方” 这项竞技活动,选手需先观察魔方几秒,然后闭眼还原。以一次世界纪录的成绩 12.78s 为例,其中观察阶段约 5.5s,由于魔方可能的排列数约为 4.3x1016≈265,则最终信息传输速率约为 11.8bits/s。
使用类似方式,作者估算了更多场景下的信息处理速度(从经典实验室实验到现代电子竞技等),结果显示为 5~50bits/s 之间。
由此也得出一个整体结论:人类思考的速度始终在 10bits/s 的尺度范围内。
按照这一标准,假设我们能活 100 岁,每天 24 小时不间断学习(且剔除遗忘因素),那么我们最终的 “知识储量” 也将不到 4GB。
事实上,与 10bits/s 形成鲜明对照的是——人类感官系统以约 10 亿 bits/s 的速率收集数据。
10bits/s VS 10 亿 bits/s
具体来说,我们每天从周围环境中获取信息的速率就以 Gbps/s 起算。
举个栗子,视觉系统中单个视锥细胞能以 270bits/s 的速度传输信息,而一只眼睛就拥有约 600 万个视锥细胞。
那么,光是双眼视觉系统接收信息的速度就高达 3.2Gbps/s。照此推算,我们接收信息的速度与处理信息的速度之间的差距比值竟然达到了 108:1。
要知道,人类大脑里有超过 850 亿个神经元,其中三分之一集中在大脑皮层组成了复杂的神经网络。也就是说,明明单个神经元就能轻松处理超过 10bits/s 的信息。
而现在所观察到的现象却与之不符,显而易见,上述二者之间存在一定矛盾。
从神经元本身的性能来看,它们具备快速处理和传输信息的能力,但这并没有直接转化为整体认知速度的提升,说明还有其他因素在起作用。
那么,为什么人类信息处理速度如此之慢?
按照论文分析,原因可能在以下几个方面:
最主要的,中枢神经系统在处理信息时采用的是串行方式,对信息传输速率有所限制。
这里要提到并行处理和串行处理之间的区别。
所谓并行处理,显然指多个任务同时进行。以我们看东西为例,视网膜每秒会产生 100 万个输出信号,每一个信号都是视网膜神经元对视觉图像局部计算的结果,由此同时处理大量视觉信息。
而在中枢神经系统中,他们观察到了一种 “心理不应期”(psychological refractory period)效应,即同时面对多个任务,中枢神经系统只将注意力集中在一个任务上。
当然,他们也进一步探究了出现 “串行” 背后的原因,结论是这与演化过程早期的神经系统功能有关。
展开来说,那些最早拥有神经系统的生物,核心利用大脑来检测气味分子的浓度梯度,以此判断运动方向进行捕食和避开敌人。长此以往,这种特定功能需求使得大脑逐渐形成了 “一次处理一个任务” 的认知架构。
在进化过程中,大脑的这种架构逐渐固化,虽然随着物种的进化,大脑的功能越来越复杂,但这种早期形成的认知架构仍然在一定程度上限制了我们同时处理多个任务和快速处理信息的能力。
除此之外,还有理论认为存在 “注意瓶颈” 等限制了信息处理。注意力是认知过程中的一个重要因素,它就像一个瓶颈,限制了能够进入认知加工阶段的信息数量和速度,不过其具体运作机制目前人类尚未完全理解。
总之,按照论文的观点,10bits/s 这样的速度已经可以满足人类生存需求,之所以还存在庞大的神经元网络,原因可能是我们需要频繁切换任务,并整合不同神经回路之间的信息。
马斯克脑机接口过于理想化
不过话虽如此,鉴于 10bits/s 和 10 亿 bits/s 之间的巨大差距,人类越来越无法忍受慢节奏了。
由此论文也得出一个推断:随着算力的不断提升,机器在各类任务中的表现超越人类只是时间问题。
换成今天的话说,以 AI 为代表的新物种将大概率逐渐 “淘汰” 人类。
另外,论文还顺带调侃了马斯克的脑机接口系统。
其中提到,马斯克的行动基于肉体带宽不足对处理信息的限制。按照老马的设想,一旦通过高带宽接口直接连接人脑和计算机,人类就可以更自由地和 AI 交流,甚至共生。
然而他们认为这一想法有些过于理想化。
10bits/s 的限制源于大脑基本结构,一般无法通过外部设备来突破。
由此也提出开头提到的建议:
与其使用 Neuralink 的电极束,不如直接使用电话,因为电话的数据传输率已经被设计得与人类语言相匹配,而人类语言又与感知和认知的速度相匹配。
不过上述言论也并非意味着他们对脑机接口失去信心,他们认为其关键并不在于突破信息速率限制,而是以另一种方式提供和解码患者所需信息。
作者之一为上海交大校友
这项研究由来自加州理工学院生物学与生物工程系的两位学者完成。
Jieyu Zheng 目前是加州理工学院五年级博士研究生,她还是上海交大本科校友,还有康奈尔大学生物工程学士学位,在剑桥大学获得教育与心理学硕士学位。
她的研究重点聚焦于认知灵活性、学习和记忆,特别关注大脑皮层和海马体在这些功能中的核心作用。目前她正在进行一个名为 “曼哈顿迷宫中的小鼠” 项目。
Markus Meister 是 Jieyu Zheng 的导师,1991 年起在哈佛大学担任教授,2012 年于加州理工学院担任生物科学教授,研究领域是大型神经回路的功能,重点关注视觉和嗅觉的感官系统。
Markus Meister 曾于 1993 年被评为 Pew 学者,2009 年因其在视觉和大脑研究方面的贡献获 Lawrence C. Katz 神经科学创新研究奖以及 Minerva 基金会颁发的 “金脑奖”。
新研究发布后,作者们就在 X 上当起了自个儿的自来水。
我们提出的特征是脑科学中最大的未解数值。
Markus Meister 还调侃每秒 10bit 的处理速度可是经过了同行评审的。
随后学术圈各大社区也针对这项研究开始讨论起来。
有人认为论文读起来很有意思,发人深省:
简化内容,只聚焦于中枢神经系统并且将讨论的内容分为内部和外部大脑两部分后,更有意义了。
这是一个非常重要的视角,值得深思……
然鹅,也有不少人提出疑问。
我越想这篇论文中的某些估计,就越怀疑。例如,关于打字员与听者之间比特率的等效性(S.3)似乎有误。正如香农所指出的,英文字母的熵约为每字符 1bit。但如果是一连串的单词或是概念,情况又如何呢?
作者默认了一个假设,即每秒 10bit 是慢的。与我们在硅基底上实现的通用计算系统相比,这的确很慢,但这种假设并不能线性地转化为大脑的信息吞吐量和存在的感知。
对于这项研究,你有什么看法呢?
参考链接:
[1]http://www.caltech.edu/about/news/…
[2]http://www.cell.com/neuron/abst…
[3]news.ycombinator.com/item?id=424…
[4]arxiv.org/pdf/2408.10…
欢迎在评论区留下你的想法!
— 完 —
来源:juejin.cn/post/7492778249534619648
【实例】H5呼起摄像头进行拍照、扫福等操作
主要是借助navigator.mediaDevices.getUserMedia
方法来呼气摄像头获取视频流
// 初始化摄像头
async function initCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' } // 后置摄像头
});
video.srcObject = stream; // 将数据流传入到视频组件当中
return new Promise((resolve) => {
video.onloadedmetadata = () => {
video.play(); // 播放数据, 微信当中需要手动播放,无法自动播放
resolve();
};
});
} catch (err) {
alert(JSON.stringify(err))
}
}
获取到视频流之后,点击按钮去对视频截图,上传视频到后端,对视频截图可以使用canvas
来实现。
// 捕获图像
function captureImage() {
video.pause()
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
capturedImageBlob = blob;
const imageUrl = URL.createObjectURL(blob);
let result = document.getElementById('result')
result.src = imageUrl
$('#page-3').show().siblings().hide()
}, 'image/jpeg', 0.8);
}
来源:juejin.cn/post/7516910928669130787
为什么一个文件的代码不能超过300行?
大家好,我是前端林叔,掘金小册《如何写出高质量的前端代码》 作者。
先说观点:在进行前端开发时,单个文件的代码行数推荐最大不超过300行,而超过1000行的都可以认为是垃圾代码,需要进行重构。
为什么是300
当然,这不是一个完全精准的数字,你一个页面301行也并不是什么犯天条的大罪,只是一般情况下,300行以下的代码可读性会更好。
起初,这只是林叔根据自己多年的工作经验拍脑袋拍出来的一个数字,据我观察,常规的页面开发,或者说几乎所有的前端页面开发,在进行合理的组件化拆分后,页面基本上都能保持在300行以下,当然,一个文件20行也并没有什么不妥,这里只是说上限。
但是拍脑袋得出的结论是不能让人信服的,于是林叔突发奇想想做个实验,看看这些开源大佬的源码文件都是多少行,于是我开发了一个小脚本。给定一个第三方的源文件所在目录,读取该目录下所有文件的行数信息,然后统计该库下文件的最长行数、最短行数、平均行数、小于500行/300行/200行/100行的文件占比。
脚本实现如下,感兴趣的可以看一下,不感兴趣的可以跳过看统计结果。统计排除了css样式文件以及测试相关文件。
const fs = require('fs');
const path = require('path');
let fileList = []; //存放文件路径
let fileLengthMap = {}; //存放每个文件的行数信息
let result = { //存放统计数据
min: 0,
max: 0,
avg: 0,
lt500: 0,
lt300: 0,
lt200: 0,
lt100: 0
}
//收集所有路径
function collectFiles(sourcePath){
const isFile = function (filePath){
const stats = fs.statSync(filePath);
return stats.isFile()
}
const shouldIgnore = function (filePath){
return filePath.includes("__tests__")
|| filePath.includes("node_modules")
|| filePath.includes("output")
|| filePath.includes("scss")
|| filePath.includes("style")
}
const getFilesOfDir = function (filePath){
return fs.readdirSync(filePath)
.map(file => path.join(filePath, file));
}
//利用while实现树的遍历
let paths = [sourcePath]
while (paths.length){
let fileOrDirPath = paths.shift();
if(shouldIgnore(fileOrDirPath)){
continue;
}
if(isFile(fileOrDirPath)){
fileList.push(fileOrDirPath);
}else{
paths.push(...getFilesOfDir(fileOrDirPath));
}
}
}
//获取每个文件的行数
function readFilesLength(){
fileList.forEach((filePath) => {
const data = fs.readFileSync(filePath, 'utf8');
const lines = data.split('\n').length;
fileLengthMap[filePath] = lines;
})
}
function statisticalMin(){
let min = Infinity;
Object.keys(fileLengthMap).forEach((key) => {
if (min > fileLengthMap[key]) {
min = fileLengthMap[key];
}
})
result.min = min;
}
function statisticalMax() {
let max = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (max < fileLengthMap[key]) {
max = fileLengthMap[key];
}
})
result.max = max;
}
function statisticalAvg() {
let sum = 0;
Object.keys(fileLengthMap).forEach((key) => {
sum += fileLengthMap[key];
})
result.avg = Math.round(sum / Object.keys(fileLengthMap).length);
}
function statisticalLt500() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 500) {
count++;
}
})
result.lt500 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
function statisticalLt300() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 300) {
count++;
}
})
result.lt300 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
function statisticalLt200() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 200) {
count++;
}
})
result.lt200 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
function statisticalLt100() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 100) {
count++;
}
})
result.lt100 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
//统计
function statistics(){
statisticalMin();
statisticalMax();
statisticalAvg();
statisticalLt500();
statisticalLt300();
statisticalLt200();
statisticalLt100();
}
//打印
function print(){
console.log(fileList)
console.log(fileLengthMap)
console.log('最长行数:', result.max);
console.log('最短行数:', result.min);
console.log('平均行数:', result.avg);
console.log('小于500行的文件占比:', result.lt500);
console.log('小于300行的文件占比:', result.lt300);
console.log('小于200行的文件占比:', result.lt200);
console.log('小于100行的文件占比:', result.lt100);
}
function main(path){
collectFiles(path);
readFilesLength();
statistics();
print();
}
main(path.resolve(__dirname,'./vue-main/src'))
利用该脚本我对Vue、React、ElementPlus和Ant Design这四个前端最常用的库进行了统计,结果如下:
库 | 小于100行占比 | 小于200行占比 | 小于300行占比 | 小于500行占比 | 平均行数 | 最大行数 | 备注 |
---|---|---|---|---|---|---|---|
vue | 60.8% | 84.5% | 92.6% | 98.0% | 112 | 1000 | 仅1个模板文件编译的为1000行 |
react | 78.0% | 92.0% | 94.0% | 98.0% | 96 | 1341 | 仅1个JSX文件编译的为1341行 |
element-plus | 73.6% | 90.9% | 95.8% | 98.8 | 75 | 950 | |
ant-design | 86.9% | 96.7% | 98.7% | 99.5% | 47 | 722 |
可以看出95%左右的文件行数都不超过300行,98%的都低于500行,而每个库中超过千行以上的文件最多也只有一个,而且还都是最复杂的模板文件编译相关的代码,我们平时写的业务代码复杂度远远小于这些优秀的库,那我们有什么理由写出那么冗长的代码呢?
从这个数据来看,林叔的判断是正确的,代码行数推荐300行以下,最好不超过500行,禁止超过1000行。
为什么不要超过300
现在,请你告诉我,你见过最难维护的代码文件是什么样的?它们有什么特点?
没错,那就是大,通常来说,难维护的代码会有3个显著特点:耦合严重、可读性差、代码过长,而代码过长是难以维护的最重要的原因,就算耦合严重、可读性差,只要代码行数不多,我们总还能试着去理解它,但一旦再伴随着代码过长,就超过我们大脑(就像计算机的CPU和内存)的处理上限了,直接死机了。
这是由于我们的生理结构决定的,大脑天然就喜欢简单的事物,讨厌复杂的事物,不信咱们做个小测试,试着读一遍然后记住下面的几个字母:
F H U T L P
怎么样,记住了吗?是不是非常简单,那我们再来看下下面的,还是读一遍然后记住:
J O Q S D R P M B C V X
这次记住了吗?这才12个字母而已,而上千行的代码中,包含各种各样的调用关系、数据结构等,为了搞懂一个功能可能还要跳转好几个函数,这么复杂的信息,是不是对大脑的要求有点过高了。
代码行数过大通常是难以维护的最大原因。
怎么不超过300
现在前端组件化编程这么流行,这么方便,我实在找不出还要写出超大文件的理由,我可以"武断"地说,凡是写出大文件的同学,都缺乏结构化思维和分治思维。
面向结构编程,而不是面向细节编程
以比较简单的官网开发为例,喜欢面向细节编程的同学,可能得实现是这样的:
<div>
<div class="header">
<img src="logo.png"/>
<h1>网站名称</h1>
<!-- 其他头部代码 -->
</div>
<div class="main-content">
<div class="banner">
<ul>
<li><img src="banner1.png"></li>
<!-- 省略n行代码 -->
</ul>
</div>
<div class="about-us">
<!-- 省略n行代码 -->
</div>
<!-- 省略n行代码 -->
</div>
</div>
其中省略了N行代码,通常他们写出的页面都非常的长,光Dom可能都有大几百行,再加上JS逻辑以及CSS样式,轻松超过1000行。
现在假如领导让修改"关于我们"的相关代码,我们来看看是怎么做的:首先从上往下阅读代码,在几千行代码中找到"关于我们"部分的DOM,然后再从几千行代码中找到相关的JS逻辑,这个过程中伴随着鼠标的反复上下滚动,眼睛像扫描仪一样一行行扫描,生怕错过了某行代码,这样的代码维护起来无疑是让人痛苦的。
面向结构开发的同学实现大概是这样的:
<div>
<Header/>
<main>
<Banner/>
<AboutUs/>
<Services/>
<ContactUs/>
</main>
<Footer/>
</div>
我们首先看到的是页面的结构、骨架,如果领导还是让我们修改"关于我们"的代码,你会怎么做,是不是毫不犹豫地就进入AboutUs组件的实现,无关的信息根本不会干扰到你,而且AboutUs的逻辑都集中在组件内部,也符合高内聚的编程原则。
特别是关于表单的开发,面向细节编程的情况特别严重,也造成表单文件特别容易变成超大文件,比如下面这个图,在一个表单中有十几个表单项,其中有一个选择商品分类的下拉选择框。
面向细节编程的同学喜欢直接把每个表单项的具体实现,杂糅在表单组件中,大概如下这样:
<template>
<el-form :model="formData">
<!--忽略其他代码-->
<el-form-item label="商品分类" prop="group">
<el-select v-model="formData.group"
@visible-change="$event && getGr0upOptions()"
>
<el-option v-for="item in groupOptions"
:key="item.id"
:label="item.label"
:value="item.value"
></el-option>
</el-select>
</el-form-item>
</el-form>
</template>
<script>
export default {
data(){
return {
formData: {
//忽略其他代码
group: ''
},
groupOptions:[]
}
},
methods:{
groupOptions(){
//获取分类信息,赋给groupOptions
this.groupOptions = [];
}
}
}
</script>
这还只是一个非常简单的表单项,你看看,就增加了这么多细节,如果是比较复杂点的表单项,其代码就更多了,这么多实现细节混合在这里,你能轻易地搞明白每个表单项的实现吗?你能说清楚这个表单组件的主线任务吗?
面向结构编程的同学会把它抽取为表单项组件,这样表单组件中只需要关心表单初始化、校验规则配置、保存逻辑等应该表单组件处理的内容,而不再呈现各种细节,实现了关注点的分离。
<template>
<el-form :model="formData">
<!--忽略其他代码-->
<el-form-item label="商品分类" prop="group">
<select-group v-model="formData.group" />
</el-form-item>
</el-form>
</template>
<script>
export default {
data(){
return {
formData: {
//忽略其他代码
group: ''
}
}
}
}
</script>
分而治之,大事化小
在进行复杂功能开发时,应该首先通过结构化思考,将大功能拆分为N个小功能,具体每个小功能怎么实现,先不用关心,在结构搭建完成后,再逐个问题击破。
仍然以前面提到的官网为例,首先把架子搭出来,每个子组件先不要实现,只要用一个简单的占位符占个位就行。
<div>
<Header/>
<main>
<Banner/>
<AboutUs/>
<Services/>
<ContactUs/>
</main>
<Footer/>
</div>
每个子组件刚开始先用个Div占位,具体实现先不管。
<template>
<div>关于我们</div>
</template>
<script>
export default {
name: 'AboutUs'
}
</script>
架子搭好后,再去细化每个子组件的实现,如果子组件很复杂,利用同样的方式将其拆分,然后逐个实现。相比上来就实现一个超大的功能,这样的实现更加简单可执行,也方便我们看到自己的任务进度。
可以看到,我们实现组件拆分的目的,并不是为了组件的复用(复用也是组件化拆分的一个主要目的),而是为了更好地呈现功能的结构,实现关注点的分离,增强可读性和可维护性,同时通过这种拆分,将复杂的大任务变成可执行的小任务,更容易完成且能看到进度。
总结
前端单个文件代码建议不超过300行,最大上限为500行,严禁超过100行。
应该面向结构编程,而不是面向细节编程,要能看到一个组件的主线任务,而不被其中的实现细节干扰,实现关注点分离。
将大任务拆分为可执行的小任务,先进行占位,后逐个实现。
本文内容源自我的掘金小册 《如何写出高质量的前端代码》
来源:juejin.cn/post/7431575865152618511
i人的福音!一个强大开源的文本转语音工具!
大家好,我是 Java陈序员
。
现在的自媒体可谓是十分火热,各个视频剪辑软件提供了文本生成语音的功能,但大多都是千篇一律的音色,比如“这个男人叫小帅”。
如果你想做自媒体,既不想录制自己的语音,又想自己的视频配音与他人不同,可以考虑使用大模型来训练生成自己的语音。
今天,给大家介绍一个开源免费的文本转语音工具,支持十几种语言生成!
关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。
项目介绍
EmotiVoice
—— 一个强大的开源 TTS
引擎(Text To Speech,即文本转语音),完全免费开源!
EmotiVoice
供了一个易于使用的 Web 界面用于文本转语音,支持中英文双语,包含 2000 多种不同的音色,以及特色的情感合成功能,支持合成包含快乐、兴奋、悲伤、愤怒等广泛情感的语音。
此外,EmotiVoice
还提供了用于批量生成结果的 API 接口。
项目使用
启动工具
EmotiVoice
的使用方法十分简单,在 Windows 环境下,解压软件压缩包后,双击运行 start.bat
即可启动。
双击运行 start.bat
后,将会在 CMD 命令窗口中运行服务:
并在浏览器中自动打开 Web 界面:
单句合成
1、选择说话人,工具提供了十几种不同的男女音色供选择
2、选择情绪,工具提供了普通、生气、开心、惊讶、悲伤、厌恶、恐惧等语音情绪
3、输入合成文本
4、点击合成
5、合成的音频可以进行在线播放和下载,或者在合成时勾选生成音频后直接保存在wav_file目录下
故事剧本多人合成
EmotiVoice
除了提供单句合成,还提供了故事剧本多人合成的功能。
1、输入角色和文本
2、为角色选定声音情感
3、为角色选定配音员
4、点击合成音频
快速上手
完整安装
conda create -n EmotiVoice python=3.8 -y
conda activate EmotiVoice
pip install torch torchaudio
pip install numpy numba scipy transformers soundfile yacs g2p_en jieba pypinyin pypinyin_dict
更多的模型训练,可参考项目文档。
Docker 部署
尝试
EmotiVoice
最简单的方法是运行 Docker 镜像,需要一台带有 NVidia GPU 的机器!
docker run -dp 127.0.0.1:8501:8501 syq163/emoti-voice:latest
容器启动成功后,访问:
http://localhost:8501/
EmotiVoice
作为一款 TTS 引擎,可以说功能十分强大,而且开源免费,大家快去围观体验吧~
项目地址:https://github.com/netease-youdao/EmotiVoice
最后
推荐的开源项目已经收录到 GitHub
项目,欢迎 Star
:
https://github.com/chenyl8848/great-open-source-project
或者访问网站,进行在线浏览:
https://chencoding.top:8090/#/
大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!
来源:juejin.cn/post/7393746524473278527
彻底解决PC滚动穿透问题
背景:
之前在做需求的时候,产品有提到一个bug,说是在某些情况下不应该触发外部滚动条滚动,例如鼠标在气泡框的内部就不能产生外部滚动条滚动,这样会影响用户的体验
原效果:

禁止滚动穿透之后效果:

可以看到浏览器的默认行为就是会滚动穿透的,因此我也查找了一些解决方案,但是似乎效果都不太理想,例如最简单有效的方式就是通过设置一个css属性来解决这个问题
overscroll-behavior: contain;
但是呢这个属性实现的效果并不完美,以上方示例的黄色滚动区域为例,如果黄色区域是可以滚动的,那么该属性有效,如果黄色区域不可以滚动,该属性就会失效了,所以没办法只能另寻他法,用JS去解决这个问题
原理:通过监听目标元素内部的滚轮事件,如果内部还有元素可滚动则不做处理,如果内部元素已无法滚动,则禁止滚轮事件冒泡至外部,从而导致无法触发外部滚动条滚动的行为
以下是整体代码,我封装了一个VUE版本的通用HOOKS函数,具体实现大家可参考代码,希望给大家带来帮助!
import { isRef, onMounted, onUnmounted, nextTick } from "vue"
import type { Ref } from "vue"
/**
* 可解析为 DOM 元素的数据源类型
* - 选择器字符串 | Ref | 返回dom函数
*/
type TElement<T> = string | Ref<T> | (() => T)
/**
* HOOKS: 使用滚动隔离
*
* @author dyb-dev
* @date 21/06/2025/ 14:20:34
* @param {(TElement<HTMLElement | HTMLElement[]>)} target 目标元素 `[选择器字符串 | ref对象 | 返回dom函数]`
* @param {TElement<HTMLElement>} [scope=() => document.documentElement] 作用域元素(注意:目标元素为 `选择器字符串` 才奏效) `[选择器字符串 | ref对象 | 返回dom函数]`
*/
export const useScrollIsolate = (
target: TElement<HTMLElement | HTMLElement[]>,
scope: TElement<HTMLElement> = () => document.documentElement
) => {
/** LET: 当前绑定监听器的目标元素列表 */
let _targetElementList: HTMLElement[] = []
/** HOOKS: 挂载钩子 */
onMounted(async() => {
await nextTick()
// 获取目标元素列表
_targetElementList = _getTargetElementList()
// 遍历绑定 `滚轮` 事件
_targetElementList.forEach(_element => {
_element.addEventListener("wheel", _onWheel, { passive: false })
})
})
/** HOOKS: 卸载钩子 */
onUnmounted(() => {
_targetElementList.forEach(_element => {
_element.removeEventListener("wheel", _onWheel)
})
})
/**
* FUN: 获取目标元素列表
* - 字符串时基于作用域选择器查找
*
* @returns {HTMLElement[]} 目标元素列表
*/
const _getTargetElementList = (): HTMLElement[] => {
let _getter: () => unknown
if (typeof target === "string") {
_getter = () => {
const _scopeElement = _getScopeElement()
return _scopeElement ? [..._scopeElement.querySelectorAll(target)] : []
}
}
else {
_getter = _createGetter(target)
}
const _result = _getter()
const _normalized = Array.isArray(_result) ? _result : [_result]
return _normalized.filter(_node => _node instanceof HTMLElement)
}
/**
* FUN: 获取作用域元素(scope)
* - 字符串时使用 querySelector
*
* @returns {HTMLElement | null} 作用域元素
*/
const _getScopeElement = (): HTMLElement | null => {
let _getter: () => unknown
if (typeof scope === "string") {
_getter = () => document.querySelector(scope)
}
else {
_getter = _createGetter(scope)
}
const _result = _getter()
return _result instanceof HTMLElement ? _result : null
}
/**
* FUN: 创建公共 getter 函数
* - 支持 Ref、函数、直接值
*
* @param {unknown} target 目标元素
* @returns {(() => unknown)} 公共 getter 函数
*/
const _createGetter = (target: unknown): (() => unknown) => {
if (isRef(target)) {
return () => (target as Ref<unknown>).value
}
if (typeof target === "function") {
return target as () => unknown
}
return () => target
}
/**
* FUN: 监听滚轮事件
*
* @param {WheelEvent} event 滚轮事件
*/
const _onWheel = (event: WheelEvent) => {
const { target, currentTarget, deltaY } = event
let _element = target as HTMLElement
while (_element) {
// 启用滚动时
if (_isScrollEnabled(_element)) {
// 无法在当前滚动方向上继续滚动时
if (!_isScrollFurther(_element, deltaY)) {
event.preventDefault()
}
return
}
// 向上查找不到滚动元素且到达当前目标元素边界时
if (_element === currentTarget) {
event.preventDefault()
return
}
_element = _element.parentElement as HTMLElement
}
}
/**
* FUN: 是否启用滚动
*
* @param {HTMLElement} element 目标元素
* @returns {boolean} 是否启用滚动
*/
const _isScrollEnabled = (element: HTMLElement): boolean =>
/(auto|scroll)/.test(getComputedStyle(element).overflowY) && element.scrollHeight > element.clientHeight
/**
* FUN: 是否能够在当前滚动方向上继续滚动
*
* @param {HTMLElement} element 目标元素
* @param {number} deltaY 滚动方向
* @returns {boolean} 是否能够在当前滚动方向上继续滚动
*/
const _isScrollFurther = (element: HTMLElement, deltaY: number): boolean => {
/** 是否向下滚动 */
const _isScrollingDown = deltaY > 0
/** 是否向上滚动 */
const _isScrollingUp = deltaY < 0
const { scrollTop, scrollHeight, clientHeight } = element
/** 是否已到顶部 */
const _isAtTop = scrollTop === 0
/** 是否已到底部 */
const _isAtBottom = scrollTop + clientHeight >= scrollHeight - 1
/** 是否还能向下滚动 */
const _willScrollDown = _isScrollingDown && !_isAtBottom
/** 是否还能向上滚动 */
const _willScrollUp = _isScrollingUp && !_isAtTop
return _willScrollDown || _willScrollUp
}
}
来源:juejin.cn/post/7519695901289267254
字节跨平台框架 Lynx 开源:一个 Web 开发者的原生体验
最近各大厂都在开源自己的跨平台框架,前脚腾讯刚宣布计划四月开源基于 Kotlin 的跨平台框架 「Kuikly」 ,后脚字节跳动旧开源了他们的跨平台框架「 Lynx」,如果说 Kuikly 是一个面向客户端的全平台框架,那么 Lynx 就是一个完全面向 Web 前端的跨平台全家桶。
为什么这么说?我们简单看官方提供的一个 Demo ,相信你可以看到许多熟悉的身影:
- scss
- React
- useEffect
- react native 的
view
import "../index.scss";
import { useEffect, useMainThreadRef, useRef } from "@lynx-js/react";
import { MainThread, type ScrollEvent } from "@lynx-js/types";
import type { NodesRef } from "@lynx-js/types";
import LikeImageCard from "../Components/LikeImageCard.jsx";
import type { Picture } from "../Pictures/furnitures/furnituresPictures.jsx";
import { calculateEstimatedSize } from "../utils.jsx";
import { NiceScrollbar, type NiceScrollbarRef } from "./NiceScrollbar.jsx";
import { adjustScrollbarMTS, NiceScrollbarMTS } from "./NiceScrollbarMTS.jsx";
export const Gallery = (props: { pictureData: Picture[] }) => {
const { pictureData } = props;
const scrollbarRef = useRef<NiceScrollbarRef>(null);
const scrollbarMTSRef = useMainThreadRef<MainThread.Element>(null);
const galleryRef = useRef<NodesRef>(null);
const onScrollMTS = (event: ScrollEvent) => {
"main thread";
adjustScrollbarMTS(
event.detail.scrollTop,
event.detail.scrollHeight,
scrollbarMTSRef,
);
};
const onScroll = (event: ScrollEvent) => {
scrollbarRef.current?.adjustScrollbar(
event.detail.scrollTop,
event.detail.scrollHeight,
);
};
useEffect(() => {
galleryRef.current
?.invoke({
method: "autoScroll",
params: {
rate: "60",
start: true,
},
})
.exec();
}, []);
return (
<view className="gallery-wrapper">
<NiceScrollbar ref={scrollbarRef} />
<NiceScrollbarMTS main-thread:ref={scrollbarMTSRef} />
<list
ref={galleryRef}
className="list"
list-type="waterfall"
column-count={2}
scroll-orientation="vertical"
custom-list-name="list-container"
bindscroll={onScroll}
main-thread:bindscroll={onScrollMTS}
>
{pictureData.map((picture: Picture, index: number) => (
<list-item
estimated-main-axis-size-px={calculateEstimatedSize(picture.width, picture.height)}
item-key={"" + index}
key={"" + index}
>
<LikeImageCard picture={picture} />
</list-item>
))}
</list>
</view>
);
};
export default Gallery;
没错,目前 Lynx 开源的首个支持框架就是基于 React 的 ReactLynx,当然官方也表示Lynx 并不局限于 React,所以不排除后续还有 VueLynx 等其他框架支持,而 Lynx 作为核心引擎支持,其实并不绑定任何特定前端框架,只是当前你能用的暂时只有 ReactLynx :
对于支持平台,目前开源版本支持 Android、iOS 和 Web,而 Lynx 官方也表示其实内部已经支持了鸿蒙平台,不过由于时间的关系,暂没有开放。
至于是否支持小程序,这个从设计上看其实应该并不会太困难。
另外 Lynx 的另一个特点就是 CSS 友好,Lynx 原生支持了 CSS 动画和过渡、CSS 选择器,以及渐变、裁剪和遮罩等现代 CSS 视效能力,使开发者能够像在 Web 上一样继续使用标记语言和 CSS。
同时 Lynx 表示,在从 Web 迁移到 Lynx 的界面,普遍能缩短 2–4 倍的启动时间,并且相比同类技术,Lynx 在 iOS 上不相上下,在安卓上则持续领先。
性能主要体现在自己特有的排版引擎、线程模型和更新机制。
而在实现上,源代码中的标签,会在运行时被 Lynx 引擎解析,翻译成用于渲染的 Element,嵌套的 Element 会组成的一棵树,从而构建出复杂的界面:
而 Lynx Element 是和平台无关的统一抽象支持,它们会被 Lynx 引擎渲染为原生平台的 UI 控件,比如 iOS 与 Android 中的 Native View,或 Web 中的 HTML 元素(包括 custom_elements),从目前的 Demo 直出 App 我们也可以看到这一点:
那看到这里,你是不是想说,这不就是 react-native 吗?这里有几个不同点:
- Lynx 默认在引擎层就支持 Web
- Lynx 有自己特有的线程模型和布局模型
- Lynx 在官方宣传中可以切换到自渲染,虽然暂时没找到
事实上,Lynx 官方并没有避讳从其他框架里学习相应优势的情况,官方就很大方的表示,Lynx 项目就是使用了 react-native 和 Flutter 的部分优势能力,从这一点看Lynx 还是相当真诚的:
react-native 不用说,比如 JSI 等概念都可以在项目内找到,而类似 Flutter 里的 buildroot 和 Runner 也可以在项目内看到,包含 Flutter 里的 message loop 等事件驱动的线程编程模型:
例如 Lynx 的 Virtual Thread 概念,对应 Lynx 托管的“执行线程” ,用于提供 Task 的顺序执行,并且它与物理线程可能存在不是一一对应的关系,这和 Flutter 的 Task Runners 概念基本一样,支持将 Task 发布上去执行,但不关心其线程模型情况。
另外 Lynx 最大的特点之一是「双线程架构」,JavaScript 代码会在「主线程」和「后台线程」两个线程上同时运行,并且两个线程使用了不同的 JavaScript 引擎作为其运行时:
- Lynx 主线程负责处理直接处理屏幕像素渲染的任务,包括:执行主线程脚本、处理布局和渲染图形等等,比如负责渲染初始界面和应用后续的 UI 更新,让用户能尽快看到第一屏内容。
- Lynx 的后台线程会运行完整的 React 运行时,处理的任务不直接影响屏幕像素的显示,包括在后台运行的脚本和任务(生命周期和其他副作用),它们与主线程分开运行,这样可以让主线程专注于处理用户交互和渲染,从而提升整体性能。
比如下面这个代码,当组件 <HelloComponent/>
被渲染时,你可能会在控制台看到 "Hello" 被打印两次,因为代码运行在两个线程上:
const HelloComponent = () => {
console.log('Hello'); // 这行会被打印两次
return <text>Hello</text>;
};
在 Lynx 规则里,事件处理器、Effect、标注
background only
、backgroundOnlyFunction 等只能运行在后台线程,因为后台线程才有完整的 React 运行时。
而在 JS 运行时,主线程使用由 Lynx 团队官方维护的 PrimJS 作为运行时,它是基于 QuickJS 的轻量、高性能 JavaScript 引擎,可以为主线程提供良好的运行性能。
而 Lynx 的后台线程:
- Android:出于包体积和性能的综合考量,默认使用 PrimJS 作为运行时
- iOS:默认情况下使用 JavaScriptCore 作为运行时,但由于调试协议支持度的原因,当需要调试的时候,需要切换到 PrimJS
同时 PrimJS 提供了一种高性能的 FFI 能力,可以较低成本的将 Lynx 对象封装为 JS 对象返回给 FFI 调用者,相比传统的 FFI 性能优势明显,但是这种类型的 JS 对象并不是 Object Model,Lynx 引擎无法给该对象绑定 setter getter 方法,只能提供 FFI 将其作为参数传入,实现类似的功能。
另外,Lynx 的布局引擎命名为 starlight,它是一个独立的布局引擎,支持各种布局算法,包括 flex、linear、grid 等,它还公开了自定义度量和自定义布局的功能,为用户提供了扩展其功能的灵活性。
在 Lynx 内部,LynxView
的作用类似于原生的 WebView
,用于加载渲染对应 Bundle 文件,其中 LynxView
对应的就是 Page,Page 就是 Lynx App 的 Root Element。
客户端可以给 LynxView 设置不同的大小约束,也就是给 Page 设置大小约束,Lynx 排版引擎会使用这些约束来计算 Page 节点以及所有子节点的大小位置信息:
<page>
是页面的根节点,一个页面上只能有一个<page>
。你也可以不在页面最外层显式写<page>
,前端框架会默认生成根节点。
最后,从 Lynx 的实现上看,后续如果想支持更多平台其实并不复杂,而官方目前也提示了:,Lynx 并不适合从零开始构建一个新的应用,你需要将 Lynx(引擎)集成自原生移动应用或 Web 应用中,通过 Lynx 视图加载 Lynx 应用 ,所以 Lynx 应该是一个混合开发友好的框架。
那么,对于你来说,Lynx 会是你跨平台开发的选择之一吗?
来源:juejin.cn/post/7478167090530320424
最通俗的前端监控方案
最通俗的前端监控方案

都说面试造飞机,现实打螺丝
不管如何,多学一点总是好。抱着好奇心态,我收集网上资料整理形成自己眼中的前端监控实现思路,当然这个还是很简陋
不过我想复杂监控系统框架,核心也是通过这些 api 收集完成,只是更加系统全面化
都说面试造飞机,现实打螺丝
不管如何,多学一点总是好。抱着好奇心态,我收集网上资料整理形成自己眼中的前端监控实现思路,当然这个还是很简陋
不过我想复杂监控系统框架,核心也是通过这些 api 收集完成,只是更加系统全面化
理清思路
所谓的监控,我这里大致分为 4 步,分别是定义监控范围,上报数据,分析数据,解决系统问题
所谓的监控,我这里大致分为 4 步,分别是定义监控范围,上报数据,分析数据,解决系统问题
1、定义监控范围
定义好基础数据标准,便于后续分析
- 错误类数据结构
参数名 类型 必填 说明 type string 是 错误类型,如'js'、'resource'、'custom'、'performance' subType string 是 错误子类型,如'onerror'、'promise'、'xhr'、'business' msg string 是 错误信息 userAgent string 是 用户设备信息 url string 否 错误发生的当前对象,资源 url,请求 url,页面 url stack string 否 错误堆栈信息 time number 是 错误发生的时间戳 lineNo number 否 发生错误的代码行号 columnNo number 否 发生错误的代码列号 businessData object 否 自定义业务数据 performanceData object 否 性能相关数据 appId string 是 应用 ID,用于区分不同应用 userId string 否 用户 ID,用于区分不同用户 page string 否 当前页面 url
- 错误主类型和子类型对应关系(这里可以自己指定规则和类型)
const validSubTypes = {
js: ["onerror", "promise", "xhr", "fetch"],
resource: ["img", "script", "link", "audio", "video"],
custom: ["business"],
performance: ["component_render"],
};
js 和 resource 类型错误,会自动上报,其他类型错误,需要手动上报;比如:页面上订单创建失败,你可以上报一个 custom + business
的业务错误;首页加载速度超过 5s,你可以上报一个 performance + component_render
的性能错误
- 请求类数据结构
参数名 类型 必填 说明 type string 是 请求类型,如'xhr'、'fetch'、'vuex_action' url string 是 请求 URL method string 是 请求方法,如'GET'、'POST' duration number 是 请求耗时,单位毫秒 status number 是 HTTP 状态码 success boolean 是 请求是否成功 time number 是 请求发生的时间戳 payload object 否 请求负载数据 appId string 是 应用 ID,用于区分不同应用 userId string 否 用户 ID,用于区分不同用户 page string 否 当前页面 url
- 页面类数据机构
参数名 类型 必填 说明 appId string 是 应用 ID,用于区分不同应用 userId string 否 用户 ID,用于区分不同用户 title string 是 页面 标题 url string 是 页面 URL referrer string 是 页面来源 URL screenWidth string 是 可视区域宽度 screenHeight string 是 可视区域高度 language string 是 页面语言版本 userAgent string 是 用户设备信息 time number 是 上报发生的时间戳 dnsTime number 是 dns 解析时间 tcpTime number 是 tcp 连接时间 sslTime number 是 ssl 握手时间 requestTime number 是 请求时间 responseTime number 是 响应时间 domReadyTime number 是 dom 解析 loadTime number 是 页面完全加载时间
定义好基础数据标准,便于后续分析
- 错误类数据结构
参数名 | 类型 | 必填 | 说明 |
---|---|---|---|
type | string | 是 | 错误类型,如'js'、'resource'、'custom'、'performance' |
subType | string | 是 | 错误子类型,如'onerror'、'promise'、'xhr'、'business' |
msg | string | 是 | 错误信息 |
userAgent | string | 是 | 用户设备信息 |
url | string | 否 | 错误发生的当前对象,资源 url,请求 url,页面 url |
stack | string | 否 | 错误堆栈信息 |
time | number | 是 | 错误发生的时间戳 |
lineNo | number | 否 | 发生错误的代码行号 |
columnNo | number | 否 | 发生错误的代码列号 |
businessData | object | 否 | 自定义业务数据 |
performanceData | object | 否 | 性能相关数据 |
appId | string | 是 | 应用 ID,用于区分不同应用 |
userId | string | 否 | 用户 ID,用于区分不同用户 |
page | string | 否 | 当前页面 url |
- 错误主类型和子类型对应关系(这里可以自己指定规则和类型)
const validSubTypes = {
js: ["onerror", "promise", "xhr", "fetch"],
resource: ["img", "script", "link", "audio", "video"],
custom: ["business"],
performance: ["component_render"],
};
js 和 resource 类型错误,会自动上报,其他类型错误,需要手动上报;比如:页面上订单创建失败,你可以上报一个
custom + business
的业务错误;首页加载速度超过 5s,你可以上报一个performance + component_render
的性能错误
- 请求类数据结构
参数名 | 类型 | 必填 | 说明 |
---|---|---|---|
type | string | 是 | 请求类型,如'xhr'、'fetch'、'vuex_action' |
url | string | 是 | 请求 URL |
method | string | 是 | 请求方法,如'GET'、'POST' |
duration | number | 是 | 请求耗时,单位毫秒 |
status | number | 是 | HTTP 状态码 |
success | boolean | 是 | 请求是否成功 |
time | number | 是 | 请求发生的时间戳 |
payload | object | 否 | 请求负载数据 |
appId | string | 是 | 应用 ID,用于区分不同应用 |
userId | string | 否 | 用户 ID,用于区分不同用户 |
page | string | 否 | 当前页面 url |
- 页面类数据机构
参数名 | 类型 | 必填 | 说明 |
---|---|---|---|
appId | string | 是 | 应用 ID,用于区分不同应用 |
userId | string | 否 | 用户 ID,用于区分不同用户 |
title | string | 是 | 页面 标题 |
url | string | 是 | 页面 URL |
referrer | string | 是 | 页面来源 URL |
screenWidth | string | 是 | 可视区域宽度 |
screenHeight | string | 是 | 可视区域高度 |
language | string | 是 | 页面语言版本 |
userAgent | string | 是 | 用户设备信息 |
time | number | 是 | 上报发生的时间戳 |
dnsTime | number | 是 | dns 解析时间 |
tcpTime | number | 是 | tcp 连接时间 |
sslTime | number | 是 | ssl 握手时间 |
requestTime | number | 是 | 请求时间 |
responseTime | number | 是 | 响应时间 |
domReadyTime | number | 是 | dom 解析 |
loadTime | number | 是 | 页面完全加载时间 |
2、上报数据
前端错误大致分为:js 运行错误,资源加载错误,请求接口错误
请求数据
页面相关数据
前端错误大致分为:js 运行错误,资源加载错误,请求接口错误
请求数据
页面相关数据
1、如何收集 js 运行错误
这里是通过 window.onerror 监听全局错误来实现的
收集到关键的几个信息,如下代码里解释
// 监听全局错误
window.onerror = (msg, url, lineNo, columnNo, error) => {
this.captureError({
type: "js",
subType: "onerror",
msg, // 错误信息
url, // 报错的文件地址
lineNo, // 错误行号
columnNo, // 错误列号
stack: error?.stack || "", // 错误堆栈信息
time: new Date().getTime(),
});
return true; // 阻止默认行为
};
因为onerror
无法收集到promise
报的错误,这里特殊化处理下
// 监听Promise错误
this.unhandledRejectionListener = (event) => {
this.captureError({
type: "js",
subType: "promise",
msg: event.reason?.message || "Promise Error",
stack: event.reason?.stack || "",
time: new Date().getTime(),
});
};
window.addEventListener("unhandledrejection", this.unhandledRejectionListener);
// ps:记得页面组件销毁时,注销掉当前的事件监听
这里是通过 window.onerror 监听全局错误来实现的
收集到关键的几个信息,如下代码里解释
// 监听全局错误
window.onerror = (msg, url, lineNo, columnNo, error) => {
this.captureError({
type: "js",
subType: "onerror",
msg, // 错误信息
url, // 报错的文件地址
lineNo, // 错误行号
columnNo, // 错误列号
stack: error?.stack || "", // 错误堆栈信息
time: new Date().getTime(),
});
return true; // 阻止默认行为
};
因为
onerror
无法收集到promise
报的错误,这里特殊化处理下
// 监听Promise错误
this.unhandledRejectionListener = (event) => {
this.captureError({
type: "js",
subType: "promise",
msg: event.reason?.message || "Promise Error",
stack: event.reason?.stack || "",
time: new Date().getTime(),
});
};
window.addEventListener("unhandledrejection", this.unhandledRejectionListener);
// ps:记得页面组件销毁时,注销掉当前的事件监听
2、如何收集资源加载错误
这里是通过window.addEventListener('error', ...)
监听资源加载错误来实现的
不过需要过滤掉上面已经监听的 js 错误,避免重复上报
// 监听资源加载错误
this.resourceErrorListener = (event) => {
// 过滤JS错误,因为JS错误已经被window.onerror捕获
if (event.target !== window) {
this.captureError({
type: "resource",
subType: event.target.tagName.toLowerCase(),
url: event.target.src || event.target.href || "",
msg: `资源加载失败: ${event.target.tagName}`,
time: new Date().getTime(),
});
}
};
window.addEventListener("error", this.resourceErrorListener, true); // 使用捕获模式
这里是通过
window.addEventListener('error', ...)
监听资源加载错误来实现的不过需要过滤掉上面已经监听的 js 错误,避免重复上报
// 监听资源加载错误
this.resourceErrorListener = (event) => {
// 过滤JS错误,因为JS错误已经被window.onerror捕获
if (event.target !== window) {
this.captureError({
type: "resource",
subType: event.target.tagName.toLowerCase(),
url: event.target.src || event.target.href || "",
msg: `资源加载失败: ${event.target.tagName}`,
time: new Date().getTime(),
});
}
};
window.addEventListener("error", this.resourceErrorListener, true); // 使用捕获模式
3、如何收集请求异常错误和请求基础数据
通过监听AJAX
请求,监听Fetch
请求,收集错误。具体错误包含:请求自身错误事件,请求超时事件,非成功状态码的请求,以及成功状态码请求(用于后续性能分析)
- 监听
AJAX
请求
/**
* 监控XMLHttpRequest请求
*/
monitorXHR() {
const originalXHR = window.XMLHttpRequest;
const _this = this;
window.XMLHttpRequest = function () {
const xhr = new originalXHR();
const originalOpen = xhr.open;
const originalSend = xhr.send;
// 记录请求开始时间
let startTime;
let reqUrl;
let reqMethod;
xhr.open = function (method, url, ...args) {
reqUrl = url;
reqMethod = method;
return originalOpen.apply(this, [method, url, ...args]);
};
xhr.send = function (data) {
startTime = new Date().getTime();
// 添加错误事件监听
xhr.addEventListener("error", function () {
const duration = new Date().getTime() - startTime;
// 记录请求信息
_this.captureRequest({
type: "xhr",
url: reqUrl,
method: reqMethod || "GET",
duration,
status: 0,
success: false,
time: new Date().getTime(),
});
// 记录错误信息
_this.captureError({
type: "js",
subType: "xhr",
msg: `XHR请求错误: ${reqUrl}`,
url: reqUrl,
stack: "",
time: new Date().getTime(),
});
});
// 添加超时事件监听
xhr.addEventListener("timeout", function () {
const duration = new Date().getTime() - startTime;
// 记录请求信息
_this.captureRequest({
type: "xhr",
url: reqUrl,
method: reqMethod || "GET",
duration,
status: 0,
success: false,
time: new Date().getTime(),
});
// 记录错误信息
_this.captureError({
type: "js",
subType: "xhr",
msg: `XHR请求超时: ${reqUrl}`,
url: reqUrl,
stack: "",
time: new Date().getTime(),
});
});
xhr.addEventListener("loadend", function () {
const duration = new Date().getTime() - startTime;
const status = xhr.status;
const success = status >= 200 && status < 300;
_this.captureRequest({
type: "xhr",
url: reqUrl,
method: reqMethod || "GET",
duration,
status,
success,
time: new Date().getTime(),
});
// 对于HTTP错误状态码,也捕获为错误
if (!success) {
_this.captureError({
type: "js",
subType: "xhr",
msg: `XHR请求失败: 状态码 ${status}`,
url: reqUrl,
stack: "",
time: new Date().getTime(),
});
}
});
return originalSend.apply(this, arguments);
};
return xhr;
};
}
- 监听
Fetch
请求
/**
* 监控Fetch请求
*/
monitorFetch() {
const originalFetch = window.fetch;
const _this = this;
window.fetch = function (input, init) {
const startTime = new Date().getTime();
const url = typeof input === "string" ? input : input.url;
const method = init?.method || (input instanceof Request ? input.method : "GET");
return originalFetch
.apply(this, arguments)
.then((response) => {
const duration = new Date().getTime() - startTime;
const status = response.status;
const success = response.ok;
_this.captureRequest({
type: "fetch",
url,
method,
duration,
status,
success,
time: new Date().getTime(),
});
return response;
})
.catch((error) => {
const duration = new Date().getTime() - startTime;
_this.captureRequest({
type: "fetch",
url,
method,
duration,
status: 0,
success: false,
time: new Date().getTime(),
});
// 记录错误信息
_this.captureError({
type: "js",
subType: "fetch",
msg: error.message || "Fetch Error",
url,
stack: error.stack || "",
time: new Date().getTime(),
});
throw error;
});
};
}
通过监听
AJAX
请求,监听Fetch
请求,收集错误。具体错误包含:请求自身错误事件,请求超时事件,非成功状态码的请求,以及成功状态码请求(用于后续性能分析)
- 监听
AJAX
请求
/**
* 监控XMLHttpRequest请求
*/
monitorXHR() {
const originalXHR = window.XMLHttpRequest;
const _this = this;
window.XMLHttpRequest = function () {
const xhr = new originalXHR();
const originalOpen = xhr.open;
const originalSend = xhr.send;
// 记录请求开始时间
let startTime;
let reqUrl;
let reqMethod;
xhr.open = function (method, url, ...args) {
reqUrl = url;
reqMethod = method;
return originalOpen.apply(this, [method, url, ...args]);
};
xhr.send = function (data) {
startTime = new Date().getTime();
// 添加错误事件监听
xhr.addEventListener("error", function () {
const duration = new Date().getTime() - startTime;
// 记录请求信息
_this.captureRequest({
type: "xhr",
url: reqUrl,
method: reqMethod || "GET",
duration,
status: 0,
success: false,
time: new Date().getTime(),
});
// 记录错误信息
_this.captureError({
type: "js",
subType: "xhr",
msg: `XHR请求错误: ${reqUrl}`,
url: reqUrl,
stack: "",
time: new Date().getTime(),
});
});
// 添加超时事件监听
xhr.addEventListener("timeout", function () {
const duration = new Date().getTime() - startTime;
// 记录请求信息
_this.captureRequest({
type: "xhr",
url: reqUrl,
method: reqMethod || "GET",
duration,
status: 0,
success: false,
time: new Date().getTime(),
});
// 记录错误信息
_this.captureError({
type: "js",
subType: "xhr",
msg: `XHR请求超时: ${reqUrl}`,
url: reqUrl,
stack: "",
time: new Date().getTime(),
});
});
xhr.addEventListener("loadend", function () {
const duration = new Date().getTime() - startTime;
const status = xhr.status;
const success = status >= 200 && status < 300;
_this.captureRequest({
type: "xhr",
url: reqUrl,
method: reqMethod || "GET",
duration,
status,
success,
time: new Date().getTime(),
});
// 对于HTTP错误状态码,也捕获为错误
if (!success) {
_this.captureError({
type: "js",
subType: "xhr",
msg: `XHR请求失败: 状态码 ${status}`,
url: reqUrl,
stack: "",
time: new Date().getTime(),
});
}
});
return originalSend.apply(this, arguments);
};
return xhr;
};
}
- 监听
Fetch
请求
/**
* 监控Fetch请求
*/
monitorFetch() {
const originalFetch = window.fetch;
const _this = this;
window.fetch = function (input, init) {
const startTime = new Date().getTime();
const url = typeof input === "string" ? input : input.url;
const method = init?.method || (input instanceof Request ? input.method : "GET");
return originalFetch
.apply(this, arguments)
.then((response) => {
const duration = new Date().getTime() - startTime;
const status = response.status;
const success = response.ok;
_this.captureRequest({
type: "fetch",
url,
method,
duration,
status,
success,
time: new Date().getTime(),
});
return response;
})
.catch((error) => {
const duration = new Date().getTime() - startTime;
_this.captureRequest({
type: "fetch",
url,
method,
duration,
status: 0,
success: false,
time: new Date().getTime(),
});
// 记录错误信息
_this.captureError({
type: "js",
subType: "fetch",
msg: error.message || "Fetch Error",
url,
stack: error.stack || "",
time: new Date().getTime(),
});
throw error;
});
};
}
4. 上报页面数据
案例中,使用是 vue 框架,页面上报方法,是放到路由守卫中进行调用
reportPage(info = {}) {
const pageInfo = { ... }
if (window.performance) {
const performanceInfo = {}
Object.assign(pageInfo, performanceInfo);
}
// 发送页面信息
this.send("/api/pages/create", pageInfo);
}
// vue 部分代码
router.afterEach((to, from) => {
// 获取全局monitor实例
const monitor = appInstance.config.globalProperties.$monitor;
if (monitor) {
// 手动上报页面访问
monitor.reportPage();
}
});
传统的页面,可以在 window.onload
中进行上报
案例中,使用是 vue 框架,页面上报方法,是放到路由守卫中进行调用
reportPage(info = {}) {
const pageInfo = { ... }
if (window.performance) {
const performanceInfo = {}
Object.assign(pageInfo, performanceInfo);
}
// 发送页面信息
this.send("/api/pages/create", pageInfo);
}
// vue 部分代码
router.afterEach((to, from) => {
// 获取全局monitor实例
const monitor = appInstance.config.globalProperties.$monitor;
if (monitor) {
// 手动上报页面访问
monitor.reportPage();
}
});
传统的页面,可以在 window.onload
中进行上报
5. 上报时机
- 定时批量上报:增加一个队列,放置 js 错误数据,请求数据。页面的数据因为不是很多,采用立即上报;
- 传统的 ajax\fench 请求,页面卸载请求会丢失。这里采用
navigator.sendBeacon
发送,如果浏览器不支持,则采用图片请求的方式发送数据。
/**
* 发送数据到服务器
* @param {string} path API路径
* @param {Object} data 数据
*/
send(path, data) {
// 如果没有baseURL则不发送
if (!this.baseURL) return;
// 使用Beacon API发送,避免页面卸载时丢失数据
if (navigator.sendBeacon) {
const fullURL = this.baseURL + path;
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
navigator.sendBeacon(fullURL, blob);
return;
}
// 后备方案:使用图片请求
const img = new Image();
img.src = `${this.baseURL}${path}?data=${encodeURIComponent(JSON.stringify(data))}&t=${new Date().getTime()}`;
}
- 定时批量上报:增加一个队列,放置 js 错误数据,请求数据。页面的数据因为不是很多,采用立即上报;
- 传统的 ajax\fench 请求,页面卸载请求会丢失。这里采用
navigator.sendBeacon
发送,如果浏览器不支持,则采用图片请求的方式发送数据。
/**
* 发送数据到服务器
* @param {string} path API路径
* @param {Object} data 数据
*/
send(path, data) {
// 如果没有baseURL则不发送
if (!this.baseURL) return;
// 使用Beacon API发送,避免页面卸载时丢失数据
if (navigator.sendBeacon) {
const fullURL = this.baseURL + path;
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
navigator.sendBeacon(fullURL, blob);
return;
}
// 后备方案:使用图片请求
const img = new Image();
img.src = `${this.baseURL}${path}?data=${encodeURIComponent(JSON.stringify(data))}&t=${new Date().getTime()}`;
}
3、分析数据
这是整个方案中比较难的部分,如何运用基础数据来分析出有价值的东西。以下是我思考几个方向
这是整个方案中比较难的部分,如何运用基础数据来分析出有价值的东西。以下是我思考几个方向
js 错误分析
- 内置一些常见 js 错误分类标准,根据错误信息匹配得出错误原因
语法错误(SyntaxError):
原因:代码书写不符合 JavaScript 语法规则。
示例:let x = "123"; 缺少分号。
解决方法:检查并修正代码中的语法错误,例如确保所有语句都正确结束,括号和引号正确匹配等。
类型错误(TypeError):
原因:变量或参数不是预期的类型,或者尝试对未定义或 null 的值进行操作。
- 内置一些常见 js 错误分类标准,根据错误信息匹配得出错误原因
语法错误(SyntaxError):
原因:代码书写不符合 JavaScript 语法规则。
示例:let x = "123"; 缺少分号。
解决方法:检查并修正代码中的语法错误,例如确保所有语句都正确结束,括号和引号正确匹配等。
类型错误(TypeError):
原因:变量或参数不是预期的类型,或者尝试对未定义或 null 的值进行操作。
2. 接入大模型,提供文件内容和报错信息,让 ai 给出分析原因
请求分析
- 请求时间超过 1s 请求有哪些
- 每个页面有多少个请求
- 重复请求有哪些
- 请求异常有哪些
页面分析
- 首屏加载时间
- 哪个页面加载时间最长
- 哪个用户访问了哪些页面
- pv/uv
4、解决系统问题
图表可视化展示 每天早上 9 点统计,当前存在的问题错误,短信,邮件,电话告警开发人员 灰度版本上线后,监控 24 小时,错误数量,页面性能情况,超过一定值,自动清除灰度版本测试的用户信息 给错误打上分类标签,增加错误状态【待处理】、以及错误分析指导意见。开发人员通过指导意见快速解决问题,修改错误状态为【已完成】
图表可视化展示 每天早上 9 点统计,当前存在的问题错误,短信,邮件,电话告警开发人员 灰度版本上线后,监控 24 小时,错误数量,页面性能情况,超过一定值,自动清除灰度版本测试的用户信息 给错误打上分类标签,增加错误状态【待处理】、以及错误分析指导意见。开发人员通过指导意见快速解决问题,修改错误状态为【已完成】
5、总结
有点惭愧,本人目前待过的公司,还没有实际的前端监控项目落地。对于具体如何使用,解决现实中问题,也欢迎大家给出分享案例。
这里更多是给大家一个抛砖引玉的作用。像成熟的页面性能分析产品:百度统计 网上提到成熟前端监控产品:sentry,目前还没有来得急学习,后续有时间写一篇入门学习指南
文章中案例代码:gitee.com/banmaxiaoba…
作者:东坡白菜
来源:juejin.cn/post/7519074019620159523
有点惭愧,本人目前待过的公司,还没有实际的前端监控项目落地。对于具体如何使用,解决现实中问题,也欢迎大家给出分享案例。
这里更多是给大家一个抛砖引玉的作用。像成熟的页面性能分析产品:百度统计 网上提到成熟前端监控产品:sentry,目前还没有来得急学习,后续有时间写一篇入门学习指南
文章中案例代码:gitee.com/banmaxiaoba…
来源:juejin.cn/post/7519074019620159523
用dayjs解析时间戳,我被提了bug
引言
前几天开发中突然接到测试提的一个 Bug,说我的时间组件显示异常。
我很诧异,这里初始化数据是后端返回的,我什么也没改,这bug提给我干啥。我去问后端:“这数据是不是有问题?”。后端答:“没问题啊,我们一直都是这么返回的时间戳,其他人用也没报错。”
于是,对比生产环境数据,我终于找到了问题根源:后端时间戳的类型,从 Number 静悄悄地变成了 String。
Bug原因
问题的原因,肯定就出现在时间数据解析上了,代码中,我统一用的dayjs做的时间解析。
如图,对时间戳的解析我都是这么写的
const time = dayjs(res.endTime).format('YYYY-MM-DD HH:mm:ss')
于是,我分别试了两种数据类型的解析方式:
- 字符型
dayjs('175008959900').format('YYYY-MM-DD hh:mm:ss') // 1975-07-19 01:35:59
- 数值型
dayjs(Number('175008959900')).format('YYYY-MM-DD HH:mm:ss') // 2025-07-17 06:59:59
看来,问题原因显而易见了:
由于后端返回的是字符串类型 的 '175008959900'
,dayjs()
在处理字符串时,会尝试按“常见的日期字符串格式”进行解析(如 YYYY-MM-DD
、YYYYMMDD
等),并不会自动识别为时间戳。所以它不会把这个字符串当作毫秒时间戳来解析,而是直接失败(解析成无效日期),但 dayjs 会退化为 Unix epoch(1970 年)或给出错误结果,最终导致返回的是错误的时间。
如何避免此类问题
同dayjs一样,原生的 new Date()
在解析时间戳时也存在类似的问题,因此,不管是 Date
还是 dayjs
,一律对后端返回的时间戳 Number(input)
兜底处理,永远不要信任它传的是数字还是字符串:
const ts = Number(res.endTime);
const date = new Date(ts);
思考
其实出现这个问题,除了后端更改时间戳类型,也在于我没有充分理解“时间戳”的含义。我一直以为时间戳就是一段字符或一段数字,因此,从来没有想过做任何兜底处理。那么,什么是时间戳?
时间戳(Timestamp) 是一种用来表示时间的数字,通常表示从某个“起点时刻”到某个指定时间之间所经过的时间长度。这个“起点”大多数情况下是 1970 年 1 月 1 日 00:00:00 UTC(Unix 纪元) 。
常见时间戳类型:
类型 | 单位 | 示例值 | 说明 |
---|---|---|---|
Unix 时间戳(秒) | 秒 | 1750089599 | 常见于后端接口、数据库存储 |
毫秒时间戳 | 毫秒 | 1750089599000 | JavaScript 常用,Date.now() |
时间戳的意义:
- 它是一个 绝对时间的数字化表示,可以跨语言、跨平台统一理解;
- 更容易做计算:两个时间戳相减就能得到毫秒差值(时间间隔);
- 更紧凑:比如比字符串
"2025-07-17 06:59:59"
更短,处理性能更高。
在 JavaScript 中的使用:
console.log(Date.now()); // 比如:1714729530000
// 将时间戳转为日期
console.log(new Date(1750089599000)); // Thu Jul 17 2025 06:59:59 GMT+0800
来源:juejin.cn/post/7499730881830125568
Vue 列表截断组件:vue-truncate-list
vue-truncate-list , 点击查看 demo
在前端开发中,列表展示是最常见的需求之一。但当列表内容过多时,如何优雅地处理长列表展示成为了一个挑战。今天要介绍的 vue-truncate-list 组件,正是为解决这一问题而生的强大工具。
组件简介
vue-truncate-list 是一个灵活的 Vue 组件,同时支持 Vue 2 和 Vue 3 框架,专为移动端和桌面端设计。它的核心能力是实现列表的智能截断,并支持完全自定义的截断器渲染。无论是需要展示商品列表、评论列表还是其他类型的内容列表,该组件都能帮助你轻松实现优雅的截断效果,提升用户体验。
核心功能亮点
1. 自动列表截断
组件能够动态检测列表内容,自动隐藏溢出的列表项,无需手动计算高度或数量,极大简化了长列表处理的逻辑。
2. 自定义截断器
提供完全自由的截断器 UI 渲染能力。你可以根据项目风格自定义截断显示的内容,例如常见的 +3 more 样式,或者更复杂的交互按钮。
3. 响应式设计
内置响应式机制,能够根据容器尺寸自动调整截断策略,在手机、平板、桌面等不同设备上都能呈现最佳效果。
4. 可扩展列表
支持展开 / 折叠功能,用户可以通过点击截断器来查看完整列表内容,适用于需要展示部分内容但保留查看全部选项的场景。
安装指南
通过 npm 安装
npm install @twheeljs/vue-truncate-list
通过 yarn 安装
yarn add @twheeljs/vue-truncate-list
使用示例
基础用法
下面是一个基础的列表截断示例,通过 renderTruncator 自定义截断器的显示:
<template>
<TruncateList
:renderTruncator="({ hiddenItemsCount }) => h('div', { class: 'listItem' }, `+${hiddenItemsCount}`)"
>
<div class="listItem">Item 1</div>
<div class="listItem">Item 2</div>
<div class="listItem">Item 3</div>
<!-- 更多列表项... -->
</TruncateList>
</template>
<script>
import { h } from 'vue'
import TruncateList from '@twheeljs/vue-truncate-list'
export default {
components: {
TruncateList
}
}
</script>
在这个示例中,我们通过 h 函数创建了一个简单的截断器,显示隐藏项的数量。
可扩展列表用法
更复杂的可展开 / 折叠列表示例:
<template>
<TruncateList
:class="['list', 'expandable', expanded ? 'expanded' : '']"
:alwaysShowTruncator="true"
:renderTruncator="({ hiddenItemsCount, truncate }) => {
if (hiddenItemsCount > 0) {
return h(
'button',
{
class: 'expandButton',
onClick: () => {
handleExpand();
// 重要:使用 nextTick 确保布局重计算后调用 truncate
nextTick(() => {
truncate();
})
}
},
`${hiddenItemsCount} more...`
);
} else {
return h(
'button',
{
class: 'expandButton',
onClick: handleCollapse
},
'hide'
);
}
}"
>
<div class="listItem">foo</div>
<!-- 更多列表项... -->
<div class="listItem">thud</div>
</TruncateList>
</template>
<script>
import { h, ref, nextTick } from 'vue'
import TruncateList from '@twheeljs/vue-truncate-list'
export default {
components: {
TruncateList
},
setup() {
const expanded = ref(false);
const handleExpand = () => {
expanded.value = true;
}
const handleCollapse = () => {
expanded.value = false;
}
return {
expanded,
handleExpand,
handleCollapse
}
}
}
</script>
注意事项:当设置 expanded 类时,虽然将 max-height 设置为 none,但容器高度不会立即更新,导致 ResizeObserver 不会触发。因此,需要在 nextTick 中手动调用 truncate() 方法来确保布局重新计算。
API 参考
Props
名称 | 类型 | 默认值 | 描述 | |
---|---|---|---|---|
renderTruncator | ({ hiddenItemsCount, truncate }) => string | VNode | 无 | 用于渲染截断器 UI 的函数,接收 hiddenItemsCount(隐藏项数量)和 truncate(重新计算布局的函数) |
alwaysShowTruncator | boolean | false | 是否始终显示截断器,即使没有隐藏项 |
贡献与开发
本地开发
# 安装依赖
npm install
# 启动开发服务器
npm run dev
结语
vue-truncate-list 组件通过简洁的 API 和强大的功能,为 Vue 开发者提供了处理长列表展示的最佳实践。无论是移动端还是桌面端应用,它都能帮助你实现优雅的列表截断效果,提升用户体验。
项目灵感来源于 maladr0it/react-truncate-list,在此表示感谢。
欢迎大家贡献代码、提交 Issues 或提出改进建议!
来源:juejin.cn/post/7517107495392919578
Web Worker + OffscreenCanvas,实现真正多线程渲染体验
前端开发常说“JavaScript 是单线程的”,但如果你正在做动画、数据可视化、图像处理、游戏开发、或任何基于 Canvas 的复杂渲染,你一定体会过——主线程的“卡顿地狱” 。
UI 不响应、FPS 降到个位数、稍微有点计算或渲染逻辑,就能卡住整个页面。
这种时候,Web Worker + OffscreenCanvas 是你的救命稻草。它不仅能将耗时任务移出主线程,还能真正让 Canvas 的绘制多线程执行。
这篇文章将带你深度理解并实践:
- 为什么你需要 Web Worker + OffscreenCanvas?
- 如何正确使用它们协同工作?
- 适配浏览器的兼容性与降级方案
- 实际场景中的优化技巧与踩坑合集
“主线程”到底卡在哪?
Canvas 的渲染过程其实包含两个部分:
- 逻辑计算(生成要绘制的数据,如位置、颜色、形状等)
- 图形绘制(通过 2D 或 WebGL API 渲染)
这两个过程在传统用法中都跑在主线程。
一旦数据量一大、图形一多,你的 UI 就会被“图形更新”压得喘不过气。比如你尝试每帧绘制上千个粒子、图像变换、实时数据曲线更新时:
主线程就像个老人推着超重购物车,边推边喘,既要绘图又要处理 UI 和事件。
此时,如果我们能把逻辑计算和绘图任务拆出去,放到 Worker 中执行,主线程就能专注于 UI 响应,从而实现真正的“多线程协作”。
OffscreenCanvas 是什么?
OffscreenCanvas 是一种不依赖 DOM 的 Canvas 对象,它可以在 Worker 线程中创建和操作,拥有与普通 Canvas 几乎相同的绘图 API。
核心特性:
- 可以在主线程或 Worker 中创建
- 支持 2D 和 WebGL 上下文
- 可以将普通
<canvas>
转换为 OffscreenCanvas 进行共享 - 能通过
transferControlToOffscreen()
实现跨线程控制
Web Worker + OffscreenCanvas 工作原理
- 主线程中创建
<canvas>
元素 - 将 canvas 转为 OffscreenCanvas,并传给 Worker
- Worker 中接管 canvas 渲染逻辑(2D/WebGL)
- 主线程继续负责 UI 响应、控件交互等
这样你就实现了:
- 主线程干净:不会因绘图阻塞 UI
- 渲染帧率高:不受主线程任务干扰
- 计算更快:Worker 可以专注高频计算任务
实战:用 OffscreenCanvas 实现 Worker 内渲染
我们来动手写一个最小工作示例,用 Web Worker 绘制一个不断更新的粒子系统。
主线程(main.js)
const canvas = document.querySelector('canvas')
// 支持性检测
if (canvas.transferControlToOffscreen) {
const offscreen = canvas.transferControlToOffscreen()
const worker = new Worker('render.worker.js')
// 把 OffscreenCanvas 发送给 Worker,使用 transferable objects
worker.postMessage({ canvas: offscreen }, [offscreen])
} else {
console.warn('当前浏览器不支持 OffscreenCanvas')
}
Worker 线程(render.worker.js)
self.onmessage = function (e) {
const canvas = e.data.canvas
const ctx = canvas.getContext('2d')
const particles = Array.from({ length: 1000 }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2
}))
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
for (let p of particles) {
p.x += p.vx
p.y += p.vy
if (p.x < 0 || p.x > canvas.width) p.vx *= -1
if (p.y < 0 || p.y > canvas.height) p.vy *= -1
ctx.beginPath()
ctx.arc(p.x, p.y, 2, 0, 2 * Math.PI)
ctx.fillStyle = '#09f'
ctx.fill()
}
requestAnimationFrame(render)
}
render()
}
浏览器支持情况
浏览器 | 支持 OffscreenCanvas | 支持 transferControlToOffscreen |
---|---|---|
Chrome | ✅(全功能) | ✅ |
Firefox | ✅(需开启设置) | ✅ |
Safari | ❌(尚不支持) | ❌ |
Edge | ✅ | ✅ |
Safari 目前仍未完全支持,你可以做降级方案:如果不支持,就在主线程绘制。
冷知识:你以为是主线程的 requestAnimationFrame,其实在 Worker 中也能用!
在 Worker 中使用 requestAnimationFrame()
?听起来离谱,但确实在 OffscreenCanvas 上启用了这个能力。
前提是你在 Worker 中用的是 OffscreenCanvas.getContext('2d' or 'webgl')
,而不是 fake canvas。
所以,你可以用 Worker 实现完整的帧驱动动画系统,与主线程完全无关。
实战应用场景
✅ 实时数据可视化(如股票图、心电图)
让绘图脱离主线程,保证交互流畅性。
✅ 游戏引擎粒子系统、WebGL 场景
WebGL 渲染逻辑搬到 Worker,UI 操作丝滑不掉帧。
✅ 视频滤镜或图像处理
用 Canvas + 图像 API 进行像素级变换、裁剪、调色等任务。
✅ 后台复杂渲染任务(地图、3D 建模)
用户没感知,但主线程不会阻塞。
常见坑与优化建议
❗ 坑1:不能在 Worker 中操作 DOM
Worker 是脱离 DOM 世界的,不支持 document、window、canvas 元素。你只能使用传入的 OffscreenCanvas。
❗ 坑2:transferControlToOffscreen 只能用一次
一旦你把 canvas “交给”了 Worker,主线程就不能再操作它。就像是 canvas 被“搬家”了。
❗ 坑3:调试困难?用 console.log + postMessage 结合
Worker 的日志不一定出现在主线程控制台。建议:
console.log = (...args) => postMessage({ type: 'log', args })
主线程监听 message
然后打出日志。
✅ 优化1:帧率控制,别无限 requestAnimationFrame
在计算密集任务中,可能帧率过高导致 GPU 占用过重。可以加入 setTimeout
限制帧频。
✅ 优化2:TypedArray 数据共享
大量数据传输时,考虑用 SharedArrayBuffer
或 transferable objects
,减少拷贝。
延伸阅读:WebAssembly + Worker + OffscreenCanvas?
如果你已经把渲染任务移到 Worker 中,还可以更进一步——用 WebAssembly(如 Rust、C++)执行核心逻辑,把性能提升到极限。
这就是现代浏览器下的“性能金三角”:
WebAssembly 负责逻辑 + Worker 解耦线程 + OffscreenCanvas 渲染输出
这是很多 Web 游戏、3D 可视化平台的核心架构方式。
结语:把计算和渲染“赶出”主线程,是前端性能进化的方向
OffscreenCanvas 不只是一个新 API,它代表了一种思维方式的转变:
- 从“所有任务都塞主线程” → 到“职责分离、主线程清洁化”
- 从“怕卡 UI” → 到“性能可控、结构合理”
如果你在做复杂动画、WebGL 图形、游戏、实时可视化,不使用 Web Worker + OffscreenCanvas,就像在用拖拉机跑 F1,注定要掉队。
拥抱现代浏览器的能力,开启真正的多线程渲染体验吧!
来源:juejin.cn/post/7508968054875308043
Element 分页表格跨页多选状态保持方案(十几行代码解决)
问题背景
在使用 Element-Plus/Element-UI
的分页表格(或者其他表格组件)时,默认的多选功能无法跨页保持选中状态。当切换分页请求新数据后,之前选中的数据会被清空。
之前遇到这个问题搜了挺多网上的文章,看着代码又多又复杂,下面是很简单的实现方法,主要代码就十几行
。
先贴效果
核心思路
- 全局选中的ID集合:维护一个全局的
全局ID集合(Set)
存储所有选中的数据ID
。 - 数据同步:在操作
选中
、取消选中
、全选
、取消全选
时同步全局ID集合
- 状态回显:在分页变化,每次加载新数据后,如果数据(当前页的数据数组)的
ID
存在于全局ID集合
就回显选中项,让对应的行数据选中。
关键代码解析
1. 全局状态定义
const globalSelectedIds = ref(new Set()); // 全局选中id
const isPageChanging = ref(false); // 标记是否正在切换分页
2. 请求接口数据加载与表格状态回显
const getList = async () => {
isPageChanging.value = true;
let res = await mockApi({
page: currentPage.value,
pageSize: pageSize.value,
});
tableData.value = res;
// 数据更新后回显选中状态
nextTick(() => {
tableData.value.forEach((row) => {
tableRef.value?.toggleRowSelection(
row,
globalSelectedIds.value.has(row.id)
);
});
isPageChanging.value = false;
});
};
3. 选择状态变更处理 (核心代码)
const handleSelectionChange = (selection) => {
// // 如果是请求数据后回显表格选中状态导致的变化事件,则不处理
if (isPageChanging.value) return;
const currentPageIds = tableData.value.map((row) => row.id);
// 先移除当前页所有ID
currentPageIds.forEach((id) => globalSelectedIds.value.delete(id));
// 再添加当前选中的ID
selection.forEach((row) => globalSelectedIds.value.add(row.id));
};
4. 从全局移除选中项(可选,仅演示点击标签删除选中数据使用)
const removeSelected = (id) => {
globalSelectedIds.value.delete(id);
const row = tableData.value.find((row) => row.id === id);
row && tableRef.value?.toggleRowSelection(row, false);
};
完整实现方案代码 (vue3 + element plus)
<template>
<div>
<!-- 表格 -->
<el-table
:data="tableData"
@selection-change="handleSelectionChange"
ref="tableRef"
border
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="姓名" />
</el-table>
<el-pagination
class="mt-4"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="100"
layout="total, sizes, prev, pager, next"
@size-change="handlePageSizeChange"
@current-change="handlePageChange"
/>
<!-- 展示全局选中的数据id -->
<div>
<h4>全局选中数据 ({{ globalSelectedIds.size }}个)</h4>
<div v-if="globalSelectedIds.size > 0">
<el-tag
v-for="id in Array.from(globalSelectedIds)"
:key="id"
closable
@close="removeSelected(id)"
class="mr-2 mb-2"
>
ID: {{ id }}
</el-tag>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from "vue";
const currentPage = ref(1);
const pageSize = ref(5);
const tableRef = ref();
const tableData = ref([]);
const globalSelectedIds = ref(new Set()); // 全局选中id
const isPageChanging = ref(false); // 标记是否正在切换分页
// 模拟从接口获取数据
const mockApi = ({ page, pageSize }) => {
const data = [];
const startIndex = (page - 1) * pageSize;
for (let i = 1; i <= pageSize; i++) {
const id = startIndex + i;
data.push({
id: id,
name: `用户${id}`,
});
}
return new Promise((resolve) => {
setTimeout(() => {
resolve(data);
}, 100);
});
};
// 获取列表数据
const getList = async () => {
isPageChanging.value = true;
let res = await mockApi({
page: currentPage.value,
pageSize: pageSize.value,
});
tableData.value = res;
// 数据更新后回显选中状态
nextTick(() => {
tableData.value.forEach((row) => {
tableRef.value?.toggleRowSelection(
row,
globalSelectedIds.value.has(row.id)
);
});
isPageChanging.value = false;
});
};
// 统一处理表格选择变化
const handleSelectionChange = (selection) => {
// 如果是请求数据后回显表格选中状态导致的变化事件,则不处理
if (isPageChanging.value) return;
const currentPageIds = tableData.value.map((row) => row.id);
// 先移除当前页所有ID
currentPageIds.forEach((id) => globalSelectedIds.value.delete(id));
// 再添加当前选中的ID
selection.forEach((row) => globalSelectedIds.value.add(row.id));
};
// 从全局移除选中项,如果删除的ID在当前页,需要更新表格选中状态
const removeSelected = (id) => {
globalSelectedIds.value.delete(id);
const row = tableData.value.find((row) => row.id === id);
row && tableRef.value?.toggleRowSelection(row, false);
};
// 处理分页变化
const handlePageChange = () => {
getList();
};
// 处理分页数量变化
const handlePageSizeChange = () => {
currentPage.value = 1;
getList();
};
// 请求接口获取数据
getList();
</script>
总结
这个功能的实现其实挺简单的,核心就是维护一个全局的选中 ID 集合。每次用户勾选表格时,对比当前页的数据和全局 ID 集合来更新状态;翻页或者重新加载数据时,再根据这个集合自动回显勾选状态,保证选中项不会丢失。
来源:juejin.cn/post/7517911614778064933
3小时,从0到1上线MVP海外AI网站产品!深度复盘
三、新手3小时极限上线AI产品网站
作为程序员来说,最喜欢务实,不讲虚的。
所以讲完了创业史(具体看这篇:1个人,创业2年心酸史,做各种海外赚美金项目。这一次,你终于悟道了)我们再来讲讲如何3小时极限上线一个AI产品网站。
作为程序员来说,最喜欢务实,不讲虚的。
所以讲完了创业史(具体看这篇:1个人,创业2年心酸史,做各种海外赚美金项目。这一次,你终于悟道了)我们再来讲讲如何3小时极限上线一个AI产品网站。
3.1 什么是AI产品网站出海?
AI产品网站出海,简单来说基于AI技术的产品,通过网站的形式面向海外市场。这不仅仅是语言的翻译,更是对海外用户需求的深度理解和产品的本地化适配。在如今这个AI快速发展的时代,海外市场对AI应用的接受度和付费意愿相对较高,特别是欧美市场。
AI网站出海的核心在于:
技术门槛低:借助现有的开发工具Cursor和AI API大模型,独立开发者也能快速搭建产品
市场需求旺盛:海外用户对AI工具的付费习惯已经形成(比如OpenAI,Claude)
竞争相对较小:相比国内市场,海外AI工具市场仍有大量空白领域
变形路径清晰:订阅制、按次付费等付费模式已被广泛接受。
AI产品网站出海,简单来说基于AI技术的产品,通过网站的形式面向海外市场。这不仅仅是语言的翻译,更是对海外用户需求的深度理解和产品的本地化适配。在如今这个AI快速发展的时代,海外市场对AI应用的接受度和付费意愿相对较高,特别是欧美市场。
AI网站出海的核心在于:
技术门槛低:借助现有的开发工具Cursor和AI API大模型,独立开发者也能快速搭建产品
市场需求旺盛:海外用户对AI工具的付费习惯已经形成(比如OpenAI,Claude)
竞争相对较小:相比国内市场,海外AI工具市场仍有大量空白领域
变形路径清晰:订阅制、按次付费等付费模式已被广泛接受。
3.2 为什么选择AI产品网站出海作为创业方向?
3.2.1 个人兴趣和优势
首先我做了5年内核开发程序员,一开始接触知识星球的时候,看到的都是国内各种平台,很明显就不感兴趣。
所以还是打工人的时候,一开始做的项目就是海外工具站。
但是虽然我是程序员,但程序员也分很多种,前端,后端,对于做一个网站,完全没有经验。
当时我记得捣鼓了很久,上线后各种报错,代码完全看不懂,后来就放弃了。
现在,完全不同了。
不需要懂代码,Cursor直接帮你搞定。
当然,要想做出一个成熟的AI产品网站,肯定还是要去学代码的,不然每次编程就和抽卡一样,太随机了。
每当一个bug解决、一个功能实现的实现,程序员的那种成就感油然而生。
其次,我做过很多海外项目,对这一块还是比较熟悉的,所以这些都是我的优势。
自然而然,我应该做AI产品网站出海。
3.2.2 试错成本很低
这里主要是讲资金成本。
相比于我做过的很多海外项目来说,AI产品网站的开发成本特别低。
一个网站:最便宜的几美金一年。
AI编程工具:Cursor(20美金一个月,之前的教育优惠直接免费1年)
其他:都是免费(是的,你没有看错)
下面这张图是网友总结的,可以看到:除了域名和AI编程工具,其他真的是免费的。 (程序员很老实,从来不骗人)

但是,这里要讲下但是,这里讲的是启动资金成本。
如果你的网站有流量了,很大,那你肯定也要投入资金买服务器了。
但这个时候,你也已经开始赚钱了,并且还不少。
所以说,试错成本真的很低。
自从我做过FB跨境电商后,真的再也不想去碰成本这么高的了。
这里还有一个费用,肯定很多人都很关心。
API调用费用
先给大家看一张图,我这次用的photomaker这个API
它一下生成4张图片,才0.0011美金。
从我开发到上线后找人测试,也才花了2.8美金。

并且我的API账号还是之前薅的羊毛,一共两个号,薅了100美金(用不完,完全用不完。)
就算你的网站上线后,你放心,也只会有少量的API调用。
除非很多人用,但这时候你已经开始赚钱了。
我现在用的有:
- Claude 4 :
某宝买共享账号,它给你一个账号池,每3个小时可以使用40次,非常方便。
用于写开发者文档,和claude讨论需求。

- Cursor pro账号
3.2.1 个人兴趣和优势
首先我做了5年内核开发程序员,一开始接触知识星球的时候,看到的都是国内各种平台,很明显就不感兴趣。
所以还是打工人的时候,一开始做的项目就是海外工具站。
但是虽然我是程序员,但程序员也分很多种,前端,后端,对于做一个网站,完全没有经验。
当时我记得捣鼓了很久,上线后各种报错,代码完全看不懂,后来就放弃了。
现在,完全不同了。
不需要懂代码,Cursor直接帮你搞定。
当然,要想做出一个成熟的AI产品网站,肯定还是要去学代码的,不然每次编程就和抽卡一样,太随机了。
每当一个bug解决、一个功能实现的实现,程序员的那种成就感油然而生。
其次,我做过很多海外项目,对这一块还是比较熟悉的,所以这些都是我的优势。
自然而然,我应该做AI产品网站出海。
3.2.2 试错成本很低
这里主要是讲资金成本。
相比于我做过的很多海外项目来说,AI产品网站的开发成本特别低。
一个网站:最便宜的几美金一年。
AI编程工具:Cursor(20美金一个月,之前的教育优惠直接免费1年)
其他:都是免费(是的,你没有看错)
下面这张图是网友总结的,可以看到:除了域名和AI编程工具,其他真的是免费的。 (程序员很老实,从来不骗人)
但是,这里要讲下但是,这里讲的是启动资金成本。
如果你的网站有流量了,很大,那你肯定也要投入资金买服务器了。
但这个时候,你也已经开始赚钱了,并且还不少。
所以说,试错成本真的很低。
自从我做过FB跨境电商后,真的再也不想去碰成本这么高的了。
这里还有一个费用,肯定很多人都很关心。
API调用费用
先给大家看一张图,我这次用的photomaker这个API
它一下生成4张图片,才0.0011美金。
从我开发到上线后找人测试,也才花了2.8美金。
并且我的API账号还是之前薅的羊毛,一共两个号,薅了100美金(用不完,完全用不完。)
就算你的网站上线后,你放心,也只会有少量的API调用。
除非很多人用,但这时候你已经开始赚钱了。
我现在用的有:
- Claude 4 :
某宝买共享账号,它给你一个账号池,每3个小时可以使用40次,非常方便。
用于写开发者文档,和claude讨论需求。
- Cursor pro账号
之前某鱼搞的教育优惠,100多块直接用1年。
- 其他
也就上站的时候买一个域名,几美金就行。其他真没了。
3.2.3 市场机会巨大
从个人兴趣和优势出发,不断试错,那也要去能赚到钱的地方是吧。
海外AI工具市场正处于爆发期,用户对新产品的接受度高,愿意为解决实际问题的AI工具付费。
光说没有用,我看看实际案例,我们都喜欢看见再相信。
- Pieter Levels
这是一个海外的独立开发者,一个人,做了这么多产品。
而且他所有的收入都列在了他的推上。
- 刘小排老师
老外大家可能觉得很遥远,我们看看国内。
一个人,创业后第一个产品就做到了100万月活,只用了1个月,并且做完就放放那了,都没做任何付费推广。
- 百里登风
这个人大家可能不太熟悉,这个人就是我(哈哈哈)
从0到1,上线一个MVP产品,用了3个多小时。
我特意用秒表测了一下自己的极限开发速度。
从看到需求到正式上线大概花了3个多小时(包含吃饭时间)。
个人认为还可以继续优化,大概2个小时就差不多可以完成。
这是我开发的产品,10大场景下的AI摄影生成工具。(目前小bug还比较多,当然后面需要慢慢优化,比如登陆界面还没做,支付还没接等等。)
看到现在,大家肯定很枯燥了吧。
所以,我们先来看看我花了3个小时,做出的产品,让你先感受下我的乐趣,或者以后也会成为你的乐趣。
原始图片:
场景一:专业正件照片
场景二:社交和约会照片
场景三:健身锻炼照片
场景四:旅行照片
场景五、婚礼照片
好,不能再放了,再放怕你睡不着。
是不是很牛逼,很逼真,虽然我不知道有没有人做过这种产品,但至少它很有趣,那就可以了。
网站地址:http://www.headshotpro.art/。
- 大家
AI时代,每一个人都有机会,每个人都可以做出自己的AI产品网站,所以能看到这里的,都要给大家留个位置。
3.3 如何发现海外AI摄影场景需求?
当然不是我想出来的,自己拍脑袋想出来的几乎都不行。
我是群里看到的,一个网站,一个月13万刀。
这时候,好奇心就来了,这是什么网站?(好奇心往往会有一些发现)
这个网站其中一个功能就是上传自拍,制作自己的AI人物
刚才我才发现,它也有AI场景功能,不过它要输入提示词,我直接固定10个场景。
大家都很懒,肯定会直接吃喂到嘴边的饭,而不是自己去找饭吃,所以固定10个场景,不需要用户输入提示词(这一点很重要)
真正吸引我的是真实感,连眼球都能看清楚。这也太太厉害了,AI一般给人的感觉都很假。
所以我也想开发一个类似的AI摄影图片功能。
其实这一步之后,还需要市场调研,竞品分析,差异化思考。
因为你能想出来的东西,肯定大家都做过了,但不妨碍我们去玩。
是的,你没有看错,就是玩。
3.4 从0到1:3小时极限MVP开发全流程
3.4.1 用claude写需求文档
总算进入正题了,到这你已经看了3908个字,我已经写了一下午加一晚上了,腰都酸了,现在已经22:48分了,奈何就是想写呢。
好,继续(下面的教程大家放心,因为我之前公众号写AI工具使用方法都写的很细,自己都体验过一遍,生怕大家错过每一个步骤。)
看到一个需求后,第一步,不是直接用Cursor实现一个完整的产品,而是先和AI讨论,写一个开发者文档。(这一步很重要)
为什么很重要?这里要说一下。
因为你如果不写这个文档,可能你一边开发,一边脑子里就已经想到一个新功能,做着做着就跑偏了。
很多人觉得写开发者文档很难,需要长篇大论。
其实,不需要。
AI就是你的员工,就是你的伙伴,就是你的合伙人。
比如:我要开发一个AI摄影产品。
你这么说:
我想要开发一个网站,用户上传头像后,为用户创建各种场合的逼真的照片。我们来聊聊这个需求,你觉得应该怎么做?
接下来,你就继续和它聊天,随便聊,就把它当作你的员工,直到你的想法和它的答案对上,就可以了。
比如我说:
我需要10个有痛点需求的场景。
下一步:做出MVP需求文档
你和它说:
你先做MVP,帮我产生一份MVP需求文档,尽量简单。
好,这时候,你的员工就帮你干活了。把这份文档保存在飞书里。
下面的是我让AI生成的需求文档。
这里有一个比较有意思的点,claude 可以生成图文形式的需求文档。
给大家看看我的,非常好玩。一目了然。
3.4.2 V0做出产品原型
有了需求文档,不是去直接开发产品了,而是先做个产品原型。
这个就像你想吃鸡腿,你脑海里就会有一个鸡腿的样子(这个叫心理表征,可以看《刻意练习》)
那你现在只有一个文档,没有一个产品的样子,所以先把这个样子做出来。
V0网址:v0.dev/
这里注意一点,我们做一个产品原型,只做壳子就行。具体的功能先不实现,因为具体的功能比较复杂,它比较难做,后面去Cursor里做。
你这样告诉V0:
我要做一个给外国人生成AI摄影图片的产品,使用NextJS框架。
你只需要帮我做一个产品原型,不需要实现具体的功能,
设计的风格尽可能参照小红书的风格。
下面是需求开发文档。(这里你把刚才生成的需求文档复制给他)
然后,等一会,十分钟吧,他就给你生成好了,中间可能会遇到一些报错,直接点击按钮让它修复就可以。
我们来看看具体的效果。
是不是非常不错?
是的,但是我们还需要实现它实际的功能。
3.4.3 Cursor开发实际功能
好,现在我们来真正做实际的功能。
- 下载V0生成的代码,点击右上角下载代码。
- 打开Cursor,先让Cursor帮你总结下代码功能
告诉Cursor员工:帮我看看我的所有代码,告诉我这个项目干了什么?
- 让代码在本地运行起来
总结完之后,我们需要把代码在本地跑起来。
因为V0生成的代码是在他们自己的服务器上运行起来的,我们下载下来后,需要重新在本地运行。
告诉Cursor员工:帮我在本地运行这个项目。
Cursor员工一顿操作后,终于搞定了。
运行命令:npm run dev
就会给你一个地址:
鼠标移动到上面,单击就可以打开,看到网站效果。
如果这时候出现问题,比如网站打开报错,直接截图告诉Cursor员工,同时把终端里的报错信息告诉Cursor员工。
一遍不行两遍,两遍不行三遍,直到修复成功。
- 选择合适的API
比如我开发的产品是AI摄影图片,这个时候不知道用什么API。
此时,就可以问claude:
我想做一款产品,用户上传头像后,为用户创建各种场合逼真的照片。我参照的是这个网站:photoai.com。我想知道,有没有现成的API可以让我调用?
好,这个时候claude就会提供各种API。
由于我在fal.ai上有余额,就可以让claude限定在fal.ai上找。
网址:fal.ai/
在上方搜索栏中搜索:photomaker
就出现了这个模型
我们点击API,就可以看到所有的API文档介绍。
这里我们只需要把这个地址链接复制下来,文档都不需要看:
这里还有一步,你需要创建一个密钥。
4.1 点击账号设置
4.2 点击 API keys
4.3 点击增加密钥
4.4 给它起个名字,根据你的项目来定,比较好分辨。点击创建。
4.5 点击复制,之后把这串密钥保存在你的记事本上。
- Cursor实现核心功能
告诉Cursor:
请帮我实现核心功能,使用photomaker API
这是API的说明文档:@ fal.ai/models/fal-…
这是API key:XXX
这里为了安全,API key这个信息比较敏感。
所以我们一般会直接在项目根目录创建一个.env环境变量文件,手动把key进去。
因为Cursor读取不了这个文件。
这里就是不断的调试了,cursor员工自认为干好之后,你就要去检查了。
和之前一样,运行命令:npm run dev
然后在本地调试,测试实际功能。
一旦哪个功能不对,报错,就告诉AI,截图,复制都可以。
最后,一顿操作,你觉得差不多了,就可以了。
3.4.4 将代码提交到Github
在Cursor中开发代码的时候,有可能出现之前改好的功能它又给你改错了,结果可能改了半天的代码都白改了。
点击这里,就可以开始代码管理,初始化仓库。
每次修改完一个主要功能之后,都可以在这里提交一下。最后代码没问题后,我们可以提交到Github。在提交代码之前,我们还要确认下代码是否正确。
运行命令:npm run build
如果像这样全是勾,那就是没问题。
当代码没问题后,点击提交。
这里输入你想在Github上创建的仓库名,选择私有仓库。
最后提交成功。
3.4.5 Vercel部署
- 打开vercel,创建一个新项目
- 打开vercel,创建一个新项目
网址:vercel.com
- 选择部署的项目,点击 导入,点击 部署
- 部署成功后添加环境变量,在设置中找到环境变量,把.env文件内的内容放进去。
- 开始部署
部署成功后,以后每次提交代码后都可以自动部署。部署不成功的话,可以把logs信息直接复制给Cursor就可以。
3.4.6 购买域名 正式上线
- 给网站取名字
- 给网站取名字
域名就是 baidu.com 这种,首先我们要确定给我们的产品网站起一个名字。
比如,我让claude 帮我取了一个,叫:headshotpro
- 购买域名
确定好名字之后,去域名注册平台购买域名,比如:Namecheap
购买完成后,就可以去vercel上配置域名了。
3.4.7 上线后的用户测试以及初步反馈
上线之后,只是代表你的产品MVP开发完成了,这只是第一步。
还需要进行用户测试,得到初步的反馈。
比如我把网站发在了各种群里,让用户给我提建议。
这时候,你会得到很多很多建议。比如:
是的,这些都很正常。
但也不是每个问题都要去修复,首先一定要确保核心功能没问题。其他的问题可以先记下来,等后面用户量上来后再修改也不迟。
3.4.8 后续工作
后面还有很多很多工作:
比如:注册、登陆功能
比如:增加各种页面
比如:接入支付功能
比如:每天查看网站数据
比如:每天通过屏幕录制看用户在干什么
虽然事情很多,但是在这个过程中,你会发现很多乐趣。
四、个人成长与收获
4.1 从每日复盘中发现问题
目前的这一切,都源自开始每日复盘,发现自己的问题,然后不断改正。
从复盘中学到了:
- 创业要厚脸皮,遇到不懂的,就要问清楚,就算被大佬骂,也要问明白。
- 要放下自己的ego,不骄傲,不自大,保持谦卑。不去辩驳,为了一个观点争的面红耳赤。
- 要每日复盘,每周复盘,每月复盘。
- 带着发现的眼光去体验世界,感受创业,你会发现很多有意思的东西。
- 要不断刻意练习,找到自己的导师,及时纠正自己的错误,获得有效的正反馈。
- 赚到钱只是一个结果,要不断创造自己的价值。通过自己的专长,找到社会的需求,从而创造价值。
等等。
4.2 目前的收获
- 这里非常感谢洋哥,是洋哥拿生命在做的交付,让我能一直坚持下去。从商业IP课到心力课,洋哥一直没有放弃我们。
- 这里非常感谢洋哥,是洋哥拿生命在做的交付,让我能一直坚持下去。从商业IP课到心力课,洋哥一直没有放弃我们。
具体讲讲在破局的收获,以及给新手朋友一点启发。
1.1 首先,看各种精华文章,一开始把破局所有的精华都刷一遍,给自己一个星期的时间去刷。
这里特别注意,看的时候不要焦虑,就把它当作看书。
因为你只有建立赚钱的认知,看过很多案例,才有可能赚到钱。
并且一定要限定时间,不能一直看。
我现在每天都会固定时间看看精华帖和中标。
1.2 看完之后,立马行动,根据自己的兴趣和优势。
比如我参加了很多行动营。不断行动才有机会。
干中学,不断刻意练习,遇到不懂的就在群里问,教练就是你的导师,可以不断指出你的问题,获得及时有效的正反馈。
所以一定要积极去参加行动营,这些都是低成本尝试,只有做,才有结果,只是一直想,特别耗费心力。
事情是做出来的,而不是想出来的。
1.3 其实最重要的是线下组局。
也正是在洋哥的鼓励下,我组了第一次局,当时来了好多小伙伴,并且大家都非常年轻,来自周边城市,南京的,苏州的小伙伴也都赶了过来。
大家聊的很爽,还一起吃了晚上。
我现在的一起赚钱小群有4个人,有两个都是那天线下组局认识的,大家在群里一起分享认知,收获,一起互相鼓励。
创业路上需要这样一群人,一群志同道合的人。
还有北京行动家大会,一共3天,真的是头脑风暴,大家一聊聊到凌晨,一天就睡4个小时。
在回来的高铁上,看着洋哥给的《超级个体 从0到百万变现》,写下了下面这么多感悟。
1.4 还有洋哥用生命的交付
从一开始的商业IP课到产品课,心力课等等,各种各样的课程。这价值我就不说了,生怕大家跑不出来。
各种密训,各种直播。
我的手机里全都是洋哥的录屏,常听常新。
焦虑了,听一听,缓解缓解。
迷茫了,听一听,找找方向。
没有动力了,听一听,打个鸡血,继续往前。
这里建议大家还是看直播,因为录屏后肯定不会去看,看直播的效果最好。
真的,只要你不放弃自己,什么时候都有可能。每个人都有自己的节奏,按照自己的节奏来,成功只是时间问题。
目前的成绩:
今天写的这篇公众号已经到了400多阅读,目前还在上涨。
后面,继续努力,争取做出自己的IP,再次感谢洋哥。
- 感谢惰总凌晨3点还在群里给我们解决问题
就怕我们赚不到钱,都要直接投喂了。
是惰总让我知道了事前逻辑。
创业,做项目一定不能用事后逻辑,别人的成功是不可复制的。时刻用事前逻辑,根据自己的优势、经历、资源来选择你的创业项目,形成自己的创业选品逻辑。
一个财富自由的大佬居然愿意每个月带着我出去跑一次,我何德何能啊。这个多大价值就不说了吧,再次感谢惰总。
- 感谢刘小排老师,是刘小排老师让我明白了做产品可以这么快乐。
小排老师每次都会在视频结尾说一句类似的话:赶紧去玩起来吧。玩个一整天吧。
是啊,玩起来,跟着自己的兴趣,不断探索,多有意思啊。
这些都是我创业路上的贵人,能及时发现你的问题,给你提供有效的正反馈。
也希望大家都能找到自己的乐趣,去吧,赶紧去玩起来吧!
tips:
好了,今天写完了AI网站产品的复盘,如果点赞多的话,下一篇就会很快与大家见面了(预告:一个完全不懂代码的新手(我女朋友),从0到1,做出一款类似forest的专注森林,学习倒计时APP,目前已经上架。)
技术完全没有门槛,一个拼创意的时代来了!
学会了或有启发,别忘了给我点个赞,评论,转发~因为你的反馈真的很重要!
最后,如果你对AI网站出海感兴趣,欢迎关注公众号 百里登风AI
加 luffy10004 ,领取一份《AI网站出海新手指南》资料包。
来源:juejin.cn/post/7517841519253848116
告别玄学!JavaScript的随机数终于能“听话”了!🎲
关注梦兽编程微信公众号,轻松摸鱼一下午。
用了十几年Math.random()
,你是不是也遇到过这样的尴尬:想要在游戏里生成一个每次加载都一样的地图?想在测试中复现那个只出现一次的随机bug?面对Math.random()
这个完全看心情的家伙,你只能望洋兴叹,因为它每次运行都会给你一个全新的、不可预测的数字。就像一个完全不记事的“失忆”随机数生成器。
// 每次运行都不一样,随缘得很
console.log(Math.random()); // 0.123456789
console.log(Math.random()); // 0.987654321
你无法“喂”给它一个特定的“种子”,让它沿着你预设的轨迹生成数字。这在很多需要确定性行为的场景下,简直是噩梦。
别担心!救星来了!JavaScript即将迎来一个重量级新成员——Random API提案(目前处于Stage 2阶段),它将彻底改变我们与随机数打交道的方式,带来一个革命性的特性:可复现的随机性(Reproducible Randomness)!
Random API:给随机数装上“记忆”和“超能力”🚀
新的Random API的核心在于引入了“种子”(Seed)的概念。你可以给随机数生成器一个特定的种子,只要种子一样,它就能生成完全相同的随机数序列。这就像给随机数装上了“记忆”,让它每次都能按照同一个剧本表演。
创建带有种子的随机数生成器:
// 用一个特定的种子创建一个生成器,比如种子是 [42, 0, 0, 0]
const seededRandom = new Random.Seeded(new Uint8Array([42, 0, 0, 0]));
// 见证奇迹!这两行代码,无论你运行多少次,结果都是一样的!
console.log(seededRandom.random()); // 0.123456789 (每次都一样)
console.log(seededRandom.random()); // 0.987654321 (每次都一样)
请注意这里的“一样”是什么意思:如果你使用相同的种子,并且按照相同的顺序调用.random()
方法,你将总是得到完全相同的随机数序列。 第一次调用总是返回同一个值,第二次调用总是返回同一个(但通常与第一次不同)值,以此类推。这并不是说每次调用.random()
都会返回同一个数字,而是整个序列是可预测、可复现的。
实战演练:用确定性随机生成游戏世界!🏞
想象一下,你在开发一个沙盒游戏,每次进入游戏,地形都应该不同,但如果你保存游戏再加载,地形必须和保存时一模一样。使用Random API,这变得轻而易举:
class TerrainGenerator {
constructor(seed) {
// 用种子创建随机生成器
this.random = new Random.Seeded(seed);
this.heightMap = [];
}
generateTerrain(width, height) {
for (let y = 0; y < height; y++) {
this.heightMap[y] = [];
for (let x = 0; x < width; x++) {
// 使用带有种子的随机数生成地形高度
this.heightMap[y][x] = this.random.random() * 100;
}
}
return this.heightMap;
}
// 保存当前生成器的状态,以便之后恢复
saveState() {
return this.random.getState();
}
// 从保存的状态恢复生成器
restoreState(state) {
this.random.setState(state);
}
}
// 使用种子42生成第一块地形
const generator = new TerrainGenerator(new Uint8Array([42]));
const terrain1 = generator.generateTerrain(10, 10);
// 使用相同的种子42创建另一个生成器
const generator2 = new TerrainGenerator(new Uint8Array([42]));
const terrain2 = generator2.generateTerrain(10, 10);
// 见证奇迹!两块地形竟然完全相同!
console.log(JSON.stringify(terrain1) === JSON.stringify(terrain2)); // true
Random API的进阶超能力!🛠️
Random API的功能远不止于此,它还有一些非常实用的进阶特性:
1. 创建“子生成器”:
有时候你需要多个独立的随机数序列,但又想它们之间有某种关联(比如都源自同一个主种子)。你可以从一个父生成器派生出子生成器:
// 创建一个父生成器
const parent = new Random.Seeded(new Uint8Array([42]));
// 创建两个子生成器,它们的种子来自父生成器
const child1 = new Random.Seeded(parent.seed());
const child2 = new Random.Seeded(parent.seed());
// 这两个子生成器会产生不同的随机数序列,但它们的生成过程是确定的
console.log(child1.random()); // 0.123456789
console.log(child2.random()); // 0.987654321
2. 序列化和恢复状态:
你不仅可以保存和恢复整个生成器,还可以保存它当前的“状态”。这意味着你可以在生成到一半的时候暂停,保存状态,之后再从这个状态继续生成,保证后续序列完全一致。这对于需要中断和恢复的随机过程非常有用。
// 创建一个生成器
const generator = new Random.Seeded(new Uint8Array([42]));
// 生成一些数字
console.log(generator.random()); // 0.123456789
console.log(generator.random()); // 0.987654321
// 保存当前状态
const state = generator.getState();
// 继续生成更多数字
console.log(generator.random()); // 0.456789123
// 恢复之前保存的状态
generator.setState(state);
// 再次生成,你会得到和恢复状态前一样的数字!
console.log(generator.random()); // 0.456789123 (和上面一样)
更多实用场景:不止是游戏!💡
Random API的应用场景非常广泛:
1. 确定性测试:
在编写单元测试或集成测试时,你经常需要模拟随机数据。使用带有种子的随机数,你可以确保每次测试运行时都能得到相同的随机输入,从而更容易复现和调试问题。你可以临时替换掉Math.random()
来实现确定性测试:
function testWithSeededRandom() {
// 创建一个带有种子的随机生成器
const random = new Random.Seeded(new Uint8Array([42]));
// 临时替换掉 Math.random
const originalRandom = Math.random;
Math.random = random.random.bind(random);
try {
// 运行你的测试代码,里面的 Math.random 现在是确定的了
runTests();
} finally {
// 恢复原来的 Math.random
Math.random = originalRandom;
}
}
2. 程序化内容生成:
除了游戏地形,你还可以用它来生成角色属性、关卡布局、艺术图案等等,只要给定相同的种子,就能得到完全相同的结果。
class ProceduralContentGenerator {
constructor(seed) {
this.random = new Random.Seeded(seed);
}
generateCharacter() {
const traits = ['brave', 'cunning', 'wise', 'strong', 'agile'];
const name = this.generateName();
// 使用 seededRandom 生成随机索引
const trait = traits[Math.floor(this.random.random() * traits.length)];
return { name, trait };
}
generateName() {
const prefixes = ['A', 'E', 'I', 'O', 'U'];
const suffixes = ['dor', 'lin', 'mar', 'tor', 'vin'];
// 使用 seededRandom 生成随机前后缀
const prefix = prefixes[Math.floor(this.random.random() * prefixes.length)];
const suffix = suffixes[Math.floor(this.random.random() * suffixes.length)];
return prefix + suffix;
}
}
// 使用种子42创建生成器
const generator = new ProceduralContentGenerator(new Uint8Array([42]));
// 生成一个角色
const character = generator.generateCharacter();
console.log(character); // { name: 'Ador', trait: 'brave' }
// 使用相同的种子42创建另一个生成器
const generator2 = new ProceduralContentGenerator(new Uint8Array([42]));
// 生成的角色竟然完全相同!
const character2 = generator2.generateCharacter();
console.log(character2); // { name: 'Ador', trait: 'brave' }
3. 安全随机数生成:
Random API也提供了生成密码学安全随机数的方法,这对于需要高度安全性的场景(如密钥生成)至关重要。
// 生成一个密码学安全的随机种子
const secureSeed = Random.seed();
// 使用安全种子创建生成器
const secureRandom = new Random.Seeded(secureSeed);
// 生成密码学安全的随机数
console.log(secureRandom.random()); // 这是一个密码学安全的随机数
为什么这很重要?超越基础的意义!✨
新的Random API解决了几个长期存在的痛点:
它提供了可复现性,让你每次运行代码都能得到相同的随机数序列,这对于调试和复现bug至关重要。它保证了确定性,让你的代码在不同环境下表现一致。它支持状态管理,允许你保存和恢复随机数生成器的状态,实现更灵活的控制。当然,它也能生成密码学安全的随机数,满足高安全性的需求。
核心要点总结!🎯
新的Random API带来了可复现的随机性,让随机数不再完全失控。你可以用种子创建多个独立的随机生成器,并且能够保存和恢复它们的状态。这个API在程序化生成、测试等领域有着巨大的潜力。记住,它目前还在Stage 2阶段,但离我们越来越近了!
结语:JavaScript随机数的未来,是确定的!🚀
Random API的到来,对于需要精确控制随机行为的JavaScript开发者来说,无疑是一个游戏规则的改变者。无论你是游戏开发者、测试工程师,还是在进行任何需要可预测随机性的工作,这个API都将极大地提升你的开发效率和代码可靠性。
JavaScript随机数的未来已来,而且,它是确定的!
(注意:Random API目前处于TC39流程的Stage 2阶段,尚未在浏览器中普遍可用。你可以关注其在GitHub上的进展。)
来源:juejin.cn/post/7517285070798897187
Interact.js 一个轻量级拖拽库
Interact.js的核心优势
- 轻量级:仅约10KB(gzipped),不依赖其他库
- 多点触控支持:完美适配移动设备
- 高度可定制:限制区域、惯性效果、吸附功能等
- 简洁API:直观的语法,学习曲线平缓
- 现代浏览器支持:兼容所有主流浏览器
安装与引入
通过npm安装:
npm install interactjs
或使用CDN:
<script src="https://cdn.jsdelivr.net/npm/interactjs/dist/interact.min.js"></script>
基础使用:创建可拖拽元素
<div id="draggable" class="box">
拖拽我
</div>
interact('#draggable').draggable({
inertia: true, // 启用惯性效果
autoScroll: true,
listeners: {
move: dragMoveListener
}
});
function dragMoveListener(event) {
const target = event.target;
const x = (parseFloat(target.getAttribute('data-x')) || 0) + event.dx;
const y = (parseFloat(target.getAttribute('data-y')) || 0) + event.dy;
target.style.transform = `translate(${x}px, ${y}px)`;
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
}
核心API详解
1. 拖拽功能(Draggable)
interact('.draggable').draggable({
// 限制在父元素内移动
modifiers: [
interact.modifiers.restrictRect({
restriction: 'parent',
endOnly: true
})
],
// 开始拖拽时添加样式
onstart: function(event) {
event.target.classList.add('dragging');
},
// 拖拽结束
onend: function(event) {
event.target.classList.remove('dragging');
}
});
2. 调整大小(Resizable)
interact('.resizable').resizable({
edges: { left: true, right: true, bottom: true, top: true },
// 限制最小尺寸
modifiers: [
interact.modifiers.restrictSize({
min: { width: 100, height: 100 }
})
],
listeners: {
move: function(event) {
const target = event.target;
let x = parseFloat(target.getAttribute('data-x')) || 0;
let y = parseFloat(target.getAttribute('data-y')) || 0;
// 更新元素尺寸
target.style.width = event.rect.width + 'px';
target.style.height = event.rect.height + 'px';
// 调整位置(当从左侧或顶部调整时)
x += event.deltaRect.left;
y += event.deltaRect.top;
target.style.transform = `translate(${x}px, ${y}px)`;
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
}
}
});
3. 放置区域(Dropzone)
interact('.dropzone').dropzone({
accept: '.draggable', // 只接受特定元素
overlap: 0.5, // 至少重叠50%才算放置有效
ondropactivate: function(event) {
event.target.classList.add('drop-active');
},
ondragenter: function(event) {
event.target.classList.add('drop-target');
},
ondragleave: function(event) {
event.target.classList.remove('drop-target');
},
ondrop: function(event) {
// 处理放置逻辑
event.target.appendChild(event.relatedTarget);
},
ondropdeactivate: function(event) {
event.target.classList.remove('drop-active');
event.target.classList.remove('drop-target');
}
});
高级功能
1. 限制与约束
// 限制在特定区域内移动
interact.modifiers.restrict({
restriction: document.getElementById('boundary'),
elementRect: { top: 0, left: 0, bottom: 1, right: 1 },
endOnly: true
})
// 吸附到网格
interact.modifiers.snap({
targets: [
interact.snappers.grid({ x: 20, y: 20 })
],
range: Infinity,
relativePoints: [ { x: 0, y: 0 } ]
})
2. 手势支持
interact('.gesture').gesturable({
listeners: {
move: function(event) {
const target = event.target;
const scale = parseFloat(target.getAttribute('data-scale')) || 1;
const rotation = parseFloat(target.getAttribute('data-rotation')) || 0;
target.style.transform =
`rotate(${rotation + event.da}deg)
scale(${scale * (1 + event.ds)})`;
target.setAttribute('data-scale', scale * (1 + event.ds));
target.setAttribute('data-rotation', rotation + event.da);
}
}
});
性能优化技巧
- 使用CSS变换而非定位:优先使用transform而非top/left
- 事件委托:对动态元素使用事件委托
- 适当限制事件频率:使用requestAnimationFrame节流事件
- 避免复杂选择器:在拖拽元素上使用简单类名
- 及时销毁实例:移除元素时调用
unset()
方法
// 销毁实例
const draggable = interact('#element');
// 移除拖拽功能
draggable.unset();
来源:juejin.cn/post/7515391516787261474
🧑🎤音乐MCP,听歌走起
引言
在当今AI
技术飞速发展的时代呢,如何将传统应用程序与自然语言交互
相结合成为一个非常有趣的技术方向呀。嗯嗯,本文将详细介绍一个基于FastMCP
框架开发的智能音乐播放器呢,它能够通过自然语言指令
实现音乐播放控制,为用户提供全新的交互体验哦。啊,这个项目最初支持在线音乐播放
功能来着,但是呢,出于版权
考虑嘛,开源版本就仅保留了本地音乐播放
功能啦。
项目概述
这个音乐播放器项目采用Python
语言开发呢,核心功能包括:
- 嗯~
本地音乐文件
的扫描与加载 - 多种
播放模式
(单曲循环
呀、列表循环
啦、随机播放
这样子) - 啊~
播放控制
(播放
/暂停
/停止
/上一首
/下一首
) - 嗯嗯,
播放列表管理
功能 - 通过
FastMCP
框架提供自然语言接口
呢
项目采用模块化设计
哦,主要依赖pygame
处理音频播放,FastMCP
提供AI交互接口
,整体架构非常清晰呢,易于扩展和维护的啦。
技术架构解析
1. 核心组件
项目主要包含以下几个关键组件哦:
import os.path
import requests
import re
import json
import pygame
import threading
import queue
import random
pygame.mixer
:负责音频文件的加载
和播放
呢threading
:实现后台播放线程
呀,避免阻塞主程序queue
:用于线程间通信
(虽然最终版本没直接使用队列啦)random
:支持随机播放
模式嘛FastMCP
:提供AI工具调用
接口哦
2. 全局状态管理
播放器通过一组全局变量
和线程事件
来管理播放状态呢:
current_play_list = [] # 当前播放列表呀
current_play_mode = "single" # 播放模式啦
current_song_index = -1 # 当前歌曲索引哦
# 线程控制事件
is_playing = threading.Event() # 播放状态标志呢
is_paused = threading.Event() # 暂停状态标志呀
should_load_new_song = threading.Event() # 加载新歌曲标志啦
playback_thread = # 播放线程句柄哦
这种设计实现了播放状态
与UI/控制逻辑
的分离呢,使得系统更加健壮
和可维护
呀。
核心实现细节
1. 音乐播放线程
播放器的核心是一个独立的后台线程
呢,负责实际的音乐播放逻辑哦:
def music_playback_thread():
global current_song_index, current_play_list, current_play_mode
# 确保mixer在线程中初始化呀
if not pygame.mixer.get_init():
pygame.mixer.init()
while True:
# 检查是否需要加载新歌曲啦
if should_load_new_song.is_set():
pygame.mixer.music.stop()
should_load_new_song.clear()
# 处理歌曲加载逻辑哦
if not current_play_list:
print("播放列表为空,无法加载新歌曲呢~")
is_playing.clear()
is_paused.clear()
continue
# 验证歌曲索引有效性呀
if not (0 <= current_song_index < len(current_play_list)):
current_song_index = 0
# 加载并播放歌曲啦
song_file_name = current_play_list[current_song_index]
song_to_play_path = os.path.join("music_file", song_file_name)
if not os.path.exists(song_to_play_path):
print(f"错误: 歌曲文件 '{song_file_name}' 未找到,跳过啦~")
continue
try:
pygame.mixer.music.load(song_to_play_path)
if not is_paused.is_set():
pygame.mixer.music.play()
print(f"正在播放 (后台): {song_file_name}哦~")
is_playing.set()
except pygame.error as e:
print(f"Pygame加载/播放错误: {e}. 可能音频文件损坏或格式不支持呢。跳过啦~")
continue
# 播放状态管理呀
if is_playing.is_set():
if pygame.mixer.music.get_busy() and not is_paused.is_set():
pygame.time.Clock().tick(10)
elif not pygame.mixer.music.get_busy() and not is_paused.is_set():
# 歌曲自然结束啦,根据模式处理下一首哦
if current_play_list:
if current_play_mode == "single":
should_load_new_song.set()
elif current_play_mode == "list":
current_song_index = (current_song_index + 1) % len(current_play_list)
should_load_new_song.set()
elif current_play_mode == "random":
current_song_index = random.randint(0, len(current_play_list) - 1)
should_load_new_song.set()
else:
is_playing.clear()
is_paused.clear()
pygame.mixer.music.stop()
elif is_paused.is_set():
pygame.time.Clock().tick(10)
else:
pygame.time.Clock().tick(100)
这个线程实现了完整的播放状态机
呢,能够处理各种播放场景哦,包括正常播放
呀、暂停
啦、歌曲切换
等等呢。
2. FastMCP工具函数
项目通过FastMCP
提供了一系列可被AI调用
的工具函数呢:
播放本地音乐
@mcp.tool()
def play_musics_local(song_name: str = "", play_mode: str = "single") -> str:
"""播放本地音乐呀
:param song_name: 要播放的音乐名称呢,可以留空哦,留空表示加载进来的歌曲列表为本地文件夹中的所有音乐啦
:param play_mode: 播放模式呀,可选single(单曲循环),list(列表循环),random(随机播放)哦
:return: 播放结果呢
"""
global current_play_list, current_play_mode, current_song_index, playback_thread
# 确保音乐文件夹存在哦
if not os.path.exists("music_file"):
os.makedirs("music_file")
return "本地文件夹中没有音乐文件呢,已创建文件夹 'music_file'啦~"
# 扫描音乐文件呀
music_files = [f for f in os.listdir("music_file") if f.endswith(".mp3")]
if not music_files:
return "本地文件夹中没有音乐文件呢~"
# 构建播放列表啦
play_list_temp = []
if not song_name:
play_list_temp = music_files
else:
for music_file in music_files:
if song_name.lower() in music_file.lower():
play_list_temp.append(music_file)
if not play_list_temp:
return f"未找到匹配 '{song_name}' 的本地音乐文件呢~"
current_play_list = play_list_temp
current_play_mode = play_mode
# 设置初始播放索引哦
if play_mode == "random":
current_song_index = random.randint(0, len(current_play_list) - 1)
else:
if song_name:
try:
current_song_index = next(i for i, f in enumerate(current_play_list) if song_name.lower() in f.lower())
except StopIteration:
current_song_index = 0
else:
current_song_index = 0
# 确保播放线程运行呀
if playback_thread is or not playback_thread.is_alive():
playback_thread = threading.Thread(target=music_playback_thread, daemon=True)
playback_thread.start()
print("后台播放线程已启动啦~")
# 触发播放哦
pygame.mixer.music.stop()
is_paused.clear()
is_playing.set()
should_load_new_song.set()
return f"已加载 {len(current_play_list)} 首音乐到播放列表呢。当前播放模式:{play_mode}哦。即将播放:{current_play_list[current_song_index]}呀~"
播放控制函数
@mcp.tool()
def pause_music(placeholder: str = ""):
"""暂停当前播放的音乐呀"""
global is_paused, is_playing
if pygame.mixer.music.get_busy():
pygame.mixer.music.pause()
is_paused.set()
return "音乐已暂停啦~"
elif is_paused.is_set():
return "音乐已处于暂停状态呢"
else:
return "音乐未在播放中哦,无法暂停呀"
@mcp.tool()
def unpause_music(placeholder: str = ""):
"""恢复暂停的音乐呢"""
global is_paused, is_playing
if not pygame.mixer.music.get_busy() and pygame.mixer.music.get_pos() != -1 and is_paused.is_set():
pygame.mixer.music.unpause()
is_paused.clear()
is_playing.set()
return "音乐已恢复播放啦~"
elif pygame.mixer.music.get_busy() and not is_paused.is_set():
return "音乐正在播放中呢,无需恢复哦"
else:
return "音乐未在暂停中呀,无法恢复呢"
@mcp.tool()
def stop_music(placeholder: str = ""):
"""停止音乐播放并清理资源哦"""
global is_playing, is_paused, current_song_index, should_load_new_song
pygame.mixer.music.stop()
is_playing.clear()
is_paused.clear()
should_load_new_song.clear()
current_song_index = -1
return "音乐已停止啦,程序准备好接收新的播放指令哦~"
歌曲导航函数
@mcp.tool()
def next_song(placeholder: str = "") -> str:
"""播放下一首歌曲呀"""
global current_song_index, current_play_list, is_playing, is_paused, current_play_mode, should_load_new_song
if not current_play_list:
return "播放列表为空呢,无法播放下一首哦~"
is_playing.set()
is_paused.clear()
# 从单曲循环切换到列表循环啦
if current_play_mode == "single":
current_play_mode = "list"
print("已从单曲循环模式切换到列表循环模式啦~")
# 计算下一首索引哦
if current_play_mode == "list":
current_song_index = (current_song_index + 1) % len(current_play_list)
elif current_play_mode == "random":
current_song_index = random.randint(0, len(current_play_list) - 1)
should_load_new_song.set()
return f"正在播放下一首: {current_play_list[current_song_index]}呢~"
@mcp.tool()
def previous_song(placeholder: str = "") -> str:
"""播放上一首歌曲呀"""
global current_song_index, current_play_list, is_playing, is_paused, current_play_mode, should_load_new_song
if not current_play_list:
return "播放列表为空呢,无法播放上一首哦~"
is_playing.set()
is_paused.clear()
if current_play_mode == "single":
current_play_mode = "list"
print("已从单曲循环模式切换到列表循环模式啦~")
if current_play_mode == "list":
current_song_index = (current_song_index - 1 + len(current_play_list)) % len(current_play_list)
elif current_play_mode == "random":
current_song_index = random.randint(0, len(current_play_list) - 1)
should_load_new_song.set()
return f"正在播放上一首: {current_play_list[current_song_index]}呢~"
播放列表查询
@mcp.tool()
def get_playlist(placeholder: str = "") -> str:
"""获取当前播放列表呀"""
global current_play_list, current_song_index
if not current_play_list:
return "播放列表当前为空呢~"
response_lines = ["当前播放列表中的歌曲哦:"]
for i, song_name in enumerate(current_play_list):
prefix = "-> " if i == current_song_index else " "
response_lines.append(f"{prefix}{i + 1}. {song_name}")
return "\n".join(response_lines)
部署与使用
1. 环境准备
项目依赖较少呢,只需安装以下库哦:
pip install pygame requests fastmcp
// 或者 指定阿里云的镜像源去加速下载(阿里源提供的PyPI镜像源地址)
pip install pygame requests fastmcp -i https://mirrors.aliyun.com/pypi/simple/
2. 运行程序
python play_music.py
3. 与AI助手集成
- 在支持
AI助手
的客户端中配置SSE MCP
呀 - 添加
MCP地址
:http://localhost:4567/sse
哦 - 启用所有
工具函数
啦 - 设置工具为
自动执行
以获得更好体验呢
配置,模型服务我选的是大模型openRouter:
然后去配置mcp服务器,类型一定要选sse
然后保存。
4. 使用示例
- "
播放本地歌曲
呀,使用随机播放模式
哦" - "
下一首
啦" - "
暂停一下
嘛" - "
继续播放
呀" - "
停止播放
呢" - "
播放歌曲xxx
哦,使用单曲循环模式
啦" - "
查看当前音乐播放列表
呀"
JJ的歌真好听。
来源:juejin.cn/post/7520960903743963174