Postgres 杀疯了,堪称 “六边形战士”,还要 Redis 干啥?
我们需要谈谈困扰我几个月的事情。我一直看到独立黑客和初创公司创始人疯狂地拼凑各种技术栈,用 Redis 做缓存,用 RabbitMQ 做队列,用 Elasticsearch 做搜索,还有用 MongoDB……为什么?
我也犯过这种错误。当我开始构建UserJot(我的反馈和路线图工具)时,我的第一反应是规划一个“合适的”架构,为所有功能提供独立的服务。然后我停下来问自己:如果我把所有功能都用 Postgres 来做会怎么样?
事实证明,房间里有一头大象,但没人愿意承认:
| Postgres 几乎可以做到这一切。 |
|---|
而且它的效果比你想象的还要好。
“Postgres 无法扩展”的谬论正在让你损失金钱?
让我猜猜——有人告诉你,Postgres“只是一个关系数据库”,需要专门的工具来完成专门的工作。我以前也是这么想的,直到我发现 Instagram 可以在单个 Postgres 实例上扩展到 1400 万用户。Discord 处理数十亿条消息。Notion 的整个产品都是基于 Postgres 构建的。
但问题是:他们不再像 2005 年那样使用 Postgres。
队列系统
别再为 Redis 和 RabbitMQ 付费了。Postgres 原生支持LISTEN/NOTIFY并且比大多数专用解决方案更好地处理作业队列:
-- Simple job queue in pure Postgres
CREATETABLE job_queue (
id SERIAL PRIMARY KEY,
job_type VARCHAR(50),
payload JSONB,
status VARCHAR(20) DEFAULT'pending',
created_at TIMESTAMPDEFAULT NOW(),
processed_at TIMESTAMP
);
-- ACID-compliant job processing
BEGIN;
UPDATE job_queue
SET status ='processing', processed_at = NOW()
WHERE id = (
SELECT id FROM job_queue
WHERE status ='pending'
ORDERBY created_at
FORUPDATESKIP LOCKED
LIMIT 1
)
RETURNING *;
COMMIT;
这让你无需任何额外的基础设施就能实现 Exactly-Once 的处理。不妨试试用 Redis 来实现,会让你很抓狂。
在 UserJot 中,我正是使用这种模式来处理反馈提交、发送通知和更新路线图项目。只需一次事务,即可保证一致性,无需消息代理的复杂性。
键值存储
Redis 在大多数平台上的最低价格为 20 美元/月。Postgres JSONB 已包含在您现有的数据库中,可以满足您的大部分需求:
-- Your Redis alternative
CREATETABLE kv_store (
key VARCHAR(255) PRIMARY KEY,
value JSONB,
expires_at TIMESTAMP
);
-- GIN index for blazing fast JSON queries
CREATE INDEX idx_kv_value ON kv_store USING GIN (value);
-- Query nested JSON faster than most NoSQL databases
SELECT*FROM kv_store
WHEREvalue @>'{"user_id": 12345}';
运算符@>是 Postgres 的秘密武器。它比大多数 NoSQL 查询更快,并且数据保持一致。
全文搜索
Elasticsearch 集群价格昂贵且复杂。Postgres 内置的全文搜索功能非常出色:
-- Add search to any table
ALTERTABLE posts ADDCOLUMN search_vector tsvector;
-- Auto-update search index
CREATEOR REPLACE FUNCTION update_search_vector()
RETURNStriggerAS $
BEGIN
NEW.search_vector := to_tsvector('english',
COALESCE(NEW.title, '') ||' '||
COALESCE(NEW.content, '')
);
RETURNNEW;
END;
$ LANGUAGE plpgsql;
-- Ranked search results
SELECT title, ts_rank(search_vector, query) as rank
FROM posts, to_tsquery('startup & postgres') query
WHERE search_vector @@ query
ORDERBY rank DESC;
这可以处理模糊匹配、词干提取和相关性排名。
对于 UserJot 的反馈搜索,此功能可让用户跨标题、描述和评论即时查找功能请求。无需 Elasticsearch 集群 - 只需使用 Postgres 即可发挥其优势。
实时功能
忘掉复杂的 WebSocket 基础架构吧。Postgres LISTEN/NOTIFY无需任何附加服务即可为您提供实时更新:
-- Notify clients of changes
CREATEOR REPLACE FUNCTION notify_changes()
RETURNStriggerAS $
BEGIN
PERFORM pg_notify('table_changes',
json_build_object(
'table', TG_TABLE_NAME,
'action', TG_OP,
'data', row_to_json(NEW)
)::text
);
RETURNNEW;
END;
$ LANGUAGE plpgsql;
您的应用程序会监听这些通知并向用户推送更新。无需 Redis 的发布/订阅机制。
“专业”工具的隐性成本
我们来算一下。一个典型的“现代”堆栈的成本是:
- Redis:20美元/月
- 消息队列:25美元/月
- 搜索服务:50美元/月
- 监控 3 项服务:30 美元/月
- 总计:每月 125 美元
但这还只是托管成本。真正的痛点在于:
运营开销:
- 三种不同的服务用于监控、更新和调试
- 不同的缩放模式和故障模式
- 需要维护多种配置
- 单独的备份和灾难恢复程序
- 每项服务的安全考虑因素不同
开发复杂性:
- 客户端库和连接模式
- 多个服务的部署
- 间数据不一致
- 的测试场景
- 的性能调优方法
如果您自行托管,请添加服务器管理、安全补丁以及当 Redis 决定消耗所有内存时不可避免的凌晨 3 点调试会话。
Postgres 使用您已经管理的单一服务来处理所有这些。
扩展的单一数据库
大多数人可能没有意识到:单个 Postgres 实例就能处理海量数据。我们指的是每天数百万笔交易、数 TB 的数据以及数千个并发连接。
真实世界的例子:
- Airbnb:单个 Postgres 集群处理数百万个预订
- Robinhood:数十亿笔金融交易
- GitLab:Postgres 上的整个 DevOps 平台
Postgres 的架构魅力非凡。它被设计成具备极佳的垂直扩展能力,而当你最终需要水平扩展时,它也有以下成熟的方案可供选择:
- 用于查询扩展的读取副本
- 大表分区
- 并发连接池
- 分布式设置的逻辑复制
大多数企业从未达到过这些限制。在处理数百万用户或复杂的分析工作负载之前,单个实例可能就足够了。
将此与管理所有以不同方式扩展的单独服务进行比较 - 您的 Redis 可能会耗尽内存,而您的消息队列则会遇到吞吐量问题,并且您的搜索服务需要完全不同的硬件。
从第一天起就停止过度设计
现代开发中最大的陷阱是架构式的“宇航员”。我们设计系统时,面对的是我们从未遇到过的问题,我们面对的是从未见过的流量,我们可能永远无法达到的规模。
过度设计循环:
- “我们可能有一天需要扩大规模”
- 添加 Redis、队列、微服务、多个数据库
- 花费数月时间调试集成问题
- 向 47 位用户推出
- 每月支付 200 美元购买可在 5 美元 VPS 上运行的基础设施
与此同时,您的竞争对手的发货速度更快,因为他们在需要分布式系统之前并没有管理它。
更好的方法:
- 从 Postgres 开始
- 监控实际的瓶颈,而不是想象的瓶颈
- 当达到实际极限时扩展特定组件
- 仅在解决实际问题时才增加复杂性
你的用户并不关心你的架构。他们关心的是你的产品是否有效,是否能解决他们的问题。
当你真正需要专用工具时
别误会我的意思——专用工具自有其用处。但你可能在以下情况之前不需要它们:
- 您每分钟处理 100,000 多个作业
- 您需要亚毫秒级的缓存响应
- 您正在对数 TB 的数据进行复杂的分析
- 您有数百万并发用户
- 您需要具有特定一致性要求的全局数据分布
如果您在公众号上阅读强哥这篇文章,那么您可能还没有到达那一步。
为什么这真的很重要
让我大吃一惊的是:Postgres 可以同时充当您的主数据库、缓存、队列、搜索引擎和实时系统。同时还能在所有方面保持 ACID 事务。
-- One transaction, multiple operations
BEGIN;
INSERT INTO users (email) VALUES ('user@example.com');
INSERT INTO job_queue (job_type, payload)
VALUES ('send_welcome_email', '{"user_id": 123}');
UPDATE kv_store SET value = '{"last_signup": "2024-01-15"}'
WHERE key = 'stats';
COMMIT;
尝试在 Redis、RabbitMQ 和 Elasticsearch 上执行此操作,不要哭泣。
无聊的技术却能获胜
Postgres 并不引人注目。它没有华丽的网站,也没有在 TikTok 上爆红。但几十年来,在其他数据库兴衰更迭之际,它一直默默地支撑着互联网。
选择简单、可靠且有效的技术是有道理的。
下一个项目的行动步骤
- 仅从 Postgres 开始- 抵制添加其他数据库的冲动
- 使用 JSONB 实现灵活性- 借助 SQL 的强大功能,您可以获得无架构的优势
- 在 Postgres 中实现队列——节省资金和复杂性
- 仅当达到实际极限时才添加专用工具- 而不是想象中的极限
我的真实经历
UserJot 的构建是这一理念的完美测试案例。它是一个反馈和路线图工具,需要:
- 提交反馈时实时更新
- 针对数千个功能请求进行全文搜索
- 发送通知的后台作业
- 缓存经常访问的路线图
- 用于用户偏好和设置的键值存储
我的整个后端只有一个 Postgres 数据库。没有 Redis,没有 Elasticsearch,没有消息队列。从用户身份验证到实时 WebSocket 通知,一切都由 Postgres 处理。
结果如何?我的功能交付速度更快,需要调试的部件更少,而且基础设施成本也降到了最低。当用户提交反馈、搜索功能或获取路线图变更的实时更新时,一切都由 Postgres 完成。
这不再只是理论上的。它正在实际生产中,通过真实的用户和真实的数据发挥作用。
令人不安的结论
Postgres 或许好得过头了。它功能强大,以至于大多数其他数据库对于 90% 的应用程序来说都显得多余。业界一直说服我们,所有事情都需要专门的工具,但或许我们只是把事情弄得比实际需要的更难。
你的初创公司不必成为分布式系统的样板。它需要为真正的人解决真正的问题。Postgres 让你专注于此,而不是照看基础设施。
因此,下次有人建议添加 Redis 来“提高性能”或添加 MongoDB 来“提高灵活性”时,请问他们:“您是否真的先尝试过在 Postgres 中执行此操作?”
答案可能会让你大吃一惊。我知道,当我完全在 Postgres 上构建UserJot时,它就一直运行顺畅。
| 本文为译文,英文原文地址(可能需要使用魔法访问):dev.to/shayy/postg… |
|---|
来源:juejin.cn/post/7517200182725296178
如果产品经理突然要你做一个像抖音一样流畅的H5
从前端到爆点!抖音级 H5 如何炼成?
在万物互联的时代,H5 页面已成为产品推广的利器。当产品经理丢给你一个“像抖音一样流畅的 H5”任务时,是挑战还是机遇?别慌,今天就带你走进抖音 H5 的前端魔法世界。
一、先看清本质:抖音 H5 为何丝滑?
抖音 H5 之所以让人欲罢不能,核心在于两点:极低的卡顿率和极致的交互反馈。前者靠性能优化,后者靠精心设计的交互逻辑。比如,你刷视频时的流畅下拉、点赞时的爱心飞舞,背后都藏着前端开发的“小心机”。
二、性能优化:让页面飞起来
(一)懒加载与预加载协同作战
懒加载是 H5 性能优化的经典招式,只在用户即将看到某个元素时才加载它。但光靠懒加载还不够,聪明的抖音 H5 还会预加载下一个可能进入视野的元素。以下是一个基于 IntersectionObserver 的懒加载示例:
document.addEventListener('DOMContentLoaded', () => {
const lazyImages = [].slice.call(document.querySelectorAll('img.lazy'));
if ('IntersectionObserver' in window) {
let lazyImageObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
let lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImageObserver.unobserve(lazyImage);
}
});
});
lazy Images.forEach((lazyImage) => {
lazyImageObserver.observe(lazyImage);
});
}
});
(二)图片压缩技术大显神威
图片是 H5 的“体重”大户。抖音 H5 常用 WebP 格式,它在保证画质的同时,能将图片体积压缩到 JPEG 的一半。你可以用以下代码轻松实现图片格式转换:
function compressImage(inputImage, quality) {
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = inputImage.naturalWidth;
canvas.height = inputImage.naturalHeight;
ctx.drawImage(inputImage, 0, 0, canvas.width, canvas.height);
const compressedImage = new Image();
compressedImage.src = canvas.toDataURL('image/webp', quality);
compressedImage.onload = () => {
resolve(compressedImage);
};
});
}
三、交互设计:让用户欲罢不能
(一)微动画营造沉浸感
在点赞、评论等关键操作上,抖音 H5 会加入精巧的微动画。比如点赞时的爱心从手指位置飞出,这其实是一个 CSS 动画加 JavaScript 事件监听的组合拳。以下是一个简易版的点赞动画代码:
@keyframes flyHeart {
0% {
transform: scale(0) translateY(0);
opacity: 0;
}
50% {
transform: scale(1.5) translateY(-10px);
opacity: 1;
}
100% {
transform: scale(1) translateY(-20px);
opacity: 0;
}
}
.heart {
position: fixed;
width: 30px;
height: 30px;
background-image: url('../assets/heart.png');
background-size: contain;
background-repeat: no-repeat;
animation: flyHeart 1s ease-out;
}
document.querySelector('.like-btn').addEventListener('click', function(e) {
const heart = document.createElement('div');
heart.className = 'heart';
heart.style.left = e.clientX + 'px';
heart.style.top = e.clientY + 'px';
document.body.appendChild(heart);
setTimeout(() => {
heart.remove();
}, 1000);
});
(二)触摸事件优化
在移动设备上,触摸事件的响应速度直接影响用户体验。抖音 H5 通过精准控制触摸事件的捕获和冒泡阶段,减少了延迟。以下是一个优化触摸事件的示例:
const touchStartHandler = (e) => {
e.preventDefault(); // 防止页面滚动干扰
// 处理触摸开始逻辑
};
const touchMoveHandler = (e) => {
// 处理触摸移动逻辑
};
const touchEndHandler = (e) => {
// 处理触摸结束逻辑
};
const element = document.querySelector('.scrollable-container');
element.addEventListener('touchstart', touchStartHandler, { passive: false });
element.addEventListener('touchmove', touchMoveHandler, { passive: false });
element.addEventListener('touchend', touchEndHandler);
四、音频处理:让声音为 H5 增色
抖音 H5 的音频体验也很讲究。它会根据用户的操作实时调整音量,甚至在不同视频切换时平滑过渡音频。以下是一个简单的声音控制示例:
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioElement = document.querySelector('audio');
const audioSource = audioContext.createMediaElementSource(audioElement);
const gainNode = audioContext.createGain();
audioSource.connect(gainNode);
gainNode.connect(audioContext.destination);
// 调节音量
function setVolume(level) {
gainNode.gain.value = level;
}
// 音频淡入效果
function fadeInAudio() {
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + 1);
}
// 音频淡出效果
function fadeOutAudio() {
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 1);
}
五、跨浏览器兼容:让 H5 无处不在
抖音 H5 能在各种浏览器上保持一致的体验,这离不开前端开发者的兼容性优化。常用的手段包括使用 Autoprefixer 自动生成浏览器前缀、为老浏览器提供 Polyfill 等。以下是一个为 CSS 动画添加前缀的示例:
const autoprefixer = require('autoprefixer');
const postcss = require('postcss');
const css = '.example { animation: slidein 2s; } @keyframes slidein { from { transform: translateX(0); } to { transform: translateX(100px); } }';
postcss([autoprefixer]).process(css).then(result => {
console.log(result.css);
/*
输出:
.example {
animation: slidein 2s;
}
@keyframes slidein {
from {
-webkit-transform: translateX(0);
transform: translateX(0);
}
to {
-webkit-transform: translateX(100px);
transform: translateX(100px);
}
}
*/
});
打造一个像抖音一样的流畅 H5,需要前端开发者在性能优化、交互设计、音频处理和跨浏览器兼容等方面全方位发力。希望这些技术点能为你的 H5 开发之旅提供助力,让你的产品在激烈的市场竞争中脱颖而出!
来源:juejin.cn/post/7522090635908251686
做个大屏既要不留白又要不变形还要没滚动条,我直接怒斥领导,大屏适配就这四种模式
在前端开发中,大屏适配一直是个让人头疼的问题。领导总是要求大屏既要不留白,又要不变形,还要没有滚动条。这看似简单的要求,实际却压根不可能。今天,我们就来聊聊大屏适配的四种常见模式,以及如何根据实际需求选择合适的方案。
一、大屏适配的困境
在大屏项目中,适配问题几乎是每个开发者都会遇到的挑战。屏幕尺寸的多样性、设计稿与实际屏幕的比例差异,都使得适配变得复杂。而领导的“既要...又要...还要...”的要求,更是让开发者们感到无奈。不过,我们可以通过合理选择适配模式来尽量满足这些需求。
二、四种适配模式
在大屏适配中,常见的适配模式有以下四种:
(以下截图中模拟视口1200px*500px和800px*600px,设计稿为1920px*1080px)
1. 拉伸填充(fill)


- 特点:内容会被拉伸变形,以完全填充视口框。这种方式可以确保视口内没有空白区域,但可能会导致内容变形。
- 适用场景:适用于对内容变形不敏感的场景,例如全屏背景图。
2. 保持比例(contain)


- 特点:内容保持原始比例,不会被拉伸变形。如果内容的宽高比与视口不一致,会在视口内出现空白区域(黑边)。这种方式可以确保内容不变形,但可能会留白。
- 适用场景:适用于需要保持内容原始比例的场景,例如视频或图片展示。
3. 滚动显示(scroll)


- 特点:内容不会被拉伸变形,当内容超出视口时会添加滚动条。这种方式可以确保内容完整显示,但用户需要滚动才能查看全部内容。
- 适用场景:适用于内容较多且需要完整显示的场景,例如长列表或长文本。
4. 隐藏超出(hidden)


- 特点:内容不会被拉伸变形,当内容超出视口时会隐藏超出部分。这种方式可以避免滚动条的出现,但可能会隐藏部分内容。
- 适用场景:适用于内容较多但不需要完整显示的场景,例如仪表盘。
三、为什么不能同时满足所有要求?
这四种适配模式各有优缺点,但它们在逻辑上是相互矛盾的。具体来说:
- 不留白:要求内容完全填充视口,没有任何空白区域。这通常需要拉伸或缩放内容以适应视口的宽高比。
- 不变形:要求内容保持其原始宽高比,不被拉伸或压缩。这通常会导致内容无法完全填充视口,从而出现空白区域(黑边)。
- 没滚动条:要求内容完全适应视口,不能超出视口范围。这通常需要隐藏超出部分或限制内容的大小。
这三个要求在逻辑上是相互矛盾的:
- 如果内容完全填充视口(不留白),则可能会变形。
- 如果内容保持原始比例(不变形),则可能会出现空白区域(留白)。
- 如果内容超出视口范围,则需要滚动条或隐藏超出部分。
四、【fitview】插件快速实现大屏适配
fitview 是一个视口自适应的 JavaScript 插件,它支持多种适配模式,能够快速实现大屏自适应效果。
github地址:github.com/pbstar/fitv…
在线预览:pbstar.github.io/fitview
以下是它的基本使用方法:
配置
- el: 需要自适应的 DOM 元素
- fit: 自适应模式,字符串,可选值为 fill、contain(默认值)、scroll、hidden
- resize: 是否监听元素尺寸变化,布尔值,默认值 true
安装引入
npm 安装
npm install fitview
esm 引入
import fitview from "fitview";
cdn 引入
<script src="https://unpkg.com/fitview@[version]/lib/fitview.umd.js"></script>
使用示例
<div id="container">
<div style="width:1920px;height:1080px;"></div>
</div>
const container = document.getElementById("container");
new fitview({
el: container,
});
五、总结
大屏适配是一个复杂的问题,不同的项目有不同的需求。虽然不能同时满足“不留白”“不变形”和“没滚动条”这三个要求,但可以通过合理选择适配模式来尽量满足大部分需求。在实际开发中,我们需要根据项目的具体需求和用户体验来权衡,选择最合适的适配方案。
在选择适配方案时,fitview 这个插件可以提供很大的帮助。它支持多种适配模式,能够快速实现大屏自适应效果。如果你正在寻找一个简单易用的适配工具,fitview 值得一试。你可以通过 npm 安装或直接使用 CDN 引入,快速集成到你的项目中。
希望这篇文章能帮助你更好地理解和选择大屏适配方案。如果你有更多问题或建议,欢迎在评论区留言。
来源:juejin.cn/post/7513059488417497123
弃用 html2canvas!快 93 倍的截图神器!
作者:前端开发爱好者
原文:mp.weixin.qq.com/s/t0s5dCOrs…
在前端开发中,网页截图是个常用功能。从前,html2canvas 是大家的常客,但随着网页越来越复杂,它的性能问题也逐渐暴露,速度慢、占资源,用户体验不尽如人意。
好在,现在有了 SnapDOM,一款性能超棒、还原度超高的截图新秀,能完美替代 html2canvas,让截图不再是麻烦事。

什么是 SnapDOM
SnapDOM 就是一个专门用来给网页元素截图的工具。

它能把 HTML 元素快速又准确地存成各种图片格式,像 SVG、PNG、JPG、WebP 等等,还支持导出为 Canvas 元素。

它最厉害的地方在于,能把网页上的各种复杂元素,比如 CSS 样式、伪元素、Shadow DOM、内嵌字体、背景图片,甚至是动态效果的当前状态,都原原本本地截下来,跟直接看网页没啥两样。
SnapDOM 优势
快得飞起
测试数据显示,在不同场景下,SnapDOM 都把 html2canvas 和 dom-to-image 这俩老前辈远远甩在身后。

尤其在超大元素(4000×2000)截图时,速度是 html2canvas 的 93.31 倍,比 dom-to-image 快了 133.12 倍。这速度,简直就像坐火箭。
还原度超高
SnapDOM 截图出来的效果,跟在网页上看到的一模一样。
各种复杂的 CSS 样式、伪元素、Shadow DOM、内嵌字体、背景图片,还有动态效果的当前状态,都能精准还原。

无论是简单的元素,还是复杂的网页布局,它都能轻松拿捏。
格式任你选
不管你是想要矢量图 SVG,还是常用的 PNG、JPG,或者现代化的 WebP,又或者是需要进一步处理的 Canvas 元素,SnapDOM 都能满足你。

多种格式,任你挑选,适配各种需求。
怎么用 SnapDOM
安装
SnapDOM 的安装超简单,有好几种方式:
用 NPM 或 Yarn:在命令行里输
# npm
npm i @zumer/snapdom
# yarn
yarn add @zumer/snapdom
就能装好。
用 CDN 在 HTML 文件里加一行:
<script src="https://unpkg.com/@zumer/snapdom@latest/dist/snapdom.min.js"></script>
直接就能用。
要是项目里用的是 ES Module:
import { snapdom } from '@zumer/snapdom
基础用法示例
一键截图
const card = document.querySelector('.user-card');
const image = await snapdom.toPng(card);
document.body.appendChild(image);
这段代码就是找个元素,然后直接截成 PNG 图片,再把图片加到页面上。简单粗暴,一步到位。
高级配置
const element = document.querySelector('.chart-container');
const capture = await snapdom(element, {
scale: 2,
backgroundColor: '#fff',
embedFonts: true,
compress: true
});
const png = await capture.toPng();
const jpg = await capture.toJpg({ quality: 0.9 });
await capture.download({
format: 'png',
filename: 'chart-report-2024'
});
这儿可以对截图进行各种配置。比如 scale 能调整清晰度,backgroundColor 能设置背景色,embedFonts 可以内嵌字体,compress 能压缩优化。配置好后,还能把截图存成不同格式,或者直接下载到本地。
和其他库比咋样
和 html2canvas、dom-to-image 比起来,SnapDOM 的优势很明显:
| 特性 | SnapDOM | html2canvas | dom-to-image |
|---|---|---|---|
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐ |
| 准确度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| 文件大小 | 极小 | 较大 | 中等 |
| 依赖 | 无 | 无 | 无 |
| SVG 支持 | ✅ | ❌ | ✅ |
| Shadow DOM 支持 | ✅ | ❌ | ❌ |
| 维护状态 | 活跃 | 活跃 | 停滞 |
用的时候注意点
用 SnapDOM 时,有几点得注意:
跨域资源
要是截图里有外部图片等跨域资源,得确保这些资源支持 CORS,不然截不出来。
iframe 限制
SnapDOM 不能截 iframe 内容,这是浏览器的安全限制,没办法。
Safari 浏览器兼容性
在 Safari 里用 WebP 格式时,会自动变成 PNG。
大型页面截图
截超大页面时,建议分块截,不然可能会内存溢出。
SnapDOM 能干啥及代码示例
社交分享
async function shareAchievement() {
const card = document.querySelector('.achievement-card');
const image = await snapdom.toPng(card, { scale: 2 });
navigator.share({
files: [new File([await snapdom.toBlob(card)], 'achievement.png')],
title: '我获得了新成就!'
});
}
报表导出
async function exportReport() {
const reportSection = document.querySelector('.report-section');
await preCache(reportSection);
await snapdom.download(reportSection, {
format: 'png',
scale: 2,
filename: `report-${new Date().toISOString().split('T')[0]}`
});
}
海报导出
async function generatePoster(productData) {
document.querySelector('.poster-title').textContent = productData.name;
document.querySelector('.poster-price').textContent = `¥${productData.price}`;
document.querySelector('.poster-image').src = productData.image;
await new Promise((resolve) => setTimeout(resolve, 100));
const poster = document.querySelector('.poster-container');
const blob = await snapdom.toBlob(poster, { scale: 3 });
return blob;
}
写在最后
SnapDOM 就是这么一款简单、快速、准确,还零依赖的网页截图神器。
无论是社交分享、报表导出、设计保存,还是营销推广,它都能轻松搞定。
而且它是免费开源的,背后还有活跃的社区支持。要是你还在为网页截图的事儿发愁,赶紧试试 SnapDOM 吧。
要是你在用 SnapDOM 的过程中有啥疑问,或者碰上啥问题,可以去下面这些地方找答案:
- 项目地址 :github.com/zumerlab/sn…
- 在线演示 :zumerlab.github.io/snapdom/
- 详细文档 :github.com/zumerlab/sn…
来源:juejin.cn/post/7524740743165722634
别再用 100vh 了!移动端视口高度的终极解决方案
作为一名前端开发者,我们一定都遇到过这样的需求:实现一个占满整个屏幕的欢迎页、弹窗蒙层或者一个 fixed 定位的底部菜单。
直觉告诉我们,这很简单,给它一个 height: 100vh 就行了。
.fullscreen-element {
height: 100vh;
width: 100%;
color: #000;
display: flex;
justify-content: center;
align-items: center;
font-size: 10em;
background-color: #fff;
}
在PC端预览,完美!然而,当你在手机上打开时,可能会看到下面这个令人抓狂的场景:

明明是 100vh,为什么会超出屏幕高度?这个烦人的滚动条到底从何而来?
如果你也曾为此抓耳挠腮,那么恭喜你,这篇文章就是你的“终极答案”。今天,我将带你彻底搞懂 100vh 在移动端的“坑”,并为你介绍当下最完美的解决方案。
1. 问题根源:移动端动态变化的“视口”
要理解问题的本质,我们首先要明白 vh (Viewport Height) 单位的定义:1vh 等于视口高度的 1%。
在PC端,浏览器窗口大小是相对固定的,所以 100vh 就是浏览器窗口的可见高度,这没有问题。
但在移动端,情况变得复杂了。为了在有限的屏幕空间里提供更好的浏览体验,手机浏览器(尤其是Safari和Chrome)的地址栏和底部工具栏是动态变化的。
- 初始状态:当你刚进入页面时,地址栏和工具栏是完全显示的。
- 滚动时:当你向下滚动页面,这些UI元素会自动收缩,甚至隐藏,以腾出更多空间展示网页内容。
关键点来了:大多数移动端浏览器将 100vh 定义为“最大视口高度”,也就是当地址栏和工具栏完全收起时的高度。
这就导致了:
在页面初始加载、地址栏还未收起时,
100vh的实际计算高度 > 屏幕当前可见区域的高度。
于是,那个恼人的滚动条就出现了。

2. “过去式”的解决方案:JavaScript 动态计算
在很长一段时间里,前端开发者们只能求助于 JavaScript 来解决这个问题。思路很简单:通过 window.innerHeight 获取当前可见视口的高度,然后用它来动态设置元素的 height。
JavaScript
function setRealVH() {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
// 初始加载时设置
window.addEventListener('load', setRealVH);
// 窗口大小改变或旋转屏幕时重新设置
window.addEventListener('resize', setRealVH);
然后在 CSS 中这样使用:
CSS
.fullscreen-element {
height: calc(var(--vh, 1vh) * 100);
}
这个方案的缺点显而易见:
- 性能开销:监听
resize事件过于频繁,可能会引发性能问题。 - 逻辑耦合:纯粹的样式问题却需要JS来解决,不够优雅。
- 时机问题:执行时机需要精确控制,否则可能出现闪烁。
虽然能解决问题,但这绝不是我们想要的“终极方案”。
3. “现在时”的终极解决方案:CSS动态视口单位
谢天谢地,CSS 工作组听到了我们的呼声!为了解决这个老大难问题,CSS Values and Units Module Level 4 引入了一套全新的动态视口单位。
它们就是我们今天的“主角”:
svh(Small Viewport Height): 最小视口高度。对应于地址栏和工具栏完全展开时的可见高度。lvh(Large Viewport Height): 最大视口高度。对应于地址栏和工具栏完全收起时的高度(这其实就等同于旧的100vh)。dvh(Dynamic Viewport Height): 动态视口高度。这是最智能、最实用的单位!它的值会随着浏览器UI元素(地址栏)的出现和消失而动态改变。
所以,我们的终极解决方案就是:
CSS
.fullscreen-element {
height: 100svh; /* 如果你希望高度固定,且永远不被遮挡 */
/* 或者,也是我最推荐的 */
height: 100dvh; /* 如果你希望元素能动态地撑满整个可见区域 */
}
使用 100dvh,当地址栏收起时,元素高度会平滑地增加以填满屏幕;当地址栏滑出时,元素高度又会平滑地减小。整个过程如丝般顺滑,没有任何滚动条,完美!
浏览器兼容性
你可能会担心兼容性问题。好消息是,从2023年开始,所有主流现代浏览器(Safari, Chrome, Edge, Firefox)都已经支持了这些新的视口单位。


(数据截至2025年6月,兼容性已非常好)
可以看到,兼容性已经非常理想。除非你需要支持非常古老的浏览器版本,否则完全可以放心地在生产环境中使用。
告别 100vh 的时代
让我们来快速回顾一下:
- 问题:在移动端,
100vh通常被解析为“最大视口高度”,导致在浏览器UI未收起时内容溢出。 - 旧方案:使用 JavaScript 的
window.innerHeight动态计算,但有性能和维护问题。 - 终极方案:使用CSS新的动态视口单位,尤其是
100dvh,它能根据浏览器UI的变化自动调整高度,完美解决问题。
当需要实现移动端全屏布局时,请大胆地告别 100vh,拥抱 100dvh!
来源:juejin.cn/post/7520548278338322483
为什么响应性语法糖最终被废弃了?尤雨溪也曾经试图让你不用写 .value
你将永远需要在 Vue3 中写
.value
前言
相信有不少新手在初次接触 Vue3 的组合式 API 时都会产生一个疑问:”为什么一定要写 .value ?",一些 Vue3 老玩家也认为到处写 .value 十分不优雅。
那么有没有办法能不用写 .value 呢?有的兄弟,至少曾经有的,那就是响应性语法糖,可惜在 Vue 3.4 之后已经被移除了。
响应性语法糖是如何实现免去 .value 的?这一特性为何最终被废弃了呢?
响应性语法糖
Vue 的响应性语法糖是一个编译时的转换步骤,让我们可以像这样书写代码:
<script setup>
let count = $ref(0)
console.log(count)
function increment() {
count++
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
这里的 $ref() 方法是一个编译时的宏命令:它不是一个真实的、在运行时会调用的方法,而是用作 Vue 编译器的标记。使用 $ref() 声明的响应式变量可以直接读取与修改,无需 .value。
上面例子中 <script> 部分的代码会被编译成下面这样,在代码中自动加上 .value:
import { ref } from 'vue'
let count = ref(0)
console.log(count.value)
function increment() {
count.value++
}
每一个会返回 ref 的响应式 API 都有一个相对应的、以 $ 为前缀的宏函数。包括以下这些 API:
ref->$refcomputed->$computedshallowRef->$shallowRefcustomRef->$customReftoRef->$toRef
通过 $() 解构
当一个组合式函数返回包含数个 ref 的对象,我们希望解构得到这些 ref,并且在后续使用它们时也不用写 .value 时,可以使用 $() 这个宏:
import { useMouse } from '@vueuse/core'
const { x, y } = $(useMouse())
// x,y 和用 $ref 声明的响应式变量一样,不用写 .value
console.log(x, y)
通过 $$() 防止响应性丢失
假设有一个期望接收一个 ref 对象为参数的函数:
function trackChange(x: Ref<number>) {
watch(x, (x) => {
console.log('x 改变了!')
})
}
let count = $ref(0)
trackChange(count) // 无效!
上面的例子不会正常工作,因为代码被编译成了这样子:
let count = ref(0)
trackChange(count.value)
trackChange 函数期望接收的参数是一个 ref 类型值,而我们传入的 count.value 实际是一个 number 类型。
对于一个使用 $ref() 声明的响应式变量,当我们希望获取到它的原始 ref 值时,可以使用 $$()。
我们将上述例子改写为:
let count = $ref(0)
- trackChange(count)
+ trackChange($$(count))
此时代码可以正常工作,不会再丢失响应性了。
看到这里,聪明的你可能已经意识到了问题:使用响应性语法糖的初衷是为了免去到处写
.value的麻烦,结果现在新引入了$ref()、$()、$(),各自还有不同的使用场景,不是更麻烦了吗?
废弃原因
最终,在收集了大量来自社区的反馈后,经过 Vue 核心团队全员投票,决定移除这一特性。在 Vue 3.3 版本中使用会报 warning,从 3.4 版本开始正式移除。
尤雨溪本人也在 github 上发表了决定将响应性语法糖移除的根本原因,链接:github.com/vuejs/rfcs/…
原文是英语,担心有小伙伴可能看不懂,在这里简单翻译一下:
响应性语法糖的初衷是提供一些简练的语法提升开发体验。我们将它作为实验特性发布并在真实场景的使用中获得反馈。尽管它有一些好处,但我们还是发现了下列问题:
- 没有
.value使得难以辨认响应式变量的读取和设置。这个问题在 SFC 中可能不那么明显,但是在大型项目中会造成心智负担的明显增大,尤其是在 SFC 外也使用此语法时。
- 因为第一条,一些开发者倾向于只在 SFC 中使用响应性语法糖,这就造成了代码的不一致性以及在不同心智模型间切换的成本。这是一个进退两难的窘境:只在 SFC 中使用会造成不一致性,而在 SFC 之外使用则会降低可维护性。
- 既然总有外部函数会使用原始 ref,那么在响应性语法糖与原始 ref 之间的转换是不可避免的。这就产生了另一个需要学习的东西以及额外的认知负担,并且我们发现这会比纯粹的组合式 API 更让初学者感到困惑。
最重要的是,响应性语法糖会带来代码风格分裂的潜在危险。尽管这一功能是自愿使用的,一些使用者还是强烈反对该提议,因为他们不想维护用了语法糖和没用两种风格的代码。这确实值得担心因为使用响应性语法糖需要的心智模型违背了 JavaScript 的基本语义(对变量赋值会触发响应式副作用)。
在考虑了所有因素之后,我们认为将它发布为一个稳定功能带来的问题会大于收益。
结语
在 Vue3 发布之初,Vue 核心团队就考虑到了 ref 需要到处使用 .value 的繁琐,推出了响应性语法糖试图解决这一问题。
响应性语法糖提供了一系列编译器宏,让开发者在书写代码时不必使用 .value,而是在编译阶段由编译器自动加上。
最终,出于代码风格一致性和可维护性上的考量,这一特性最终在 Vue 3.4 版本被正式废弃。
来源:juejin.cn/post/7523231174620102671
为什么 Go 语言非常适合开发 AI Agent
原文:Alexander Belanger - 2025.06.03
如同地球上几乎所有人一样,过去的几个月里,我们也一直在关注着 Agent 的发展。
特别值得一提的是,我们观察到 Agent 的采用推动了我们编排平台的增长,这让我们对哪些技术栈和框架——或者干脆没有框架——在此领域表现良好有了一些见解。
我们看到的一个更有趣的现象是混合技术栈的激增:一个典型的 Next.js 或 FastAPI 后端,搭配着一个用 Go 语言编写的 Agent,甚至在非常早期阶段就如此。
作为一名长期的 Go 语言开发者,这着实令人兴奋;下面我将解释为何我认为这将成为未来更普遍的做法。
什么是 Agent?
这里的术语有些混乱,但通常我指的是一个在循环中执行的进程,该进程对其执行路径中的下一步操作拥有一定的自主权。这与预定义的执行路径(例如定义为有向无环图的一组步骤,我们称之为工作流)形成对比。Agent 通常包含一个基于最大深度或满足某个条件(如“测试通过”)的退出条件。

当 Agent 开始规模化(即:拥有实际用户)时,它们通常具有一些共同特征:
- 它们是长时间运行的——从几秒到几分钟甚至几小时不等。
- 每次执行的成本都很高——不仅仅是 LLM 调用的成本,Agent 的本质是取代通常需要人工操作员完成的任务。开发环境、浏览器基础设施、大型文档处理——这些都花费 $$$ 钱的。
- 在它们的执行周期中,经常需要在某个时刻接收用户(或另一个 Agent!)的输入。
- 它们花费大量时间等待 I/O 或人类输入。
让我们将这一系列特征转化为对运行时的要求。为了限定问题范围,假设我们正在处理一个在远程执行的 Agent,而非在用户本地机器上(尽管 Go 对于分发本地 Agent 也是一个绝佳选择)。在远程执行的情况下,为每次 Agent 执行运行一个单独的容器成本会高得惊人。因此,在大多数情况下(尤其是当我们的 Agent 主要是简单的 I/O 和 LLM 调用时),我们最终会得到大量并发运行的轻量级进程。每个进程可以处于特定状态(例如,“搜索文件中”、“生成代码中”、“测试中”)。请注意,不同 Agent 执行的状态顺序可能并不相同。

这种包含许多并发、长时间运行进程的系统,与大约十年前的传统 Web 架构截然不同。在传统架构中,对服务器的请求处理速度要快得多,使用一些缓存、高效的处理程序和 OLTP 数据库就能高效地服务数千名日活用户。
事实证明,这种架构转变非常适合 Go 语言的并发模型、依赖通道(channel)进行通信、集中的取消机制以及围绕 I/O 构建的工具链。
高并发性
让我们从最明显的一点开始——Go 拥有极其简单且强大的并发模型。创建一个新的 goroutine 所需的内存和时间成本非常低,因为每个 goroutine 只有 2KB 的预分配内存。

这实际上意味着你可以同时运行许多 goroutine 而开销很小,并且它们在底层运行在多个操作系统线程上,能够利用服务器中的所有 CPU 核心。这一点非常重要,因为如果你碰巧在某个 goroutine 中执行非常消耗 CPU 的操作(比如反序列化一个大型 JSON 结构),其影响会比你使用单线程运行时(如 Node.js)要小(在 Node.js 中,你需要为阻塞线程的操作创建 worker 线程或子进程),或者比使用 Python 的 async/await 也要好。
这对于 Agent 意味着什么?因为 Agent 的运行时间比典型的 Web 请求长得多,所以并发性就成为了一个更关键的问题。在 Go 中,相比于在 Python 中为每个 Agent 运行一个线程,或者在 Node.js 中为每个 Agent 运行一个 async 函数,你受到为每个 Agent 生成一个 goroutine 的限制要小得多。再加上较低的基础内存占用和编译成单一二进制文件的特点,在轻量级基础设施上同时运行数千个并发 Agent 执行变得异常简单。
通过通信共享内存
对于那些不了解的人,Go 语言有一个常见的习语:不要通过共享内存来通信;相反,通过通信来共享内存。
在实践中,这意味着不需要尝试跨多个并发进程同步内存内容(这是使用类似 Python 的 multithreading 库时的常见问题),每个进程可以通过在通道(channel)上获取和释放对象来获得该对象的所有权。这样做的效果是,每个进程只在拥有对象所有权时关心该对象的本地状态,而其他时候不需要协调所有权——无需互斥锁(mutex)!
老实说——在我编写过的大多数 Go 程序中,我使用等待组(wait groups)和互斥锁(mutexes)的次数往往比使用通道(channels)更多,因为这样通常更简单(这也符合 Go 社区的建议),并且只有一个地方需要并发访问数据。
但是,在建模 Agent 时,这种范式非常有用,因为 Agent 通常需要异步响应用户或其他 Agent 发来的消息,并且将应用程序实例视为一个 Agent 池来思考是很有帮助的。
为了更具体说明,让我们编写一些示例代码来表示 Agent 循环的核心逻辑:
// 注意:在真实世界的例子中,我们需要一种机制来优雅地
// 关闭循环并防止通道关闭;
// 这是一个简化示例。
func Agent(in <-chan Message, out chan<- Output, status chan<- State) {
internal := make(chan Message, 10) // 内部缓冲区大小为 10 的通道
for {
select {
case msg := <-internal: // 从内部通道读取消息
processMessage(msg, internal, out, status)
case msg := <-in: // 从外部输入通道读取消息
processMessage(msg, internal, out, status)
}
}
}
func processMessage(msg Message, internal chan<- Message, out chan<- Output, status chan<- State) {
result := execute(msg) // 执行消息处理
status <- State{msg.sessionId, result.status} // 发送状态更新
if next := result.next(); next != nil { // 获取下一步消息(如果有)
internal <- next // 将下一步消息发送到内部通道
}
out <- result // 发送处理结果
}
(请注意,<-chan 表示接收者只能从通道读取,而 chan<- 表示接收者只能向通道写入。)
这个 Agent 是一个长时间运行的进程,它等待消息到达 in 通道,处理消息,然后异步地将结果发送到 out 通道。status 通道用于发送关于 Agent 状态的更新,这对于监控或向用户发送增量结果很有用;而 internal 通道用于处理 Agent 的内部循环。例如,内部循环可以实现下图中的“直到测试通过”循环:

尽管我们使用 for 循环来运行 Agent,但该 Agent 的实例在消息之间不需要维护任何内部状态。它本质上是一个无状态归约器,其决策执行路径的下一步操作不依赖于某些内部状态。重要的是,这意味着任何 Agent 实例都能够处理下一条消息。这也允许 Agent 在消息之间使用持久化边界,例如将消息写入数据库或消息队列。
使用 context.Context 的集中取消机制
还记得 Agent 执行成本很高吗?假设一个用户触发了一个价值 10 美元的执行任务,但突然改变主意并点击“停止生成”——为了节省成本,你希望取消这次执行。
事实证明,在 Node.js 和 Python 中取消长时间运行的工作极其困难,原因有很多:
- 库之间缺乏统一的取消机制——虽然两种语言都支持中止信号(AbortSignal)和控制器(Controller),但这并不能保证你调用的第三方库会尊重这些信号。
- 如果信号取消失败,强行终止线程是个痛苦的过程,并可能导致线程泄漏或资源损坏。
幸运的是,Go 采用 context.Context 使得取消工作变得轻而易举,因为绝大多数库都预期并尊重这种模式。即使某些库不支持:由于 Go 只有一种并发模型,因此有像 goleak 这样的工具,可以更容易地检测出泄漏的 goroutine 和有问题的库。
丰富的标准库
当你开始使用 Go 时,你会立即注意到 Go 的标准库非常丰富且质量很高。它的许多部分也是为 Web I/O 构建的——比如 net/http、encoding/json 和 crypto/tls——这些对于 Agent 的核心逻辑非常有用。
Go 还有一个隐含的假设:所有 I/O 在 goroutine 内部都是阻塞的——再次强调,因为 Go 只有一种方式运行并发工作——这鼓励你将业务逻辑的核心编写为直线式程序。你不需要担心用 await 包装每个函数调用来将执行推迟给调度器。
与 Python 对比:库开发者需要考虑 asyncio、多线程(multithreading)、多进程(multiprocessing)、eventlet、gevent 以及其他一些模式,几乎不可能同等地支持所有并发模型。因此,如果你用 Python 编写 Agent,你需要研究每个库对你所采用的并发模型的支持情况,并且如果你的第三方库不完全支持你想要的模式,你可能需要采用多种模式。
(Node.js 的情况要好得多,尽管 Bun 和 Deno 等其他运行时的加入增加了一些不兼容的层面。)
性能剖析(Profiling)
由于其有状态性(statefulness)和大量长时间运行的进程,Agent 似乎特别容易出现内存泄漏和线程泄漏。Go 在 runtime/pprof 中提供了出色的工具,可以使用堆(heap)和分配(alloc)配置文件找出内存泄漏的来源,或者使用 goroutine 配置文件找出 goroutine 泄漏的来源。

额外优势:LLM 擅长编写 Go 代码
由于 Go 语法非常简单(一个常见的批评是 Go 有点“啰嗦”)并且拥有丰富的标准库,LLM 非常擅长编写符合 Go 语言习惯的代码。我发现它们在编写表格测试(table tests)方面尤其出色,这是 Go 代码库中的一种常见模式。
Go 工程师也往往反对框架(anti-framework),这意味着 LLM 不需要跟踪你使用的是哪个框架(或框架的哪个版本)。
不足之处
尽管有以上诸多好处,仍然有很多理由让你可能不会选择 Go 来开发你的 Agent:
- 第三方库支持仍然落后于 Python 和 Typescript。
- 使用 Go 进行任何涉及真正机器学习(real machine learning)的工作几乎是不可能的。
- 如果你追求最佳性能,那么有比 Go 更好的语言,如 Rust 和 C++。
- 你特立独行,不喜欢(显式)处理错误。
来源:juejin.cn/post/7514621534339055631
纯前端用 TensorFlow.js 实现智能图像处理应用(一)
随着人工智能技术的不断发展,图像处理应用已经在医疗影像分析、自动驾驶、视频监控等领域得到广泛应用。TensorFlow.js 是 Google 开源的机器学习库 TensorFlow 的 JavaScript 版本,能够让开发者在浏览器中运行机器学习模型,在前端应用中轻松实现图像分类、物体检测和姿态估计等功能。本文将介绍如何使用 TensorFlow.js 在纯前端环境中实现这三项任务,并帮助你构建一个智能图像处理应用。
什么是 TensorFlow.js?
TensorFlow.js 是一个能够让开发者在前端直接运行机器学习模型的 JavaScript 库。它允许你无需将数据发送到服务器,便可以在浏览器中运行模型进行推理,这不仅减少了延迟,还可以更好地保护用户的隐私数据。通过 TensorFlow.js,前端开发者能够轻松实现图像分类、物体检测和姿态估计等功能。
TensorFlow.js 的应用非常广泛,尤其是在一些实时交互和隐私敏感的场景下,例如 医疗影像分析、自动驾驶 和 智能监控。在这些领域,前端模型推理能够提升响应速度,并且避免将用户的数据上传到服务器,从而保护用户的隐私。
要开始使用 TensorFlow.js,你需要安装相关的模型库。以下是你需要安装的 npm 包:
npm install @tensorflow/tfjs
npm install @tensorflow-models/mobilenet
npm install @tensorflow-models/coco-ssd
npm install @tensorflow-models/posenet
加载预训练模型
在 TensorFlow.js 中,加载预训练模型非常简单。首先,确保 TensorFlow.js 已经准备好,然后加载所需的模型进行推理。
// 导入
import * as tf from '@tensorflow/tfjs'
// 加载
tf.ready(); // 确保 TensorFlow.js 准备好
用户上传图片
为了使用这些模型进行推理,我们需要让用户上传一张图片。以下是一个处理图片上传的代码示例:
const handleImageUpload = async (event) => {
const file = event.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = async (e) => {
imageSrc.value = e.target.result
await runModels(e.target.result)
}
reader.readAsDataURL(file)
}
}
图像分类:识别图片中的物体
图像分类是计算机视觉中的基本任务,目的是将输入图像归类到某个类别中。例如,我们可以用图像分类模型识别图像中的“猫”还是“狗”。


使用预训练模型进行图像分类
TensorFlow.js 提供了多个预训练模型,MobileNet 是其中一个常用的图像分类模型。它是一个轻量级的卷积神经网络,适合用来进行图像分类。接下来,我们通过 MobileNet 实现一个图像分类功能:
const mobilenetModel = await mobilenet.load()
const predictions = await mobilenetModel.classify(image)
classification.value = `分类结果: ${predictions[0].className}, 信心度: ${predictions[0].probability.toFixed(3)}`
这段代码实现了图像分类。我们加载 MobileNet 模型,并对用户上传的图像进行推理,最后返回图像的分类结果。
物体检测:找出图像中的所有物体
物体检测不仅仅是识别图像中的物体是什么,还需要标出它们的位置,通常用矩形框来框住物体。Coco-SSD 是一个强大的物体检测模型,能够在图像中检测出多个物体并标出它们的位置。
使用 Coco-SSD 进行物体检测
const cocoModel = await cocoSsd.load();
const detectionResults = await cocoModel.detect(image);
objects.value = detectionResults.map((prediction) => ({
class: prediction.class,
bbox: prediction.bbox,
}));
通过 Coco-SSD 模型,我们可以检测图像中的多个物体,并标出它们的位置。
绘制物体的边界框
为了更直观地展示检测结果,我们可以在图像上绘制出物体的边界框:
// 绘制物体检测边界框
const drawObjects = (detectionResults, image) => {
nextTick(() => {
const ctx = objectCanvas.value.getContext('2d')
const imageWidth = image.width
const imageHeight = image.height
objectCanvas.value.width = imageWidth
objectCanvas.value.height = imageHeight
ctx.clearRect(0, 0, objectCanvas.value.width, objectCanvas.value.height)
ctx.drawImage(image, 0, 0, objectCanvas.value.width, objectCanvas.value.height)
// 绘制边界框
detectionResults.forEach((prediction) => {
const [x, y, width, height] = prediction.bbox
ctx.beginPath()
ctx.rect(x, y, width, height)
ctx.lineWidth = 2
ctx.strokeStyle = 'green'
ctx.stroke()
// 添加标签
ctx.font = '16px Arial'
ctx.fillStyle = 'green'
ctx.fillText(prediction.class, x + 5, y + 20)
})
})
}
这段代码通过绘制边界框来标出检测到的物体位置,同时在边界框旁边显示物体类别。
姿态估计:识别人体的关键点
姿态估计主要是识别人类的身体部位,例如头部、手臂、腿部等。通过这些关键点,我们可以了解一个人当前的姿势。TensorFlow.js 提供了 PoseNet 模型来进行姿态估计。

使用 PoseNet 进行姿态估计
// 加载 PoseNet 模型
const posenetModel = await posenet.load()
const poseResult = await posenetModel.estimateSinglePose(image, {
flipHorizontal: false
})
// 人体关键点
pose.value = poseResult.keypoints.map((point) => `${point.part}: (${point.position.x.toFixed(2)}, ${point.position.y.toFixed(2)})`)
PoseNet 模型会估计图像中人物的关键点,并返回每个关键点的位置。
绘制姿态估计骨架图
const drawPose = (keypoints, image) => {
nextTick(() => {
const ctx = canvas.value.getContext('2d')
const imageWidth = image.width
const imageHeight = image.height
canvas.value.width = imageWidth
canvas.value.height = imageHeight
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height)
// 绘制图像
ctx.drawImage(image, 0, 0, canvas.value.width, canvas.value.height)
const scaleX = canvas.value.width / image.width
const scaleY = canvas.value.height / image.height
// 绘制关键点并标记名称
keypoints.forEach((point) => {
const { x, y } = point.position
const scaledX = x * scaleX
const scaledY = y * scaleY
ctx.beginPath()
ctx.arc(scaledX, scaledY, 5, 0, 2 * Math.PI)
ctx.fillStyle = 'red'
ctx.fill()
// 标记点的名称
ctx.font = '12px Arial'
ctx.fillStyle = 'blue'
ctx.fillText(point.part, scaledX + 8, scaledY)
})
// 连接骨架
const poseConnections = [
['leftShoulder', 'rightShoulder'],
['leftShoulder', 'leftElbow'],
['leftElbow', 'leftWrist'],
['rightShoulder', 'rightElbow'],
['rightElbow', 'rightWrist'],
['leftHip', 'rightHip'],
['leftShoulder', 'leftHip'],
['rightShoulder', 'rightHip'],
['leftHip', 'leftKnee'],
['leftKnee', 'leftAnkle'],
['rightHip', 'rightKnee'],
['rightKnee', 'rightAnkle'],
['leftEye', 'rightEye'],
['leftEar', 'leftShoulder'],
['rightEar', 'rightShoulder']
]
poseConnections.forEach(([partA, partB]) => {
const keypointA = keypoints.find((point) => point.part === partA)
const keypointB = keypoints.find((point) => point.part === partB)
if (keypointA && keypointB && keypointA.score > 0.5 && keypointB.score > 0.5) {
const scaledX1 = keypointA.position.x * scaleX
const scaledY1 = keypointA.position.y * scaleY
const scaledX2 = keypointB.position.x * scaleX
const scaledY2 = keypointB.position.y * scaleY
ctx.beginPath()
ctx.moveTo(scaledX1, scaledY1)
ctx.lineTo(scaledX2, scaledY2)
ctx.lineWidth = 2
ctx.strokeStyle = 'blue'
ctx.stroke()
}
})
})
}
这段代码通过 PoseNet 返回的人体关键点信息,绘制人体姿态的骨架图,帮助用户理解图像中的人物姿势。

总结
通过 TensorFlow.js,我们可以轻松地将图像分类、物体检测和姿态估计等功能集成到前端应用中,无需依赖后端计算,提升了应用的响应速度并保护了用户隐私。在本文中,我们介绍了如何使用 MobileNet、Coco-SSD 和 PoseNet 等预训练模型,在前端实现智能图像处理应用。无论是开发图像识别应用还是增强现实应用,TensorFlow.js 都是一个强大的工具,值得前端开发者深入学习和使用。
来源:juejin.cn/post/7437392938441687049
AI独角兽团队Manus裁员80人,剩下40人迁至新加坡总部!
大家好,我是程序员小灰。
大家是否还记得,今年3月份横空出世的AI产品Manus?

Manus号称是全球首款通用Agent的产品,它刚刚上线的时候在AI圈子里红极一时,许多人都在争抢Manus的内测激活码,这些内测码甚至在闲鱼平台被炒到了10万元!
小灰一直没有机会真正使用Manus,但我看过许多网友的演示视频,虽然这款Agent产品没有发布会上说的那么神奇,存在种种瑕疵,但是在某种程度上确实可以帮助人们解决一些流程化的工作。
在当时,Manus不止在国内受到追捧,也受到了全世界的关注。在4月25日,Manus母公司蝴蝶效应获得了美国风险投资公司Benchmark领投的7500万美元融资。
包括小灰在内的许多同行都认为,Manus一定会在资本的加持下继续做大做强,成为中国甚至全世界的AI Agent领军人。
可是万万没想到,在7月8日这一天,Manus团队被爆出了裁员消息。Manus在中国区大约有120名员工,其中40多位核心员工被迁往新加坡总部,剩下80人左右将被裁员,补偿方案是N+3或者2N。
仅仅4个月的时间,Manus就从刚刚诞生时候的辉煌走到了如今的局面,难怪圈子里都在说:AI一天,人间十年。
那么,Manus为什么会面临裁员呢?主要有三个原因:
1.政策影响
2025年1月生效的《对外投资安全计划》限制美元基金对中国AI企业的投资,Manus的主要投资方Benchmark在审查压力下,被迫要求Manus迁出中国。
2.芯片断供
从去年开始,美国商务部就禁止向中国大陆客户供应AI芯片,到了今年年初,禁令执行越来越严格,这对Manus的打击是致命的。Manus研发负责人也在内部会议中坦承,因无法及时获取英伟达最新AI芯片,智能体的迭代升级进度被迫延缓。
3.成本问题
Manus从今年3月底开始向用户收费,基础版每月39美元,高级版每月199美元。相比于国内很多物美价廉的AI产品,Manus收费真的很贵。
但是从Manus官方的角度,他们的运营成本也确实很高,目前的收费根本无法覆盖运营成本。再加上国内又诞生了扣子空间这样的平替产品,导致Manus用户增长乏力,无法形成规模效应。
Manus如今的结局是令人遗憾的,但是像DeepSeek和Manus这样了不起的产品,也让全世界看到了中国AI的希望。
小灰相信,中国AI行业的未来发展只会越发迅猛,今后一定会有越来越多优秀的AI产品走进大众的视野,让全世界的人们看到我们中国人的智慧和努力。
大家对Manus裁员和迁移新加坡这件事怎么看?欢迎在留言区说出你的想法。
来源:juejin.cn/post/7524931208746975270
我为什么放弃了“大厂梦”,去了一家“小公司”?
我,前端八年。我的履历上,没有那些能让HR眼前一亮的名字,比如字节、阿里,国内那些头部的互联网公司。
“每个程序员都有一个大厂梦”,这句话我听了八年。说实话,我也有过,而且非常强烈。
刚毕业那几年,我把进大厂当作唯一的目标。我刷过算法题,背过“八股文”,也曾一次次地在面试中被刷下来。那种“求之不得”的滋味,相信很多人都体会过。
但今天,我想聊的是,我是如何从一开始的“执念”,到后来的“审视”,再到现在的“坦然”,并最终心甘情愿地在一家小公司里,找到了属于我自己的价值。
这是一个普通的、三十多岁的工程师,与自己和解的经历。
那段“求之不得”的日子
我还记得大概四五年前,是我冲击大厂最疯狂的时候。
市面上所有关于React底层原理、V8引擎、事件循环的面经,我都能倒背如流。我把LeetCode热题前100道刷了两遍,看到“数组”、“链表”这些词,脑子里就能自动冒出“双指针”、“哈希表”这些解法。
我信心满满地投简历,然后参加了一轮又一轮的面试。
结果呢?大部分都是在三轮、四轮之后,收到一句“感谢您的参与,我们后续会保持联系”。我一次次地复盘,是我哪里没答好?是项目经验不够亮眼?还是算法题的最优解没写出来?
那种感觉很糟糕。你会陷入一种深深的自我怀疑,觉得自己的能力是不是有问题,是不是自己“不配”进入那个“高手如云”的世界。
开始问自己:“大厂”真的是唯一的出路吗?
在经历了一段密集而失败的面试后,我累了,也开始冷静下来思考。
我观察身边那些成功进入大厂的朋友。他们确实有很高的薪水和很好的福利,但他们也常常在半夜的朋友圈里,吐槽着无休止的会议、复杂的流程、以及自己只是庞大系统里一颗“螺丝钉”的无力感。
我看到他们为了一个需求,要跟七八个不同部门的人“对齐”;看到他们写的代码,90%都是在维护内部庞大而陈旧的系统;看到他们即使想做一个小小的技术改进,也要经过层层审批。
我突然问自己:这真的是我想要的生活吗?我想要的是什么?
当我把这些想清楚之后,我发现,大厂的光环,对我来说,好像没那么耀眼了。
在“小公司”,找到了意想不到的“宝藏”
后来,我加入了一家规模不大的科技公司。在这里,我确实找到了我想要的东西。
成了一个“产品工程师”,而不仅仅是“前端工程师”
在小公司,边界是模糊的。
我不仅要写前端代码,有时候也得用Node.js写一点中间层。我需要自己去研究CI/CD,把自动化部署的流程跑起来。我甚至需要直接跟客户沟通,去理解他们最原始的需求。
这个过程很“野”,也很累,但我的成长是全方位的。我不再只关心页面好不好看,我开始关心整个产品的逻辑、服务器的成本、用户的留存。我的视野被强制性地拉高了。
“影响力”被无限放大
在这里,我就是前端的负责人。
用Vue还是React?用Tailwind CSS还是CSS Modules?这些技术决策,我能够和老板、和团队一起讨论,并最终拍板。我们建立的每一个前端规范,写的每一个公共组件,都会立刻成为整个团队的标准。
这种“规则制定者”的身份,和在大厂当一个“规则遵守者”,是完全不同的体验。你能清晰地看到自己的每一个决定,都对产品和团队产生了直接而深远的影响。
离“价值”更近了
最重要的一点是,我能非常直接地感受到自己工作的价值。
我花一周时间开发的新功能上线后,第二天就能从运营同事那里拿到用户的反馈数据。我知道用户喜不喜欢它,它有没有帮助公司赚到钱。这种即时的、正向的反馈,比任何KPI或者年终奖金,更能给我带来成就感。
还会羡慕那些在大厂的朋友吗?
当然会。我羡慕他们优厚的薪酬福利,羡慕他们能参与到改变数亿人的项目中去。
但我不再因此而焦虑,也不再因此而自我否定。
你可以多想一想你真正想要的是什么? 一个公司的名字,并不能定义你作为一名工程师的价值。你的价值,体现在你写的代码里,体现在你解决的问题里,也有可能体现在你创造的产品里。
找到一个能让你发光发热的地方,比挤进一个让你黯淡无光的地方,重要得多。
分享完毕。谢谢大家🙂
来源:juejin.cn/post/7525011608366579758
网易微前端架构实战:如何管理100+子应用而不崩
你知道网易有多少个前端项目吗?
超过 1000 个代码仓库、200+子应用、每天发布300次。
如果没有一套微前端治理系统,项目早炸了。
今天就来带你拆解网易微前端架构的核心——基座 + 动态加载 + 权限隔离 + 独立发布。
一、网易为什么早早就用上了微前端?
因为早年起就有大量频道、游戏门户、社区运营、CMS后台等:
- 功能多样、团队独立
- 迭代频繁、部署不可等待
- 技术栈各异:Vue2、Vue3、React、甚至还有 jQuery...
👇于是他们选择了模块化能力最强的方案:微前端架构(Micro Frontends)
二、整体架构图(网易实战版)

三、主子应用通信怎么做?(网易方案)
网易没有用 qiankun,而是基于内部封装的微前端 SDK,核心原理类似。
// 主应用提供通信桥
window.__MICRO_APP_EVENT_BUS__ = new EventTarget()
// 子应用监听事件
window.__MICRO_APP_EVENT_BUS__.addEventListener('global-refresh', () => {
window.location.reload()
})
// 主应用触发事件
window.__MICRO_APP_EVENT_BUS__.dispatchEvent(new Event('global-refresh'))
👉这种方式:
- 不侵入框架(Vue/React 通吃)
- 不耦合代码,只用浏览器原生事件系统
四、部署与权限如何统一管理?
网易配套了一整套“发布平台 + 权限系统”,做到:
| 功能 | 说明 |
|---|---|
| 独立部署 | 每个子应用都有独立 Jenkins/流水线 |
| 权限接入 | 每个子应用上线必须绑定角色权限模块 |
| 域名配置 | 主应用统一路由配置,动态注入 iframe 或模块 |
| 沙箱运行 | 子应用运行在 iframe + ShadowDOM + CSP 下,完全隔离 |
五、实战代码:子应用注册和加载
// 主应用注册子应用(JSON 配置化)
const microAppList = [ { name: 'content-manage', entry: 'https://cdn.xxx.com/apps/content-manage/index.html', activeRule: '/content' }, { name: 'user-center', entry: 'https://cdn.xxx.com/apps/user-center/index.html', activeRule: '/user' }]
// 动态加载示例(简化版)
function loadMicroApp(appConfig) {
const iframe = document.createElement('iframe')
iframe.src = appConfig.entry
iframe.style = 'width:100%;height:100%;border:none'
document.getElementById('micro-container').appendChild(iframe)
}
六、网易踩过的3个坑(干货!)
| 坑 | 解决方案 |
|---|---|
| 子应用样式污染 | 每个子应用编译时加 prefixCls,搭配 ShadowDOM 隔离 |
| 子应用登录状态不一致 | 所有项目统一通过 Cookie + SSO 网关授权 |
| 子应用发布顺序冲突 | 发布系统支持灰度 + 停发自动依赖检查 |
七、总结:你能从中学到什么?
- 不要迷信 qiankun,自己也能搞微前端(原理简单)
- 微前端不仅是技术,更是权限、部署、治理一整套体系
- 想要稳定运行,必须有主子应用契约 + 灰度发布 + 统一通信策略
尾声:
“你看到的稳定,其实是他们踩了无数坑后的优雅。”
来源:juejin.cn/post/7510653719672094739
30+程序员如何不被小事击垮
引言
老张最近有一个重点项目在推进,他准备今天回家加班赶赶进度。
可到家之后,发现孩子发烧了,由于妻子出差,他赶紧放下手头的事情,抱起带孩子往医院跑。
堵车、吃饭、挂号、排队,一路折腾下来,已经九点了。
可就是这个点,儿科急诊仍是人山人海。孩子好奇的问东问西,手机里还不断跳出加急的消息,老张焦急的不断盘算着还有多久能排到他们。
马上到老张了,突然有一个人抱着孩子挤进了医生房间,老张愣了一下,火一下子就上来了,冲进屋子和对方吵了起来......
幸运的是孩子没什么事,可老张熬了一晚上啥也没干成。第二天上班状态不好,和业务方沟通时及其不耐烦。
下午收到了业务方的投诉,理由是态度消极。
老张一天什么也没干,却感觉自己马上就要崩溃了。
英雄就是这么被击垮的。据说是伏尔泰有句话说,“使人疲惫的不是远方的高山,而是你鞋里的一粒沙子”。
工作繁忙、孩子生病、业务方催促进度......都不是什么“重大事件”,可就是会压的你喘不过气来。
处理不好这些小麻烦,不但会影响你的情绪和工作表现,还有可能会影响我们的健康,最重要的是,如果被这些小事击垮了,我们哪还有心思去想什么大事呢?
我现在“大概”可以做到情绪稳定,我有四个自己在用的心法,分享给大家。
学会忽略
学会忽略,是我看到一句,据说是爱因斯坦说过的话:“弱者报复,强者原谅,智者忽略”。
就拿开车举例,我在路上发现过一个特别有意思的现象,如果开车的时候遇见插队,不同的司机有三种反应。
第一种人是对抗型,面对插队丝毫不让,眼看着两辆车的距离都塞不下一根手指头了,插队车决定放弃,他可能赢得了这场面子仗。
第二种人是原谅型,一开始会正常起步,但如果对方强行变道, 他会及时刹车,毕竟剐蹭了浪费时间浪费金钱。
第三种人是忽略型,他可能根本就没有在意这辆车在插队,他会直到前车完全进来,才会继续出发。他可能正沉浸在播客或者音乐里,神游在另一个维度。
你觉着哪种应对方式最好?
之前我是第二种人,并且还觉着第三种人是技术不佳,或者说是“好欺负”,直到最近我也有点了第三种人的样子。
有一次我在路上,听播客听的入迷,前方有个车有变道的意图。我也不着急,就让他变道进来了。

随后我才意识到,自己好像甚至都没有为这件事情,分散一点注意力。
那一刻我才明白,忽略,不是退让,也不用压抑情绪——
而是你根本没分配注意力分配给小事,负面情绪自然也就不会被激活。
忽略负面情绪非常有必要,有一项针对1300名男性做的研究,让他们对自己遇见类似于加塞这种小麻烦的反应打分,分数越高代表情绪反应越大。
结果发现,经常对小事有激烈反应的男性,他们的健康状况和死亡率,和面对那些重大人生压力的人一样。反应最激烈的那一组,在同样时间段内的死亡率,竟然是正常人的三倍。
我想起之前我奶奶家挂着一副《莫生气》的字画,里面有一句话:别人生气我不气,气出病来无人替。
看来对小事有激烈反应,真的会影响身心健康啊。我实践下来,忽略情绪有三个小技巧:
第一个是觉察情绪。当你发现自己心跳加快、紧握双拳,要注意情绪可能要来了。你需要深吸一口气,把注意力放到自己身上,让自己停下来,别被情绪牵着鼻子。
第二是控制自己的反应。维克多·弗兰克尔有一句话:“在面对外界刺激时,我们拥有选择如何回应的自由”。人真正能控制的只有自己的行动和态度,你控制不了堵车,但你能控制堵车的时候听一首钢琴曲。
第三是发现身边的美好,也就是感知生活中的“小确幸”。比如阳台上的花突然开了,孩子自己穿衣服了。有研究表明,对生活中积极细节的留意,能有效中和压力引发的负面情绪。
不恶意揣测
不过有些事情你很难忽略,比如公司考核政策调整,甚至说你持仓的股票大跌。
你感觉这个世界充满了恶意:公司打压你、社会在压榨你、资本在收割你。
可现实真的是这样吗?
帮助我解决心结的法则叫做「汉隆剃刀」。简单的说,它的意思就是「能解释为愚蠢的,就不要解释为恶意」。
这里说的愚蠢,代表各种无知的、偶然的、非故意的原因。这些情况发生的可能性远远大于恶意,汉隆剃刀大多数情况下反应了客观事实。
比如你开车,前方突然有车插队,你怒不可遏,心想:你是不是觉着我好欺负?
但其实他根本不认识你,只不过恰巧他意识到前方需要拐弯了。
这个法则在理解社会、组织层面,特别有效。
比如你持有的一只股票突然暴跌,你会听到一些传闻:说这是“庄家”在故意控盘。
我之前特别相信这种阴谋论,觉着股价是被人为操控的。可真实情况是,大公司的股价是很难操控的,投入很多钱也不一定能成功,一旦失败就会受到很大损失。而且市场上每个股民的互动、追涨杀跌,也会给股价造成很大影响。

再比如一家公司突然开始了绩效改革,给研发人员制定了和销售额相关的KPI,而且目标不完成还会对薪资产生影响。
可研发的考核怎么可能和销售的KPI挂钩呢?你怀疑管理层在变相降薪。
可更大的可能性是:管理层也不知道如何满足老板制定的目标,只能先套个模版应付一下。
不是你被打压了,只是碰巧他们不专业,你以为它是在有意的做坏事,但更大的可能是它没能力做好事。
我们大脑为了认知方便,常常会把一家公司或者一个政府当成一个人,假设他有自由意志,是一个决策缜密、心怀不轨的敌人。但其实组织就是一部机器而已。
不是组织在有意针对你,这世界其实就是个草台班子。
超越身份思维
你有没有经历过这种场面,过年回家刚坐下,七大姑八大姨联合开麦:
“你看你三十多了还在北京漂着,准备啥时候结婚啊?你表哥孩子都上小学了。”
“北京租房多贵啊,在咱老家,这都够还房贷了,干嘛不考个公务员安稳点?”
你努力工作、认真生活,熬过了失业焦虑、加班压力,结果成了他们口中的“混日子”。
你不是气他们说了什么,而是他们压根不懂你,却笃定地定义你。
可他们的确不懂你,也不会真正的懂你。他们只不过是在维护自己的世界观而已。
学术界有一个流行的说法是,人们的行为和观点,是由身份认同决定的。你的长辈,可能在一个小城过了一辈子,有编制、有房子、生儿育女,就是他们眼里的体面生活。
你异地漂泊、私企行业、租房未婚,就是他们眼中的“不务正业”。
你不需要反驳,也不需要忍耐。而是你明白了,你不需要从所有人眼里获得认可,你可以看到不同身份的局限性。

超越身份思维,在养育孩子的过程中特别有用。
那天我带儿子去上烘焙课,糖刚撒进面粉中,他就开始一边揉一边往嘴里塞。
我试图制止,他越发固执,并且不耐烦的喊:“我要回家!我要回家!”
我有点崩溃,一边怕他吃坏肚子,一边气他为什么这么不听话。
但那一刻我突然意识到,他不是故意气我,他只不过多想吃几口糖而已。
我们常说父母要包容孩子,为此你需要先理解他们的行为。糖果能刺激大脑释放多巴胺,这是最本能的反应,而且他们理性大脑还没有开始发育,你不能要求一个孩子有“自控力”
我们总以“大人的身份”要求孩子守规矩,可孩子的很多行为,并不是“不听话”,而是“做不到”。
就像人类的大脑,在25岁才能完全发育完成。也就是说,一个孩子即使成年之后,也会做出一些你不理解的事情。一个人成长的过程中,本就充满了能力滞后,
你不理解他,就会对抗他。你理解了,才能包容他。
找到目标
有一天我正坐在电脑前,正为了文章选题抓耳挠腮。
我儿子“砰”地一下推开门,一屁股坐在我旁边,拿着小汽车喊:“爸爸!爸爸...”
以往工作时被打断,我会特别不耐烦。可那天,我关上电脑开始陪他玩,我发现自己变得很有耐心。
过了一会他自己去客厅玩了,我坐在电脑前,去想我为什么会有这种改变。

是我变得有耐心了?还是我终于佛系了?后来我想明白了——是自己的目标更明确了。
过去一被打断就恼火,其实都是因为自己都不知道要干什么。我只是模糊的觉得,自己得做点有意义的事情。
但现在写好每一篇文章就是我想做的事情,就算短暂的停下来,也没有任何影响。
心理学上有一个「自我决定理论」:真正让人持续投入的,从来不是外界的压力或奖励,而是自己选的方向。
比如说当你在街道上跑步,周围车水马龙、喧嚣嘈杂,可你根本就听不见。因为你眼中,只有即将抵达的下一个路口。目标感就像降噪耳机,能让你不被外界打扰。
但如果你没有目标呢?虽然一天什么都没干,却很容易因为工作上的催促,家人的一句话,就能让你心烦意乱。
你不是没有承受力,你可能只是没有方向而已。
尼采说:“一个人知道自己为什么而活,就可以忍受任何一种生活。”
现在看来这不是鸡汤,而是硬道理。
真正让你内心安定的,不是时间管理、情绪技巧,而是——你有没有在朝着自己认可的方向,慢慢靠近。
说在最后
最后分享一句据说是爱默生说的一段话来结尾吧。
当整个世界似乎都在暗中图谋,用琐事一次又一次侵扰你、纠缠你,当所有人都来敲你的门,并且说,“到我们这里来吧。”绝对不要动心,不要加入到他们的喧闹中。始终保有一颗自助自主的心,不受外界影响和左右,活在自己的意志里,才能够使心灵得到宁静,才会过上真正独立的生活。
以上就是我自己应对生活中一些“小麻烦”的个人心得,如果你也有面对“小麻烦”时处理情绪的技巧,欢迎在评论区分享~
这是东东拿铁的第84篇原创文章,欢迎关注。
来源:juejin.cn/post/7522751214263992370
苹果官网前端技术解析:极简设计背后的技术精粹
苹果公司官网(apple.com)以其优雅的极简设计、流畅的交互体验和卓越的性能表现闻名于世。本文将深入解析苹果官网前端实现的技术亮点,并附上关键技术的示例代码实现。
一、视觉滚动效果:视差与滚动驱动动画
苹果官网最显著的特点是其精致的滚动效果,包括视差滚动、滚动触发的动画和流畅的页面过渡。
1. 高级视差效果实现
苹果官网的视差效果不是简单的背景固定,而是多层元素以不同速度移动创造的深度感。
// 视差滚动控制器
class ParallaxController {
constructor() {
this.elements = [];
this.scrollY = 0;
this.init();
}
init() {
this.cacheElements();
this.addEventListeners();
this.animate();
}
cacheElements() {
this.elements = Array.from(
document.querySelectorAll('[data-parallax]')
).map(el => ({
el,
speed: parseFloat(el.getAttribute('data-parallax-speed')) || 0.3,
offset: 0
}));
}
addEventListeners() {
window.addEventListener('scroll', () => {
this.scrollY = window.scrollY;
});
// 使用IntersectionObserver来优化性能
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('parallax-active');
}
});
}, { threshold: 0.1 });
this.elements.forEach(({ el }) => observer.observe(el));
}
animate() {
requestAnimationFrame(() => {
this.elements.forEach(({ el, speed }) => {
const rect = el.getBoundingClientRect();
const offset = this.scrollY * speed;
// 只对可见元素应用变换
if (rect.top < window.innerHeight && rect.bottom > 0) {
el.style.transform = `translate3d(0, ${offset}px, 0)`;
}
});
this.animate();
});
}
}
new ParallaxController();
2. 滚动触发动画(ScrollTrigger)
苹果官网大量使用滚动位置触发的精细动画:
// 滚动触发动画系统
class ScrollAnimation {
constructor() {
this.animations = [];
this.init();
}
init() {
document.querySelectorAll('[data-scroll-animation]').forEach(el => {
const animation = {
el,
trigger: el.getAttribute('data-scroll-trigger') || 'center',
class: el.getAttribute('data-scroll-class'),
reverse: el.hasAttribute('data-scroll-reverse')
};
this.animations.push(animation);
});
window.addEventListener('scroll', this.checkAnimations.bind(this));
this.checkAnimations();
}
checkAnimations() {
const viewportHeight = window.innerHeight;
const scrollY = window.scrollY;
this.animations.forEach(animation => {
const rect = animation.el.getBoundingClientRect();
const elementTop = rect.top + scrollY;
const elementCenter = elementTop + rect.height / 2;
const triggerPoint = scrollY + viewportHeight * this.getTriggerRatio(animation.trigger);
if (elementCenter < triggerPoint) {
animation.el.classList.add(animation.class);
} else if (animation.reverse) {
animation.el.classList.remove(animation.class);
}
});
}
getTriggerRatio(trigger) {
const ratios = {
top: 0.1,
center: 0.5,
bottom: 0.9
};
return ratios[trigger] || 0.5;
}
}
new ScrollAnimation();
二、响应式图片与艺术指导(Art Direction)
苹果官网针对不同设备展示不同裁剪和分辨率的图片,实现最佳视觉效果。
1. 响应式图片实现
<picture>
<!-- 大屏幕显示宽屏图片 -->
<source media="(min-width: 1440px)"
srcset="product-large.jpg 1x,
product-large@2x.jpg 2x">
<!-- 中等屏幕显示标准图片 -->
<source media="(min-width: 768px)"
srcset="product-medium.jpg 1x,
product-medium@2x.jpg 2x">
<!-- 小屏幕显示竖版图片 -->
<source srcset="product-small.jpg 1x,
product-small@2x.jpg 2x">
<!-- 默认回退 -->
<img src="product-medium.jpg"
alt="Apple Product"
class="responsive-image"
loading="lazy">
</picture>
2. 动态图片加载优化
class ImageLoader {
constructor() {
this.images = [];
this.init();
}
init() {
this.cacheImages();
this.observeImages();
}
cacheImages() {
this.images = Array.from(document.querySelectorAll('img[data-src]'));
}
observeImages() {
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
observer.unobserve(entry.target);
}
});
}, {
rootMargin: '200px 0px',
threshold: 0.01
});
this.images.forEach(img => observer.observe(img));
}
loadImage(img) {
const src = img.getAttribute('data-src');
if (!src) return;
const tempImg = new Image();
tempImg.src = src;
tempImg.onload = () => {
img.src = src;
img.removeAttribute('data-src');
img.classList.add('loaded');
};
}
}
new ImageLoader();
三、高性能动画实现
苹果官网的动画以60fps的流畅度著称,这得益于他们对动画性能的极致优化。
1. GPU加速动画
/* 苹果官网风格的CSS动画 */
.product-card {
transform: translateZ(0); /* 触发GPU加速 */
will-change: transform, opacity; /* 提示浏览器元素将变化 */
transition: transform 0.6s cubic-bezier(0.25, 0.1, 0.25, 1),
opacity 0.6s ease-out;
}
.product-card:hover {
transform: translateY(-10px) scale(1.02);
opacity: 0.9;
}
2. 基于WebGL的复杂动画
苹果官网有时会使用WebGL实现复杂的3D产品展示:
// 简化的WebGL产品展示实现
class ProductViewer {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
antialias: true,
alpha: true
});
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
this.product = null;
this.init();
}
async init() {
this.setupCamera();
this.setupLighting();
await this.loadProduct();
this.setupControls();
this.animate();
this.handleResize();
}
setupCamera() {
this.camera.position.z = 5;
this.updateAspectRatio();
}
updateAspectRatio() {
const width = this.canvas.clientWidth;
const height = this.canvas.clientHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
}
setupLighting() {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(1, 1, 1);
this.scene.add(directionalLight);
}
async loadProduct() {
const loader = new THREE.GLTFLoader();
const { scene } = await loader.loadAsync('product-model.glb');
this.product = scene;
this.scene.add(this.product);
}
setupControls() {
this.controls = new THREE.OrbitControls(this.camera, this.canvas);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.minDistance = 3;
this.controls.maxDistance = 10;
}
animate() {
requestAnimationFrame(this.animate.bind(this));
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
handleResize() {
window.addEventListener('resize', () => {
this.updateAspectRatio();
});
}
}
new ProductViewer('product-canvas');
四、自适应与响应式布局系统
苹果官网的自适应布局系统能够在各种设备上提供完美的视觉体验。
1. 基于CSS Grid的布局系统
/* 苹果官网风格的自适应网格系统 */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
padding: 0 40px;
max-width: 2560px;
margin: 0 auto;
}
@media (max-width: 768px) {
.product-grid {
grid-template-columns: 1fr;
padding: 0 20px;
gap: 16px;
}
}
/* 苹果特色的全屏分段布局 */
.section {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
padding: 120px 10%;
position: relative;
overflow: hidden;
}
.section-content {
max-width: 1200px;
margin: 0 auto;
position: relative;
z-index: 2;
}
2. 动态字体大小调整
// 基于视口宽度调整字体大小
class FluidTypography {
constructor() {
this.minFont = 16;
this.maxFont = 24;
this.minWidth = 320;
this.maxWidth = 1920;
this.init();
}
init() {
this.updateFontSizes();
window.addEventListener('resize', this.updateFontSizes.bind(this));
}
updateFontSizes() {
const viewportWidth = window.innerWidth;
const clampedWidth = Math.min(Math.max(viewportWidth, this.minWidth), this.maxWidth);
const scale = (clampedWidth - this.minWidth) / (this.maxWidth - this.minWidth);
const fontSize = this.minFont + (this.maxFont - this.minFont) * scale;
document.documentElement.style.setProperty(
'--fluid-font-size',
`${fontSize}px`
);
}
}
new FluidTypography();
五、微交互与状态管理
苹果官网的按钮、导航等交互元素有着精细的状态反馈。
1. 按钮交互效果
/* 苹果风格的按钮 */
.apple-button {
display: inline-block;
padding: 12px 22px;
border-radius: 30px;
background: linear-gradient(to bottom, #42a1ec, #0070c9);
color: white;
font-size: 17px;
font-weight: 400;
text-align: center;
cursor: pointer;
border: none;
outline: none;
transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
position: relative;
overflow: hidden;
}
.apple-button:hover {
background: linear-gradient(to bottom, #2d92e8, #0068b8);
transform: scale(1.02);
box-shadow: 0 5px 15px rgba(0, 112, 201, 0.3);
}
.apple-button:active {
transform: scale(0.98);
background: linear-gradient(to bottom, #1b7fd1, #005ea3);
}
.apple-button::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 5px;
height: 5px;
background: rgba(255, 255, 255, 0.5);
opacity: 0;
border-radius: 100%;
transform: scale(1, 1) translate(-50%, -50%);
transform-origin: 50% 50%;
}
.apple-button:focus:not(:active)::after {
animation: ripple 1s ease-out;
}
@keyframes ripple {
0% {
transform: scale(0, 0);
opacity: 0.5;
}
100% {
transform: scale(20, 20);
opacity: 0;
}
}
2. 全局状态管理
苹果官网使用类似状态机的模式管理复杂的UI状态:
class UIStateManager {
constructor() {
this.states = {
NAV_OPEN: false,
MODAL_OPEN: false,
DARK_MODE: false,
PRODUCT_VIEW: null
};
this.subscribers = [];
this.init();
}
init() {
// 监听系统颜色偏好
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.setDarkMode(darkModeMediaQuery.matches);
darkModeMediaQuery.addListener(e => this.setDarkMode(e.matches));
}
setState(key, value) {
if (this.states[key] !== undefined) {
this.states[key] = value;
this.notifySubscribers(key);
}
}
subscribe(callback) {
this.subscribers.push(callback);
}
notifySubscribers(changedKey) {
this.subscribers.forEach(cb => cb(changedKey, this.states));
}
toggleNav() {
this.setState('NAV_OPEN', !this.states.NAV_OPEN);
}
setDarkMode(enabled) {
this.setState('DARK_MODE', enabled);
document.documentElement.classList.toggle('dark-mode', enabled);
}
}
const stateManager = new UIStateManager();
// 示例组件使用状态
class NavMenu {
constructor() {
this.el = document.querySelector('.nav-menu');
stateManager.subscribe(this.onStateChange.bind(this));
}
onStateChange(key, states) {
if (key === 'NAV_OPEN') {
this.el.classList.toggle('open', states.NAV_OPEN);
document.body.style.overflow = states.NAV_OPEN ? 'hidden' : '';
}
}
}
new NavMenu();
六、性能优化策略
苹果官网加载速度快且运行流畅,这得益于多项性能优化技术。
1. 资源预加载
<!-- 预加载关键资源 -->
<link rel="preload" href="hero-image.jpg" as="image">
<link rel="preload" href="main.css" as="style">
<link rel="preload" href="main.js" as="script">
<!-- 预连接重要第三方源 -->
<link rel="preconnect" href="https://cdn.apple.com">
<link rel="dns-prefetch" href="https://cdn.apple.com">
2. 代码分割与懒加载
// 动态导入非关键模块
document.addEventListener('DOMContentLoaded', async () => {
if (document.querySelector('.product-carousel')) {
const { initCarousel } = await import('./carousel.js');
initCarousel();
}
if (document.querySelector('.video-player')) {
const { initVideoPlayer } = await import('./video-player.js');
initVideoPlayer();
}
});
3. Service Worker缓存策略
// service-worker.js
const CACHE_NAME = 'apple-v3';
const ASSETS = [
'/',
'/main.css',
'/main.js',
'/images/logo.svg',
'/images/hero.jpg'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(ASSETS))
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request).then(response => {
if (!response || response.status !== 200 ||
response.type !== 'basic' ||
!event.request.url.includes('/assets/')) {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => cache.put(event.request, responseToCache));
return response;
});
})
);
});
结语
苹果官网的前端实现代表了Web开发的最高水准,其技术特点可以总结为:
- 极致的性能优化:确保60fps的流畅动画和即时加载
- 精细的交互设计:每个微交互都经过精心调校
- 自适应的视觉系统:在各种设备上提供完美体验
- 高效的状态管理:复杂UI的有序控制
- 渐进增强策略:平衡功能与兼容性
这些技术不仅适用于大型企业网站,其中的许多模式和技巧也可以应用于各种Web项目,帮助开发者创建更高质量的用户体验。
来源:juejin.cn/post/7521160007854866467
三年写了很多代码,也想写写自己
前言
从我进入公司开始,我给自己立了一个三年成为中级前端工程师的目标,或者说flag吧,最近正好三年合同到期了,我开始思考过去的这三年我都做了什么,是否完成了flag,所以我在个人博客中增加了一个模块《项目需求》用于管理自己在生活、工作、学习过程中开发过的项目文档和思考,在整理的过程中才发现很多东西都忘记是怎么做的了,大概是只知道做,不知道为什么做吧,所以这里想写一篇文章记录下或者说获取一些建议吧。

很多人说博客和todo没啥用,开发完几乎都不会使用,我觉得看怎么用吧,我这里把项目和todo、技术文档、技术栈绑定了。编辑项目和可以和todo绑定,我觉得还挺好使,这样在整理技术文档、技术调研的时候也可以用


过去三年我都做了什么
2022年
基本情况
因为在学校的时候根本没想过这么早出社会,原本是打算走学术路线的,结果考研那年因为家里临时出了点状况,最后决定先工作再说。现在回头看也挺好的,早点接触社会,其实成长得更快。
在这一年我主要是疯狂补项目经验和疯狂加班,因为社会始终不同于学校,排期让人很头疼,在以往我缺失了很多的项目经验,导致对企业级项目几乎不通,导致我在做任何项目都比较挣扎,再加上进的是一个小厂,完全没有导师制,属于放养,并且大家都很忙,所以只能自己多花时间,并且当时我学的vue的技术栈,但是公司是react的,光是搞懂 React 那一套生态就花了不少时间。
项目经历
- 早期主要是做企业后台 & figma插件,其中有一个插件让我特别头疼,一方面因为当时的自己比较菜,另一方面因为这个需求是一个刚毕业的设计驱动的,属于是啥也不通,导致产品逻辑也不对,写出来之后疯狂打补丁,最后也还是啥也不是。
- 后面做了设计社区,都是一些简单的ui和业务逻辑,因为是一个设计编辑器平台
- 在写了几个后台之后我逐渐熟悉了公司开发技术栈和后台开发逻辑,最早这个公司吸引我的是编辑器,后面我就申请去做了编辑器
- 最开始是编辑器的字体加载、和一些创新的业务需求、设计工具中不同模式的数据结构复用、画布设置【缩放、背景等等】、对齐至像素网格
成长
- 技术上,从 Vue 转到 React 后,逐步掌握了 React 的核心理念和生态工具,像状态管理、组件设计、性能优化这些都有了比较实战的理解;
- 项目上,能独立负责模块开发,也逐步学会了和产品、设计磨合需求;
- 心态上,从一开始觉得“自己不会”到现在敢主动挑任务、遇到新东西也不怕折腾,整体自驱力和解决问题的能力都提升了不少。
- 这时候还是需要拿着电脑到处抓人问问题,非常感谢当时给予我帮助的小伙伴
2023年
基本情况
这个阶段已经开始逐步适应工作生活了,但是还是会畏惧需求,因为还有很多没接触过的东西,依然还是疯狂加班,不光是需求,还有自己学习一些东西,这时候我已经切换到了编辑器领域,其中涉及很多图形学知识,比如坐标转换、矩阵运算、渲染原理、渲染库的使用等等,因为公司里的人几乎不写文档,代码也是anyscript,并且这时候ai还没有,编辑器这个领域,网上几乎搜不到什么文档啥的,只能生啃代码和类型文件并尝试demo,这时候用的就是最笨却最有效的方法,做到哪就把哪个模块整理一篇文档记录下来,慢慢的也就掌握了项目的大部分逻辑和架构思路
项目经历
- 设计工具中不同模式的数据结构复用,一个创新业务需求,使用同一套数据,快捷的进行模式切换,非常巨大的需求,正是因为这个需求让我学会了很多东西,开始能独立负责一个模块的开发
- 白板项目:其中做了节点数据结构定义【便签、图形文本、连接线等等】、节点树的处理、节点的渲染、项目框架搭建、画布设置、缩放、属性控制、属性工具条、性能优化等等,几乎所有功能都写了一部分,这个项目是我从0-1经历的一个项目,历时10个月,早期有很多人,后来因团队调整,部分成员转入新项目,剩余的人员就非常的少了,我是其中之一。
- 这一年我几乎都在做白板项目,其中连接线是最难的,我写了其中的一部分,还因此挨了一批,给我上了社会上的第一堂课【能力不足就得背锅】
成长
- 编辑器初步入门:理解了编辑器的基本架构 & 实现原理,如模块划分、碰撞检测、矩阵运算原理、渲染库的基本使用、复制粘贴的实现原理、svg的解析原理等等、数据节点定义、节点树的使用等等
- canvas、svg、渲染库的基本使用 & 原理
- 学到了很多调试技巧、看了很多的技术文章
- 能够独立负责一个模块的开发和推进
- 自己开始从0-1写一些项目,23年主要是一个工程化的项目,因为当时公司没有脚手架,每次创建新项目都要配置一遍,我就想写一个脚手架来用,就开始探究一些前端工程化的东西,这里主要是一个前端工程化的demo
2024年
基本情况
这时候已经对编辑器开发很熟练了,能够自己调研、处理一些模块,不会畏惧需求,拿到什么都能做也敢做,再加上ai的爆发,现在很轻松就能应付日常需求,还有一些时间开发自己的东西。这时候我几乎不用在追着别人问了,开始探究一些技术上的原理,看了react的源码,了解了react的运行原理,我最初看这个的驱动是因为我想看懂编辑器底层的数据结构和自定义渲染器的实现原理,因为是相通的。
项目经历
- 多模式切换的设计工具,在23年双模式切换有一个比较客观的数据基础上,想要开发多模式切换
- 编辑器重构:真真正正的从0-1实现编辑器,学到了非常多的东西
成长
- 这个阶段成长是最快的,在之前自己主动学习和开发过程中的积累,达到了量变到质变的过程,再加上重构过程中全是实实在在的技术类需求,这个阶段开发的非常爽,学到了非常多的东西,一度让我觉得我们也能做出一个超级nb的东西
- 对编辑器的整体实现理解更加深入,对大型项目的推进和管理有一些了解
- 对于代码质量,代码风格,团队管理,团队交流都有更深的体会
2025年
基本情况
进入新团队后,开始参与 AI 方向的生成类产品开发。虽然整体技术难度相比之前的编辑器项目要低一些,但节奏非常快,很多需求都是边想边做,对响应能力和落地能力要求更高。项目整体强调快速迭代、快速验证,很多时候从 0 到上线都只有几天时间。
项目经历
- 核心流程重构:参与整个生成流程的梳理和重构,数据结构的重设计,提升了整体稳定性和扩展性。
- AI 创新功能研发:参与多个创意方向的原型开发,包括智能组件、AI 引导式操作流程等,既需要理解前端,也需要深入 AI 接口的能力边界。
- 常规前端需求:除了核心 AI 功能,也参与大量 Web 端页面开发、交互优化、组件抽象等日常前端需求的处理。
- 跨角色协作:与产品、模型工程师保持密切沟通,协助设计 prompt、测试接口,探索“产品-模型-前端”的协作流程,理解 prompt 工程的基本逻辑。
成长
- AI 与前端的深度结合:理解 AI 接口的调用逻辑、数据结构设计、模型能力边界,并在多个场景中尝试 prompt 调优、token 限制控制、输出结构稳定性等关键问题。
- 需求落地能力提升:现在不管接到什么需求,都能独立完成从调研、设计、开发、联调到交付的完整流程,并且具备识别风险、提前发现问题并推动解决的能力。
- 项目推进与优化意识:开始更关注整体产品的合理性和可维护性,不再仅仅关注功能实现,会主动提出重构建议、设计优化点、体验提升方案。
- 应变与协作能力加强:面对快节奏、多变的需求场景,能够保持清晰的优先级判断
- 干了很多新鲜的东西,还挺好玩的,ai接入支付、ai表单等等。
我自己写的项目
就这几个,其他的几乎都是一些不成型的demo,还有几乎都在写公司的项目
- nextjs-blog:一个用nextjs写的高聚合的全栈博客,还在持续更新,有时候有点犯懒,在线访问
- handwriting_js:没事写点手写题,写了忘,忘了写,一个也记不住
- react_demo :前端工程化demo,配合这个专栏食用最佳
- debug_react:18.2.0的react源码调试环境
- debug_webpack: webpack5源码调试环境
现在我是怎么想的
- 快速学习的能力比技术能力更值得培养
技术更新太快了,几乎每一年都会冒出一堆新概念、新框架、新工具,追是追不完的。相比“掌握某个技术”,更在意的是“有没有能力快速理解它、上手它、找到它的边界”,这是一个更本质的能力。 - 技术是为业务服务的,但我们也要有自己的判断和坚持
做久了之后会发现,代码写得好不好,有时候并不是最关键的,能不能解决问题、把事情落地才是关键。谁也不想变成“只写业务”的人——在完成需求的同时,尽可能把事情做得优雅些,至少对得起自己的审美和标准。 - 保持初心
有时候项目节奏快、需求不讲理、上线压力大,很容易被“搞完就行”的心态裹挟,还是需要保持自己的思考 - 身体是革命的本钱!!!!
24年7月份左右,体检,发现小毛病快20项,全是久坐,熬夜搞出来的小毛病,可能因为之前加班太多了,也不运动,在7-11月胖了20多斤,并且整日没精打采,身体没有力气,像是气血亏虚一样,我去看了中医,开了一些调养的中药,12月底才开始有好转,我找了私教,去健身房开始健身,大半年了现在就好多了,增肌也小成 - 培养工作之外的兴趣爱好
健身、摄影、钓鱼都可以,长时间的工作会使人麻木和疲惫,需要一些工作之外的爱好调和
我还做了一个在线相册,从大佬Innei获取到的思路,但技术栈是不一样的,也比较简单

以后我想干啥
说实话,我现在还没完全想明白这个问题。过去几年我经历了从业务开发,到编辑器、到 AI 项目,接触了很多不同的方向,也成长了不少。但也正因为尝试了很多,现在反而更谨慎,不想轻易贴标签。
我想我还是会继续写代码,但不一定只写代码。我更在意的是:“我做的东西有没有价值?有没有可能改变点什么?”也许未来会往架构方向走,也许会继续在 AI 产品方向深入,也可能有一天突然转向一个完全不同的方向,比如做点属于自己的产品。
我目前能确定的就是三件事:
- 我希望能一直保持学习和探索的状态,不断拓宽认知边界;
- 我希望在一个让我有成长、有挑战感的环境里工作;
- 我希望做的事能让我感到值得,能有一点点“留下痕迹”的感觉。
剩下的,就边走边看吧,继续更新文章,但不会跟之前一样那么频繁,从中学到东西,并且能够帮助别人
来源:juejin.cn/post/7524602914514763819
老板:咱们公司的设备开机,怎么显示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











