✨ 前端实现打字机效果的主流插件推荐
🎯 总结对比
| 插件名 | 体积 | 自定义 | 动画丰富 | 推荐场景 |
|---|---|---|---|---|
| TypeIt | 中 | 很强 | 很丰富 | 高级动画、官网 |
| Typed.js | 小 | 一般 | 常用足够 | 个人/博客/主页 |
| t-writer.js | 中 | 很强 | 丰富 | 多样动画 |
| ityped | 极小 | 一般 | 简单 | 极简、加载快 |
1️⃣ TypeIt(超强大,推荐!)
🎉 特点:高自定义、易用、支持暂停、删除、换行等丰富动画
安装:
npm install typeit
# 或直接用 CDN
简单用法:
<div id="typeit"></div>
<script src="https://cdn.jsdelivr.net/npm/typeit@8.8.3/dist/typeit.min.js"></script>
<script>
new TypeIt("#typeit", {
strings: ["Hello, 掘金!", "我是打字机效果~"],
speed: 100,
breakLines: false,
loop: true
}).go();
</script>
2️⃣ Typed.js(最流行的打字机插件)
🚀 轻量、简单、社区大,支持多字符串轮播
安装:
npm install typed.js
# 或 CDN
用法:
<span id="typed"></span>
<script src="https://cdn.jsdelivr.net/npm/typed.js@2.0.12"></script>
<script>
new Typed('#typed', {
strings: ['欢迎来到掘金!', '一起学习前端吧~'],
typeSpeed: 80,
backSpeed: 40,
loop: true
});
</script>
3️⃣ t-writer.js(国人开发,API友好)
🔥 支持多种打字动画,API 设计简洁
安装:
npm install t-writer.js
用法:
<div id="twriter"></div>
<script src="https://cdn.jsdelivr.net/npm/t-writer.js/dist/t-writer.min.js"></script>
<script>
const target = document.getElementById('twriter');
const writer = new TypeWriter(target, {
loop: true,
typeColor: 'blue'
})
writer
.type('你好,掘金!')
.rest(500)
.changeOps({ typeColor: 'orange' })
.type('打字机效果轻松实现~')
.rest(1000)
.clear()
.start()
</script>
4️⃣ ityped(极简小巧)
⚡️ 零依赖,体积小,适合极简需求
安装:
npm install ityped
用法:
<div id="ityped"></div>
<script src="https://unpkg.com/ityped@1.0.3"></script>
<script>
ityped.init(document.querySelector("#ityped"), {
strings: ['Hello 掘金', '前端打字机效果'],
loop: true
})
</script>
🛠️ 补充:用原生 JS 实现简单打字效果
如果你不想引入第三方库,也可以用 setTimeout/async 实现基础打字动画:
<div id="simpleType"></div>
<script>
const text = "Hello, 掘金!这是原生JS打字机效果~";
let i = 0;
function typing() {
if (i < text.length) {
document.getElementById('simpleType').innerHTML += text[i];
i++;
setTimeout(typing, 100);
}
}
typing();
</script>
🌟 结语
- 需要高级动画,选 TypeIt/t-writer.js
- 需要轻量简单,选 Typed.js/ityped
- 只需基础效果,也可以原生 JS 10 行搞定!
来源:juejin.cn/post/7497801626670546984
断网、弱网、关页都不怕:前端日志上报怎么做到不丢包
系列回顾(性能·错误·埋点三部曲):不做工具人|从 0 到 1 手搓前端监控 SDK
在前三篇文章中,我们搞定了性能、行为和错误的采集。但有掘友在评论区灵魂发问:“数据是抓到了,
发不出去有啥用?进电梯断网了咋办?页面关太快请求被掐了咋办?”
今天这篇,我们就来聊聊如何上报数据?
- 用什么方式上报最稳、最省事?
- 什么时候上报最合适?
- 遇到断网/弱网/关页怎么兜底?
一、上报方式与策略:如何选出最优解?
我们平时上报数据主要有三种方式:Image(图片请求)、sendBeacon 和 XHR/Fetch。
1. 三种上报方式详解
1. GIF/Image
这招就是利用图片请求(new Image().src)来传数据。
- 原理很简单:把要上报的数据拼在 URL 后面(如
https://log.demo.com/gif?id=123),浏览器发起请求,服务器解析参数拿到数据,然后返回一张 1×1 的透明GIF图(体积小、看不见),浏览器收到后触发onload回调即完成上报。 - 特点:天然支持跨域,绝无“预检”请求(因为是简单请求)。
- 局限:只能发 GET 请求,URL 长度有限(通常 < 2KB),无法携带大数据。
2. sendBeacon
- 原理:
navigator.sendBeacon(url, data)。浏览器会将数据放入后台队列,即使页面关闭了,浏览器也会尽力发送。 - 特点:异步非阻塞(不卡主线程),且可靠性极高。
- 局限:数据量有限(约 64KB),且无法自定义复杂的请求头。
3. XHR / Fetch
普通的网络请求。
- 原理:使用
XMLHttpRequest或fetch发送 POST 请求。 - 特点:容量极大(几兆都没问题),适合发送录屏、长堆栈。
- 局限:跨域时通常会触发
OPTIONS预检(成本高),且页面关闭时请求容易被掐断(fetch需配合keepalive)。
注: 所谓的预检,就是浏览器在发送跨域且非简单请求前,先偷偷发个 OPTIONS 问服务器:“大佬,我能发这个请求吗?”。只要你用了自定义 Header 或 application/json 就会触发。这会导致请求量直接翻倍,在弱网下多一次往返就多一分失败的风险。
2. 策略篇:如何组合使用?
怎么选并不是随意决定的,而是为了解决两个核心痛点:
- 成本问题(CORS 预检):所谓的预检,就是浏览器在发送跨域且非简单请求前,先偷偷发个
OPTIONS问服务器:“大佬,我能发这个请求吗?”。
- 什么时候触发? 只要你用了自定义 Header(如
X-Token)或者Content-Type: application/json,就会触发。 - 后果是啥? 请求量直接翻倍,弱网下成功率腰斩。
- 避坑指南:这也是为什么很多监控SDK通常都故意使用
text/plain来发送 JSON 数据。虽然数据格式是 JSON,但告诉浏览器“这是纯文本”,就能骗过预检,直接发送!
- 什么时候触发? 只要你用了自定义 Header(如
- 存活问题(页面卸载):用户关闭页面时,浏览器通常会直接掐断挂起的异步请求,导致“临终遗言”发不出去。
基于这两个维度,我们将三种方式排个序,也就形成了我们的降级策略:
1. 首选方案:sendBeacon(六边形战士)
这是现代浏览器的首选方案。
- 优势:专为监控设计,页面关闭了也能发(浏览器将其放入后台队列)。
- 特点:容量适中(~64KB),且通常不触发预检,完美平衡了“存活”与“成本”。
- 适用:绝大多数监控事件。
2. 降级方案:GIF/Image(老牌救星)
当 sendBeacon 不可用(如 IE)或数据极小的时候用它。
- 优势:天然跨域,绝无预检。利用
new Image().src发起请求,服务器返回一张 1x1 透明图即可。 - 特点:兼容性无敌,但数据量受 URL 长度限制(~2KB),且页面关闭时发送成功率低。
- 适用:PV、点击、心跳等轻量指标。
3. 兜底方案:XHR / Fetch
只有前两招搞不定时(数据量太大)才用它。
- 优势:容量极大,适合传录屏、大段错误堆栈。
- 劣势:跨域麻烦(需配 CORS),有预检成本。
- 注意:使用 Fetch 时务必加
keepalive: true,告诉浏览器“就算页面关了也别杀我”,尽量提升卸载时的成功率。
选型对比表
| 方案 | 跨域/预检 | 卸载可靠性 | 数据容量 | 核心优势 | 适用场景 |
|---|---|---|---|---|---|
| sendBeacon | 支持 / 无预检 | 高 | 中 (~64KB) | 关页也能发,不占主线程 | 首选,大多数监控事件 |
| GIF/Image | 支持 / 无预检 | 低 | 小 (~2KB) | 兼容性强,无预检 | 降级方案,PV/点击/心跳 |
| XHR/Fetch | 需 CORS / 有 | 低 | 大 | 能传大数据 | 错误堆栈、录屏 |
总结我们的代码套路(降级策略):
- 小包(< 2KB,单条事件):优先
sendBeacon;若不支持,再走ImageGET(附_ts防缓存)。 - 中包(≤ 64KB):
sendBeacon为首选;若不支持,回退到Fetch/XHR,Content-Type: text/plain+keepalive: true。 - 大包(> 64KB):
Fetch/XHR承载,必要时拆包分批发送。
下面是封装好的 transport上报函数,直接拿去用:
const REPORT_URL = 'https://log.your-domain.com/collect';
const MAX_URL_LENGTH = 2048;
const MAX_BEACON_BYTES = 64 * 1024;
function byteLen(s) {
try {
return new TextEncoder().encode(s).length;
} catch (e) {
return s.length;
}
}
/**
* 通用上报函数
* @param {Object|Array} data - 上报数据
* @returns {Promise<void>} - 成功 resolve,失败 reject
*/
function transport(data) {
const isArray = Array.isArray(data);
const json = JSON.stringify(data);
return new Promise((resolve, reject) => {
// 1. 优先尝试 sendBeacon
// 注意:sendBeacon 是同步入队,返回 true 仅代表入队成功,不一定是发送成功
if (navigator.sendBeacon && byteLen(json) <= MAX_BEACON_BYTES) {
const blob = new Blob([json], { type: 'text/plain' });
// 如果入队成功,直接 resolve(乐观策略)
if (navigator.sendBeacon(REPORT_URL, blob)) {
resolve();
return;
}
// 如果入队失败(如队列已满),不 reject,而是继续往下走降级方案
console.warn('[Beacon] 入队失败,尝试降级...');
}
// 2. 单条小数据尝试 Image (GET)
if (!isArray) {
const params = new URLSearchParams(data);
params.append('_ts', String(Date.now()));
const qs = params.toString();
const sep = REPORT_URL.includes('?') ? '&' : '?';
if (REPORT_URL.length + sep.length + qs.length < MAX_URL_LENGTH) {
const img = new Image();
img.onload = () => resolve(); // 成功
img.onerror = () => reject(new Error('Image 上报失败')); // 失败
img.src = REPORT_URL + sep + qs;
return;
}
}
// 3. 兜底方案:Fetch > XHR
if (window.fetch) {
fetch(REPORT_URL, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: json,
keepalive: true, // 关键:允许页面关闭后继续发送
})
.then((res) => {
if (res.ok) resolve();
else reject(new Error(`Fetch 失败: ${res.status}`));
})
.catch(reject);
} else {
// IE 兼容
const xhr = new XMLHttpRequest();
xhr.open('POST', REPORT_URL, true);
xhr.setRequestHeader('Content-Type', 'text/plain');
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`XHR 失败: ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('XHR 网络错误'));
xhr.send(json);
}
});
}
二、上报时机:不阻塞主线程干扰业务,断网了也不丢数据
1. 调度层:区分优先级,关键时刻不等待
不是所有数据都适合“攒着发”。我们需要根据重要程度将日志分为两类:
- 即时上报(Immediate):收集到立即上报。
- 场景:JS 报错阻断了流程、用户点击了“支付”按钮、接口返回 500 等。
- 原因:这些数据对实时性要求极高,或者关系到监控系统的报警(比如线上白屏了,你得马上知道),不能因为攒着发而耽误了。
- 批量上报(Batch):攒一波再发。
- 场景:用户点击、滚动、性能指标、API 成功日志。这类数据量大但实时性要求低
- 策略:“量”与“时”双重触发(竞态关系)。比如:攒够 10 条立马发(防止堆积太多),或者每隔 5 秒发一次(防止数量不够一直不发)。
代码怎么写?其实就是一个简单的双保险调度器:
let queue = [];
let timer = null;
const QUEUE_MAX = 10;
const QUEUE_WAIT = 5000;
function flush() {
if (!queue.length) return;
// 1. 把当前队列的数据复制出来
const batch = queue.slice();
// 2. 清空队列与定时器
queue.length = 0;
clearTimeout(timer);
timer = null;
// 3. 利用空闲时间发送(性能优化点)
if ('requestIdleCallback' in window) {
requestIdleCallback(() => transport(batch), { timeout: 2000 });
} else {
// 降级兼容
setTimeout(() => transport(batch), 0);
}
}
function report(log, immediate = false) {
// 1. 紧急情况:绕过队列,直接发
if (immediate) {
transport(log);
return;
}
// 2. 普通情况:进入队列(如 点击、PV)
queue.push({ ...log, ts: Date.now() });
// 3. 检查触发条件(双重保险)
if (queue.length >= QUEUE_MAX) {
flush();
} else if (!timer) {
timer = setTimeout(flush, QUEUE_WAIT);
}
}
// 4. 临终兜底:页面关闭/隐藏时,强制把剩下的都发走
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') flush();
});
window.addEventListener('pagehide', flush);
整体思路:队列暂存 + 多重触发
我们用一个数组(queue)来暂存日志,然后通过 “量够了”、“时间到了”或“页面要关了” 这三个时机来触发发送,确保既不积压也不频繁打扰服务器。
性能优化:闲时优先
发送时,我们首选 requestIdleCallback。告诉浏览器你先忙你的(渲染、响应点击),等你有空了再帮我发监控数据
- 这样能最大限度减少对业务主线程的阻塞,让用户感觉不到监控的存在。
- 当然,如果浏览器不支持这个 API,我们再降级用
setTimeout兜底。
2. 容灾层:断网了,日志怎么办?
如果在电梯里断网了或者弱网环境下,请求发不出去怎么办?日志丢了怎么办。
我们的策略是 “先记在本子上,等有网了再补交作业”:
- 断网时:把日志存到
localStorage里(注意设置上限,别把用户浏览器撑爆了,可用IndexedDB优化)。 - 连网时:监听
online事件,把存的日志拿出来,分批发给服务器(别一次性全发过去,容易把后端打挂)。
具体怎么判断有没有网呢?
通常我们用 navigator.onLine 来看。如果返回值是 false ,那肯定是没网,直接存本地。
但坑就坑在,这玩意儿有时候会 “撒谎” —— 比如连上了酒店 WiFi 但没登录,或者宽带欠费了。这时候它虽然显示 true (在线),但其实根本上不了网。
所以咱们得留一手:
哪怕它说“在线”,我们也先试着上报一下。 要是报错了发不出去,别管三七二十一,先把这条日志存本地保底(千万别丢数据),然后再去 Ping 一下看看到底是不是真断网了 ,顺便更新一下网络状态。这样最稳。
1. 网络状态的检测
NetworkManager这个模块专门负责盯着网络,它很聪明,只有在发送日志失败的时候才会去复核网络真伪。
const NetworkManager = {
online: navigator.onLine,
// 初始化:盯着系统的 online/offline 事件
init(onBackOnline) {
window.addEventListener('online', async () => {
// 别高兴太早,先看看是不是真的能上网
const realWait = await this.verify();
if (realWait) {
this.online = true;
onBackOnline(); // 真的回网了,赶紧补传!
}
});
window.addEventListener('offline', () => this.online = false);
},
// “测谎仪”:发个 HEAD 请求看看
async verify() {
try {
// 请求个 favicon 或者 1x1 图片,只要响应了说明网通了
await fetch('/favicon.ico', { method: 'HEAD', cache: 'no-store' });
return true;
} catch {
return false;
}
}
};
2. 核心上报:能发就发,不行就存本地
上报函数现在变得非常有弹性。
export async function reportData(data) {
// 1. 如果明确知道没网,直接存本地 (省一次请求)
if (!NetworkManager.online) {
saveToLocal(data);
return;
}
// 2. 尝试发送
try {
await transport(data);
} catch (err) {
console.error('上报请求失败:', err);
// 3. 不管是因为断网、超时、还是服务器挂了
// 只要没成功,第一件事就是存本地!保证这条日志不丢!
saveToLocal(data);
// 4. 然后再来诊断网络,决定后续策略
// 只有当是网络层面的错误(如 fetch throw Error)才去怀疑网络
// 如果是 500 错误,其实网是通的,不用 forceOffline
if (isNetworkError(err)) {
// 5. Ping 确认
NetworkManager.verify().then(res => NetworkManager.online = res);
}
}
}
/**
* 判断是否为网络层面的错误
*/
function isNetworkError(err) {
// 原生 fetch 的网络错误通常是 TypeError: Failed to fetch
// 如果是使用 Axios,则可以通过 !err.response 来判断
return err instanceof TypeError || (err.request && !err.response);
}
const RETRY_KEY = 'RETRY_LOGS';
const RETRY_MAX_ITEMS = 1000;
function saveToLocal(data) {
const raws = localStorage.getItem(RETRY_KEY);
const logs = raws ? JSON.parse(raws) : [];
logs.push(data);
if (logs.length > RETRY_MAX_ITEMS) {
logs.splice(0, logs.length - RETRY_MAX_ITEMS);
}
localStorage.setItem(RETRY_KEY, JSON.stringify(logs));
}
3. 补传逻辑:别把服务器干崩了
等到网络恢复,本地攒了一堆“欠账”,千万别一股脑儿全发过去(万一本地存了 500 条,一次全发会把服务器打爆的)。
我们要有节奏地补传:
async function flushLogs() {
let logs = JSON.parse(localStorage.getItem('RETRY_LOGS') || '[]');
if (!logs.length) return;
console.log(`[回血] 发现 ${logs.length} 条欠账,开始补传...`);
while (logs.length > 0) {
// 1. 每次只取 5 条,小碎步走
const batch = logs.slice(0, 5);
try {
// 2. 调用上报中心
await transport(batch);
// 3. 只有成功了,才把这 5 条从 logs 里剔除
logs.splice(0, 5);
localStorage.setItem(RETRY_LOGS, JSON.stringify(logs));
} catch (err) {
// 4. 如果失败了(断网或服务器挂了)
// 此时 logs 里面还保留着那 5 条数据,所以不用担心丢失
// 记录一下状态,直接跳出循环,等下次 NetworkManager 唤醒
console.error('补传中途失败,保留剩余欠账');
break;
}
// 2. 歇半秒钟,给正常业务请求让个道
await new Promise(r => setTimeout(r, 500));
}
}
三、总结与实战建议
监控上报这事儿看着不难,其实门道不少。要在数据不丢和不打扰用户之间找平衡,咱们得来一套“组合拳”:
- 上报方式:sendBeacon 为主,Image 为辅,XHR/Fetch 兜底。利用
sendBeacon的特性解决页面卸载时的丢包问题,利用Image解决跨域预检的成本问题。 - 上报时机:闲时上报 + 批量打包。利用
requestIdleCallback不占用主线程,通过队列机制减少 HTTP 请求频次。 - 断网处理:本地缓存 + 网络侦测。断网时将数据持久化到 LocalStorage,待网络恢复后分批补传,确保“一条都不丢”。
最后,给开发者的 3 个避坑小贴士:
- 不要迷信
navigator.onLine:它只能判断有没有连接到局域网,不能判断是否真的能上网。一定要配合实际的请求探测。 - 控制补传节奏:网络恢复后,千万别一次性把积压的几百条日志全发出去,这属于“DDoS 攻击”自家服务器。要分批、甚至加随机延迟发送。
- 隐私与合规:上报数据前,务必对敏感信息(如 Token、用户手机号)进行脱敏处理,这是红线。
如果你有更好的思路,欢迎在评论区交流!
来源:juejin.cn/post/7596247009815412762
AI驱动的大前端开发工作流
在日常的大前端需求开发中,我们常常需要同时兼顾UI还原和业务逻辑两部分工作。UI方面,就是要尽可能细致地还原设计稿上的每个细节;业务逻辑方面,则往往和需求复杂度以及项目代码规模正相关。今天,我们就来聊聊如何利用AI驱动,提升需求实现过程中的效率和体验。
UI设计稿还原
如今市面上已经有不少做得不错的AI设计稿转代码工具,比如v0、bolt.new、codefun等。对于一些个人独立项目来说,这些工具真心牛逼:只需传入设计稿,就可以快速生成模块化、精确的UI代码,而且还能通过对话式的交互来不断调整细节,直到完全符合预期。
但如果把这些工具直接应用于一个成熟项目中,就会暴露一些问题。首先,目前大部分工具主要支持vue和react,而我们的项目往往还涉及flutter、android、iOS等多端开发。其次,成熟项目中往往都有一套独特的代码规范和UI组件库,而这些工具生成的代码往往并不了解这些细节,直接将生成的代码拷贝进项目之后还得二次调整,额外的成本不可忽视。
那么有没有办法既能支持更多编程语言,又能在生成UI代码时就结合项目中已有的规范和组件库,从而减少二次调整成本呢?答案是有的!
目前,figma是市面上使用比较主流的设计稿工具。Builder.io最近发布了一款基于figma设计稿、AI驱动的前端代码生成插件——Visual Copilot。使用它,你只需要把figma切换到Dev Mode,然后选择设计稿中的任意图层,接着在Visual Copilot中直接导出代码。

Visual Copilot生成的代码不仅支持vue、react,还涵盖了其它几乎所有主流的大前端语言和框架,十分全面。你可以实时预览生成的效果,并进行细节调整,直到满意为止。

生成代码后,如何让这些代码完美融入到我们的项目规范中呢? 这里就需要结合Visual Copilot与Cursor的配合来实现。
具体做法是:当Visual Copilot将设计稿图层转化成代码后,它会自动生成一个可远程执行的工作空间,并提供相关命令(图中红框所示)将工作空间的代码集成到我们项目中。 
接下来,我们只需在Cursor的Terminal中运行指定指令,就能连接Visual Copilot的远程工作空间,实时获取生成的代码,同时通过交互指令界面明确我们后续的需求。

那么,最终生成的代码如何与项目中的各种规范结合呢? 这就要用到Cursor的AI规则机制——CusorRules。你只需在项目根目录下配置一个.cursorrules文件,声明你的UI代码规范以及封装的组件信息。Cursor在生成代码时就会充分参照这些规则。同时,新版Cursor还支持配置一系列rule文件,你可以通过正则路径来指定规则应用于不同的模块或文件类型,整个过程非常灵活高效(具体可以参与Cursor官方文档)。

业务逻辑开发
在一些中小型规模的项目中,如果想在Cursor中高效地实现业务逻辑,我们需要关注两个关键点:
- CursorRule的应用
和上文UI的规范梳理类似,我们还需要对项目各模块的架构规范等信息进行说明,这样可以让Curosr生成的代码能尽量保障和我们项目规范的一致性。 - 需求拆解:先构建框架,再处理细节
对于较为复杂的需求,我们可以先把实际需求拆解成多个阶段。首先,构建大致的需求框架,并转换成一系列对Cursor友好的Prompt指令(要求简单、准确)。在Cursor的Compose Agent模式下,通过这些指令生成整体的业务逻辑框架。接下来,再利用Cursor Tab在编辑器区域快速完善那些生成时不够精准的细微逻辑。
在一些中小型项目中,通过以上两个关键点我们通常可以快速完成业务逻辑开发。但在一些大型项目中,会遇到Cursor生成的代码经常不尽如人意的问题。这主要是因为项目越大,整个代码库的复杂性增加,Cursor对项目的理解难度也随之上升,每次修改都可能会有不确定因素,提示词如果不能准确描述需要改动的部分,就容易出错。
此时,我们可以尝试另一种思路:用”软件架构师“与”开发者“角色区分需求规划与执行细节。应该怎么做呢?
我们可以将AI驱动的开发流程分为两个阶段:
- 软件架构师角色
这个角色负责对需求进行高层次的分析和解决方案设计,帮助我们总结、提炼和润色需求中的关键信息,同时生成说明性的提示词。这些提示词会告诉我们:这个需求需要创建哪些文件、修改哪些文件、如何做修改等等。 - 开发者角色
开发者则负责把架构师给出的高层次解决方案转化为具体代码。也就是说,开发者依据架构师生成的详细提示词来生成或修改具体文件,从而实现精确的改动,避免大范围理解失误带来的问题。
这种方式的好处在于,只要“软件架构师”生成的提示词足够精准(即它能详细说明需要修改的文件、具体的改动内容和改动范围),那么“开发者”便能够依照这些提示词精确地进行代码修改,极大降低了因大范围理解产生的错误风险。所以这里的性能瓶颈就在于如何定义”软件架构师“角色以及让其接到需求后能生成精确的提示词。
在实际操作中,我们引入了一个“项目地图”的概念。所谓项目地图,就是为大型项目构建一个完整的文档体系(借助AI辅助生成也完全可行),其中包括了项目架构设计、开发流程、模块划分与用途、文件名和其功能说明等内容。这套文档体系可以独立于一个实际的Cursor项目存在,充当“软件架构师”的角色。也就是说,当遇到bug或新需求时,我们可以通过咨询这个“项目地图”,让AI回答问题并给出相对准确的修改思路。
举个例子,拿知名的fast-api项目来说,我为它生成了一个项目地图,其中包含了核心概念说明、系统设计、项目规范以及各模块和文件的用途说明等内容:

同时,在该项目的 .cursorrule 文件中,我详细规定了“软件架构师”如何根据实际需求生成精确的修改指令:
## 项目背景信息
项目名称:FastAPI
项目类型:Python Web 框架
项目地图:参考 fileNames.md
架构文档:参考 architecture/ 目录
编码规范:参考 guidelines/coding-standards.md
## 需求分析模板
1. 需求描述
[简要描述需要实现的功能或修改]
2. 涉及组件
- 核心组件:[列出受影响的核心组件]
- 依赖组件:[列出相关的依赖组件]
- 测试组件:[列出需要修改的测试]
3. 修改范围
- 主要文件:[列出需要修改的主要文件]
- 次要文件:[列出可能需要修改的次要文件]
- 文档文件:[列出需要更新的文档]
4. 技术要点
- 使用的框架特性:[列出需要使用的 FastAPI 特性]
- 数据验证:[描述数据验证要求]
- 兼容性考虑:[描述向后兼容性要求]
5. 潜在风险
[列出可能的风险点和注意事项]
## 执行指导模板
### 给大模型的执行指导
1. 修改步骤
[详细的步骤说明]
2. 验证点
[列出需要验证的关键点]
4. 测试建议
[提供测试建议和用例]
## 实际案例:添加用户电话号码字段
### 1. 需求分析
需求描述:
在用户模型中添加可选的 phone_number 字段,并在相关 API 端点中支持该字段。
涉及组件:
- 核心组件:用户模型(UserIn, UserOut, UserInDB)
- 依赖组件:无
- 测试组件:用户相关测试
修改范围:
- 主要文件:/docs_src/extra_models/tutorial001.py
- 次要文件:无
- 文档文件:API 文档可能需要更新
技术要点:
- 使用 Pydantic BaseModel
- 字段类型:Union[str, ]
- 保持向后兼容性
潜在风险:
- 确保不破坏现有的数据验证
- 保持与现有字段风格一致
### 2. 执行指导
给大模型的具体修改指导:
1. 修改步骤:
a. 在 UserIn 模型中添加 phone_number 字段
b. 在 UserOut 模型中添加对应字段
c. 在 UserInDB 模型中添加对应字段
d. 确保字段定义与 full_name 保持一致的风格
2. 验证点:
- 字段是可选的
- 类型提示正确
- 与现有代码风格一致
- 不影响现有功能
3. 测试建议:
- 测试不提供电话号码的情况
- 测试提供有效电话号码的情况
- 测试提供无效数据的情况
## 使用说明
1. **分析阶段**
- 使用项目背景信息了解上下文
- 按需求分析模板进行分析
- 生成详细的执行指导
2. **交付阶段**
- 将执行指导传递给大模型
- 确保指导清晰且可执行
- 包含必要的验证点
3. **验证阶段**
- 检查实施结果
- 确认所有要求都已满足
- 验证代码质量和一致性
4. **输出**
- 输出执行指导,包括修改步骤、验证点和测试建议,新建analysis目录,在该目录下用一个独立的文件承载,文件名要求符合问题描述诉求<cursor-tools Integration>
# Instructions
基于这些配置,当提交需求时,AI生成的修改思路大致如下,它会精准指出应对哪些文件进行哪些具体改动。
# 需求分析
## 1. 需求描述
在高并发场景下,针对大量用户同时请求数据库导致的性能瓶颈,需要在应用程序中增加缓存机制,降低数据库查询次数,提升整体响应速度和系统稳定性。
## 2. 涉及组件
- 核心组件:数据查询相关业务逻辑(可能涉及CRUD操作的函数或类)
- 依赖组件:可能需要引入第三方缓存库(如 redis、in‐memory 缓存等)
- 测试组件:与数据库查询逻辑相关的测试用例
## 3. 修改范围
- 主要文件:核心业务逻辑所在模块(例如:/app/database.py 或 /app/services/xxx.py)
- 次要文件:如果需要封装缓存工具,则可能新增 /app/utils/cache.py
- 文档文件:如果提供额外的缓存配置,需要更新相关文档
## 4. 技术要点
- FastAPI 配合缓存解决方案(如 in‐memory 缓存、Redis、memcached 等)
- 选择合适的缓存失效策略(LRU、TTL、手动失效等)
- 线程安全、进程安全或分布式缓存方案
- 保证与现有数据校验和返回结构的一致性
## 5. 潜在风险
- 引入缓存后数据一致性问题(可能出现短时间的数据延迟)
- 缓存过期策略不当导致性能问题或脏数据
- 多节点部署时的缓存同步与一致性
- 第三方库的稳定性和可维护性
---
# 执行指导
## 1. 修改步骤
1. 确定缓存方案(例如:Redis 或者内存缓存),并根据业务需求配置失效策略。
2. 在核心数据库查询逻辑中添加缓存查询逻辑:
- 查询前先在缓存中查找数据;
- 如果未命中或缓存过期,则查询数据库并将结果写入缓存;
- 如果命中,则直接返回缓存结果。
3. 为关键业务接口添加缓存逻辑(可封装成一个装饰器,或在查询函数内部实现)。
4. 在配置文件或环境变量中添加缓存相关配置,如主机、端口、缓存大小、过期时间等。
5. 编写或修改测试用例,确保在开启缓存后仍能正确测试业务逻辑。
## 2. 验证点
- 并发请求多时,数据库查询数量显著减少
- 当缓存命中时,响应速度显著提升
- 缓存失效策略(TTL 等)按预期生效
- 高并发情况下是否存在数据不一致或缓存击穿/雪崩问题
## 3. 测试建议
- 正常请求:依次验证在缓存未命中和命中时的响应时间与结果正确性
- 并发请求:使用压力测试工具(locust、JMeter 等)模拟大量请求并观察数据库查询次数与响应时间
- 失效测试:设置短 TTL 并观察缓存自动失效后对系统性能的影响
- 异常测试:故意使缓存服务不可用或网络异常,验证系统能否正常回退到直接查询数据库
随后,我们再将这些详细的提示应用到原项目中,通过AI进一步生成或补全代码,最终大大提高了开发效率和代码准确性。只要前期对项目地图、架构角色和开发规则进行充分准备,我们就能充分借助AI,把原本耗时、易出错的开发流程变得高效且精准。
结语
显而易见,尽管上文主要探讨了大前端领域的AI工作流,但这种思路其实完全可以迁移到其他开发领域。只要我们不断尝试和实践,总结出符合自身业务特点的AI工作模式,就能极大提升我们的工作效率。无论是前端、后端,还是其他技术领域,AI驱动的开发流程都能帮助我们更加精准、高效地解决各类需求和问题。
总之,拥抱AI技术,不断优化工作流程,是我们应对快速变化、不断增长的项目复杂度的关键所在。未来,随着技术的进一步成熟和实践经验的积累,我们必将迎来一个更智能、更高效的开发时代。
来源:juejin.cn/post/7474100684374769698
前端人必懂的浏览器指纹:不止是技术,更是求职加分项
你有没有过这样的经历?
没登录淘宝逛了件卫衣,转头刷抖音、B 站,相似款式的推荐就精准找上门;
或者参与线上投票时,明明没注册账号,却提示 同一用户仅能投一次?
其实这背后藏着一个前端人绕不开的实用技术,浏览器指纹。哪怕你开着无痕模式、频繁切换网络,它依然一样精准识别你,而这门技术,不仅是日常上网的隐形推手,更是前端求职面试中的高频考点
一、浏览器指纹:到底是怎么认出你的?
核心逻辑很简单:世界上没有完全相同的浏览器环境,就像没有两片一模一样的树叶。
浏览器指纹会收集一系列设备和环境特征,再通过算法组合成唯一的 哈希值,这个哈希值就是你的专属 网络标识。这些特征包括但不限于:
- 基础信息:浏览器类型及版本 Chrome、Safari 等、操作系统Windows/macOS 等、屏幕分辨率、系统语言;
- 硬件细节:CPU 核心数、内存大小、显卡型号;
- 高级特征:Canvas 绘图差异 不同设备绘制同一图形,像素级有细微区别、WebGL 渲染信息、已安装字体列表;
- 动态信息:IP 地址 虽可变,但结合其他特征仍有识别价值。
举个直观的例子:Canvas 是 HTML5 的绘图功能,我们用一段简单代码就能提取它的指纹可直接在浏览器控制台运行:
function getCanvasFingerprint() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const text = 'frontend-fingerprint';
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(0, 0, 100, 60);
ctx.fillStyle = '#069';
ctx.fillText(text, 2, 15);
return canvas.toDataURL();
}
function hashFingerprint(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
const dataUrl = getCanvasFingerprint();
const fingerprint = hashFingerprint(dataUrl);
console.log('Canvas指纹结果:', fingerprint);
试着在不同浏览器、甚至不同设备上运行,你会发现每次得到的数值都不一样
这就是浏览器指纹的识别核心。
二、前端人必须掌握的应用场景
浏览器指纹不是 黑科技,而是前端开发、风控、产品设计中高频用到的技术,面试时遇到相关问题,能说清这些场景直接加分:
- 广告精准投放:跨平台识别用户兴趣,比如用户在 A 网站浏览电子产品,在 B 网站就能收到相关广告推送,核心是前端与后端的指纹匹配;
- 防刷防作弊:投票、抢券、秒杀等场景,通过指纹限制同一用户多次操作,前端需负责特征采集与校验逻辑;
- 风控与安全:检测恶意登录、账号盗刷,哪怕黑客换了 IP,浏览器环境特征不变仍能被识别,是前端安全模块的重要知识点;
- 区域限制检测:用 VPN 换 IP 后仍能被识别,就是因为浏览器指纹未变,这也是跨境相关产品的常见需求。
对于前端求职者来说,这些场景不仅是面试高频题,更是实际工作中可能遇到的开发需求。如果能在简历中体现对浏览器指纹的理解,或在面试中清晰拆解实现逻辑,很容易让面试官眼前一亮 ,但很多同学要么不懂核心原理,要么不知道怎么把技术点转化为面试优势。
四、最后说句实在的
浏览器指纹是前端领域的 实用 技术,懂它不仅能解决实际开发问题,更能成为求职路上的加分项。但求职不止是懂技术,更要会 表达技术, 简历怎么写才能脱颖而出?面试怎么说才能打动面试官?这些都需要技巧和经验。
如果你正在为前端求职发愁,想让自己的技术优势被看到,不妨试试我的「前端简历面试辅导」和「前端求职陪跑」服务。从技术亮点提炼到面试答题技巧,从简历优化到 offer 谈判,我会全程帮你针对性提升,让你在众多求职者中脱颖而出,顺利拿下心仪岗位
来源:juejin.cn/post/7592789801708257321
大小仅 1KB!超级好用!计算无敌!
js 原生的数字计算是一个令人头痛的问题,最常见的就是浮点数精度丢失。
// 1. 加减运算
0.1 + 0.2 // 结果:0.30000000000000004(预期 0.3)
0.7 - 0.1 // 结果:0.6000000000000001(预期 0.6)
// 2. 乘法精度偏移
0.1 * 0.2 // 结果:0.020000000000000004(预期 0.02)
3 * 0.3 // 结果:0.8999999999999999(预期 0.9)
// 3. 除法结果异常
0.3 / 0.; // 结果:2.9999999999999996(预期 3)
1.2 / 0.2 // 结果:5.999999999999999(预期 6)
在金额计算的场景中出现这种问题是很危险的,例如「0.1 元 + 0.2 元」本应等于 0.3 元,原生计算却会得出 0.30000000000000004 元,直接导致金额显示错误或支付逻辑异常。
不少人会用toFixed四舍五入,保留 2 位小数来格式化数字,它本质上是 字符串格式化工具,而非精度修复工具,而且还会带来新的精度问题 —— toFixed的四舍五入规则是 “银行家舍入法”,无法解决底层计算的精度误差。
// 问题1. 四舍五入规则不符合预期
1.005.toFixed(2); // 结果:"1.00"(预期 "1.01")
2.005.toFixed(2); // 结果:"2.00"(同样问题)
1.235.toFixed(2); // 结果:"1.23"(预期 "1.24")
// 问题2. 无法修复底层计算误差
const sum = 0.1 + 0.2; // 0.30000000000000004
sum.toFixed(2); // 结果:"0.30"(表面正确,但误差仍存在,后续再运算仍然有问题)
sum.toFixed(10); // 结果:"0.3000000000"(仅隐藏误差,未消除)
而 number-precision 能解决这些问题。
number-precision 的优势在哪?
- 轻量化,大小仅
1kb - API 极简化,只有
加减乘除和四舍五入 - 专注精度问题,无额外心智负担
兼容性好,无额外依赖
适用场景
- 中小型项目、仅需解决基础加减乘除精度问题的场景(如电商、金融类简单计算)
- 对包体积敏感的前端项目。
如何使用?
pnpm install number-precision
import NP from 'number-precision'
NP.strip(0.09999999999999998); // = 0.1
NP.plus(0.1, 0.2); //加法计算 = 0.3, not 0.30000000000000004
NP.plus(2.3, 2.4); //加法计算 = 4.7, not 4.699999999999999
NP.minus(1.0, 0.9); //减法计算 = 0.1, not 0.09999999999999998
NP.times(3, 0.3); //乘法计算 = 0.9, not 0.8999999999999999
NP.times(0.362, 100); //乘法法计算 = 36.2, not 36.199999999999996
NP.divide(1.21, 1.1); //除法计算 = 1.1, not 1.0999999999999999
NP.round(0.105, 2); //四舍五入,保留2位小数 = 0.11, not 0.1
混合的计算:
import NP from 'number-precision'
// (0.8-0.5)x1000,保留2位小数
NP.round(NP.times(NP.minus(0.8, 0.5), 1000), 2)
// 计算股票收益率
NP.round(NP.times(NP.divide(NP.minus(+price, +cost), +cost), 100),2)
更复杂的计算场景用什么
number-precision有短小精悍的优势在,基本的运算都能拿捏,但那些要求更高的计算场景用什么库呢?
总结了目前社区流行的几款计算库,大家按需取用。
| 库 | 特点场景 | 库体积 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|---|
toFixed | 内置方法,仅用于数字格式化,不解决底层精度问题 | 0 | 无需额外引入,使用便捷 | 无法修复计算误差,四舍五入规则非标准 | 非精确场景的临时格式化 |
number-precision | 轻量化,提供加减乘除、四舍五入基础功能,无多余 | 1KB | 体积极小,API 极简,学习成本低 | 不支持超大整数,无复杂数学运算 | 电商价格计算、表单数字校验 |
big.js | 专注十进制浮点数运算,API 简洁,默认精度可配置 | 6KB | 平衡体积与功能,兼容性好 | 功能少于 decimal.js | 中小型项目精确计算、数据统计 |
decimal.js | 功能全面,支持高精度控制、大数字处理、进制转换、三角函数等,可自定义精度配置 | 32KB | 精度极高,功能覆盖全,灵活性强 | 体积较大,API 较复杂 | 金融核心计算、科学计算 |
math.js | 全能型数学库,支持表达式解析、矩阵运算、单位转换等复杂数学能力 | 160KB | 综合数学能力强,场景覆盖广 | 体积庞大,性能开销高 | 数据可视化、工程计算 |
附上地址:
number-precision:github.com/nefe/number…
big.js:github.com/MikeMcl/big…
decimal.js:github.com/MikeMcl/dec…
math.js:github.com/josdejong/m…
作品推荐
Haotab 新标签页,一个优雅的新标签页
静待你的体验❤
来源:juejin.cn/post/7555400502711320576
做个大屏既要不留白又要不变形还要没滚动条,我直接怒斥领导,大屏适配就这四种模式
在前端开发中,大屏适配一直是个让人头疼的问题。领导总是要求大屏既要不留白,又要不变形,还要没有滚动条。这看似简单的要求,实际却压根不可能。今天,我们就来聊聊大屏适配的四种常见模式,以及如何根据实际需求选择合适的方案。
一、大屏适配的困境
在大屏项目中,适配问题几乎是每个开发者都会遇到的挑战。屏幕尺寸的多样性、设计稿与实际屏幕的比例差异,都使得适配变得复杂。而领导的“既要...又要...还要...”的要求,更是让开发者们感到无奈。不过,我们可以通过合理选择适配模式来尽量满足这些需求。
二、四种适配模式
在大屏适配中,常见的适配模式有以下四种:
(以下截图中模拟视口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
Vue3 生态再一次加强,网站开发无敌!
如果你正在做官网开发,还在辛苦的手动实现那些动画特效,那今天推荐的这个库,至少让你提前4小时开始摸鱼!
以前,面对设计师的那些炫酷动画,实现起来是最耗头发的;产品经理还时不时的说一下,这效果不好看,我要的是五彩斑斓的黑!
还抱着 Element UI + Animate.css 在那里辛苦调试,苦苦思考好好的效果怎么到了 safari 就变形了呢 ?
现如今,时代变了!
什么是 Inspira UI
Inspira UI 是专门为 Vue3/Nuxt 开发的可复用的动画组件集合。

- 完全免费和开源
- 完美支持
vue3/Nuxt3 - 包括
按钮、输入框、背景、卡片、设备模拟、光标、2D/3D效果等120+个特效组件 - 样式基于
TailwindCSS - 动画使用
motion-v、gsap实现 - 对移动设备特别优化
来欣赏一下效果:

视频文字

图库

3d文字

走马灯

spline
Inspira UI 的优势
1.兼顾视觉与功能
以**「轻量动效组件库」为定位,核心组件覆盖基础 UI(按钮、输入框等)和模块(3D 交互、动态背景等),所有组件均内置微交互**设计。动效无需额外开发完美适配企业官网、电商页面等需视觉增强的场景,实现 “拿即用” 的开发体验。

Liquid Logo
2.基于Tailwind CSS V4
底层基于 Tailwind CSS 构建组件基础样式,确保原子类叠加的灵活性;支持浅色、深色模式一键切换;支持 ypeScript,所有组件与 API 均提供完整类型定义。

浅色模式
3.深度兼容 Vue/Nuxt 生态,性能提升
无论是 Vue 单页应用还是 Nuxt 服务端渲染项目,都能无缝融入现有技术栈,降低开发者的学习与迁移成本。
同时基于 Vue 3.4+ 新增的 defineModel 与 watchEffect 语法重构,减少了至少 30% 的响应式依赖开销;
4.多端性能优化
对于 3D 组件,在支持 WebGPU 的浏览器中,渲染帧率较旧版 WebGL 提升 2-3 倍.
而对于移动端设备、低配置设备会自动调节动效帧率,性能大大提高;同时,对所有组件做了 “懒加载 + 预渲染” 优化,首屏加载速度较旧版提升 35%
如何使用?
Inspira UI 官方文档支持中文,写的也很接地气,通俗易懂 5 分钟就能上手!
- 安装依赖
# 安装 tainlwind
pnpm install tailwindcss @tailwindcss/vite
# 安装 tailwindcss 库和实用工具
pnpm install -D clsx tailwind-merge class-variance-authority tw-animate-css
# 安装 VueUse 和其他支持库
pnpm install @vueuse/core motion-v
- 配置 vite
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: \[
tailwindcss(),
],
})
- 配置主题
可以根据需要自由配置主题色。
@import tailwindcss;
@import tw-animate-css;
@custom-variant dark (&:is(.dark *));
:root {
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
}
@theme inline {
--color-background: var(--background);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
html {
color-scheme: light dark;
}
html.dark {
color-scheme: dark;
}
html.light {
color-scheme: light;
}
最后一步,可以复制源码或者通过 Cli 来安装。
- 直接使用源码
找到想要的组件,复制粘贴到自己的项目中即可。

- 通过 Cli 安装
pnpm dlx shadcn-vue\@latest add "https://registry.inspira-ui.com/gradient-button.json>"
然后,你就有了一个炫酷的按钮。

Gradient Button 效果
最后
Vue3/Nuxt3开发者再也不用羡慕 React生态的 Aceternity UI、Magic UI 了。
Inspira UI 直接填补了 vue3 生态中动效开发这一块的缺陷,可以将这些奇妙的设计应用在企业官网、特效开发中,大大节省开发成本。
让 Vue3 生态再一次得到加强,快去试试这个炫酷的项目把!
附上官网地址:inspira-ui.com/docs/cn
作品推荐
Haotab 新标签页,一个优雅的新标签页
静待你的体验❤
来源:juejin.cn/post/7554572856147984424
放下你手里的 GIF,这才是前端动画最终的归宿!!
一、前端动画的"至暗时刻":每个像素都在燃烧经费
618 前夕,我的 PM 突然发来灵魂拷问:"菜鸡,这个购物车弹性动画,为什么安卓和 iOS 的抖动幅度不一样?还有这个圣诞飘雪特效,为什么 iPhone 13 Pro Max 的耗电量能煎鸡蛋?"
我默默擦掉额头的冷汗,回想起被这些需求支配的恐惧:
- GIF地狱:
- 一个3秒的 loading 动画,设计师随手甩来的 GIF 居然有
5MB - 在安卓低端机上播放时,仿佛在看 PPT 版的《黑客帝国》
- 一个3秒的 loading 动画,设计师随手甩来的 GIF 居然有
- SVG炼狱:
- 设计师用 AE 做的酷炫路径动画,转成
SVG + CSS后变成了毕加索抽象画 - 当产品要求动态修改渐变色时,我仿佛听到了 CPU 的惨叫声
- 设计师用 AE 做的酷炫路径动画,转成
- 平台鸿沟:
- iOS 工程师用 Core Animation 优雅实现的弹性动画
- Android 同学用 ValueAnimator 艰难复刻
- Web 端同事的 CSS transition 在 Safari 上直接摆烂
直到某天,隔壁组的前端突然拍案而起:"用Lottie!这玩意能直接吃AE动画!" —— 那一刻,我仿佛看到了前端动画的文艺复兴曙光。
二、Lottie:动画界的 Rosetta Stone(罗塞塔石碑)
1. 打破巴别塔诅咒的技术本质

Lottie的魔法可以拆解为三个核心环节:
- 魔法卷轴(JSON文件):
设计师在AE中使用 Bodymovin插件 导出的动画配方,包含所有图层、关键帧、路径等元数据,体积通常只有GIF的1/10 - 咒语解析器(Lottie Runtime):
各平台的解析引擎(Web/iOS/Android/Flutter等),像精密的手术刀般逐帧解析JSON指令 - 元素召唤阵(Canvas/SVG/OpenGL):
根据设备性能自动选择最优渲染方案,低端机用轻量SVG,旗舰机秀Canvas魔法
2. 那些年被 Lottie 拯救的惨案现场
| 传统方案 | Lottie解决方案 | 性能对比 |
|---|---|---|
| 序列帧动画 | 矢量路径动画 | 体积减少90% |
| CSS关键帧 | 复杂贝塞尔曲线运动 | 渲染速度提升300% |
| GIF动图 | 透明通道+高清显示 | 内存占用降低80% |
| Lottie的跨平台特性让设计还原度达到99.99%,从此告别"安卓特供版动画"的尴尬 |
三、Lottie 的文艺复兴之路:从加载动画到元宇宙门票
1. 业务舞台的常青树场景
- 轻量级演出:
Loading 动画、按钮微交互、表情包(微信的[呲牙]动画仅28KB) - 重量级剧场:
新手引导流程、电商促销动效、直播礼物特效(某直播平台的火箭升空动画仅182KB) - 沉浸式演出:
游戏化运营活动、元宇宙3D场景过渡(某电商 App 的虚拟试衣间加载动画)
2. 那些让程序员笑醒的代码片段
Web 端 React 全家桶套餐:
import { Player } from '@lottiefiles/react-lottie-player';
<Player
src="/emoji.lottie.json"
style={{ height: '300px' }}
autoplay
loop
onEvent={event => {
if (event === 'complete') console.log('老板说这个动画要播10086遍')
}}
/>
Vue3 优雅食用姿势:
<template>
<Lottie :animation-data="rocketJSON" @ready="startLaunch" />
</template>
<script setup>
import { Lottie } from 'lottie-web-vue';
import rocketJSON from './rocket-launch.json';
const startLaunch = (anim) => {
anim.setSpeed(1.5);
anim.play();
}
</script>
微信小程序性能优化版:
<lottie
animationData="{{lottieData}}"
path="https://static.example.com/animations/coupon.json"
autoPlay="{{true}}"
css="{{'width: 100%; height: 300rpx;'}}"
bind:ready="onAnimReady"
/>
四、打开 Lottie 的正确姿势:从青铜到王者的进阶之路
1. 设计师的防跑偏指南
- AE图层命名规范:禁止出现"最终版-真的不改了-V12"这类薛定谔命名
- 合理使用预合成:嵌套层级不要超过俄罗斯套娃的极限
- 动态属性标记:需要运行时修改的颜色/文字要提前标注
2. 工程师的性能调优包
压缩黑科技三件套:
# 使用 lottie-tools 进行瘦身
npx lottie-tools compress animation.json -o animation.min.json
# 删除无用元数据
npx lottie-tools remove-unused animation.json
# 提取公共资源
npx lottie-tools split animation.json --output-dir ./assets
按需加载策略:
const loadLottie = async () => {
const animation = await import(
/* webpackPrefetch: true */
/* webpackChunkName: "lottie-animation" */
'./animation.json'
);
lottie.loadAnimation({
container: document.getElementById('lottie'),
animationData: animation.default
});
}
五、当Lottie遇到次元壁:那些年我们填过的坑
1. 跨平台兼容性排雷手册
| 问题现象 | 解决方案 | 原理剖析 |
|---|---|---|
| iOS 闪退 | 检查 mask 路径是否闭合 | CoreAnimation 的路径容错较低 |
| 安卓颜色失真 | 禁用硬件加速 | 某些 GPU 对渐变支持不完善 |
| 微信小程序渲染错位 | 使用 px 单位替代 rpx | 部分机型 transform-origin 计算 bug |
2. 性能优化急救包
// 帧率节流大法
animation.addEventListener('enterFrame', () => {
if(performance.now() - lastTime < 16) return;
lastTime = performance.now();
// 真正执行渲染逻辑
});
// 内存泄漏防护
useEffect(() => {
const anim = lottie.loadAnimation({...});
return () => anim.destroy(); // 比卸载微信还干净
}, []);
六、未来展望:Lottie的元宇宙野望
当我在AR眼镜里看到Lottie渲染的3D购物动画时,突然意识到这个技术正在打开新次元:
- Lottie 3D Beta:
支持 AE 的 3D 图层导出,在WebGL中渲染立体动画 - 动态数据绑定:
实时修改3D模型的材质参数,实现千人千面的营销动画 - 物理引擎集成:
给动画元素添加重力、碰撞等物理特性,让每个像素都遵循真实世界法则
也许不久的将来,我们能用Lottie在元宇宙里复刻《盗梦空间》的折叠城市动画——当然,得先确保产品经理不会要求实时修改地心引力参数。
总结
从被 GIF 支配的恐惧,到用 JSON 驾驭动画的自由,Lottie 让我们离"设计即代码"的理想国又近了一步。下次当设计师又甩来 500MB 的 AE 工程时,你可以优雅地打开 Bodymovin 插件:"亲爱的,这次咱们换个姿势加载。"
来源:juejin.cn/post/7506418053997428751
Vue3 防重复点击指令 - clickOnce
Vue3 防重复点击指令 - clickOnce
一、问题背景
在实际的 Web 应用开发中,我们经常会遇到以下问题:
- 用户快速多次点击提交按钮:导致重复提交表单,产生多条相同数据
- 异步请求未完成时再次点击:可能导致数据不一致或服务器压力增大
- 用户体验不佳:没有明确的加载状态反馈,用户不知道操作是否正在进行
这些问题在以下场景中尤为常见:
- 表单提交(注册、登录、创建订单等)
- 数据保存操作
- 文件上传
- 支付操作
- API 调用
二、解决方案
clickOnce 指令通过以下机制解决上述问题:
1. 节流机制
使用 @vueuse/core 的 useThrottleFn,在 1.5 秒内只允许执行一次点击操作。
2. 按钮禁用
点击后立即禁用按钮,防止用户再次点击。
3. 视觉反馈
自动添加 Element Plus 的 Loading 图标,让用户明确知道操作正在进行中。
4. 智能恢复
- 如果绑定的函数返回 Promise(异步操作),则在 Promise 完成后自动恢复按钮状态
- 如果是同步操作,则立即恢复
三、核心特性
✅ 自动防重复点击:1.5秒节流时间
✅ 自动 Loading 状态:无需手动管理 loading 变量
✅ 支持异步操作:自动检测 Promise 并在完成后恢复
✅ 优雅的清理机制:组件卸载时自动清理事件监听
✅ 类型安全:完整的 TypeScript 支持
四、技术实现
关键技术点
- Vue 3 自定义指令:使用
Directive类型定义 - VueUse 节流:
useThrottleFn提供稳定的节流功能 - 动态组件渲染:使用
createVNode和render动态创建 Loading 图标 - Promise 检测:自动识别异步操作并在完成后恢复状态
工作流程
用户点击按钮
↓
节流检查(1.5秒内只执行一次)
↓
禁用按钮 + 添加 Loading 图标
↓
执行绑定的函数
↓
检测返回值是否为 Promise
↓
Promise 完成后(或同步函数执行完)
↓
移除 Loading + 恢复按钮状态
五、使用方法
1. 注册指令
// main.ts
import clickOnce from '@/directives/clickOnce'
app.directive('click-once', clickOnce)
2. 在组件中使用
<template>
<!-- 异步操作示例 -->
<el-button
type="primary"
v-click-once="handleSubmit">
提交表单
</el-button>
<!-- 带参数的异步操作 -->
<el-button
type="success"
v-click-once="() => handleSave(formData)">
保存数据
</el-button>
</template>
<script setup lang="ts">
const handleSubmit = async () => {
// 模拟 API 调用
await api.submitForm(formData)
ElMessage.success('提交成功')
}
const handleSave = async (data: any) => {
await api.saveData(data)
ElMessage.success('保存成功')
}
</script>
六、优势对比
传统方式
<template>
<el-button
type="primary"
:loading="loading"
:disabled="loading"
@click="handleSubmit">
提交
</el-button>
</template>
<script setup lang="ts">
const loading = ref(false)
const handleSubmit = async () => {
if (loading.value) return
loading.value = true
try {
await api.submit()
} finally {
loading.value = false
}
}
</script>
问题:
- 需要手动管理 loading 状态
- 每个按钮都要写重复代码
- 容易遗漏 finally 清理逻辑
使用 clickOnce 指令
<template>
<el-button
type="primary"
v-click-once="handleSubmit">
提交
</el-button>
</template>
<script setup lang="ts">
const handleSubmit = async () => {
await api.submit()
}
</script>
优势:
- 代码简洁,无需管理状态
- 自动处理 loading 和禁用
- 统一的用户体验
七、注意事项
- 仅用于异步操作:该指令主要为异步操作设计,同步操作会立即恢复
- 绑定函数必须返回 Promise:对于异步操作,确保函数返回 Promise
- 节流时间固定:当前节流时间为 1.5 秒,可根据需求调整
THROTTLE_TIME常量 - 依赖 Element Plus:使用了 Element Plus 的 Loading 图标和样式
八、适用场景
✅ 适合使用:
- 表单提交按钮
- 数据保存按钮
- 文件上传按钮
- API 调用按钮
- 支付确认按钮
❌ 不适合使用:
- 普通导航按钮
- 切换/开关按钮
- 需要快速连续点击的场景(如计数器)
九、指令源码
import type { Directive } from 'vue'
import { createVNode, render } from 'vue'
import { useThrottleFn } from '@vueuse/core'
import { Loading } from '@element-plus/icons-vue'
const THROTTLE_TIME = 1500
const clickOnce: Directive<HTMLButtonElement, () => Promise<unknown> | void> = {
mounted(el, binding) {
const handleClick = useThrottleFn(
() => {
// 如果元素已禁用,直接返回(双重保险)
if (el.disabled) return
// 禁用按钮
el.disabled = true
// 添加 loading 状态
el.classList.add('is-loading')
// 创建 loading 图标容器
const loadingIconContainer = document.createElement('i')
loadingIconContainer.className = 'el-icon is-loading'
// 使用 Vue 的 createVNode 和 render 来渲染 Loading 组件
const vnode = createVNode(Loading)
render(vnode, loadingIconContainer)
// 将 loading 图标插入到按钮开头
el.insertBefore(loadingIconContainer, el.firstChild)
// 将 loading 图标存储到元素上,以便后续移除
;(el as any)._loadingIcon = loadingIconContainer
;(el as any)._loadingVNode = vnode
// 执行绑定的函数(应返回 Promise 或普通函数)
const result = binding.value?.()
const removeLoading = () => {
el.disabled = false
// 移除 loading 状态
el.classList.remove('is-loading')
const icon = (el as any)._loadingIcon
if (icon && icon.parentNode === el) {
// 卸载 Vue 组件
render(null, icon)
el.removeChild(icon)
delete (el as any)._loadingIcon
delete (el as any)._loadingVNode
}
}
// 如果返回的是 Promise,则在完成时恢复;否则立即恢复
if (result instanceof Promise) {
result.finally(removeLoading)
} else {
// 非异步操作,立即恢复(或根据需求决定是否恢复)
// 通常建议只用于异步操作,所以这里也可以不处理,或给出警告
removeLoading()
}
},
THROTTLE_TIME,
)
// 将 throttled 函数存储到元素上,以便在 unmount 时移除
;(el as any)._throttledClick = handleClick
el.addEventListener('click', handleClick)
},
beforeUnmount(el) {
const handleClick = (el as any)._throttledClick
if (handleClick) {
el.removeEventListener('click', handleClick)
// 取消可能还在等待的 throttle
handleClick.cancel?.()
delete (el as any)._throttledClick
}
},
}
export default clickOnce
十、总结
clickOnce 指令通过封装防重复点击逻辑,提供了一个开箱即用的解决方案,让开发者可以专注于业务逻辑,而不用担心重复点击的问题。它结合了节流、状态管理和视觉反馈,为用户提供了更好的交互体验。
来源:juejin.cn/post/7589839767816355878
用 npm 做免费图床,这操作绝了!
最近发现了一个骚操作 —— 用 npm 当图床,完全免费,还带全球 CDN 加速。分享一下具体实现过程。
为啥要用 npm 做图床?
先说说背景,我经常在各大平台写文章,需要上传图片。但:
- 免费图床不稳定,容易挂
- 自建图床成本高
- 其他平台限制多
然后想到 npm,这不就是现成的 CDN 吗?全球访问速度还快。
怎么实现的?
1. 基本原理
npm 包本质上就是一堆文件,我们可以把图片放进去。发布后,npm 的 CDN 会自动分发这些文件。
访问方式:
# unpkg
https://unpkg.com/包名@版本号/图片路径
# jsdelivr
https://cdn.jsdelivr.net/npm/包名@版本号/图片路径
# PS
https://unpkg.com/cosmium@latest/images/other/npm-pic.png
2. 自动化发布npm包
每次提交图片后都需要手动发布到 npm那不是很烦, 别急github Actions可以帮我们自动发包, 可以直接fork 我的项目:github.com/Cosmiumx/co…
name: Publish to npm
on:
push:
branches:
- master
jobs:
....
3. 配置步骤
- Fork 本项目
- 将本项目 Fork 到你的 GitHub 账号下。
- 修改包名
- 编辑
package.json,将包名改为你自己的:
- 编辑
{
"name": "your-package-name",
"version": "0.0.1",
...
}
注意:包名必须是 npm 上未被占用的名称。
- 创建 npm token
- 访问 npmjs.com,
- 进入 Access Tokens 页面
- 点击 Generate New Token → 选择 Bypass 2FA 类型 (npm最新规则token最长只能设置90天)
- 记住这个 token,只显示一次
- 配置 GitHub Secrets
- 在你 Fork 的仓库中:
- 仓库 Settings → Secrets and variables → Actions
- 添加
NPM_TOKEN,值为刚才的 token
- 上传图片
- 把图片放到
images目录 - 提交代码,工作流自动发布
- 把图片放到
4. 访问方式
发布后,图片可通过以下 CDN 访问:
# unpkg
https://unpkg.com/cosmium@latest/images/your-image.png
# jsdelivr
https://cdn.jsdelivr.net/npm/cosmium@latest/images/your-image.png
实际体验
优点:
- 完全免费,npm 不收费
- 全球 CDN,访问速度快
- 自动化流程,上传图片后自动发布
- 版本管理清晰
注意事项:
- ⚠️ npm 包一旦发布无法删除,版本号会永久保留
- ⚠️ 不要上传敏感信息,npm 包是完全公开的
- ⚠️ 遵守 npm 使用条款,不要滥用 CDN 服务
- ⚠️ 图片版权,确保你有权使用并分发上传的图片
总结
这个方案算是找到了一个不错的图床替代方案,特别适合经常写技术文章的同学。虽然有点折腾,但效果不错。
有兴趣的可以 fork 我的项目:github.com/Cosmiumx/co…
配置好之后,以后上传图片就只是 git push 的事情了,还是很方便的。
如果这个方法对你有帮助,别忘了点赞支持一下~
来源:juejin.cn/post/7594385386740629523
浏览器中如何摆脱浏览器下12px的限制
目前Chrome浏览器依然没有放开12px的限制,但Chrome仍然是使用人数最多的浏览器。
在笔者开发某个项目时突发奇想:如果实际需要11px的字体大小怎么办?这在Chrome中是实现不了的。关于字体,一开始想到的就是rem等非px单位。但是rem只是为了响应式适配,并不能突破这一限制。
em、rem等单位只是为了不同分辨率下展示效果提出的换算单位,常见的库
px2rem也只是利用了js将px转为rem。包括微信小程序提出的rpx单位也是一样!
这条路走不通,就只剩下一个方法:改变视觉大小而非实际大小。
理论基础
css中有一个属性:transform: scale();
- 值的绝对值>1,就是放大,比如2,就是放大2倍
- 值的绝对值 0<值<1,就是缩小,比如0.5,就是原来的0.5倍;
- 值的正负,负值表示图形翻转。
默认情况下,scale(x, y):以x/y轴进行缩放;如果y没有值,默认y==x;
也可以分开写:scaleX() scaleY() scaleZ(),分开写的时候,可以对Z轴进行缩放
第二种写法:transform: scale3d(x, y, z)该写法是上面的方法的复合写法,结果和上面的一样。
但使用这个属性要注意一点:scale 缩放的时候是以“缩放元素所在空间的中心点”为基准的。
所以如果用在改变元素视觉大小的场景下,一般还需要利用另一个元素来“恢复位置”:
transform-origin: top left;
语法上说,transform-origin 拥有三个属性值:
transform-origin: x-axis y-axis z-axis;
默认为:
transform-origin:50% 50% 0;
属性值可以是百分比、em、px等具体的值,也可以是top、right、bottom、left和center这样的关键词。作用就是更改一个元素变形的原点。
实际应用
<div class="mmcce__info-r">
<!-- 一些html结构 -->
<div v-show="xxx" class="mmcce-valid-mj-period" :class="{'mmcce-mh': showStr}">
<div class="mmcce-valid-period-child">xxx</div><!-- 父级结构,点击显示下面内容 -->
<div class="mmcce-valid-pro" ref="mmcceW">
<!-- 下面内容在后面有讲解 -->
<div class="mmcce-text"
v-for="(item, index) in couponInfo.thresholdStr"
:key="index"
:index="index"
:style="{height: mTextH[index] + 'px'}"
>{{item}}</div>
</div>
</div>
</div>
.mmcce-valid-mj-period {
max-height: 15px;
transition: all .2s ease;
&.mmcce-mh {
max-height: 200px;
}
.mmcce-valid-pro {
display: flex;
flex-direction: column;
padding-bottom: 12px;
.mmcce-text {
width: 200%; // !
font-size: 22px;
height: 15px;
line-height: 30px;
color: #737373;
letter-spacing: 0;
transform : scale(.5);
transform-origin: top left;
}
}
}
.mmcce-valid-period-child {
position: relative;
width : 200%;
white-space: nowrap;
font-size : 22px;
color : #979797;
line-height: 30px;
transform : scale(.5);
transform-origin: top left;
//xxx
}

可以明确说明的是,这样的 hack 需要明确规定缩放元素的height值 !!!
上面代码中为什么.mmcce-valid-mj-period类中要用max-height ?为什么对展开元素中的文字类.mmcce-text中使用height?
我将类.mmcce-text中的height去掉后,看下效果:

(使用min-height是一样的效果)
OK,可以看到,占高没有按我们想的“被缩放”。影响到了下面的元素位置。
本质上是“视觉大小改变了但实际(占位)大小无变化”。
这时候,宽高实际也被缩放了的。这一点通过代码中width:200%也可以看出来。或者你设置了overflow:hidden;也可以有相应的效果!
这一点需要注意,一般来说,给被缩放元素显式设置一个大于等于其font-size的height值即可。
缩放带来的其它问题
可能在很多人使用的场景中是不会考虑到这个问题的:被缩放元素限制高度以后如果元素换行那么会出现文字重叠的现象。

为此,我采用了在mounted生命周期中获取父元素宽度,然后动态计算是否需要换行以及换行的行数,最后用动态style重新渲染每一条数据的height值。
这里有三点需要注意:
- 这里用的是一种取巧的方法:用
每个文字的视觉font-size值*字符串长度。因为笔者遇到的场景不会出现问题所以可以这么用。在不确定场景中更推荐用canvas或dom实际计算每个字符的宽度再做判断(需要知道文字、字母和数字的宽度是不一样的); - 需要注意一些特殊机型的展示,比如三星的galaxy fold,这玩意是个折叠屏,它的计算会和一般的屏幕计算的不一致;
- 在vue生命周期中,mounted可以操作dom,你可以通过
this.$el获取元素。但要注意:在这个时期被获取的元素不能用v-if(即:必须存在于虚拟tree中)。这也是上面代码中笔者使用v-show和opacity的原因。
关于第三点,还涉及到加载顺序的问题。比如刚进入页面时要展示弹窗,弹窗是一个组件。那你在index.vue中是获取不到这个组件的。但是你可以将比如header也拆分出来,然后在header组件的mounted中去调用弹窗组件暴露出的方法。
mounted(){
let thresholdStr = this.info.dropDownTextList;
let minW = false;
if(this.$el.querySelector('.mmcce-valid-pro').clientWidth < 140) { // 以iPhone5位准,再小于其中元素宽度的的机型就要做特殊处理了
minW = true
}
let mmcw = this.$el.querySelector('.mmcce-valid-pro').getBoundingClientRect().width;
let mmch = [];
for(let i=0;i<thresholdStr.length;i++) {
// 11是指缩放后文字的font-size值,这是一种取巧的方式
if(11*(thresholdStr[i].length) > mmcw) {
if(minW) {
mmch[i] = Math.floor((11*thresholdStr[i].length) / mmcw) * 15;
}else {
mmch[i] = Math.floor((11*(thresholdStr[i].length) + 40) / mmcw) * 15;
}
}else {
mmch[i] = 15;
}
}
this.mTextH = mmch;
},
笔者前段时间弄了一个微信公众号:前端Code新谈。里面暂时有webrtc、前端面试和用户体验系列文章,最近暂时搁置了webrtc,新开了一个系列“three.js”,欢迎关注!希望能够帮到大家,也希望能互相交流!一起学习共同进步
来源:juejin.cn/post/7596276978808389675
🚀从 autofit 到 vfit:Vue 开发者该选哪个大屏适配工具?

在数据可视化和大屏开发中,"适配"永远是绕不开的话题。不同分辨率下如何保持元素比例、位置精准,往往让开发者头疼不已。
autofit.js 作为老牌适配工具,早已在许多项目中证明了价值;而新晋的 vfit 则专为 Vue 3 量身打造。今天我们就来深入对比这两款工具,看看谁更适合你的场景。
一、核心定位:通用方案 vs Vue 专属
首先得明确两者的定位差异:
- autofit.js:无框架依赖的通用缩放工具,通过计算容器与设计稿的比例,对整个页面进行缩放处理,核心逻辑是
transform: scale(ratio)的全局应用。 - vfit.js:专为 Vue 3 设计的轻量方案,不仅提供全局缩放,更通过组件化思想解决精细定位问题,是"缩放+定位"的一体化方案。
二、核心能力对比
1. 缩放逻辑:全局统一 vs 灵活可控
autofit.js 的缩放逻辑相对直接:
- 计算容器宽高与设计稿的比例(取宽/高比例的最小值或按配置选择)
- 对目标容器应用整体缩放,实现"一缩全缩"
vfit.js 则提供了更灵活的缩放策略:
// vfit 初始化配置
createFitScale({
target: '#app', // 监听缩放的容器
designHeight: 1080, // 设计稿高度
designWidth: 1920, // 设计稿宽度
scaleMode: 'auto' // 缩放模式:auto/height/width
})
-
auto模式会自动对比容器宽高比与设计稿比例,智能选择按宽或按高缩放 - 支持在组件内通过
useFitScale()获取当前缩放值,实现局部自定义缩放
2. 定位能力:粗犷适配 vs 精细控制
这是两者最核心的差异。
autofit.js 由于是全局缩放,元素定位依赖原始 CSS 布局,在复杂场景下容易出现:
- 固定像素定位的元素在缩放后偏离预期位置
- 相对定位元素在不同分辨率下比例失调
vfit.js 则通过 FitContainer 组件解决了这个痛点,支持两种定位单位:
<!-- 百分比定位:位置不受缩放影响,适合居中场景 -->
<FitContainer :top="50" :left="50" unit="%">
<div class="card" style="transform: translate(-50%, -50%)">居中内容</div>
</FitContainer>
<!-- 像素定位:位置随缩放自动计算,适合固定布局 -->
<FitContainer :top="90" :left="90" unit="px">
<div class="box">固定位置元素</div>
</FitContainer>
-
unit="%":位置基于容器百分比,适合居中、靠边等相对位置 -
unit="px":位置会自动乘以当前缩放值,保证设计稿像素与实际显示一致
更贴心的是,vfit.js 还支持通过 right/bottom 定位,并自动处理不同原点的缩放计算(比如右上角、右下角)。
3. 框架融合:独立工具 vs Vue 生态
autofit.js 作为独立库,需要手动在 Vue 项目中处理初始化时机(通常在 onMounted 中),且无法直接与 Vue 的响应式系统结合。
vfit.js 则完全融入 Vue 3 生态:
- 通过
app.use()安装,自动处理初始化时机 - 缩放值通过
Ref实现响应式,组件内可实时获取 FitContainer组件支持 props 动态更新,适配动态布局场景
三、适用场景分析
| 场景 | 更推荐 | 原因 |
|---|---|---|
| Vue 3 项目开发 | vfit.js | 组件化开发更自然,响应式集成更顺畅 |
| 非 Vue 项目(React/原生) | autofit.js | 无框架依赖,通用性更强 |
| 简单大屏(整体缩放即可) | 两者均可 | autofit 配置更简单,vfit 稍重 |
| 复杂布局(多元素精细定位) | vfit.js | 两种定位单位+组件化,解决位置偏移问题 |
| 需局部自定义缩放 | vfit.js | useFitScale() 可灵活控制局部元素 |
四、迁移成本与上手难度
- autofit.js:API 简单,几行代码即可初始化,学习成本低,适合快速接入简单场景。
- vfit.js:需要理解组件化定位思想,初期有一定学习成本,但对于复杂场景,后期维护成本更低。
如果你从 autofit.js 迁移到 vfit.js,只需:
- 替换初始化方式(
app.use(createFitScale(...))) - 将需要定位的元素用
FitContainer包裹 - 根据需求调整
top/left与单位
总结:没有最好,只有最合适
autofit.js 胜在通用性和简单直接,适合非 Vue 项目或简单的全局缩放场景;而 vfit.js 则在 Vue 3 生态中展现了更强的针对性,通过组件化和精细定位,解决了复杂大屏的适配痛点。
如果你是 Vue 开发者,且正在为元素定位偏移烦恼,不妨试试 vfit——它可能正是你寻找的"Vue 大屏适配最优解"。
官网地址:web-vfit.netlify.app,可以直接在线体验效果~
github:github.com/v-plugin/vf…
来源:juejin.cn/post/7577970969395445801
一张 8K 海报差点把首屏拖垮
你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。
老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。
解决方案:三条技术路线,你全踩了一遍
1. 最偷懒:原生 loading="lazy"
一行代码就能跑,浏览器帮你搞定。
<img
src="https://cdn.xxx.com/poster1.jpg"
loading="lazy"
decoding="async"
width="800" height="450"
/>
🔍 关键决策点
loading="lazy"2020 年后现代浏览器全覆盖,IE 全军覆没。- 必须写死
width/height,否则 CLS 会抖成 PPT。
适用场景:内部系统、用户浏览器可控,且图片域名已开启 Accept-Ranges: bytes(支持分段加载)。
2. 最稳妥:scroll 节流 + getBoundingClientRect
老项目里还有 5% 的 IE11 用户,我们只能回到石器时代。
// utils/lazyLoad.js
const lazyImgs = [...document.querySelectorAll('[data-src]')];
let ticking = false;
const loadIfNeeded = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
lazyImgs.forEach((img, idx) => {
const { top } = img.getBoundingClientRect();
if (top < window.innerHeight + 200) { // 提前 200px 预加载
img.src = img.dataset.src;
lazyImgs.splice(idx, 1); // 🔍 及时清理,防止重复计算
}
});
ticking = false;
});
};
window.addEventListener('scroll', loadIfNeeded, { passive: true });
🔍 关键决策点
- 用
requestAnimationFrame把 30 ms 的节流降到 16 ms,肉眼不再掉帧。 - 预加载阈值 200 px,实测 4G 网络滑动不白屏。
缺点:滚动密集时 CPU 占用仍高,列表越长越卡。
3. 最优雅:IntersectionObserver 精准观测
新项目直接上 Vue3 + TypeScript,我们用 IntersectionObserver 做统一调度。
// composables/useLazyLoad.ts
export const useLazyLoad = (selector = '.lazy') => {
onMounted(() => {
const imgs = document.querySelectorAll<HTMLImageElement>(selector);
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
const img = e.target as HTMLImageElement;
img.src = img.dataset.src!;
img.classList.add('fade-in'); // 🔍 加过渡动画
io.unobserve(img); // 观测完即销毁
}
});
},
{ rootMargin: '100px', threshold: 0.01 } // 🔍 提前 100px 触发
);
imgs.forEach((img) => io.observe(img));
});
};
- 浏览器合成线程把「目标元素与视口交叉状态」异步推送到主线程。
- 主线程回调里只做一件事:把
data-src搬到src,然后unobserve。 - 整个滚动期间,零事件监听,CPU 占用 < 1%。
原理剖析:从「事件驱动」到「观测驱动」
| 维度 | scroll + 节流 | IntersectionObserver |
|---|---|---|
| 触发时机 | 高频事件(~30 ms) | 浏览器内部合成帧后回调 |
| 计算量 | 每帧遍历 N 个元素 | 仅通知交叉元素 |
| 线程占用 | 主线程 | 合成线程 → 主线程 |
| 兼容性 | IE9+ | Edge79+(可 polyfill) |
| 代码体积 | 0.5 KB | 0.3 KB(含 polyfill 2 KB) |
一句话总结:把「我每隔 16 ms 问一次」变成「浏览器你告诉我啥时候到」。
应用扩展:把懒加载做成通用指令
在 Vue3 项目里,我们干脆封装成 v-lazy 指令,任何元素都能用。
// directives/lazy.ts
const lazyDirective = {
mounted(el: HTMLImageElement, binding) {
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.src = binding.value; // 🔍 binding.value 就是 data-src
io.disconnect();
}
},
{ rootMargin: '50px 0px' }
);
io.observe(el);
},
};
app.directive('lazy', lazyDirective);
模板里直接写:
<img v-lazy="item.url" :alt="item.title" />
举一反三:三个变体场景思路
- 无限滚动列表
把IntersectionObserver绑在「加载更多」占位节点上,触底即请求下一页,再把新节点继续observe,形成递归观测链。 - 广告曝光统计
广告位 50% 像素可见且持续 1 s 才算一次曝光。设置threshold: 0.5并在回调里用setTimeout延迟 1 s 上报,离开视口时clearTimeout。 - 背景图懒加载
背景图没有src,可以把真实地址塞在style="--bg: url(...)",交叉时把background-image设成var(--bg),同样零回流。
小结
- 浏览器新特性能救命的,就别再卷节流函数了。
- 写死尺寸、加过渡、及时
unobserve,是懒加载不翻车的三件套。 - 把观测器做成指令/组合式函数,后续业务直接零成本接入。
现在你的「企业风采」首屏降到 1.2 s,老板滑得开心,运营继续传 8K 图,世界和平。
来源:juejin.cn/post/7530854092869615635
🤦♂️ 产品又来了:"能不能把Table的滚动条放到页面底部?
😅 又是熟悉的对话
产品:"小王,这个表格用户体验不好啊,用户要滚动到底部才能看到横向滚动条,能不能把滚动条固定在页面底部?"
我:"emmm... 这个... 技术上可以实现,但是..."
产品:"那就这么定了!明天上线!"
我:"😭"
相信很多前端同学都遇到过类似的场景。面对超宽的 el-table,用户确实需要先滚动到表格底部才能进行左右滚动,体验确实不够友好。
💡干!
于是一个通用的水平滚动条组件 vue-horizontal-scrollbar 诞生了!
🌟 快速体验
想看看实际效果?访问 在线演示
🚀 最终的解决方案
<template>
<el-table style="width: 100%">
</el-table>
<HorizontalScrollbar
:target-selector="getSelector('.el-table__body-wrapper .el-scrollbar .el-scrollbar__wrap')"
:content-selector="getSelector('.el-table__body-wrapper .el-scrollbar .el-scrollbar__view')"
/>
</template>
<script setup>
import { ref } from 'vue'
import { VueHorizontalScrollbar } from 'vue-horizontal-scrollbar'
import "vue-horizontal-scrollbar/dist/style.css"
function getSelector(selector: string) {
const elements = document.querySelectorAll<HTMLElement>(selector) // 兼容展开行
if (elements.length) {
return elements[elements.length - 1]
}
else {
console.warn(`Selector "${selector}" did not match any elements.`)
return null
}
}
// 💡 tips: 如果是有侧边菜单的管理系统需要动态修改vue-horizontal-scrollbar-container的left
</script>
✨ 这样做的好处
🎯 用户体验升级
- 滚动条始终可见,无需滚动页面
- 位置固定,操作便捷
- 支持键盘和鼠标滚轮操作
🛠️ 开发体验友好
- 一行代码解决问题
- 不破坏原有组件结构
- 支持任意 DOM 元素
🎨 高度可定制
<template>
<div>
<!-- Your scrollable content -->
<div id="scroll-container" style="overflow-x: auto; width: 100%;">
<div id="scroll-content" style="width: 2000px; height: 200px;">
<!-- Wide content here -->
<p>This content is wider than the container...</p>
</div>
</div>
<!-- Horizontal Scrollbar -->
<VueHorizontalScrollbar
target-selector="#scroll-container"
content-selector="#scroll-content"
:auto-show="true"
@scroll="onScroll"
/>
</div>
</template>
<script setup>
import { VueHorizontalScrollbar } from 'vue-horizontal-scrollbar'
import "vue-horizontal-scrollbar/dist/style.css"
function onScroll(info) {
console.log('Scroll info:', info)
// { scrollLeft: 100, maxScroll: 1000, scrollPercent: 10 }
}
</script>
✨ Features
- 🎯 Vue 3 & TypeScript - Full TypeScript support with Vue 3 Composition API
- 🎨 Customizable - Flexible styling and configuration options
- ♿ Accessible - ARIA labels and keyboard navigation support
- 📱 Touch Friendly - Mobile-friendly touch gestures
- 🚀 Performance - Optimized with throttling and efficient updates
- 🎪 Flexible - Works with any scrollable content
- 🎛️ Event Rich - Comprehensive event system for interactions
- 📦 Lightweight - Minimal dependencies
📖 API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
targetSelector | string | Function | — | Required. CSS selector or function returning the scroll container element |
contentSelector | string | Function | — | Required. CSS selector or function returning the content element |
autoShow | boolean | true | Auto show/hide scrollbar based on content width |
minScrollDistance | number | 50 | Minimum scroll distance to show scrollbar (when autoShow is true) |
height | number | 16 | Scrollbar height in pixels |
enableKeyboard | boolean | true | Enable keyboard navigation (Arrow keys, Home, End) |
scrollStep | number | 50 | Scroll step for keyboard navigation |
minThumbWidth | number | 30 | Minimum thumb width in pixels |
throttleDelay | number | 16 | Throttle delay for scroll events in milliseconds |
zIndex | number | 9999 | Z-index for the scrollbar |
disabled | boolean | false | Disable the scrollbar |
ariaLabel | string | 'Horizontal scrollbar' | ARIA label for accessibility |
teleportTo | string | 'body' | Teleport to target element |
🎪 更多有趣的玩法
除了解决表格滚动问题,这个组件还能用在:
- 商品展示:电商网站的商品横向滚动
- 图片画廊:摄影作品展示
- 时间轴:项目进度展示
- 标签导航:当标签太多时的横向滚动
📦 立即使用
bash
npm install vue-horizontal-scrollbar
🎉 结语
从此以后,再也不怕产品提这种"奇葩"需求了!
产品:"这个滚动条能不能再加个渐变效果?"
我:"没问题!改个 CSS 就行!"
产品:"能不能支持触摸滑动?"
我:"早就支持了!"
项目地址:GitHub
NPM 包:vue-horizontal-scrollbar
如果这个组件帮到了你,记得给项目点个 ⭐ 哦!让我们一起让前端开发变得更轻松!🎉
来源:juejin.cn/post/7521922500773789747
uni-app使用瓦片实现离线地图的两种方案
最近接到一个安卓App的活儿,虽然功能上不算复杂,但因为原本没怎么做过安卓端,所以也是"摸着石头过河"。简单写一下踩过的坑和淌的水吧~
uni-app实现离线地图主要用 leafletjs 实现,但是因为在安卓端运行,存在渲染问题,所以还要用上 renderjs。
实现方案一:web-view
因为uni-app引入第三方可以采用传统的 NPM 安装的方式,也可以采用引入打包完的js文件的方式。
这里采用 leafletjs 打包完的文件,将 leafletjs 放入 static 文件夹内。
在网上下载了公开的瓦片地图图片,以 {z}/{x}/{y} 的目录结构放入 tiles 文件夹中,将 tiles 放入 static 文件夹内。
在static文件夹下新建一个 offline-map.html 文件
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线地图</title>
<link rel="stylesheet" href="./leaflet/leaflet.css" />
<style>
html,
body {
margin: 0;
padding: 0;
}
#map {
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id="map"></div>
<script src="./leaflet/leaflet.js"></script>
<script>
const baseUrl = './tiles/{z}/{x}/{y}.jpg';
const map = L.map('map').setView([23.56, 113.23], 15);
L.tileLayer(baseUrl, {
minZoom: 15,
maxZoom: 18,
tms: true,
attribution: 'Offline Tiles',
errorTileUrl: ''
}).addTo(map);
</script>
</body>
</html>
找到 pages/index/index.vue 文件,采用 web-view 引用的方式引入上述 html 文件。
// pages/index/index.vue
<template>
<view class="content">
<web-view src="/static/offline-map.html"></web-view>
</view>
</template>
<style>
.content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>
实现方案二:renderjs
仍然将 leafletjs 和 瓦片图片文件夹tiles 放入 static 文件夹中。
// pages/index/index.vue
<view class="content">
<view id="map" class="map-container"></view>
</view>
<script module="leaflet" lang="renderjs">
import '@/static/leaflet/leaflet.css';
import * as L from "@/static/leaflet/leaflet.js";
export default {
mounted() {
this.initMap();
},
methods: {
initMap() {
const baseUrl = 'static/tiles/{z}/{x}/{y}.jpg'
map = L.map('map').setView([23.56, 113.23], 15);
L.tileLayer(baseUrl, {
minZoom: 15,
maxZoom: 18,
tms: true,
attribution: 'Offline Tiles',
errorTileUrl: ''
}).addTo(map);
},
}
}
</script>
这里需要注意的是一定要在 renderjs 中实现上述代码,如果在常规 script 中实现,在 H5端 没有任何问题,但是运行到真机上会白屏。(这个问题我反复试了好几次都不行,结果还是上传到 Trae 上解决了这个问题)。
导致这种情况的原因是在常规 sctipt 中的代码,在真机上是运行在 逻辑层 的代码,无法干扰到 视图层 的结构,这一点和Web是不同的。
而 renderjs 是运行在 视图层 的js,具备操作 DOM 的能力。
其次是引用 static 文件的路径,import static 中的文件可以使用 @/static 的方式,但是在代码中引用 static 文件需要采用 static/ 的形式。
总结
最后我做完以后让 Trae 给了一下评价,Trae 表示不建议采用这种方式实现离线地图,首先瓦片地图文件一般非常大,我用的仅仅是其中的一小部分,也超过了 60MB,打包出来的 App 包太大了。
其次无论是 web-view 还是 renderjs 本质上是一样的。在app-vue环境下,视图层由webview渲染,而renderjs就是运行在视图层的。
所以无论是渲染效率还是开发上基本没差。
来源:juejin.cn/post/7592531796044185615
巧用辅助线,轻松实现类拼多多的 Tab 吸顶效果
前言:吸顶交互的挑战
在移动端开发中,Tab 吸顶是一种非常常见的交互效果:页面滚动时,位于内容区域的 Tab 栏会“吸附”在顶部导航栏下方,方便用户随时切换。比如拼多多百亿补贴 H5 的效果如下:

要实现这个效果、并处理其他关联吸顶的效果,开发者通常需要精确处理两个问题:
- 状态判断:如何准确判断 Tab 栏是否应进入或退出吸顶状态?
- 临界值计算:页面滚动到哪个位置时,才是触发吸顶的精确临界点?
传统的方案往往依赖于监听页面的 scroll 事件,在回调中频繁计算元素位置,不仅逻辑复杂、容易出错,还可能引发性能问题。那么,有没有一种更简单、更优雅的方式呢?
本文将介绍一种巧妙的思路,仅用一条辅助线,就能轻松解决上述两个问题,极大简化实现逻辑。
我是印刻君,一位前端程序员,关注我,了解更多有温度的轻知识,有深度的硬内容。
核心思路:一条辅助线
我们的核心方法是:在 Tab 组件的父容器内,放置一条辅助线。这条线的高度可以忽略(例如 1px),定位在 Tab 上方,与 Tab 的距离正好等于顶部导航栏的高度(navbarHeight)。

这条看似简单的辅助线,为我们提供了两个至关重要的信息:
- 判断吸顶状态:当页面滚动,导致这条辅助线完全离开视窗顶部时,恰好就是 Tab 栏需要吸顶的时刻。我们可以使用
IntersectionObserverAPI 来监听其可见性变化,从而轻松更新吸顶状态。

- 获取吸顶临界值:在页面初始布局完成后,该辅助线距离页面顶部的偏移量(
offsetTop),就等于触发 Tab 吸顶时页面的滚动距离(scrollTop)。我们无需计算,直接获取即可。

原理与实现
1. 判断吸顶状态
IntersectionObserver 是一个现代浏览器 API,可以异步观察目标元素与其祖先或顶级视窗的交叉状态,而无需在主线程上执行高频计算。
在我们的方案中,我们将辅助线作为观察目标。当它向上滚动并与视窗顶部完全分离(isIntersecting 变为 false)时,就意味着 Tab 栏的顶部即将触碰到导航栏的底部。此时,我们只需更新一个状态(例如 isSticky = true),即可触发 Tab 吸顶。这种方式性能优异且逻辑清晰。
2. 获取吸顶临界值
为什么辅助线的 offsetTop 就是吸顶时的滚动距离呢?让我们通过简单的几何关系来证明。
- 吸顶临界点:如图所示,当 Tab 栏的顶部需要滚动到导航栏(
navbar)的底部时,页面滚动的距离pageScrollTop应为:pageScrollTop = tabOffsetTop - navbarHeight
- 辅助线的位置:根据我们的设计,辅助线位于 Tab 上方
navbarHeight的位置。因此,它距离页面顶部的距离lineOffsetTop为:lineOffsetTop = tabOffsetTop - navbarHeight
结合以上两个等式,可以清晰地得出:
pageScrollTop = lineOffsetTop
这证明了我们可以在页面加载后,直接通过读取辅助线的 offsetTop 属性,预先获得精确的吸顶滚动临界值。
3. 代码示例:React Hooks 实现
下面是一个基于 React Hooks 的简单实现,展示了如何将上述原理付诸实践。
import React, { useState, useEffect, useRef } from 'react';
const StickyTabs = ({ navbarHeight }) => {
const [isSticky, setIsSticky] = useState(false);
const [stickyScrollTop, setStickyScrollTop] = useState(0);
// Ref 指向我们的辅助线
const helperLineRef = useRef(null);
useEffect(() => {
const helperLineEl = helperLineRef.current;
if (!helperLineEl) {
return;
}
// 1. 获取吸顶临界值:页面加载后,直接读取 offsetTop
setStickyScrollTop(helperLineEl.offsetTop);
// 2. 监听辅助线可见性,判断吸顶状态
const observer = new IntersectionObserver(
([entry]) => {
// 当辅助线与视窗不再交叉时,意味着 Tab 需要吸顶
setIsSticky(!entry.isIntersecting);
},
// root: null 表示观察与视窗的交叉
// threshold: 0 表示元素刚进入或刚离开视窗时触发
{ root: null, threshold: 0 }
);
observer.observe(helperLineEl);
return () => observer.disconnect();
}, [navbarHeight]);
return (
<div>
{/* ... 其他页面内容 ... */}
<div style={{ position: 'relative' }}>
{/* 辅助线:绝对定位到 Tab 上方 navbarHeight 的位置 */}
<div
ref={helperLineRef}
style={{ position: 'absolute', top: -`${navbarHeight}px`, height: '1px' }}
/>
{/* Tab 组件 */}
<div
style={{
position: isSticky ? 'fixed' : 'static',
top: isSticky ? `${navbarHeight}px` : 'auto',
width: '100%',
zIndex: 10,
// ... 其他样式
}}
>
{/* Tabs... */}
div>
div>
{/* ... 列表等内容 ... */}
div>
);
};
在这个例子中:
helperLineRef指向我们的辅助线。useEffect在组件挂载后执行:- 通过
helperLineRef.current.offsetTop一次性获取并存储吸顶临界值stickyScrollTop。 - 创建
IntersectionObserver监听辅助线,当它离开视窗时,将isSticky设为true,反之则为false。
- 通过
- Tab 组件的
position样式根据isSticky状态动态切换,从而实现吸顶和取消吸顶的效果。
总结
通过引入一条简单的辅助线,我们将一个动态、复杂的滚动计算问题,巧妙地转化为了一个静态、简单的布局问题。
这种方法的优势显而易见:
- 逻辑清晰:用
IntersectionObserver判断状态,用offsetTop获取临界值,职责分明,代码易于理解和维护。 - 性能更优:避免了高频的
scroll事件监听和其中复杂的计算,将性能开销降到最低。 - 实现简单:无需引入复杂的第三方库,仅依靠浏览器原生 API 即可优雅地实现功能。
我是印刻君,一位前端程序员,关注我,了解更多有温度的轻知识,有深度的硬内容。
来源:juejin.cn/post/7572539461479546923
为什么有些人边框不用border属性
1) border 会改变布局(占据空间)
border 会参与盒模型,增加元素尺寸。
例如,一个宽度 200px 的元素加上 border: 1px solid #000,实际宽度会变成:
200 + 1px(left) + 1px(right) = 202px
如果不想影响布局,就很麻烦。
使用 box-shadow: 0 0 0 1px #000不会改变大小,看起来像 border,但不占空间。
2) border 在高 DPI 设备上容易出现“模糊/不齐”
特别是 0.5px border(发丝线),在某些浏览器上有锯齿、断线。
transform: scale(0.5) 或伪元素能做更稳定的发丝线。
3) border 圆角 + 发丝线 常出现不规则效果
border + border-radius 在不同浏览器的渲染不一致,容易出现不均匀、颜色不一致的问题。
用 outline / box-shadow 圆角更稳定。
4) border 不适合做阴影/多层边框
如果你需要两层边框:
双层边框用 border 很难做
而用:
box-shadow: 0 0 0 1px #333, 0 0 0 2px #999;
非常简单。
5) border 和背景裁剪一起用时容易出 bug
比如 background-clip、overflow: hidden 配合 border 会出现背景被挤压、不应该被裁剪却裁剪等问题。
6) hover/active 等状态切换时会“跳动”
因为 border 会改变元素大小。
例子:
.btn { border: 0; }
.btn:hover { border: 1px solid #000; }
鼠标移上去会抖动,因为尺寸变大了。
用 box-shadow 的话就不会跳。
25/11/25更新,来自评论区大佬补充
除了动态外有时候 overflow 也会导致原本刚刚好的布局不会删除滚动条,由于有了 border 1px 导致刚好出现滚动条但其实根本滚不了。
总结
边框可以分别使用border、outline、box-shadow三种方式去实现,其中outline、box-shadow不会像border一样占据空间。而box-shadow可以用来解决两个元素相邻时边框变宽的问题。不使用border并不是因为它不好,而是因为outline和box-shadow的兼容性和灵活性相对border会更好一点。
来源:juejin.cn/post/7575065042158633010
如果产品经理突然要你做一个像抖音一样流畅的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
写 CSS 用 px?这 3 个单位能让页面自动适配屏幕
在网页开发中,CSS 单位是控制元素尺寸、间距和排版的基础。
长期以来,px(像素)因其直观、精确而被广泛使用。
然而,随着设备屏幕尺寸和用户需求的多样化,单纯依赖 px 已难以满足现代 Web 对可访问性、灵活性和响应式能力的要求。
什么是 px?
px 是 CSS 中的绝对长度单位,代表像素(pixel)。
在标准密度屏幕上,1px 通常对应一个物理像素点。
开发者使用 px 可以精确控制元素的大小,例如:
.container {
width: 320px;
font-size: 16px;
padding: 12px;
}
这种写法简单直接,在固定尺寸的设计稿还原中非常高效。但问题也正源于它的绝对性。
px 存在哪些问题?
1. 缺乏响应能力
px 的值是固定的,不会随屏幕宽度、容器大小或用户设置而变化。
在一个 320px 宽的手机上显示良好的按钮,在 4K 显示器上可能显得微不足道,反之亦然。
2. 不利于可访问性
许多用户(尤其是视力障碍者)会调整浏览器的默认字体大小。
但使用 px 定义的字体不会随之缩放,导致内容难以阅读。
相比之下,使用相对单位(如 rem)能尊重用户的偏好设置。
更好的选择
为解决上述问题,CSS 提供了一系列更智能、更灵活的单位和功能。以下是几种核心方案:
1. 相对单位:rem 与 em
rem(root em):相对于根元素()的字体大小。默认情况下,1rem = 16px,但可通过设置html { font-size: 18px }改变基准。em:相对于当前元素或其父元素的字体大小,常用于局部缩放。
示例:
html {
font-size: 16px; /* 基准 */
}
.title {
font-size: 1.5rem; /* 24px */
margin-bottom: 1em; /* 相对于自身字体大小 */
}
优势:支持用户自定义缩放,便于构建比例一致的排版系统。
2. 视口单位:vw、vh、vmin、vmax
这些单位基于浏览器视口尺寸:
1vw= 视口宽度的 1%1vh= 视口高度的 1%vmin取宽高中较小者,vmax取较大者
用途:适合全屏布局、动态高度标题等场景。
示例:
.hero {
height: 80vh; /* 占视口高度的 80% */
font-size: 5vw; /* 字体随屏幕宽度缩放 */
}
注意:在移动端,vh 可能受浏览器地址栏影响,需谨慎使用。
3. clamp() 函数:实现流体响应
clamp() 是 CSS 的一个重要进步,允许你在一个属性中同时指定最小值、理想值和最大值:
font-size: clamp(16px, 4vw, 32px);
含义:
- 在小屏幕上,字体不小于 16px;
- 在中等屏幕,按 4vw 动态计算;
- 在大屏幕上,不超过 32px。
这行代码即可替代多个 @media 查询,实现平滑、连续的响应效果。
更推荐结合相对单位使用:
font-size: clamp(1rem, 2.5vw, 2rem);
这样既保留了可访问性,又具备响应能力。
4. 容器查询(Container Queries)
过去,响应式布局只能基于整个视口(通过 @media)。
但组件常常需要根据自身容器的大小来调整样式——这就是容器查询要解决的问题。
使用步骤:
- 为容器声明
container-type:
.card-wrapper {
container-type: inline-size; /* 基于内联轴(通常是宽度) */
}
- 使用
@container编写查询规则:
@container (min-width: 300px) {
.card-title {
font-size: 1.25rem;
}
}
@container (min-width: 500px) {
.card-title {
font-size: 1.75rem;
}
}
现在,只要 .card-wrapper 的宽度变化,内部元素就能自动响应,无需关心页面整体布局。这对构建可复用的 UI 组件库至关重要。
容器查询已在主流浏览器(Chrome 105+、Firefox 116+、Safari 16+)中得到支持。
建议
- 避免在字体大小、容器宽度、内边距等关键布局属性中使用纯
px。 - 优先使用
rem作为全局尺寸基准,em用于局部比例。 - 对需要随屏幕缩放的元素,使用
clamp()+vw/rem组合。 - 构建组件时,考虑启用容器查询,使其真正“自适应”。
- 保留
px仅用于不需要缩放的场景,如边框(border: 1px solid)、固定图标尺寸等。
本文首发于公众号:程序员大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期内容
ThreadLocal 在实际项目中的 6 大用法,原来可以这么简单
重构了20个SpringBoot项目后,总结出这套稳定高效的架构设计
来源:juejin.cn/post/7593292445300899859
CSS终于支持渐变色的过渡了🎉
背景
在做项目时,总会遇到UI给出渐变色的卡片或者按钮,但在做高亮的时候,由于没有过渡,显得尤为生硬。
过去的解决方案
在过去,我们如果要实现渐变色的过渡,通常会使用如下几种方法:
- 添加遮罩层,通过改变遮罩层的透明度做出淡入淡出的效果,实现过渡。
- 通过
background-size/position使得渐变色移动,实现渐变色移动的效果。 - 通过
filter: hue-rotate滤镜实现色相旋转,实现过渡。
但这几种方式都有各自的局限性:
- 遮罩层的方式看似平滑,但不是真正的过渡,差点意思。
background-size/position的方式需要计算好background-size和background-position,否则会出现渐变不完整的情况。并且只是实现了渐变的移动,而不是过渡。filter: hue-rotate也需要计算好旋转角度,实现复杂度高,过渡的也不自然。
@property新规则
@property规则可以定义一个自定义属性,并且可以指定该属性的语法、是否继承、初始值等。
@property --color {
syntax: '<color>';
inherits: false;
initial-value: #000000;
}
我们只需要把这个自定义属性--color应用到linear-gradient中,在特定的时候改变它的值,非常轻松就可以实现渐变色的过渡了。
我们再看看@property规则中这些属性的含义。
Syntax语法描述符
Syntax用于描述自定义属性的数据类型,必填项,常见值包括:
<number>数字(如0,1,2.5)<percentage>百分比(如0%,50%,100%)<length>长度单位(如px,em,rem)<color>颜色值<angle>角度值(如deg,rad)<time>时间值(如s,ms)<image>图片<*>任意类型
Inherits继承描述符
Inherits用于描述自定义属性是否从父元素继承值,必填项:
true从父元素继承值false不继承,每个元素独立
Initial-value初始值描述符
Initial-value用于描述自定义属性的初始值,在Syntax为通用时为可选。
兼容性
@property目前仍是实验性规则,但主流浏览器较新版本都已支持。

总结与展望
@property规则的出现,标志着CSS在动态样式控制方面迈出了重要一步。它不仅解决了渐变色过渡的技术难题,更为未来的CSS动画和交互设计开辟了新的可能性。
随着浏览器支持的不断完善,我们可以期待:
- 更丰富的动画效果
- 更简洁的代码实现
- 更好的性能表现
来源:juejin.cn/post/7591697558377873450
浅谈 import.meta.env 和 process.env 的区别
这是一个前端构建环境里非常核心、也非常容易混淆的问题。下面我们从来源、使用场景、编译时机、安全性四个维度来谈谈 import.meta.env 和 process.env 的区别。
一句话结论
process.env是 Node.js 的环境变量接口import.meta.env是 Vite(ESM)在构建期注入的前端环境变量
一、process.env 是什么?
1️⃣ 本质
- 来自 Node.js
- 运行时读取 服务器 / 构建机的系统环境变量
- 本身 浏览器里不存在
console.log(process.env.NODE_ENV);
2️⃣ 使用场景
- Node 服务
- 构建工具(Webpack / Vite / Rollup)
- SSR(Node 端)
3️⃣ 前端能不能用?
👉 不能直接用
浏览器里没有 process:
// 浏览器原生环境 ❌
Uncaught ReferenceError: process is not defined
4️⃣ 为什么 Webpack 项目里能用?
因为 Webpack 帮你“编译期替换”了
process.env.NODE_ENV
// ⬇️ 构建时被替换成
"production"
本质是 字符串替换,不是运行时读取。
二、import.meta.env 是什么?
1️⃣ 本质
- Vite 提供
- 基于 ES Module 的
import.meta - 构建期 + 运行期可用(但值是构建期确定的)
console.log(import.meta.env.MODE);
2️⃣ 特点
- 浏览器里 原生支持
- 不依赖 Node 的
process - 更符合现代 ESM 规范
三、两者核心区别对比(重点)
| 维度 | process.env | import.meta.env |
|---|---|---|
| 来源 | Node.js | Vite |
| 标准 | Node API | ESM 标准扩展 |
| 浏览器可用 | ❌(需编译替换) | ✅ |
| 注入时机 | 构建期 | 构建期 |
| 是否运行时读取 | ❌ | ❌ |
| 推荐前端使用 | ❌ | ✅ |
⚠️ 两者都不是“前端运行时读取服务器环境变量”
四、Vite 中为什么不用 process.env?
1️⃣ 因为 Vite 不再默认注入 process
// Vite 项目中 ❌
process.env.API_URL
会直接报错。
2️⃣ 官方设计选择
- 避免 Node 全局污染
- 更贴近浏览器真实环境
- 更利于 Tree Shaking
五、Vite 环境变量的正确用法(非常重要)
1️⃣ 必须以 VITE_ 开头
# .env
VITE_API_URL=https://api.example.com
console.log(import.meta.env.VITE_API_URL);
❌ 否则 不会注入到前端
2️⃣ 内置变量
import.meta.env.MODE // development / production
import.meta.env.DEV // true / false
import.meta.env.PROD // true / false
import.meta.env.BASE_URL
六、安全性
⚠️ 重要警告
import.meta.env里的变量 ≠ 私密
它们会:
- 被 打进 JS Bundle
- 可在 DevTools 直接看到
❌ 不要这样做
VITE_SECRET_KEY=xxxx
✅ 正确做法
- 前端:只放“公开配置”(API 域名、开关)
- 私密变量:只放在 Node / 服务端
七、SSR / 全栈项目里怎么区分?
在 Vite + SSR(如 Nuxt / 自建 SSR):
Node 端
process.env.DB_PASSWORD
浏览器端
import.meta.env.VITE_API_URL
两套环境变量是刻意分开的。
为什么必须分成两套?(设计原因)
1️⃣ 执行环境不同(这是根因)
| 位置 | 运行在哪 | 能访问什么 |
|---|---|---|
| SSR Server | Node.js | process.env |
| Client Bundle | 浏览器 | import.meta.env |
浏览器里 永远不可能安全地访问服务器环境变量。
2️⃣ SSR ≠ 浏览器
很多人误解:
“SSR 是不是浏览器代码先在 Node 跑一遍?”
❌ 不完全对
SSR 实际是:
Node.js 先跑一份 → 生成 HTML
浏览器再跑一份 → hydrate
这两次执行:
- 环境不同
- 变量来源不同
- 安全级别不同
在 Vite + SSR 中,变量的“真实流向”
1️⃣ Node 端(SSR Server)
// server.ts / entry-server.ts
const dbPassword = process.env.DB_PASSWORD;
✔️ 真实运行时读取
✔️ 不会进 bundle
✔️ 只存在于服务器内存
2️⃣ Client 端(浏览器)
// entry-client.ts / React/Vue 组件
const apiUrl = import.meta.env.VITE_API_URL;
✔️ 构建期注入
✔️ 会打进 JS
✔️ 用户可见
3️⃣ 中间那条“禁止通道”
// ❌ 绝对禁止
process.env.DB_PASSWORD → 浏览器
SSR 不会、也不允许,自动帮你“透传”环境变量
SSR 中最容易踩的 3 个坑(重点)
❌ 坑 1:在“共享代码”里直接用 process.env
// utils/config.ts(被 server + client 共用)
export const API = process.env.API_URL; // ❌
问题:
- Server OK
- Client 直接炸(或被错误替换)
✅ 正确方式:
export const API = import.meta.env.VITE_API_URL;
或者:
export const API =typeof window === 'undefined'
? process.env.INTERNAL_API
: import.meta.env.VITE_API_URL;
❌ 坑 2:误以为 SSR 可以“顺手用数据库变量”
// Vue/React 组件里
console.log(process.env.DB_PASSWORD); // ❌
哪怕你在 SSR 模式下,这段代码:
- 最终仍会跑在浏览器
- 会被打包
- 是严重安全漏洞
❌ 坑 3:把“环境变量”当成“运行时配置”
// ❌ 想通过部署切换 API
import.meta.env.VITE_API_URL
🚨 这是 构建期值:
build 时确定
→ CDN 缓存
→ 所有用户共享
想运行期切换?只能:
- 接口返回配置
- HTML 注入 window.CONFIG
- 拉 JSON 配置文件
SSR 项目里“正确的分层模型”(工程视角)
┌──────────────────────────┐
│ 浏览器 Client │
│ import.meta.env.VITE_* │ ← 公开配置
└───────────▲──────────────┘
│
HTTP / HTML
│
┌───────────┴──────────────┐
│ Node SSR Server │
│ process.env.* │ ← 私密配置
└───────────▲──────────────┘
│
内部访问
│
┌───────────┴──────────────┐
│ DB / Redis / OSS │
└──────────────────────────┘
这是一条 单向、安全的数据流。
Nuxt / 自建 SSR 的对应关系
| 类型 | 用途 |
|---|---|
| runtimeConfig | Server-only |
| runtimeConfig.public | Client 可见 |
| process.env | 仅 server |
👉 Nuxt 本质也是在帮你维护这条边界
八、常见误区总结
❌ 误区 1
import.meta.env是运行时读取
❌ 错,仍是构建期注入
❌ 误区 2
可以用它动态切换环境
❌ 不行,想动态只能:
- 接口返回配置
- 或运行时请求 JSON
❌ 误区 3
Vite 里还能继续用
process.env
❌ 除非你手动 polyfill(不推荐)
九、总结
- 前端(Vite)只认
import.meta.env.VITE_* - 服务端(Node)只认
process.env - 永远不要把秘密放进前端 env
来源:juejin.cn/post/7592062873829916722
一些我推荐的前端代码写法
使用解构赋值简化变量声明
const obj = {
a:1,
b:2,
c:3,
d:4,
e:5,
}
// 不好的写法
const a = obj.a;
const b = obj.b;
const c = obj.c;
const d = obj.d;
const e = obj.e;
// 我推荐的
const {a: newA = '',b,c,d,e} = obj || {};
- 要注意解构的对象不能为
undefined、null。否则会报错。所以可以给个空对象作为默认值 - 解构的 key 如果不存在,可以给个默认值,避免后续逻辑出错
合并数据
const a = [1,2];
const b = [3,4];
const obj1 = {
a:1,
}
const obj2 = {
b:1,
}
// 一般的写法
const c = a.concat(b);
const obj = Object.assign({}, obj1, obj2);
// 我推荐的写法
const c = [...arr1, ...arr2];
const obj = { ...obj1, ...obj2 };
Object.assign 和 Array.concat 其实也可以,只不过拓展运算符的优势如下:
- 更简洁,阅读性更好
- 会创建新的对象/数组,不会污染原数据(避免副作用)
- 支持深层次嵌套结构的合并
- 类型安全,编译时检查
条件判断
条件判断的话有几种情况,第一种是常见的多个条件判断
// 不好的写法
if(
type == 1 ||
type == 2 ||
type == 3 ||
type == 4 ||
){
//...
}
// 我推荐的
const typeArr = [1,2,3,4]
if (typeArr.includes(type)) {
//...
}
这样写代码会更简洁。如果其他地方也有相同的条件判断逻辑,当需要同时修改时,只需要修改 typeArr 即可。
第二种是三目运算符的条件判断,三目运算符我个人认为如果是简单的判断可以写,但是稍微复杂或着未来会改动的判断,最好不要使用三目运算符。容易三目运算符无限嵌套
let c = 1, d = 2, e = 3
// 不好的写法
const obj = {
a: 1,
b: (c === 1 || d === 1) ? 'bb' : d === 2 ? 'vv' : e === 3 ? '66' : null
}
// 我推荐的写法1
const obj = {
a: 1,
}
if (c === 1 || d === 1) {
obj.b = 'bb'
} else if (d === 2) {
obj.b = 'vv'
} else if (e === 3) {
obj.b = '66'
} else {
obj.b = null
}
// 我推荐的写法2
const valueMap = [
{ condition: (c, d, e) => c === 1 || d === 1, value: 'bb' },
{ condition: (c, d, e) => d === 2, value: 'vv' },
{ condition: (c, d, e) => e === 3, value: '66' }
];
function getValueByMap(c, d, e) {
const match = valueMap.find(item => item.condition(c, d, e));
return match ? match.value : null;
}
getValueByMap(c, d, e
// 我推荐的写法3
const conditionConfig = {
rules: [
{ name: 'rule1', check: (c, d, e) => c === 1 || d === 1, result: 'bb' },
{ name: 'rule2', check: (c, d, e) => d === 2, result: 'vv' },
{ name: 'rule3', check: (c, d, e) => e === 3, result: '66' }
],
defaultValue: null
};
function evaluateConditions(c, d, e, config) {
for (const rule of config.rules) {
if (rule.check(c, d, e)) {
return rule.result;
}
}
return config.defaultValue;
}
evaluateConditions(c, d, e, conditionConfig)
写法1、写法2、写法3都可以,具体可以看团队代码规范。
一般来说,写法1适用于比较简单的条件判断,比如请求参数时,可能会不同的情况添加额外的参数
写法2适用于条件比较多的情况
写法3使用于条件判断经常改的情况,这种情况可以使用配置化的方式封装条件判断。(ps:甚至在后续迭代时,如果产品跟你battle,你可以拿代码怼回去。兜底留痕)
纯函数
最好一个函数只做一件事,可以组合可以拆分
// 不好的写法
function createObj(name, temp) {
if (temp) {
fs.create(`./temp/${name}`);
} else {
fs.create(name);
}
}
// 我推荐的写法
function createFile(name) {
fs.create(name);
}
function createTempFile(name) {
createFile(`./temp/${name}`)
}
不好的写法不满足纯函数的概念,相同的输入有了不同的输出
再举一个例子:
//不好的写法
function emailClients(clients) {
clients.forEach((client) => {
const clientRecord = database.lookup(client);
if (clientRecord.isActive()) {
email(client);
}
});
}
//我推荐的写法
function emailClients(clients) {
clients
.filter(isClientRecord)
.forEach(email)
}
function isClientRecord(client) {
const clientRecord = database.lookup(client);
return clientRecord.isActive()
}
这样写逻辑更清晰,易读。
- 巧用filter函数,把filter的回调单开一个函数进行条件处理,返回符合条件的数据
- 符合条件的数据再巧用forEach,执行email函数
函数参数个数不要超过2个
就我个人而言,当函数的参数个数超过2个时,我会以对象的形式作为参数传入
// 不好的写法
function create(p1, p2, p3, p4) {
// ...
}
create(1,'2',true,[])
// 我推荐的写法
const config = {
p1: 1,
p2: '2',
p3: true,
p4: []
}
function create(config) {
}
create(config)
这样写在调用函数时,代码更简洁,可读性更好。
获取对象属性值
// 不好的写法
const name = obj && obj.name;
// 我推荐的写法
const name = obj?.name;
可选链让语法更简洁
箭头函数简化
// 传统函数定义
function add(a, b) {
return a + b;
}
// 箭头函数简化
const add = (a, b) => a + b;
需要注意的时,如果函数体涉及到了 this,则需要注意箭头函数 this 的指向问题
简化函数参数
// 不好的写法
function greet(name) {
const finalName = name || 'Guest';
console.log(`Hello, ${finalName}!`);
}
// 我推荐的写法
function greet({ name = 'Guest' }) {
console.log(`Hello, ${name}!`);
}
过滤操作
前端一般会涉及到过滤操作,比如精准过滤
const a = [1,2,3,4,5];
// 不好的写法
const result = a.filter(
item => {
return item === 3
}
)
// 我推荐的写法
const result = a.find(item => item === 3)
find相较于 filter 来说,有结果时不会继续遍历数组,性能更好
非空条件判断
有些时候,我们要判断值是否是 null、undefined 时,可以通过 ?? 判断
// 一般的写法
if (a !== null && a !== undefined) {
const b = 'BBBB'
}
// 我推荐的写法
const b = a ?? 'BBBB'
??运算符是当左侧是 null 或者 undefined 时,会取右侧的值
注释
在适当的地方写上注释,方便后续迭代
来源:juejin.cn/post/7563391880802320436
如何优雅地实现每 5 秒轮询请求?
在做实时监控系统时,比如服务器状态面板、订单处理中心或物联网设备看板,每隔 5 秒自动拉取最新数据是再常见不过的需求了。
但你有没有遇到过这些问题?
- 页面切到后台还在疯狂发请求,浪费资源
- 上一次请求还没回来,下一次又发了,接口雪崩
- 用户切换标签页回来,发现数据“卡”在旧状态
- 页面销毁了定时器还在跑,内存泄漏
今天我就以一个运维监控平台的真实场景为例,带你从“能用”做到“好用”。
一、问题场景:设备在线状态轮询
假设我们要做一个 IDC 机房设备监控页,需求如下:
- 每 5 秒查询一次所有服务器的在线状态
- 接口
/api/servers/status响应较慢(平均 1.2s) - 用户可能切换到其他标签页处理邮件
- 页面关闭时必须停止轮询
如果直接写个 setInterval,很容易踩坑。我们一步步来优化。
二、第一版:基础轮询(能跑,但有隐患)
import { ref, onMounted, onUnmounted } from 'vue'
const servers = ref([])
let timer = null
onMounted(() => {
const poll = () => {
fetch('/api/servers/status')
.then(res => res.json())
.then(data => {
servers.value = data
})
}
poll() // 首次立即执行
timer = setInterval(poll, 5000) // 每5秒轮询
})
onUnmounted(() => {
clearInterval(timer) // 🔍 清理定时器
})
✅ 实现了基本功能
❌ 但存在三个致命问题:
- 接口未完成就发起下一次请求 → 可能雪崩
- 页面不可见时仍在轮询 → 浪费带宽和电量
- 异常未处理 → 网络错误可能导致后续不再轮询
三、第二版:可控轮询 + 可见性优化
我们改用“请求完成后再延迟 5 秒”的策略,避免并发:
import { ref, onMounted, onUnmounted } from 'vue'
const servers = ref([])
let abortController = null // 用于取消请求
const poll = async () => {
try {
// 支持取消上一次请求
abortController?.abort()
abortController = new AbortController()
const res = await fetch('/api/servers/status', {
signal: abortController.signal
})
if (!res.ok) throw new Error('Network error')
const data = await res.json()
servers.value = data
} catch (err) {
if (err.name !== 'AbortError') {
console.warn('轮询失败,将重试...', err)
}
} finally {
// 🔍 请求结束后再等5秒发起下一次
setTimeout(poll, 5000)
}
}
onMounted(() => {
poll() // 启动轮询
})
onUnmounted(() => {
abortController?.abort()
})
🔍 关键点解析:
finally中setTimeout实现“串行轮询”,避免并发AbortController可在组件卸载时主动取消进行中的请求- 错误被捕获后仍继续轮询,保证稳定性
四、第三版:智能节流 —— 页面可见性控制
现在解决“页面不可见时是否轮询”的问题。我们引入 visibilitychange 事件:
let isVisible = true
const handleVisibilityChange = () => {
isVisible = !document.hidden
console.log('页面可见性:', isVisible ? '可见' : '隐藏')
}
onMounted(() => {
// 监听页面可见性
document.addEventListener('visibilitychange', handleVisibilityChange)
const poll = async () => {
try {
abortController?.abort()
abortController = new AbortController()
const res = await fetch('/api/servers/status', {
signal: abortController.signal
})
const data = await res.json()
servers.value = data
} catch (err) {
if (err.name !== 'AbortError') {
console.warn('轮询失败:', err)
}
} finally {
// 🔍 只有页面可见时才继续轮询
if (isVisible) {
setTimeout(poll, 5000)
} else {
// 页面隐藏,等待恢复后再请求
document.addEventListener('visibilitychange', function waitVisible() {
if (!document.hidden) {
document.removeEventListener('visibilitychange', waitVisible)
setTimeout(poll, 1000) // 恢复后1秒再查
}
}, { once: true })
}
}
}
poll()
})
🔍 这里做了两层控制:
- 页面隐藏时,不再自动发起下一轮请求
- 页面重新可见时,延迟 1 秒触发一次查询,避免瞬间唤醒过多资源
五、封装成可复用的轮询 Hook
把这套逻辑抽象成通用 usePolling Hook:
// composables/usePolling.js
import { ref } from 'vue'
export function usePolling(fetchFn, interval = 5000) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
let abortController = null
let isVisible = true
const poll = async () => {
if (loading.value) return // 防止重复执行
loading.value = true
error.value = null
try {
abortController?.abort()
abortController = new AbortController()
const result = await fetchFn(abortController.signal)
data.value = result
} catch (err) {
if (err.name !== 'AbortError') {
error.value = err
console.warn('Polling error:', err)
}
} finally {
loading.value = false
// 🔍 根据可见性决定是否继续
if (isVisible) {
setTimeout(poll, interval)
}
}
}
const start = () => {
// 移除旧监听避免重复
document.removeEventListener('visibilitychange', handleVisibility)
document.addEventListener('visibilitychange', handleVisibility)
poll()
}
const stop = () => {
abortController?.abort()
document.removeEventListener('visibilitychange', handleVisibility)
}
const handleVisibility = () => {
isVisible = !document.hidden
if (isVisible) {
setTimeout(poll, 1000)
}
}
return { data, loading, error, start, stop }
}
使用方式极其简洁:
<script setup>
import { usePolling } from '@/composables/usePolling'
const fetchStatus = async (signal) => {
const res = await fetch('/api/servers/status', { signal })
return res.json()
}
const { data, loading } = usePolling(fetchStatus, 5000)
// 自动在 onMounted 启动
</script>
<template>
<div v-if="loading">加载中...</div>
<ul v-else>
<li v-for="server in data" :key="server.id">
{{ server.name }} - {{ server.status }}
</li>
</ul>
</template>
六、对比主流轮询方案
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
setInterval | 固定间隔触发 | 简单直观 | 不考虑响应时间,易并发 | 快速原型 |
| 串行 setTimeout | 请求完再延时 | 避免并发,稳定 | 周期不严格 | 多数业务场景 ✅ |
| WebSocket | 服务端推送 | 实时性最高 | 成本高,兼容性差 | 股票行情、聊天 |
| Server-Sent Events | 单向流式推送 | 轻量级实时 | 不支持 IE | 日志流、通知 |
| 智能轮询(本方案) | 可见性+串行控制 | 节能、稳定、用户体验好 | 略复杂 | 生产环境推荐 ✅ |
七、举一反三:三个变体场景实现思路
- 动态轮询频率
如网络异常时降频至 30s 一次,正常后恢复 5s。可在finally中根据error.value动态调整setTimeout时间。 - 多接口协同轮询
多个 API 轮询但希望错峰发送。可用Promise.all组合请求,在finally统一控制下一轮时机,避免瞬间并发。 - 离线重连机制
当检测到网络断开(fetch 超时),改为指数退避重试(1s → 2s → 4s → 8s),恢复后再切回 5s 正常轮询。
小结
实现“每 5 秒轮询”看似简单,但要做到稳定、节能、用户体验好,需要考虑:
- ✅ 使用 串行 setTimeout 替代 setInterval,避免请求堆积
- ✅ 利用 AbortController 主动取消无用请求
- ✅ 结合 页面可见性 API 节省资源
- ✅ 封装为 可复用 Hook,提升工程化水平
记住一句话:好的轮询,是“聪明地少做事”,而不是“拼命做事情”。
下次当你接到“每隔 X 秒刷新”的需求时,别急着写 setInterval,先问问自己:用户真的需要这么频繁吗?能不能用 WebSocket?页面看不见的时候还要刷吗?
来源:juejin.cn/post/7530948113120624675
妙啊!Js的对象属性居然还能这么写
Hi,我是石小石~
静态属性获取的缺陷
前段时间在做项目国际化时,遇到一个比较隐蔽的问题:
我们在定义枚举常量时,直接调用了 i18n 的翻译方法:
export const OverdueStatus: any = {
ABOUT_TO_OVERDUE: {
value: 'ABOUT_TO_OVERDUE',
name: i18n.global.t('common.about_to_overdue'),
color: '#ad0000',
bgColor: '#ffe1e1'
},
}
结果发现翻译始终不生效。排查后才发现原因很简单 —— OverdueStatus 对象的初始化早于 i18n 实例的生成,因此取到的翻译结果是空的。
虽然最后我通过封装自定义 Vue 插件的方式彻底解决了问题,但排查过程中其实还有一个可选思路。
当时我想到的最直接办法是:让 name 在被访问时再去执行 i18n.global.t,而不是在对象定义时就执行。比如把 OverdueStatus 定义为函数:
export const OverdueStatus = () => ({
ABOUT_TO_OVERDUE: {
value: 'ABOUT_TO_OVERDUE',
name: i18n.global.t('common.about_to_overdue'),
color: '#ad0000',
bgColor: '#ffe1e1'
},
})
这样在调用时:
OverdueStatus().ABOUT_TO_OVERDUE.name
就能确保翻译逻辑在 i18n 实例创建完成之后再执行,从而避免初始化顺序的问题。不过,这种方式也有明显的缺点:所有类似的枚举都要改成函数,调用时也得多加一层执行,整体代码会变得不够简洁。
如何优雅地实现“动态获取属性”?
上面提到的“把枚举改成函数返回”虽然能解决问题,但在实际业务中显得有些笨拙。有没有更优雅的方式,让属性本身就支持 动态计算 呢?
其实,JavaScript 本身就为我们提供了解决方案 —— getter。
举个例子,我们可以把枚举对象改写成这样:
export const OverdueStatus: any = {
ABOUT_TO_OVERDUE: {
value: 'ABOUT_TO_OVERDUE',
get name() {
return i18n.global.t('common.about_to_overdue')
},
color: '#ad0000',
bgColor: '#ffe1e1'
},
}
这样一来,在访问 name 属性时,才会真正执行 i18n.global.t,确保翻译逻辑在 i18n 实例创建完成后才生效,完美解决问题。
访问器属性的原理
在 JavaScript 规范里,get 定义的属性叫 访问器属性,区别于普通的 数据属性 (Data Property) 。简单来说getter 其实就是对象属性的一种特殊定义方式。
当我们写:
const obj = {
get foo() {
return "bar"
}
}
等价于用 Object.defineProperty:
const obj = {}
Object.defineProperty(obj, "foo", {
get: function() {
return "bar"
}
})
所以访问 obj.foo 时,其实是触发了这个 get 函数,而不是读取一个固定的值。
类比Vue的computed
在 Vue 里,我们经常写 computed 计算属性,其实就是 getter 的思想。
import { computed, ref } from "vue"
const firstName = ref("Tom")
const lastName = ref("Hanks")
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
computed 内部其实就是包装了一个 getter 函数。
注意点
- getter 不能跟属性值同时存在:
const obj = {
get name() { return "石小石" },
name: "石小石Orz" // 会报错
}
- getter 是只读的,如果你想支持赋值,需要配合
setter:
const obj = {
_age: 18,
get age() { return this._age },
set age(val) { this._age = val }
}
obj.age = 20
console.log(obj.age) // 20
其他实用场景
延迟计算
有些值计算比较复杂,但只有在真正使用时才去算,可以提升性能
const user = {
firstName: "石",
lastName: "小石",
get fullName() {
// 类比一个计算,实现开发中,一个很复杂的计算才使用此方法
console.log("计算了一次 fullName")
return `${this.firstName} ${this.lastName}`
}
}
console.log(user.fullName) // "石小石"
这种写法让 API 看起来更自然,不需要调用函数 user.getFullName(),而是 user.fullName。
数据封装与保护
有些属性可能并不是一个固定字段,而是基于内部状态计算出来的:
const cart = {
items: [100, 200, 300],
get total() {
return this.items.reduce((sum, price) => sum + price, 0)
}
}
console.log(cart.total) // 600
这样 cart.total 永远是最新的,不用担心手动维护,你也不用写一个函数专门去更新这个值。
来源:juejin.cn/post/7543300730116325403
分库分表正在被淘汰
前言
“分库分表这种架构模式会逐步的被淘汰!” 不知道在哪儿看到的观点
如果我们现在在搭建新的业务架构,如果说你们未来的业务数据量会达到千万 或者上亿的级别 还在一股脑的使用分库分表的架构,那么你们的技术负责人真的就应该提前退休了🙈
如果对未来的业务非常有信心,单表的数据量能达到千万上亿的级别,请使用NewSQL 数据库,那么NewSQL 这么牛,分布库分表还有意义吗?
今天虽然写的是一篇博客,但是更多的是抱着和大家讨论的心态来的,所以大家目前有深度参与分库分表,或者NewSQL 的都可以在评论区讨论!
什么是NewSQL
NewSQL 是21世纪10年代初出现的一个术语,用来描述一类新型的关系型数据库管理系统(RDBMS)。它们的共同目标是:在保持传统关系型数据库(如Oracle、MySQL)的ACID事务和SQL模型优势的同时,获得与NoSQL系统类似的、弹性的水平扩展能力
NewSQL 的核心理念就是 将“分库分表”的复杂性从应用层下沉到数据库内核层,对上层应用呈现为一个单一的数据库入口,解决现在 分库分表的问题;
分库分表的问题
分库分表之后,会带来非常多的问题;比如需要跨库联查、跨库更新数据如何保证事务一致性等问题,下面就来详细看看分库分表都有那些问题
- 数据库的操作变得复杂
- 跨库 JOIN 几乎不可行:原本简单的多表关联查询,因为表被分散到不同库甚至不同机器上,变得异常困难。通常需要拆成多次查询,在应用层进行数据组装,代码复杂且性能低下。
- 聚合查询效率低下:
COUNT(),SUM(),GR0UP BY,ORDER BY等操作无法在数据库层面直接完成。需要在每个分片上执行,然后再进行合并。 - 分页问题:
LIMIT 20, 10这样的分页查询会变得非常诡异。你需要从所有分片中获取前30条数据,然后在应用层排序后取第20-30条。页码越大,性能越差。
- 设计上需要注意的问题
- 分片键(Sharding Key)的选择:如果前期没有设计好,后期数据倾斜比较严重
- 全局唯一ID需要提前统一设计,规范下来
- 分布式事务问题,需要考虑使用哪种方式去实现(XA协议,柔性事务)
选择TiDB还是采用mysql 分库分表的设计
数据量非常大,需要满足OLTP (Online Transactional Processing)、OLAP (Online Analytical Processing)、HTAP 且预算充足(分布式数据库的成本也是非常高的这一点非常的重要),并且是新业务新架构落地 优先推荐使用TiDB。
当然实际上选择肯定是需要多方面考虑的,大家有什么观点都可以在评论区讨论。
可以看看一个资深开发,深度参与TiDB项目,他对TiDB的一些看法:



1 什么是TiDB?
TiDB是PingCAP公司研发的开源分布式关系型数据库,采用存储计算分离架构,支持混合事务分析处理(HTAP) 。它与MySQL 5.7协议兼容,并支持MySQL生态,这意味着使用MySQL的应用程序可以几乎无需修改代码就能迁移到TiDB。
🚀目标是为用户提供一站式 OLTP (Online Transactional Processing)、OLAP (Online Analytical Processing)、HTAP 解决方案。TiDB 适合高可用、强一致要求较高、数据规模较大等各种应用场景。
官方文档:docs.pingcap.com/zh/tidb/dev…
TiDB五大核心特性
TiDB之所以在分布式数据库领域脱颖而出,得益于其五大核心特性:
- 一键水平扩容或缩容:得益于存储计算分离的架构设计,可按需对计算、存储分别进行在线扩容或缩容,整个过程对应用透明。
- 金融级高可用:数据采用多副本存储,通过Multi-Raft协议同步事务日志,只有多数派写入成功事务才能提交,确保数据强一致性。
- 实时HTAP:提供行存储引擎TiKV和列存储引擎TiFlash,两者之间的数据保持强一致,解决了HTAP资源隔离问题。
- 云原生分布式数据库:通过TiDB Operator可在公有云、私有云、混合云中实现部署工具化、自动化。
- 兼容MySQL 5.7协议和生态:从MySQL迁移到TiDB无需或只需少量代码修改,极大降低了迁移成本。
2 TiDB与MySQL的核心差异
虽然TiDB兼容MySQL协议,但它们在架构设计和适用场景上存在根本差异。以下是它们的详细对比:
2.1 架构差异
表1:TiDB与MySQL架构对比
| 特性 | MySQL | TiDB |
|---|---|---|
| 架构模式 | 集中式架构 | 分布式架构 |
| 扩展性 | 垂直扩展,主从复制 | 水平扩展,存储计算分离 |
| 数据分片 | 需要分库分表 | 自动分片,无需sharding key |
| 高可用机制 | 主从复制、MGR | Multi-Raft协议,多副本 |
| 存储引擎 | InnoDB、MyISAM等 | TiKV(行存)、TiFlash(列存) |
2.2 性能表现对比
性能方面,TiDB与MySQL各有优势,主要取决于数据量和查询类型:
- 小数据量简单查询:在数据量百万级以下的情况下,MySQL的写入性能和点查点写通常优于TiDB。因为TiDB的分布式架构在少量数据时无法充分发挥优势,却要承担分布式事务的开销。
- 大数据量复杂查询:当数据量达到千万级以上,TiDB的性能优势开始显现。一张千万级别表关联查询,MySQL可能需要20秒,而TiDB+TiKV只需约5.57秒,使用TiFlash甚至可缩短到0.5秒。
- 高并发场景:MySQL性能随着并发增加会达到瓶颈然后下降,而TiDB性能基本随并发增加呈线性提升,节点资源不足时还可通过动态扩容提升性能。
2.3 扩展性与高可用对比
MySQL的主要扩展方式是一主多从架构,主节点无法横向扩展(除非接受分库分表),从节点扩容需要应用支持读写分离。而TiDB的存储和计算节点都可以独立扩容,支持最大512节点,集群容量可达PB级别。
高可用方面,MySQL使用增强半同步和MGR方案,但复制效率较低,主节点故障会影响业务处理[]。TiDB则通过Raft协议将数据打散分布,单机故障对集群影响小,能保证RTO(恢复时间目标)不超过30秒且RPO(恢复点目标)为0,真正实现金融级高可用。
2.4 SQL功能及兼容性
虽然TiDB高度兼容MySQL 5.7协议和生态,但仍有一些重要差异需要注意:
不支持的功能包括:
- 存储过程与函数
- 触发器
- 事件
- 自定义函数
- 全文索引(计划中)
- 空间类型函数和索引
有差异的功能包括:
- 自增ID的行为(TiDB推荐使用AUTO_RANDOM避免热点问题)
- 查询计划的解释结果
- 在线DDL能力(TiDB更强,不锁表支持DML并行操作)
3 如何选择:TiDB还是MySQL?
选择数据库时,应基于实际业务需求和技术要求做出决策。以下是具体的选型建议:
3.1 选择TiDB的场景
TiDB在以下场景中表现卓越:
- 数据量大且增长迅速的OLTP场景:当单机MySQL容量或性能遇到瓶颈,且数据量达到TB级别时,TiDB的水平扩展能力能有效解决问题。
例如,当业务数据量预计将超过TB级别,或并发连接数超过MySQL合理处理范围时。 - 实时HTAP需求:需要同时进行在线事务处理和实时数据分析的场景。
传统方案需要OLTP数据库+OLAP数据库+ETL工具,TiDB的HTAP能力可简化架构,降低成本和维护复杂度。 - 金融级高可用要求:对系统可用性和数据一致性要求极高的金融行业场景。
TiDB的多副本和自动故障转移机制能确保业务连续性和数据安全。 - 多业务融合平台:需要将多个业务数据库整合的统一平台场景。
TiDB的资源管控能力可以按照RU(Request Unit)大小控制资源总量,实现多业务资源隔离和错峰利用。 - 频繁的DDL操作需求:需要频繁进行表结构变更的业务。
TiDB的在线DDL能力在业务高峰期也能平稳执行,对大表结构变更尤其有效。
3.2 选择MySQL的场景
MySQL在以下情况下仍是更合适的选择:
- 中小规模数据量:数据量在百万级以下,且未来增长可预测。
在这种情况下,MySQL的性能可能更优,且总拥有成本更低。 - 简单读写操作为主:业务以点查点写为主,没有复杂的联表查询或分析需求。
- 需要特定MySQL功能:业务依赖存储过程、触发器、全文索引等TiDB不支持的功能。
- 资源受限环境:硬件资源有限且没有分布式数据库管理经验的团队。
MySQL的运维管理相对简单,学习曲线较平缓。
3.3 决策参考框架
为了更直观地帮助决策,可以参考以下决策表:
| 考虑因素 | 倾向TiDB | 倾向MySQL |
|---|---|---|
| 数据规模 | TB级别或预计快速增长 | GB级别,增长稳定 |
| 并发需求 | 高并发(数千连接以上) | 低至中等并发 |
| 查询类型 | 复杂SQL,多表关联 | 简单点查点写 |
| 可用性要求 | 金融级(RTO<30s,RPO=0) | 常规可用性要求 |
| 架构演进 | 微服务、云原生、HTAP | 传统单体应用 |
| 运维能力 | 有分布式系统管理经验 | 传统DBA团队 |
4 迁移注意事项
如果决定从MySQL迁移到TiDB,需要注意以下关键点:
- 功能兼容性验证:检查应用中是否使用了TiDB不支持的MySQL功能,如存储过程、触发器等。
- 自增ID处理:将AUTO_INCREMENT改为AUTO_RANDOM以避免写热点问题。
- 事务大小控制:注意TiDB对单个事务的大小限制(早期版本限制较严,4.0版本已提升到10GB)。
- 迁移工具选择:使用TiDB官方工具如DM(Data Migration)进行数据迁移和同步。
- 性能测试:迁移前务必进行充分的性能测试,特别是针对业务关键查询的测试。
5 总结
TiDB和MySQL是适用于不同场景的数据库解决方案,没有绝对的优劣之分。MySQL是优秀的单机数据库,适用于数据量小、架构简单的场景;数据量大了之后需要做分库分表。而TiDB作为分布式数据库,专注于解决大数据量、高并发、高可用性需求下的数据库瓶颈问题,但是成本也是非常的高
本人没有使用过NewSQL ,还望各位大佬批评指正
来源:juejin.cn/post/7561245020045918249
vue也支持声明式UI了,向移动端kotlin,swift看齐,抛弃html,pug升级版,进来看看新语法吧
众所周知,新生代的ui框架(如:kotlin,swift,flutter,鸿蒙)都已经抛弃了XML这类的结构化数据标记语言改为使用声明式UI
只有web端还没有支持此类ui语法,此次我开发的ovsjs为前端也带来了此类声明式UI语法的支持,语法如下
项目地址
语法插件地址:
marketplace.visualstudio.com/items?itemN…
新语法如下:

我认为更强的地方是我的新设计除了为前端带来了声明式UI,还支持了 #{ } 不渲染代码块的设计,支持在 声明式UI中编写代码,这样UI和逻辑之间的距离更近,维护更方便,抽象组件也更容易
对比kotlin,swift,flutter,鸿蒙语法如下:
kotlin的语法
import kotlinx.browser.*
import kotlinx.html.*
import kotlinx.html.dom.*
fun main() {
document.body!!.append.div {
h1 {
+"Welcome to Kotlin/JS!"
}
p {
+"Fancy joining this year's "
a("https://kotlinconf.com/") {
+"KotlinConf"
}
+"?"
}
}
}
swiftUI的语法
import SwiftUI
struct ContentView: View {
var body: some View {
VStack(spacing: 16) {
Text("Hello SwiftUI")
.font(.largeTitle)
.fontWeight(.bold)
Text("Welcome to SwiftUI world")
Button("Click Me") {
print("Button clicked")
}
}
.padding()
}
}
flutter的语法
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Hello Flutter",
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
const Text("Welcome to Flutter world"),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
print("Button clicked");
},
child: const Text("Click Me"),
)
],
),
),
),
);
}
}
鸿蒙 arkts
@Entry
@Component
struct Index {
@State message: string = 'Hello ArkUI'
build() {
Column() {
Text(this.message)
.fontSize(28)
.fontWeight(FontWeight.Bold)
Text('Welcome to HarmonyOS')
.margin({ top: 12 })
Button('Click Me')
.margin({ top: 16 })
.onClick(() => {
console.log('Button clicked')
})
}
.padding(20)
}
}
原理实现
简述一下实现原理,就是通过parser支持了新语法,然后将新语法转义为 iife包裹的vue的h函数
为什么要iife包裹
因为要支持不渲染代码块
ovs图中的代码对应的编译后的代码是这样的
import {defineOvsComponent} from "/@fs/D:/project/qkyproject/test-volar/ovs/ovs-runtime/src/index.ts";
import {$OvsHtmlTag} from "/@fs/D:/project/qkyproject/test-volar/ovs/ovs-runtime/src/index.ts";
import {ref} from "/node_modules/.vite/deps/vue.js?v=76ca4127";
export default defineOvsComponent(props => {
const msg = "You did it!";
let count = ref(0);
const timer = setInterval(() => {
count.value = count.value + 1;
},1000);
return $OvsHtmlTag.div({class:'greetings',onClick(){
count.value = 0;
}},[
$OvsHtmlTag.h1({class:'green'},[msg]),
count,
$OvsHtmlTag.h3({},[
"You've successfully created a project with ",
$OvsHtmlTag.a({href:'https://vite.dev/',target:'_blank',rel:'noopener'},['Vite']),
' + ',
$OvsHtmlTag.a({href:'https://vuejs.org/',target:'_blank',rel:'noopener'},['Vue 3']),
' + ',
$OvsHtmlTag.a({href:'https://github.com/alamhubb/ovsjs',target:'_blank',rel:'noopener'},['OVS']),
'.'
])
]);
});
parser是我自己写的,抄了 chevortain 的设计,写了个subhuti,支持定义peg语法
slimeparser,支持es2025语法的parser,基于subhuti,声明es2025语法就行
然后就是ovs继承slimeparser,添加了ovs的语法支持,并且在ast生成的时候将代码转为vue的渲染函数,运行时就是运行的vue的渲染函数的代码,所以完美支持vue的生态
感兴趣的可以试试,入门教程
由于本人能力有先,文中存在错误不足之处,请大家指正,有对新语法感兴趣的欢迎留言和我交流
来源:juejin.cn/post/7580287383788585003
让用户愿意等待的秘密:实时图片预览
你有没有经历过这样的场景?点击“上传头像”,选了一张照片,页面却毫无反应——没有提示,没有图像,只有一个静默的按钮。你开始怀疑:是没选上?网速慢?还是系统出错了?于是你犹豫要不要再点一次,甚至直接关掉页面。
而如果在你选择文件的瞬间,一张清晰的缩略图立刻出现在眼前,哪怕后端还在处理,你也会安心地等待下去。
不是用户没耐心,而是他们需要一点“确定性”来支撑等待的理由。
图片预览,正是那个微小却关键的信号:你的操作已被接收,一切正在按预期进行。
得到程序正在运行的信号之后用户才会有等待的欲望。
今天,我们就来亲手实现一个图片预览功能。
先思考:要让一张用户选中的本地图片显示在网页上,我们到底需要做些什么?
第一步:我们要显示图片,那肯定得有个 <img> 标签吧?
没错。想在页面上看到图片,最直接的方式就是用 <img :src="xxx" />。但问题来了:用户刚从电脑里选了一张照片,这张照片还在他本地硬盘上,还没传到服务器,也没有公开 URL。那 src 该填什么?
这时候你可能会想:“能不能把这张本地文件直接塞进 src?”
答案是:不能直接塞 File 对象,但——我们可以把它“变成”一个 URL。
第二步:用户选了图,我们怎么拿到它?
通常我们会用 <input type="file" accept="image/*"> 让用户选择图片。在 Vue 中,为了能“拿到”这个 input 元素本身(而不仅仅是它的值),我们会用到 ref。
<input
type="file"
ref="uploadImage"
accept="image/*"
@change="updateImageData"
/>
这里,ref="uploadImage" 就像给这个 input 贴了个标签。之后在 script 里,我们就能通过 uploadImage.value 拿到它的真实 DOM 引用。
于是,在 updateImageData 函数里,我们可以这样取到用户选中的文件:
const input = uploadImage.value;
const file = input.files[0]; // 用户选的第一张图
注意:不是 input.file,而是 input.files —— 这是一个常见的笔误,也是很多初学者卡住的地方。
第三步:有了 File 对象,怎么变成 <img> 能识别的 src?
现在我们手里有一个 File 对象,但它不能直接赋给 img.src。我们需要把它转成一种浏览器能直接渲染的格式。
这时候,FileReader 就登场了。
const reader = new FileReader();
reader.readAsDataURL(file);
readAsDataURL 会把文件内容读取为一个 Data URL,格式类似:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
这串字符串可以直接作为 <img> 的 src!是不是很巧妙?
那什么时候能拿到这个结果呢?FileReader 是异步的,所以我们监听它的 onloadend 事件:
reader.onloadend = (e) => {
imgPreview.value = e.target.result; // 这就是 Data URL
}
而我们的模板中早已准备好了一个 <img>:
<img :src="imgPreview" alt="" v-if="imgPreview" />
当 imgPreview 有值时,图片就自动显示出来了!
完整逻辑串起来
把这些碎片拼在一起,整个流程就清晰了:
- 用户点击 input 选择图片;
@change触发updateImageData;- 通过
ref拿到 input,取出files[0]; - 用
FileReader读取为 Data URL; - 把结果存到响应式变量
imgPreview; - Vue 自动更新
<img :src="imgPreview">,图片就出来了。
这整个过程完全在前端完成,不需要上传到服务器,也不依赖任何第三方库——只用了浏览器原生 API 和 Vue 的响应式系统。
最后:完整实例
在vue中实现图片预览的完整代码及效果
来源:juejin.cn/post/7585534343562608690
Arco Design 停摆!字节跳动 UI 库凉了?
1. 引言:设计系统的“寒武纪大爆发”与 Arco 的陨落
在 2019 年至 2021 年间,中国前端开发领域经历了一场前所未有的“设计系统”爆发期。伴随着企业级 SaaS 市场的崛起和中后台业务的复杂度攀升,各大互联网巨头纷纷推出了自研的 UI 组件库。这不仅是技术实力的展示,更是企业工程化标准的话语权争夺。在这一背景下,字节跳动推出了 Arco Design,这是一套旨在挑战 Ant Design 霸主地位的“双栈”(React & Vue)企业级设计系统。
Arco Design 在发布之初,凭借其现代化的视觉语言、对 TypeScript 的原生支持以及极具创新性的“Design Lab”设计令牌(Design Token)管理系统,迅速吸引了大量开发者的关注。它被定位为不仅仅是一个组件库,而是一套涵盖设计、开发、工具链的完整解决方案。然而,就在其社区声量达到顶峰后的短短两年内,这一曾被视为“下一代标准”的项目却陷入了令人费解的沉寂。
截至 2025 年末,GitHub 上的 Issue 堆积如山,关键的基础设施服务(如 IconBox 图标平台)频繁宕机,官方团队的维护活动几乎归零。对于数以万计采用了 Arco Design 的企业和独立开发者而言,这无疑是一场技术选型的灾难。
本文将深入剖析 Arco Design 从辉煌到停摆的全过程。我们将剥开代码的表层,深入字节跳动的组织架构变革、内部团队的博弈(赛马机制)、以及中国互联网大厂特有的“KPI 开源”文化,为您还原整件事情的全貌。
2. 溯源:Arco Design 的诞生背景与技术野心
要理解 Arco Design 为何走向衰败,首先必须理解它诞生时的宏大野心及其背后的组织推手。Arco 并不仅仅是一个简单的 UI 库,它是字节跳动为了解决特定业务线极其复杂的后台需求而孵化的产物。

2.1 “务实的浪漫主义”:差异化的产品定位
Arco Design 在推出时,鲜明地提出了“务实的浪漫主义”这一设计哲学。这一口号的提出,实际上是为了在市场上与阿里巴巴的 Ant Design 进行差异化竞争。
- Ant Design 的困境:作为行业标准,Ant Design 以“确定性”著称,其风格克制、理性,甚至略显单调。虽然极其适合金融和后台管理系统,但在需要更强品牌表达力和 C 端体验感的场景下显得力不从心。
- Arco 的切入点:字节跳动的产品基因(如抖音、TikTok)强调视觉冲击力和用户体验的流畅性。Arco 试图在中后台系统中注入这种基因,主张在解决业务问题(务实)的同时,允许设计师发挥更多的想象力(浪漫)。
这种定位在技术层面体现为对 主题定制(Theming) 的极致追求。Arco Design 并没有像传统库那样仅仅提供几个 Less 变量,而是构建了一个庞大的“Design Lab”平台,允许用户在网页端通过可视化界面细粒度地调整成千上万个 Design Token,并一键生成代码。这种“设计即代码”的早期尝试,是 Arco 最核心的竞争力之一。
2.2 组织架构:GIP UED 与架构前端的联姻
Arco Design 的官方介绍中明确指出,该系统是由 字节跳动 GIP UED 团队 和 架构前端团队(Infrastructure FrontEnd Team) 联合推出的。这一血统注定了它的命运与“GIP”这个业务单元的兴衰紧密绑定。
2.2.1 解密 GIP:通用信息平台 (General Information Platform)
GIP 全称为 General Information Platform(通用信息平台)。这是字节跳动早期的核心业务支柱,主要包含以下以“图文与中长视频”为核心的信息分发产品:
- 今日头条:字节跳动的起家之作,智能推荐资讯平台。
- 西瓜视频:中长视频平台。
- 番茄小说:免费网文阅读平台。
2.2.2 业务对技术的反哺与制约
GIP 的业务特点是高信息密度。今日头条的内容审核后台、广告投放系统(早期巨量引擎)、创作者管理平台(头条号后台)都需要处理海量的文本数据和复杂的表格操作。因此,Arco Design 从诞生起就带有浓重的“B 端中后台”基因,强调紧凑、理性和高效率,这正是为了服务于 GIP 庞大的内部系统需求。
在 2019-2020 年,GIP 仍是公司的绝对核心与营收主力。Arco Design 的推出,实际上是字节跳动“长子”(头条系)试图确立公司内部技术标准的一次有力尝试。
2.3 黄金时代的技术堆栈
在 2021 年左右,Arco Design 的技术选型是极具前瞻性的,这也是它能迅速获得 5.5k Star 的原因之一:
- 全链路 TypeScript:所有组件均采用 TypeScript 编写,提供了优秀的类型推导体验,解决了当时 Ant Design v4 在某些复杂场景下类型定义不友好的痛点。
- 双框架并进:@arco-design/web-react 和 @arco-design/web-vue 保持了高度统一的 API 设计和视觉风格。这对于那些技术栈不统一的大型公司极具吸引力,意味着设计规范可以跨框架复用。
- 生态闭环:除了组件库,Arco 还发布了 arco-cli(脚手架)、Arco Pro(中后台模板)、IconBox(图标管理平台)以及 Material Market(物料市场)。这表明团队不仅是在做一个库,而是在构建一个类似 Salesforce Lightning 或 SAP Fiori 的企业级生态。
然而,正是这种庞大的生态铺设,为日后的维护埋下了巨大的隐患。当背后的组织架构发生震荡时,维持如此庞大的产品矩阵所需的资源将变得不可持续。
3. 停摆的证据:基于数据与现象的法医式分析
尽管字节跳动从未发布过一份正式的“Arco Design 停止维护声明”,但通过对代码仓库、社区反馈以及基础设施状态的深入分析,我们可以断定该项目已进入实质性的“脑死亡”状态。
3.1 代码仓库的“心跳停止”
对 GitHub 仓库 arco-design/arco-design (React) 和 arco-design/arco-design-vue (Vue) 的提交记录分析显示,活跃度在 2023 年底至 2024 年初出现了断崖式下跌。

3.1.1 提交频率分析
虽然 React 版本的最新 Release 版本号为 2.66.8(截至文章撰写时),但这更多是惯性维护。
- 核心贡献者的离场:早期的高频贡献者(如 sHow8e、jadelike-wine 等)在 2024 年后的活跃度显著降低。许多提交变成了依赖项升级(Dependabot)或极其微小的文档修复,缺乏实质性的功能迭代。
- Vue 版本的停滞:Vue 版本的状态更为糟糕。最近的提交多集中在构建工具迁移(如迁移到 pnpm)或很久以前的 Bug 修复。核心组件的 Feature Request 长期无人响应。
3.1.2 积重难返的 Issue 列表
Issue 面板是衡量开源项目生命力的体温计。目前,Arco Design 仓库中积累了超过 330 个 Open Issue。
- 严重的 Bug 无人修复:例如 Issue #3091 “tree-select 组件在虚拟列表状态下搜索无法选中最后一个” 和 Issue #3089 “table 组件的 default-expand-all-rows 属性设置不生效”。这些都是影响生产环境使用的核心组件 Bug,却长期处于 Open 状态。
- 社区的绝望呐喊:Issue #3090 直接以 “又一个没人维护的 UI 库” 为题,表达了社区用户的愤怒与失望。更有用户在 Discussion 中直言 “这个是不是 KPI 项目啊,现在维护更新好像都越来越少了”。这种负面情绪的蔓延,通常是一个项目走向终结的社会学信号。
3.2 基础设施的崩塌:IconBox 事件
如果说代码更新变慢还可以解释为“功能稳定”,那么基础设施的故障则是项目被放弃的直接证据。
- IconBox 无法发布:Issue #3092 指出 “IconBox 无法发布包了”。IconBox 是 Arco 生态中用于管理和分发自定义图标的 SaaS 服务。这类服务需要后端服务器、数据库以及运维支持。
- 含义解读:当一个大厂开源项目的配套 SaaS 服务出现故障且无人修复时,这不仅仅是开发人员没时间的问题,而是意味着服务器的预算可能已经被切断,或者负责运维该服务的团队(GIP 相关的基建团队)已经被解散。这是项目“断供”的最强物理证据。
3.3 文档站点的维护降级
Arco Design 的文档站点虽然目前仍可访问,但其内容更新已经明显滞后。例如,关于 React 18/19 的并发特性支持、最新的 SSR 实践指南等现代前端话题,在文档中鲜有提及。与竞争对手 Ant Design 紧跟 React 官方版本发布的节奏相比,Arco 的文档显得停留在 2022 年的时光胶囊中。
4. 深层归因:组织架构变革下的牺牲品
Arco Design 的陨落,本质上不是技术失败,而是组织架构变革的牺牲品。要理解这一点,我们需要将视线从 GitHub 移向字节跳动的办公大楼,审视这家巨头在过去三年中发生的剧烈动荡。

4.1 战略重心的转移:从“头条”到“抖音”
2021 年底至 2024 年,字节跳动进行了多次大规模的组织架构调整。其中最关键的变化是战略重心从图文资讯(今日头条)全面转向短视频与直播(抖音/TikTok)以及后来的 AI 大模型。
- GIP 的边缘化:随着移动互联网进入存量时代,今日头条和西瓜视频的用户增长见顶,战略地位从“增长引擎”退化为“现金牛”甚至“存量维持”业务。
- 资源的抽离:GIP UED 和相关前端团队面临缩编或重组。维护 Arco Design 这样一套庞大的开源系统需要持续的人力投入。当母体部门本身都在进行“去肥增瘦”时,一个无法直接带来商业增量的开源 KPI 项目,自然成为了裁员的首选目标。
4.2 内部赛马机制:Arco Design vs. Semi Design
字节跳动素以“APP 工厂”和“内部赛马”文化著称。这种文化不仅存在于 C 端产品中,也渗透到了技术基建领域。Arco Design 的停摆,很大程度上是因为它在与内部竞争对手 Semi Design 的博弈中败下阵来。
4.2.1 Semi Design 的崛起
Semi Design 是由 抖音前端团队 与 MED 产品设计团队 联合推出的设计系统。
- 出身显赫:与 GIP 不同,Semi Design 背靠的是字节跳动的绝对核心——抖音。抖音前端团队拥有极其充裕的资源和稳固的业务地位。
- 技术路线之争:Semi Design 在架构上更为先进,采用了 Foundation/Adapter 模式,实现了逻辑与渲染分离,能以更低的成本适配不同框架。同时,Semi 深度集成了 D2C(Design-to-Code)工具链,更符合公司对 AI 和人效的追求。
4.2.2 为什么 Arco 输了?
在资源整合期,公司高层显然不需要维护两套功能高度重叠的企业级 UI 库。
- 业务绑定:Semi Design 宣称服务了内部 10 万+ 用户和近千个平台产品,深度嵌入在抖音的内容生产与运营流中。
- 结局:随着 GIP 业务权重的下降和团队的调整,Arco Design 失去了维护的资源,而 Semi Design 成为了事实上的内部标准。
4.3 中国大厂的“KPI 开源”陷阱
Arco Design 的命运也折射出中国互联网大厂普遍存在的“KPI 开源”现象。
- 晋升阶梯:在阿里的 P7/P8 或字节的 2-2/3-1 晋升答辩中,主导一个“行业领先”的开源项目是极具说服力的业绩。因此,很多工程师或团队 Leader 会发起此类项目,投入巨大资源进行推广(刷 Star、做精美官网)。
- 晋升后的遗弃:一旦发起人成功晋升、转岗或离职,该项目的“剩余价值”就被榨干了。接手的新人往往不愿意维护“前人的功劳簿”,更愿意另起炉灶做一个新的项目来证明自己。
- Arco 的轨迹:Arco 的高调发布(2021年)恰逢互联网泡沫顶峰。随着 2022-2024 年行业进入寒冬,晋升通道收窄,维护开源项目的 ROI(投入产出比)变得极低,导致项目被遗弃。
5. 社区自救的幻象:为何没有强有力的 Fork?
面对官方的停摆,用户自然会问:既然代码是开源的(MIT 协议),为什么没有人 Fork 出来继续维护?调查显示,虽然存在一些零星的 Fork,但并未形成气候。

5.1 Fork 的现状调查
通过对 GitHub 和 Gitee 的检索,我们发现了一些 Fork 版本,但并未找到具备生产力的社区继任者。
- vrx-arco:这是一个名为 vrx-arco/arco-design-pro 的仓库,声称是 "aro-design-vue 的部分功能扩展"。然而,这更像是一个补丁集,而不是一个完整的 Fork。它主要解决特定开发者的个人需求,缺乏长期维护的路线图。
- imoty_studio/arco-design-designer:这是一个基于 Arco 的表单设计器,并非组件库本身的 Fork。
- 被动 Fork:GitHub 显示 Arco Design 有 713 个 Fork。经抽样检查,绝大多数是开发者为了阅读源码或修复单一 Bug 而进行的“快照式 Fork”,并没有持续的代码提交。
5.2 为什么难以 Fork?
维护一个像 Arco Design 这样的大型组件库,其门槛远超普通开发者的想象。
- Monorepo 构建复杂度:Arco 采用了 Lerna + pnpm 的 Monorepo 架构,包含 React 库、Vue 库、CLI 工具、图标库等多个 Package。其构建脚本极其复杂,往往依赖于字节内部的某些环境配置或私有源。外部开发者即使拉下来代码,要跑通完整的 Build、Test、Doc 生成流程都非常困难。
- 生态维护成本:Arco 的核心优势在于 Design Lab 和 IconBox 等配套 SaaS 服务。Fork 代码容易,但 Fork 整个后端服务是不可能的。失去了 Design Lab 的 Arco,就像失去了灵魂的空壳,吸引力大减。
- 技术栈锁定:Arco 的一些底层实现可能为了适配字节内部的微前端框架或构建工具(如 Modern.js)做了特定优化,这增加了通用化的难度。
因此,社区更倾向于迁移,而不是接盘。
6. 用户生存指南:现状评估与迁移策略
对于目前仍在使用 Arco Design 的团队,局势十分严峻。随着 React 19 的临近和 Vue 3 生态的演进,Arco 将面临越来越多的兼容性问题。
6.1 风险评估表
| 风险维度 | 风险等级 | 具体表现 |
|---|---|---|
| 安全性 | 🔴 高危 | 依赖的第三方包(如 lodash, async-validator 等)若爆出漏洞,Arco 不会发版修复,需用户手动通过 resolutions 强行覆盖。 |
| 框架兼容性 | 🔴 高危 | React 19 可能会废弃某些 Arco 内部使用的旧生命周期或模式;Vue 3.5+ 的新特性无法享受。 |
| 浏览器兼容性 | 🟠 中等 | 新版 Chrome/Safari 的样式渲染变更可能导致 UI 错位,无人修复。 |
| 基础设施 | ⚫ 已崩溃 | IconBox 无法上传新图标,Design Lab 可能随时下线,导致主题无法更新。 |

6.2 迁移路径推荐
方案 A:迁移至 Semi Design(推荐指数:⭐⭐⭐⭐)
如果你是因为喜欢字节系的设计风格而选择 Arco,那么 Semi Design 是最自然的替代者。
- 优势:同为字节出品,设计语言的命名规范和逻辑有相似之处。Semi 目前维护活跃,背靠抖音,拥有强大的 D2C 工具链。
- 劣势:API 并非 100% 兼容,仍需重构大量代码。且 Semi 主要是 React 优先,Vue 生态支持相对较弱(主要靠社区适配)。

方案 B:迁移至 Ant Design v5/v6(推荐指数:⭐⭐⭐⭐⭐)
如果你追求极致的稳定和长期的维护保障,Ant Design 是不二之选。
- 优势:行业标准,庞大的社区,Ant Gr0up 背书。v5 版本引入了 CSS-in-JS,在定制能力上已经大幅追赶 Arco 的 Design Lab。
- 劣势:设计风格偏保守,需要设计师重新调整 UI 规范。
方案 C:本地魔改(推荐指数:⭐)
如果项目庞大无法迁移,唯一的出路是将 @arco-design/web-react 源码下载到本地 packages 目录,作为私有组件库维护。
- 策略:放弃官方更新,仅修复阻塞性 Bug。这需要团队内有资深的前端架构师能够理解 Arco 的源码。

7. 结语与启示
Arco Design 的故事是现代软件工程史上的一个典型悲剧。它证明了在企业级开源领域,康威定律(Conway's Law) 依然是铁律——软件的架构和命运取决于开发它的组织架构。
当 GIP 部门意气风发时,Arco 是那颗最耀眼的星,承载着“务实浪漫主义”的理想;当组织收缩、业务调整时,它便成了由于缺乏商业造血能力而被迅速遗弃的资产。对于技术决策者而言,Arco Design 的教训是惨痛的:在进行技术选型时,不能仅看 README 上的 Star 数或官网的精美程度,更要审视项目背后的组织生命力和维护动机。

目前来看,Arco Design 并没有复活的迹象,社区也没有出现强有力的接棒者。这套组件库正在数字化浪潮的沙滩上,慢慢风化成一座无人问津的丰碑。
来源:juejin.cn/post/7582879379441745963
前端图像五兄弟:网络 URL、Base64、Blob、ArrayBuffer、本地路径,全整明白!
你有没有在写前端的时候,突然迷糊了:
- 为啥这张图片能直接
src="https://xxx.jpg"就能展示? - 为啥有时候图片是乱七八糟的一串 Base64?
- 有的还整出来个 Blob,看不懂但好像很高级?
- 有时还来个
ArrayBuffer,这又是哪位大哥? - 最离谱的是:我本地图片路径写进去,怎么就不生效?
这些,其实都和“图像在前端的存在形式”有关。今天咱们就像唠家常一样,一口气整明白这几个常见的前端图像形式,用最接地气的方式讲明白,配上实例、场景分析,帮你彻底建立系统认知!
一、网络 URL:最熟悉的那张脸
<img src="https://example.com/image.jpg" />
这就是我们最常见的方式:网络地址。
📦 本质上是啥?
一个 HTTP(S) 请求,浏览器去服务器上拉图片回来。
👍 优点:
- 用起来最简单,能连网就能显示
- 浏览器会缓存,提高加载效率
- 图片不占你的 HTML 或 JS 文件大小
👎 缺点:
- 依赖网络,断网就 GG
- 跨域可能出问题(特别是 canvas 想处理图片时)
- 没法离线用
🧩 常见场景:
- 图床、CDN 图片
- 用户头像、商品封面等动态内容
二、本地 URL(相对路径):常被坑的老兄
<img src="./images/logo.png" />
听起来像本地文件,实际上也是被打包进项目的资源文件路径。
⚙️ 本质上是啥?
开发时是相对路径,生产环境通常会被 Webpack、Vite 等构建工具“处理成”一个真实可访问的路径,比如 dist/assets/logo.abcd1234.png。
👀 你可能踩过的坑:
- 路径写错,或者构建工具没配置资源处理,图片加载失败
- 静态服务器没开,直接打开 HTML 无法访问文件(浏览器出于安全考虑禁止 file 协议访问)
💡 使用建议:
- 放到
public目录,或者使用 import 静态资源方式处理 - 建议使用构建工具配置 alias 简化路径
三、Base64:字节转码“图片串”
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA..." />
这是把图片数据编码成 Base64 的字符串,直接塞进 HTML 或 JS 文件里。
🔬 本质上是啥?
Base64 是一种将二进制数据编码成 ASCII 字符串的方式。
✅ 优点:
- 免请求!嵌入式图片,一起打包进页面
- 没有跨域问题
- 非常适合小图标、loading 动画、SVG
❌ 缺点:
- 体积暴涨,大概比原图多 33%
- 可读性差,不利于维护
- 页面初始加载变慢
🧩 常见场景:
CSS background-image- 富文本编辑器中的粘贴图像
- 邮件嵌入图像
四、Blob:文件对象,前端造图必备
const blob = new Blob([arrayBuffer], { type: 'image/png' });
const url = URL.createObjectURL(blob);
img.src = url;
这是处理文件流时常见的一种格式。
🔍 本质上是啥?
Blob 是浏览器提供的一种二进制大对象,可以把它看作 JS 里的“文件”。
💪 优点:
- 可由 JS 动态生成,支持下载、预览、上传
- 可控制 MIME 类型,灵活性强
- 可以通过
URL.createObjectURL()生成临时地址
📉 缺点:
- 是内存对象,页面刷新就没了
- 不能跨页面共享(临时的)
🧩 常见场景:
- 前端截图(
canvas.toBlob()) - 文件上传预览
- 后台生成图片后前端下载
五、ArrayBuffer / Uint8Array:最低层的图像数据表示
fetch('image.jpg')
.then(res => res.arrayBuffer())
.then(buffer => {
// 可以转为 blob 或 base64 再显示
});
这是最底层的图像数据,直接以字节数组的形式存在。
🧠 本质上是啥?
ArrayBuffer 是一段原始的内存区域,常用于处理二进制数据,Uint8Array 是对它的视图(读取用)。
🧰 常见用途:
- 图像处理(比如 AI 模型的图片输入)
- 自定义图片加载器(如通过 WASM 解码)
- 二进制传输协议
🔄 转换方式:
- 转为 Blob:
new Blob([buffer]) - 转为 Base64:
btoa(String.fromCharCode(...new Uint8Array(buffer)))
🔄 图像形式转换总结表格
| 形式 | 可直接显示 | 是否跨域限制 | 是否可本地预览 | 推荐用途 |
|---|---|---|---|---|
| 网络 URL | ✅ | 有 | ❌ | 最常见场景 |
| 本地路径 | ✅ | 无 | ✅(需本地服务器) | 项目资源图 |
| Base64 | ✅ | 无 | ✅ | 小图标、嵌入图 |
| Blob | ✅ | 无 | ✅ | 前端生成图 |
| ArrayBuffer | ❌ | 无 | ✅ | 图像底层处理 |
🧠 最后的总结:选哪种图像形式?
- ✅ 展示外部图 → 用 URL
- ✅ 项目图标/静态资源 → 本地路径
- ✅ 上传/预览/截图 → Blob
- ✅ 处理图像数据 → ArrayBuffer
- ✅ 小图或嵌入内容 → Base64
掌握这些图像“存在形式”,不仅能帮你写出更高效、稳定的代码,更能在项目中灵活切换,游刃有余!
如果你觉得这篇有点帮助,别忘了点个赞或者收藏一下~
来源:juejin.cn/post/7495549439035195402
🔥3 kB 换 120 ms 阻塞? Axios 还是 fetch?
0. 先抛结论,再吵不迟
| 指标 | Axios 1.7 | fetch (原生) |
|---|---|---|
| gzip 体积 | ≈ 3.1 kB | 0 kB |
| 阻塞时间(M3/4G) | 120 ms | 0 ms |
| 内存峰值(1000 并发) | 17 MB | 11 MB |
| 生产 P1 故障(过去一年) | 2 次(拦截器顺序 bug) | 0 次 |
| 开发体验(DX) | 10 分 | 7 分 |
结论:
- 极致性能/SSG/Edge → fetch 已足够;
- 企业级、需要全局拦截、上传进度 → Axios 仍值得;
- 二者可共存:核心链路与首页用 fetch,管理后台用 Axios。
1. 3 kB 到底贵不贵?
2026 年 1 月,HTTP Archive 最新采样(Chrome 桌面版)显示:
- 中位 JS 体积 580 kB,3 kB 似乎“九牛一毛”;
- 但放到首屏预算 100 kB 的站点(TikTok 推荐值),3 kB ≈ 3 % 预算,再加 120 ms 阻塞,LCP 直接从 1.5 s 飙到 1.62 s,SEO 评级掉一档。
“ bundle 每 +1 kB,4G 下 FCP +8 ms”——Lighthouse 2025 白皮书。
2. 把代码拍桌上:差异只剩这几行
下面 4 个高频场景,全部给出“可直接复制跑”的片段,差异一目了然。
2.1 自动 JSON + 错误码
// Axios:零样板
const {data} = await axios.post('/api/login', {user, pwd});
// fetch:两行样板
const res = await fetch('/api/login', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({user, pwd})
});
if (!res.ok) throw new Error(res.status);
const data = await res.json();
争议:
- Axios 党:少写两行,全年少写 3000 行。
- fetch 党:gzip 后 3 kB 换两行?ESLint 模板一把就补全。
2.2 超时 + 取消
// Axios:内置
const source = axios.CancelToken.source();
setTimeout(() => source.cancel('timeout'), 5000);
await axios.get('/api/big', {cancelToken: source.token});
// fetch:原生 AbortController
const ctl = new AbortController();
setTimeout(() => ctl.abort(), 5000);
await fetch('/api/big', {signal: ctl.signal});
2025 之后 Edge/Node 22 已全支持,AbortSignal.timeout(5000) 一行搞定:
await fetch('/api/big', {signal: AbortSignal.timeout(5000)});
结论:语法差距已抹平。
2.3 上传进度条
// Axios:progress 事件
await axios.post('/upload', form, {
onUploadProgress: e => setProgress(e.loaded / e.total)
});
// fetch:借助 `xhr` 或 `ReadableStream`
// 2026 仍无原生简易方案,需要封装 `xhr` 才能拿到 `progress`。
结论:大文件上传场景 Axios 仍吊打 fetch。
2.4 拦截器(token、日志)
// Axios:全局拦截
axios.interceptors.request.use(cfg => {
cfg.headers.Authorization = `Bearer ${getToken()}`;
return cfg;
});
// fetch:三行封装
export const $get = (url, opts = {}) => fetch(url, {
...opts,
headers: {...opts.headers, Authorization: `Bearer ${getToken()}`}
});
经验:拦截器一旦>2 个,Axios 顺序地狱频发;fetch 手动链式更直观。
3. 实测!同一个项目,两套 bundle
测试场景
- React 18 + Vite 5,仅替换 HTTP 层;
- 构建目标:es2020 + gzip + brotli;
- 网络:模拟 4G(RTT 150 ms);
- 采样 10 次取中位。
| 指标 | Axios | fetch |
|---|---|---|
| gzip bundle | 46.7 kB | 43.6 kB |
| 首屏阻塞时间 | 120 ms | 0 ms |
| Lighthouse TTI | 2.1 s | 1.95 s |
| 内存峰值(1000 并发请求) | 17 MB | 11 MB |
| 生产报错(过去一年) | 2 次拦截器顺序错乱 | 0 |
数据来自 rebrowser 2025 基准 ;阻塞时间差异与 51CTO 独立测试吻合 。
4. 什么时候一定要 Axios?
- 需要上传进度(onUploadProgress)且不想回退 xhr;
- 需要请求/响应拦截链 >3 层,且团队对“黑盒”可接受;
- 需要兼容 IE11(2026 年政务/银行仍存);
- 需要Node 16 以下老版本(fetch 需 18+)。
5. 共存方案:把 3 kB 花在刀刃上
// core/http.js
export const isSSR = typeof window === 'undefined';
export const HTTP = isSSR || navigator.connection?.effectiveType === '4g'
? { get: (u,o) => fetch(u,{...o, signal: AbortSignal.timeout(5000)}) }
: await import('axios'); // 动态 import,只在非 4G 或管理后台加载
结果:
- 首屏 0 kB;
- 管理后台仍享受 Axios 拦截器;
- 整体 bundle 下降 7 %,LCP −120 ms。
6. 一句话收尸
2026 年的浏览器,fetch 已把“缺的课”补完:取消、超时、Node 原生、TypeScript 完美。
3 kB 的 Axios 不再是“默认”,而是“按需”。
上传进度、深链拦截、老浏览器——用 Axios;
其余场景,让首页飞一把,把 120 ms 还给用户。
来源:juejin.cn/post/7590011643297005606
这 5 个冷门 HTML 标签,让我直接删了100 行 JS 代码!
在写前端的时候,我们实现的比较多的一些基础交互,比如折叠面板、弹窗、输入提示、进度条或颜色选择等等,会不得不引入 JavaScript。
但其实,HTML 自己也内置了不少功能强大的原生标签,它们开箱即用、语义清晰,还能大幅减少 JS 的代码量。
下面介绍 5 个冷门但实用的 HTML 标签。
1. <details> 和 <summary> - 可折叠内容
替代: 手风琴效果、折叠面板、FAQ部分
<details>
<summary>点击查看详情</summary>
<p>隐藏的内容,无需JS实现展开/收起</p>
</details>
实现效果:

使用场景
- FAQ 折叠面板
- 设置项分组展开
- 移动端“查看更多”区域
注意事项
- 默认是关闭状态;添加
open属性可默认展开:<details open> - 可通过 CSS 的
details[open]选择器定制展开样式 - 支持键盘操作(Enter/Space 触发),无障碍友好
2. <dialog> - 原生对话框
替代:div模拟模态框 + 背景遮罩 + 关闭逻辑
<dialog id="modal">
<p>这是原生弹窗</p>
<button onclick="document.getElementById('modal').close()">关闭</button>
</dialog>
<button onclick="document.getElementById('modal').showModal()">打开弹窗</button>
实现效果:

使用场景
- 确认提示框
- 登录/注册弹窗
- 临时信息展示
注意事项
.showModal()会自动创建半透明遮罩(可通过::backdrop自定义).show()是非模态显示(不锁定背景)- 聚焦自动管理:打开时聚焦第一个可聚焦元素,关闭后焦点返回触发按钮
- 兼容性:Chrome/Firefox/Edge 支持良好;Safari 15.4+ 支持;IE 不支持
3. <datalist> - 输入建议列表
替代:监听input事件 + 动态生成下拉列表
<input list="browsers" placeholder="选择或输入浏览器">
<datalist id="browsers">
<option value="Chrome">
<option value="Firefox">
<option value="Safari">
</datalist>
实现效果:

使用场景
- 搜索建议(非强制选项)
- 表单字段预填(如城市、产品名)
- 快速输入辅助
注意事项
- 用户仍可输入不在列表中的值(与
<select>不同) - 浏览器会自动根据输入过滤匹配项
- 移动端会调出带建议的软键盘(部分浏览器支持)
4. <meter> & <progress> - 进度指示器
替代:div模拟进度条 + JS更新宽度
<!-- 已知范围内的标量值(如磁盘使用率) -->
<meter min="0" max="100" value="70">70%</meter>
<!-- 任务完成进度(如文件上传) -->
<progress value="50" max="100">50%</progress>
实现效果:

使用场景
- 搜索建议(非强制选项)
- 表单字段预填(如城市、产品名)
- 快速输入辅助
注意事项
- 用户仍可输入不在列表中的值(与
<select>不同) - 浏览器会自动根据输入过滤匹配项
- 移动端会调出带建议的软键盘(部分浏览器支持)
5. <input type="color"> - 颜色选择器
替代:自定义颜色选择器UI + 色值转换逻辑
<input type="color" value="#ff0000">
实现效果:

使用场景
- 主题配色设置
- 图表颜色配置
- 设计工具中的拾色功能
注意事项
- 返回值始终为 小写 7 位十六进制(如
#ff5733) - 移动端会调出系统级颜色选择器
- 无法自定义 UI,但可通过
::-webkit-color-swatch微调样式(有限)
总结
<details>/<summary>:实现折叠内容<dialog>:原生弹窗,自带遮罩和焦点管理<datalist>:输入建议选择<meter>/<progress>:进度展示无需手动计算宽度<input type="color">:系统级颜色选择器开箱即用
这些原生 HTML 标签虽然不太起眼,但用好它们,不仅能省去大量 JavaScript 逻辑,还能让页面更语义化、更友好。
本文首发于公众号:程序员大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
来源:juejin.cn/post/7594742976712179746
一行生成绝对唯一 ID:别再依赖 Date.now() 了!
在前端开发中,“生成唯一 ID” 是高频需求 —— 从列表项标识、表单临时存储,到数据缓存键值,都需要一个 “绝对不重复” 的标识符。但看似简单的需求下,藏着很多容易踩坑的实现方式,稍有不慎就会引发数据冲突、逻辑异常等问题。
今天我们就来拆解常见误区,带你掌握真正可靠的唯一 ID 生成方案。
一、为什么 “唯一 ID” 比想象中难?
唯一 ID 的核心要求是 “全局不重复”,但前端环境的特殊性(无状态、多标签页、高并发操作),让很多看似合理的方案在实际场景中失效。
下面两种常见实现,其实都是 “伪唯一” 陷阱。
❌ 误区 1:时间戳 + 随机数(Date.now() + Math.random())
很多开发者会直觉性地将 “时间唯一性” 和 “随机唯一性” 结合,写出这样的代码:
// 错误示例:看似合理的“伪唯一”方案
function generateNaiveId() {
// 时间戳转36进制(缩短长度)+ 随机数截取
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
// 示例输出:l6n7f4v2am50k9m7o4
这种方案的缺陷在高并发场景下会暴露无遗:
- 时间戳精度不足:
Date.now()的精度是毫秒级(1ms),如果同一毫秒内调用多次(比如循环生成、高频接口回调),ID 的 “时间部分” 会完全重复; - 伪随机性风险:
Math.random()生成的是 “非加密级随机数”,其算法可预测,在短时间内可能生成重复的序列,进一步增加冲突概率。
结论:仅适用于低频次、非核心场景(如临时展示用 ID),绝对不能用于生产环境的核心数据标识。
❌ 误区 2:全局自增计数器
另一种思路是维护一个全局变量自增,看似能保证 “有序唯一”:
// 错误示例:自增计数器方案
let counter = 0;
function generateIncrementId() {
return `id-${counter++}`;
}
// 示例输出:id-0、id-1、id-2...
但在浏览器环境中,这个方案的缺陷更致命:
- 无状态丢失:页面刷新、路由跳转后,
counter会重置为 0,之前的 ID 序列会重复; - 多标签页冲突:用户打开多个相同页面时,每个页面的
counter都是独立的,会生成完全相同的 ID(比如两个页面同时生成id-0)。
结论:浏览器环境中几乎毫无实用价值,仅能用于单次会话、单页面的临时标识。
二、王者方案:一行代码实现绝对唯一 —— crypto.randomUUID()
既然简单方案不可靠,我们需要借助浏览器原生提供的 “加密级” 能力。crypto.randomUUID() 就是 W3C 标准推荐的官方解决方案,彻底解决 “唯一 ID” 难题。
1. 用法:一行代码搞定
crypto 是浏览器内置的全局对象(无需引入任何库),专门提供加密相关能力,randomUUID() 方法可直接生成符合 RFC 4122 v4 规范 的 UUID(通用唯一标识符):
// 正确示例:生成绝对唯一ID
const uniqueId = crypto.randomUUID();
// 示例输出:3a6c4b2a-4c26-4d0f-a4b7-3b1a2b3c4d5e
2. 为什么它是 “绝对唯一” 的?
crypto.randomUUID() 的可靠性源于三个核心优势:
- 极低碰撞概率:v4 UUID 由 122 位随机数构成,组合数量高达
2^122(约 5.3×10^36),相当于 “在地球所有沙滩的沙粒中,选中某一颗特定沙粒” 的概率,实际场景中碰撞概率趋近于 0; - 加密级随机性:基于 “密码学安全伪随机数生成器(CSPRNG)”,随机性远优于
Math.random(),无法被预测或破解,避免恶意伪造重复 ID; - 跨环境兼容:生成的 UUID 是全球通用标准格式(8-4-4-4-12 位字符),前端、后端(Node.js、Java 等)、数据库(MySQL、MongoDB)都能直接识别,无需格式转换。
3. 兼容性:覆盖所有现代环境
crypto.randomUUID() 的支持范围已经非常广泛,完全满足绝大多数新项目需求:
- 浏览器:Chrome 92+、Firefox 90+、Safari 15.4+(2022 年及以后发布的版本);
- 服务器:Node.js 14.17+(LTS 版本均支持);
- 框架:Vue 3、React 18、Svelte 等现代框架无任何兼容性问题。
三、兼容性兜底方案(针对旧环境)
如果需要兼容旧浏览器(如 IE11)或低版本 Node.js,可以使用第三方库 uuid(轻量、无依赖),其底层逻辑与 crypto.randomUUID() 一致:
安装依赖:
npm install uuid
# 或 yarn add uuid
使用方式:
// 旧环境兜底方案
import { v4 as uuidv4 } from 'uuid';
const uniqueId = uuidv4();
// 示例输出:同标准UUID格式
四、总结:唯一 ID 生成的 “最佳实践”

对于 2023 年后的新项目,直接使用 crypto.randomUUID() 即可 —— 一行代码、零依赖、绝对可靠,彻底告别 “ID 重复” 的烦恼!
来源:juejin.cn/post/7561781514922688522
前端的AI路其之三:用MCP做一个日程助理
前言
话不多说,先演示一下吧。大概功能描述就是,告诉AI“添加日历,今天下午五点到六点,我要去万达吃饭”,然后AI自动将日程同步到日历。

准备工作
开发这个日程助理需要用到MCP、Mac(mac的日历能力)、Windsurf(运行mcp)。技术栈是Typescript。
思路
基于MCP我们可以做很多。关于这个日程助理,其实也是很简单一个尝试,其实就是再验证一下我对MCP的使用。因为Siri的原因,让我刚好有了这个想法,尝试一下自己搞个日程助理。关于MCP可以看我前面的分享
# 前端的AI路其之一: MCP与Function Calling# 前端的AI路其之二:初试MCP Server 。
我的思路如下: 让大模型理解一下我的意图,然后执行相关操作。这也是我对MCP的理解(执行相关操作)。因此要做日程助理,那就很简单了。首先搞一个脚本,能够自动调用mac并添加日历,然后再包装成MCP,最后引入大模型就ok了。顺着这个思路,接下来就讲讲如何实现吧
实现
第一步:在mac上添加日历
这里我们需要先明确一个概念。mac上给日历添加日程,其实是就是给对应的日历类型添加日程。举个例子

左边红框其实就是日历类型,比如我要添加一个开发日程,其实就是先选择"开发"日历,然后在该日历下添加日程。因此如果我们想通过脚本形式创建日程,其实就是先看日历类型存在不存在,如果存在,就在该类型下添加一个日程。
因此这里第一步,我们先获取mac上有没有对应的日历,没有的话就创建一个。
1.1 查找日历
参考文档 mac查找日历
假定我们的日历类型叫做 日程助手。 这里我使用了applescript的语法,因为JavaScript的方式我这运行有问题。
import { execSync } from 'child_process';
function checkCalendarExists(calendarName) {
const Script = `tell application "Calendar"
set theCalendarName to "${calendarName}"
set theCalendar to first calendar where its name = theCalendarName
end tell`;
// 执行并解析结果
try {
const result = execSync(`osascript -e '${Script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
console.log(result);
return true;
} catch (error) {
console.error('检测失败:', error.message);
return false;
}
}
// 使用示例
const calendarName = '日程助手';
const exists = checkCalendarExists(calendarName);
console.log(`日历 "${calendarName}" 存在:`, exists ? '✅ 是' : '❌ 否');
附赠检验结果

现在我们知道了怎么判断日历存不存在,那么接下来就是,在日历不存在的时候创建日历
1.2 日历创建
参考文档 mac 创建日历
import { execSync } from 'child_process';
// 创建日历
function createCalendar(calendarName) {
const script = `tell application "Calendar"
make new calendar with properties {name:"${calendarName}"}
end tell`;
try {
execSync(`osascript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
return true;
} catch (e) {
console.log('create fail', e)
return false;
}
}
// 检查日历是否存在
function checkCalendarExists(calendarName) {
....
}
// 使用示例
const calendarName = '日程助手';
const exists = checkCalendarExists(calendarName);
console.log(`日历 "${calendarName}" 存在:`, exists ? '✅ 是' : '❌ 否');
if (!exists) {
const res = createCalendar(calendarName);
console.log(res ? '✅ 创建成功' : '❌ 创建失败')
}
运行结果

接下来就是第三步了,在日历“日程助手”下创建日程
1.3 创建日程
import { execSync } from 'child_process';
// 创建日程
function createCalendarEvent(calendarName, config) {
const script = `var app = Application.currentApplication()
app.includeStandardAdditions = true
var Calendar = Application("Calendar")
var eventStart = new Date(${config.startTime})
var eventEnd = new Date(${config.endTime})
var projectCalendars = Calendar.calendars.whose({name: "${calendarName}"})
var projectCalendar = projectCalendars[0]
var event = Calendar.Event({summary: "${config.title}", startDate: eventStart, endDate: eventEnd, description: "${config.description}"})
projectCalendar.events.push(event)
event`
try {
console.log('开始创建日程');
execSync(` osascript -l JavaScript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
console.log('✅ 日程添加成功');
} catch (error) {
console.error('❌ 执行失败:', error);
}
}
// 创建日历
function createCalendar(calendarName) {
....
}
// 检查日历是否存在
function checkCalendarExists(calendarName) {
...
}
这里我们完善一下代码
import { execSync } from 'child_process';
function handleCreateEvent(config) {
const calendarName = '日程助手';
const exists = checkCalendarExists(calendarName);
// console.log(`日历 "${calendarName}" 存在:`, exists ? '✅ 是' : '❌ 否');
if (!exists) {
const createRes = createCalendar(calendarName);
console.log(createRes ? '✅ 创建日历成功' : '❌ 创建日历失败')
if (createRes) {
createCalendarEvent(calendarName, config)
}
} else {
createCalendarEvent(calendarName, config)
}
}
// 创建日程
function createCalendarEvent(calendarName, config) {
const script = `var app = Application.currentApplication()
app.includeStandardAdditions = true
var Calendar = Application("Calendar")
var eventStart = new Date(${config.startTime})
var eventEnd = new Date(${config.endTime})
var projectCalendars = Calendar.calendars.whose({name: "${calendarName}"})
var projectCalendar = projectCalendars[0]
var event = Calendar.Event({summary: "${config.title}", startDate: eventStart, endDate: eventEnd, description: "${config.description}"})
projectCalendar.events.push(event)
event`
try {
console.log('开始创建日程');
execSync(` osascript -l JavaScript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
console.log('✅ 日程添加成功');
} catch (error) {
console.error('❌ 执行失败:', error);
}
}
// 创建日历
function createCalendar(calendarName) {
const script = `tell application "Calendar"
make new calendar with properties {name:"${calendarName}"}
end tell`;
try {
execSync(`osascript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
return true;
} catch (e) {
console.log('create fail', e)
return false;
}
}
// 检查日历是否存在
function checkCalendarExists(calendarName) {
const Script = `tell application "Calendar"
set theCalendarName to "${calendarName}"
set theCalendar to first calendar where its name = theCalendarName
end tell`;
// 执行并解析结果
try {
const result = execSync(`osascript -e '${Script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
return true;
} catch (error) {
return false;
}
}
// 运行示例
const eventConfig = {
title: '团队周会',
startTime: 1744183538021,
endTime: 1744442738000,
description: '每周项目进度同步',
};
handleCreateEvent(eventConfig)
运行结果


这就是一个完善的,可以直接在终端运行的创建日程的脚本的。接下来我们要做的就是,让大模型理解这个脚本,并学会使用这个脚本
第二步: 定义MCP
基于第一步,我们已经完成了这个日程助理的基本功能,接下来就是借助MCP的能力,教会大模型知道有这个函数,以及怎么调用这个函数
// 引入 mcp
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// 声明MCP服务
const server = new McpServer({
name: "mcp_calendar",
version: "1.0.0"
});
...
// 添加日历函数 也就是告诉大模型 有这个东西以及怎么用
server.tool("add_mac_calendar", '给mac日历添加日程, 接受四个参数 startTime, endTime是起止时间(格式为YYYY-MM-DD HH:MM:SS) title是日历标题 description是日历描述', { startTime: z.string(), endTime: z.string(), title: z.string(), description: z.string() },
async ({ startTime, endTime, title, description }) => {
const res = handleCreateEvent({
title: title,
description: description,
startTime: new Date(startTime).getTime(),
endTime: new Date(endTime).getTime()
});
return {
content: [{ type: "text", text: res ? '添加成功' : '添加失败' }]
}
})
// 初始化服务
const transport = new StdioServerTransport();
await server.connect(transport);
这里附上完整的ts代码
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { execSync } from 'child_process';
import { z } from "zod";
export interface EventConfig {
// 日程标题
title: string;
// 日程开始时间 毫秒时间戳
startTime: number;
// 日程结束时间 毫秒时间戳
endTime: number;
// 日程描述
description: string;
}
const server = new McpServer({
name: "mcp_calendar",
version: "1.0.0"
});
function handleCreateEvent(config: EventConfig) {
const calendarName = '日程助手';
const exists = checkCalendarExists(calendarName);
// console.log(`日历 "${calendarName}" 存在:`, exists ? '✅ 是' : '❌ 否');
let res = false;
if (!exists) {
const createRes = createCalendar(calendarName);
console.log(createRes ? '✅ 创建日历成功' : '❌ 创建日历失败')
if (createRes) {
res = createCalendarEvent(calendarName, config)
}
} else {
res = createCalendarEvent(calendarName, config)
}
return res
}
// 创建日程
function createCalendarEvent(calendarName: string, config: EventConfig) {
const script = `var app = Application.currentApplication()
app.includeStandardAdditions = true
var Calendar = Application("Calendar")
var eventStart = new Date(${config.startTime})
var eventEnd = new Date(${config.endTime})
var projectCalendars = Calendar.calendars.whose({name: "${calendarName}"})
var projectCalendar = projectCalendars[0]
var event = Calendar.Event({summary: "${config.title}", startDate: eventStart, endDate: eventEnd, description: "${config.description}"})
projectCalendar.events.push(event)
event`
try {
console.log('开始创建日程');
execSync(` osascript -l JavaScript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
console.log('✅ 日程添加成功');
return true
} catch (error) {
console.error('❌ 执行失败:', error);
return false
}
}
// 创建日历
function createCalendar(calendarName: string) {
const script = `tell application "Calendar"
make new calendar with properties {name:"${calendarName}"}
end tell`;
try {
execSync(`osascript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
return true;
} catch (e) {
console.log('create fail', e)
return false;
}
}
// 检查日历是否存在
function checkCalendarExists(calendarName: string) {
const Script = `tell application "Calendar"
set theCalendarName to "${calendarName}"
set theCalendar to first calendar where its name = theCalendarName
end tell`;
// 执行并解析结果
try {
const result = execSync(`osascript -e '${Script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
return true;
} catch (error) {
return false;
}
}
server.tool("add_mac_calendar", '给mac日历添加日程, 接受四个参数 startTime, endTime是起止时间(格式为YYYY-MM-DD HH:MM:SS) title是日历标题 description是日历描述', { startTime: z.string(), endTime: z.string(), title: z.string(), description: z.string() },
async ({ startTime, endTime, title, description }) => {
const res = handleCreateEvent({
title: title,
description: description,
startTime: new Date(startTime).getTime(),
endTime: new Date(endTime).getTime()
});
return {
content: [{ type: "text", text: res ? '添加成功' : '添加失败' }]
}
})
const transport = new StdioServerTransport();
await server.connect(transport);
第三步: 导入Windsurf
在前文已经讲过如何引入到Windsurf,可以参考前文# 前端的AI路其之二:初试MCP Server ,这里就不过多赘述了。 其实在build之后,完全可以引入其他支持MCP的软件基本都是可以的。
接下来就是愉快的调用时间啦。
总结
这里其实是对前文# 前端的AI路其之二:初试MCP Server 的再次深入。算是大概讲明白了Tool方式怎么用,MCP当然不止这一种用法,后面也会继续输出自己的学习感悟,也欢迎各位大佬的分享和指正。
祝好。
来源:juejin.cn/post/7495598542405550107
Web PWA的极致,比App更像App
这是一个平平无奇的音乐App Vooh,你可以在里面搜索歌曲,添加播放列表,播放音乐。

你可以滑动返回上一级页面,就像任何一个普通的App那样。

你可以流畅地展开音乐播放面板,看着歌词随着播放时间滚动。

当然,你也可以在电脑端,或者iPad上使用这个App。

而它与App的唯一不同,在于安装它不需要下载庞大的安装文件,只需要一个链接。音乐播放器Vooh的本体,只是一个网页。
作为一个诞生了好几年的老技术,PWA(Progressive Web Application)自诞生以来一直都不温不火,Google对它的愿景是最终所有的网页都能做到和App一致的体验,但直到现在,它都像是一道可有可无的饭后甜点。对于网页来说,即用即走似乎是它与生俱来的诅咒,用户既没有将Web安装到桌面的必要,也没有这个耐心,毕竟对于网络延迟增加1秒都可能导致访问量降低80%的地狱难度模式的网页用户生态而言,让一个浏览器用户点击一个陌生的“Install as application”的按钮简直是天方夜谭。尽管它就在那里,但乐于尝试的人似乎总是寥寥无几。既然PWA和纯网页能做的事情相差无几,那为什么还要浪费桌面空间增加一个以后可能再也不会使用的图标呢?
我一直认为,PWA应该朝着更像App的方向努力,才能体现出它的价值。然而,目前的许多PWA,看起来只是把普通的网页做成了全屏,与在浏览器中的体验别无二致,做不出差异化,用户自然没有动力去安装PWA,PWA那些听起来十分美好的特性便成了空中楼阁,无源之水,这个名字也越来越将从人们的视野中慢慢淡去。
如何才能让PWA更像APP,这是一个问题。毕竟浏览器的交互逻辑和原生App相比,有着很大的区别,用户早已习惯了移动浏览器中的前进后退,页面加载时的白屏,以及几乎不存在的手势交互,似乎在说,没关系,这就是网页,它做到这个份上已经足够了。然而,若要把这份体验带到模仿原生App的PWA中去,那势必将迎来用户预期低落的反噬,连这样那样的交互体验都没有,还能叫App?
为了了解目前的PWA究竟能做到何种地步,我开发了Vooh,一个竭尽可能模仿原生App实现的PWA音乐播放器。它尽力实现了一个原生App应该具备的一切交互细节,包括页面间自然的动画过渡,跟手的手势交互,为触屏优化的样式细节等等,我尽可能将它的每个细节都尽可能地做到与App别无二致,就是为了探索Web能力的极限。而在这之后,我也打算将Vooh的实现原理整理出来,并且准备逐步将之前的做过的项目“App化”,来一窥Google期待的未来,究竟是什么样子。
无处不在的过渡动画
尽管Vue,React以及原生CSS都提供了方便的方式实现过渡动画,但是对于大多数网页来说,一个Loading动画可能就是整个页面里动画最多的地方了。这对于网页来说的确无关紧要,毕竟用户们早已习惯了浏览器里生硬的切换效果,没有成体系的交互反馈,以及突然消失出现的页面区域。尽管在许多成熟组件库慢慢开始注重交互动画的优化之后,这样的情况在慢慢改善,但是依然难以改变用户的刻板印象。因此,为用户的预期提供动画反馈是伪装成原生App的一个关键步骤,否则,缺少反馈的使用体验会一下子将用户安装和使用PWA的欲望拉得很低。
除去老生常谈的按钮悬浮、按下时的动画,页面间的过渡动画也是不可缺少的一环。如果你仔细观察iOS的Tab页面,就能发现在切换Tab的时候,也会有细微的不易察觉的缩放淡出渐变,正是这种细致入微的动画组成了iOS App丝滑体验中重要的一部分。
表单组件的动画效果也很重要,Vooh尽可能地使用了iOS风格的表单组件,例如Button,Switch等,以贴合用户的日常视觉体验。

手势交互
手势是网页与App的重要差异点,一般来说,很少会有网页支持用户的滑动返回,长按呼出菜单等复杂的手势操作,而这正是让你的PWA丝般顺滑的关键。
需要注意的是,由于大部分移动浏览器和JS本身单线程的限制,手势交互依赖监听器的执行速度,而很难跑满设备屏幕的帧率上限,尤其是iOS设备上,开启低电量模式的情况下,监听器的帧率可能只有不到30 FPS,肉眼可见的卡顿。目前为止,也没有看到任何浏览器厂商有关于优化手势交互的提案,手势交互就像一道横亘在网页与App之间的鸿沟,没有丝毫跨越的可能,只能尽可能地模仿。

离线访问
没有哪个用户能接受打开App时整个页面全部消失无法操作,APP的最大优点就是离线可用,好在Service Worker的推出让这一点不再是问题,通过Service Worker对网页资源进行缓存,可以实现在低网速甚至离线环境下,也能继续使用PWA,就像真正的App那样。
然而不幸的是,在iOS设备上,Service Worker离线缓存不再可用,开启飞行模式或者关闭网络连接后将无法访问任何网页,包括已经安装在桌面上的PWA,
偶遇现代IE厂商,拼尽全力无法战胜。
细节之外的细节
而Vooh在这些基础能力之外,还增加了许多其他的细节设计,让整个App在模仿原生App时更进一步。
1,存储占用管理
在移动设备上,PWA与App的存储占用是分隔开的,而且往往要经过十分复杂的步骤才能看到PWA的实际空间占用,因此对于音乐播放器这种高度依赖本地资源的应用来说,一个显而易见的存储占用管理系统能有效缓解用户的存储焦虑。

2,接入系统播放器
隆重介绍Media Session API,它能让JS直接接入系统播放器控件,即使在后台也可以允许用户通过系统自带的播放器控制媒体的播放,例如下一曲、播放暂停等,在iOS设备上,还能直接适配灵动岛,这下谁还能分辨谁是原生App。

3,深色模式
在Apple等手机厂商的推动下,大部分的App都已经适配深色模式,而网页对于深色模式的适配比起App要更为简单,毕竟CSS实在是太灵活了,Vooh当然也做了适配,在不同的模式下都能完美贴合系统的主体模式。
为了提升Vooh与其他原生播放器的(根本不存在的)竞争力,我也煞费苦心地加入了许多的细节,来让用户有真正使用它的动力,例如根据歌曲封面动态取色,自动识别的滚动歌词等,希望能让它在用户的手机桌面上多待一段时间。
未竟之事
不过,即使是做到了这个地步,PWA的能力始终是有极限的。有些App轻易能做到的事,对于PWA而言犹如天堑一般遥不可及,包括但不限于:
1,后台活动
在移动设备上,网页也好,PWA也好,基本上没有任何后台活动能力,甚至上面提到的Media Session API,在iOS上顶多也只最多能支持后台播放1~2首歌曲,然后就会被强行停止,更不用说后台导航,推送通知这种活在梦里的API了,这方面浏览器天生就是残废,未来也看不到有任何改进的可能,因此在开发PWA时,一定要远离这些方向。在js都能跑虚拟机,剪视频的当下,Web开发者们推送一条通知的希冀却只能在另一个平行时空实现了。
2,跳转到PWA
据说Andriod Chrome支持使用PWA来打开特定的链接,不过在iOS上就别想了。
3,触感反馈
同样,Web也只能使用早已被淘汰的Vibrate,细腻的振动反馈和Taptic Engine对网页来说也是天方夜谭。
4,调用原生功能
还有无数浩如烟海的功能是PWA完全无法实现的,例如系统级的音量调节,亮度调节等,我能理解这是浏览器对恶意网站的限制,但这也确实极大限制了Web的发展,比如奠定了Web安全基础的跨域限制,如今成为了许多大型Web应用的掣肘。我由衷地希望某天浏览器能制定一个更宽松的PWA标准,例如安装到桌面后能提供更多的权限,提供一个无跨域限制的fetch代替品等等,然而即使对Web上心如Google,也没有考虑过这个方向的可能性。JS正在和越来越宽松的宿主环境(Tuari,Electron)一步步蚕食着原生GUI开发的领地,而它的发源地,浏览器却只能被所谓的安全性限制,成为一个只负责播放动画的花瓶。
总结
正如所说,一切能由javascript实现的终将会用javascript实现。如今,越来越多的平台小程序,快应用,乃至于H5套壳的App越来越多,随着浏览器性能的进一步提升,Web能做到的事越来越多,但是Web的交互性却并没有随着javascript的繁荣而被重视起来,受限于javascript的单线程特性,要完全模拟App的使用体验还是有一定的差距,一个劲地往原生体验上靠,有时也并不一定是最好的选择,Vooh的出现只是给了开发者们一个可能的方向,Web的轻量,优秀的可触达性与PWA有机结合,才是Web的发展方向。同时也希望各家浏览器厂商们能加快适配新的Web特性,能够让程序们在写代码时少掉一些头发,便是最大的善事了
如果对Vooh的实现方式有兴趣的话,欢迎关注我的专栏或者博客,后续的代码也会一并开源,涉及到音乐版权相关,目前的Vooh只开放了2首免费无版权音乐的使用,代码也不会涉及版权相关的领域。
来源:juejin.cn/post/7490977437674651683
视频播放弱网提示实现
作者:陈盛靖
一、背景
业务群里面经常反馈,视频播放卡顿,视频播放总是停留在某一时刻就播放不了了。后面经过排查,发现这是因为弱网导致的。然而,用户数量众多,隔三差五总有人在群里反馈,有时问题一时半会好不了,用户就会怀疑不是网络,而是我们的系统问题。因此,我们希望能在弱网的时候展示提示,这样用户体验会更友好,同时也能减少一定的客诉。
二、现状分析
我们使用的播放器是chimee(http://www.chimee.org/index.html)。遗憾的是,chimee并没有视频播放卡顿自动展示loading的功能,不过我们可以通过其插件能力,来编写一个自定义video-loading的插件。
三、方案设计
使用NetworkInformation
常见的方法就是我们通过设定一个标准,然后检测用户设备的网络速度,在到达一定阈值时展示弱网提示。这里需要确定一个重要的点:什么情况下才算弱网?
我们的应用是h5,这里我们可以使用window对象中的NetworkInformation(developer.mozilla.org/zh-CN/docs/…),我们可以通过浏览器的debug工具,打印window.naviagtor.connection,这个对象内部就存储着网络信息:

其中各个属性含义如下表所示:
| 属性 | 含义 |
|---|---|
| downlink | 返回以兆比特每秒为单位的有效带宽估计,四舍五入到最接近的 25 千比特每秒的倍数。 |
| downlinkMax | 返回底层连接技术的最大下行速度,以兆比特每秒(Mbps)为单位。 |
| effectiveType | 返回连接的有效类型(意思是“slow-2g”、“2g”、“3g”或“4g”中的一个)。此值是使用最近观察到的往返时间和下行链路值的组合来确定的。 |
| rtt | 返回当前连接的有效往返时间估计,四舍五入到最接近的 25 毫秒的倍数。 |
| saveData | 如果用户在用户代理上设置了减少数据使用的选项,则返回 true。 |
| type | 返回设备用于网络通信的连接类型。它会是以下值之一: bluetooth cellular ethernet none wifi wimax other unknown |
| onchange | 接口的 change 事件在网络连接信息发生变化时被触发,并且该事件由 NetworkInformation(developer.mozilla.org/zh-CN/docs/…) 对象接收。 |
其中,我们可以通过effectiveType判断当前网络的大体情况,并且可以拿到一个预估的网络带宽(downlink)。我们可以通过监听onchange事件,在网络变差的时候,展示对应的弱网提示。
这个方案的优点是:
- 浏览器环境原生支持
- 实现相对简单
但缺点却十分明显:
- 网络状态变化非实时
effectiveType的变化可能是分钟级别的,对于短暂的网络波动,状态没办法做更精细的把控
- 存在兼容性问题
对于不同一些主流浏览器不支持,例如Firefox、Safari等

- 不同设备间存在差异
不同的设备和浏览器,由于其差异,在不同的网络情况下,视频的播放情况是不一样的,如果我们固定一个标准,可能会导致在不同设备下,同一个网络速度,有人明明正常播放视频,但是却提示网络异常,这样用户会感到疑惑。
那有没有更好的方法呢?
监听Video元素事件
chimee底层也是在html video上进行的二次封装,我们可以在插件的生命周期中,拿到对应的video元素节点。而在video标签中,存在这样两个事件:waiting和canplay。
其事件描述如下图所示:

当视频播放卡顿时,会触发waiting事件;而当视频播放恢复正常时,会触发canplay事件。只要监听这两个事件,我们就可以实现对应的功能了。
四、功能拓展
我们知道,现在大多数网站的视频在提示弱网的时候,都会展示当前设备的网络速度是多少。因此我们也希望在展示对应的信息。那么怎么实现网络速度的检测呢?
一个简单的方法是,我们可以通过获取一张固定大小的图片资源(不一定是图片,也可以是别的类型的资源),并统计请求该资源的请求速度,从而计算当前网络的带宽是多少。当然,图片大小要尽可能小一点,一是为了节省用户流量,二是为了避免在网络不好的情况下,图片请求太慢导致一直计算不出来。
具体代码如下:
funtion calculateSpeed() {
// 图片大小772Byte
const fileSize = 772;
// 拼接时间戳,避免缓存
const imgUrl = `https://xxx.png?timestamp=${new Date().getTime()}`;
return new Promise((resolve, reject) => {
let start = 0;
let end = 1000;
let img = document.createElement('img');
start = new Date().getTime();
img.onload = function (e) {
end = new Date().getTime();
// 计算出来的单位为 B/s
const speed = fileSize / (end > start ? end - start : 1000) * 1000;
resolve(speed);
}
img.src = imgUrl;
}).catch(err => { throw err });
}
function translateUnit(speed) {
if(speed === 0) return '0.00 B/s';
if(speed > 1024 * 1024) return `${(speed / 1024 / 1024).toFixed(2)} MB/s`;
if(speed > 1024) return `${(speed / 1024).toFixed(2)} KB/s`;
else return `${speed.toFixed(2)} B/s`;
}
我们可以通过setInterval来轮询调用该函数,从而实时展示当前网络情况。系统流程图如下:

五、总结
我们可以通过Chrome浏览器开发者工具中的Network中的网络配置来模拟弱网情况

具体效果如下:

成功实现视频弱网提示,完结撒花🎉🎉🎉🎉🎉🎉。
来源:juejin.cn/post/7593550315254218758
富文本编辑器技术选型,到底是 Prosemirror 还是 Tiptap 好 ❓❓❓
我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于
Tiptap的富文本编辑器、NestJs后端服务、AI集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了Tiptap的深度定制、性能优化和协作功能的实现等核心难点。
如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。
在前端开发中,撤销和重做功能是提升用户体验的重要特性。无论是文本编辑器、图形设计工具,还是可视化搭建平台,都需要提供历史操作的回退和前进能力。这个功能看似简单,但实现起来需要考虑性能、内存占用、用户体验等多个方面。
在构建富文本编辑器时,Tiptap 和 ProseMirror 是两个常见的技术选择。两者都强大且灵活,但它们在设计理念、易用性、扩展性等方面存在差异。对于开发者来说,选择合适的工具对于项目的成功至关重要。本文将深入探讨两者的异同,并通过实际代码示例帮助你理解它们的差异,从而根据具体需求做出决策。
ProseMirror 的优势与挑战
ProseMirror 是一个 JavaScript 库,用于构建复杂的富文本编辑器。它的设计非常底层,提供了一个高效且灵活的文档模型,开发者可以完全控制编辑器的行为和界面。ProseMirror 本身并不提供任何 UI 或组件,而是一个核心库,开发者需要自行实现具体的编辑器功能。
作为一个底层框架,ProseMirror 允许开发者完全控制编辑器的各个方面,包括文档结构、输入行为、UI 样式等。它提供了丰富的 API,可以处理复杂的编辑需求,如数学公式、代码块、图片、链接等。开发者可以为几乎任何功能编写插件,并且可以在已有插件的基础上进行二次开发。基于虚拟 DOM 的设计,使其在大文档和复杂结构下能够提供较高的性能。
然而,由于其底层设计,ProseMirror 的 API 复杂,学习曲线陡峭。开发者需要深入理解其文档模型、事务管理、节点和视图的关系。由于不提供任何 UI 组件,开发者需要从零开始构建编辑器的界面和交互,配置和初始化过程也较为复杂,需要手动处理许多底层逻辑。
ProseMirror 基础使用示例
首先需要安装必要的包:
npm install prosemirror-state prosemirror-view prosemirror-model prosemirror-schema-basic prosemirror-schema-list prosemirror-commands
创建一个基本的 ProseMirror 编辑器需要配置 schema、state 和 view:
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { Schema, DOMParser } from "prosemirror-model";
import { schema } from "prosemirror-schema-basic";
import { addListNodes } from "prosemirror-schema-list";
import { exampleSetup } from "prosemirror-example-setup";
// 扩展基础 schema,添加列表支持
const mySchema = new Schema({
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
marks: schema.spec.marks,
});
// 创建编辑器状态
const state = EditorState.create({
schema: mySchema,
plugins: exampleSetup({ schema: mySchema }),
});
// 创建编辑器视图
const view = new EditorView(document.querySelector("#editor"), {
state,
});
如果需要添加自定义命令,比如一个格式化工具条,需要手动实现:
import { toggleMark } from "prosemirror-commands";
import { schema } from "prosemirror-schema-basic";
// 创建加粗命令
const toggleBold = toggleMark(schema.marks.strong);
// 手动创建工具栏按钮
function createToolbar(view) {
const toolbar = document.createElement("div");
toolbar.className = "toolbar";
const boldBtn = document.createElement("button");
boldBtn.textContent = "Bold";
boldBtn.onclick = () => {
toggleBold(view.state, view.dispatch);
view.focus();
};
toolbar.appendChild(boldBtn);
return toolbar;
}
ProseMirror 自定义插件示例
创建一个自定义插件需要理解 ProseMirror 的插件系统:
import { Plugin } from "prosemirror-state";
// 创建一个字符计数插件
function characterCountPlugin() {
return new Plugin({
view(editorView) {
const counter = document.createElement("div");
counter.className = "char-counter";
const updateCounter = () => {
const text = editorView.state.doc.textContent;
counter.textContent = `字符数: ${text.length}`;
};
updateCounter();
return {
update(view) {
updateCounter();
},
destroy() {
counter.remove();
},
};
},
});
}
// 使用插件
const state = EditorState.create({
schema: mySchema,
plugins: [characterCountPlugin(), ...exampleSetup({ schema: mySchema })],
});
Tiptap 的便捷开发
Tiptap 是基于 ProseMirror 构建的富文本编辑器框架,它简化了 ProseMirror 的复杂性,提供了现成的 UI 组件和更易于使用的 API。Tiptap 旨在让开发者能够快速实现丰富的富文本编辑器,同时保持较高的灵活性和扩展性。
Tiptap 提供了简洁的 API,开发者不需要深入学习 ProseMirror 的底层概念即可实现基本的富文本编辑功能。它通过封装 ProseMirror 的复杂性,使得开发过程更加直观和简便。开箱即用的 UI 组件,如文本格式化、列表、图片插入等,极大地方便了开发者的使用,减少了开发时间。清晰的文档和活跃的开源社区,也为开发者提供了良好的支持和资源。虽然 Tiptap 进行了封装,但它仍然保留了 ProseMirror 的插件系统,开发者可以根据需要定制功能,并且可以轻松地集成其他插件。此外,Tiptap 可以与 Yjs 或其他 CRDT 库结合,支持实时协作编辑功能,这是 ProseMirror 本身不具备的特性。
不过,由于 Tiptap 封装了 ProseMirror 的很多底层功能,灵活性相对较低。对于一些需要极高自定义的需求,Tiptap 可能不如 ProseMirror 灵活。虽然在大多数情况下性能良好,但在处理超大文档或复杂操作时,性能可能不如直接使用 ProseMirror。
Tiptap 基础使用示例
Tiptap 的安装和使用相对简单:
npm install @tiptap/react @tiptap/starter-kit @tiptap/pm
在 React 中使用 Tiptap:
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
function TiptapEditor() {
const editor = useEditor({
extensions: [StarterKit],
content: "<p>Hello World!</p>",
});
if (!editor) {
return null;
}
return (
<div>
<div className="toolbar">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "is-active" : ""}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "is-active" : ""}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "is-active" : ""}
>
Bullet List
</button>
</div>
<EditorContent editor={editor} />
</div>
);
}
Tiptap 的 Vue 版本同样简洁:
<template>
<div>
<div class="toolbar">
<button
@click="editor.chain().focus().toggleBold().run()"
:disabled="!editor.can().chain().focus().toggleBold().run()"
:class="{ 'is-active': editor.isActive('bold') }"
>
Bold
</button>
<button
@click="editor.chain().focus().toggleItalic().run()"
:class="{ 'is-active': editor.isActive('italic') }"
>
Italic
</button>
</div>
<editor-content :editor="editor" />
</div>
</template>
<script>
import { useEditor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
export default {
components: {
EditorContent,
},
setup() {
const editor = useEditor({
extensions: [StarterKit],
content: "<p>Hello World!</p>",
});
return { editor };
},
};
</script>
Tiptap 扩展功能示例
Tiptap 支持多种扩展,添加图片功能非常简单:
import Image from "@tiptap/extension-image";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
function EditorWithImage() {
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({
inline: true,
allowBase64: true,
}),
],
});
const addImage = () => {
const url = window.prompt("图片URL");
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
};
return (
<div>
<button onClick={addImage}>添加图片</button>
<EditorContent editor={editor} />
</div>
);
}
创建自定义扩展也很直观:
import { Extension } from "@tiptap/core";
import { Plugin } from "prosemirror-state";
const CharacterCount = Extension.create({
name: "characterCount",
addProseMirrorPlugins() {
return [
new Plugin({
view(editorView) {
const counter = document.createElement("div");
counter.className = "char-counter";
const updateCounter = () => {
const text = editorView.state.doc.textContent;
counter.textContent = `字符数: ${text.length}`;
};
updateCounter();
return {
update(view) {
updateCounter();
},
destroy() {
counter.remove();
},
};
},
}),
];
},
});
// 使用自定义扩展
const editor = useEditor({
extensions: [StarterKit, CharacterCount],
});
Tiptap 实时协作示例
Tiptap 与 Yjs 集成实现实时协作非常简单:
npm install yjs y-prosemirror @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";
// 创建 Yjs 文档和提供者
const ydoc = new Y.Doc();
const provider = new WebrtcProvider("room-name", ydoc);
function CollaborativeEditor() {
const editor = useEditor({
extensions: [
StarterKit,
Collaboration.configure({
document: ydoc,
}),
CollaborationCursor.configure({
provider,
}),
],
});
return <EditorContent editor={editor} />;
}
从代码看差异
让我们通过实现一个带工具栏的编辑器来对比两者的代码复杂度:
在 ProseMirror 中,需要手动管理所有状态和命令:
import { EditorState, Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { schema } from "prosemirror-schema-basic";
import { toggleMark } from "prosemirror-commands";
const state = EditorState.create({ schema });
const toolbarPlugin = new Plugin({
view(editorView) {
const toolbar = document.createElement("div");
toolbar.className = "toolbar";
const boldBtn = document.createElement("button");
boldBtn.textContent = "B";
boldBtn.onclick = (e) => {
e.preventDefault();
const { state, dispatch } = editorView;
const command = toggleMark(schema.marks.strong);
if (command(state, dispatch)) {
editorView.focus();
}
};
toolbar.appendChild(boldBtn);
document.body.insertBefore(toolbar, editorView.dom);
return {
destroy() {
toolbar.remove();
},
};
},
});
const view = new EditorView(document.querySelector("#editor"), {
state: EditorState.create({
schema,
plugins: [toolbarPlugin],
}),
});
而在 Tiptap 中,相同的功能实现更加简洁:
const editor = useEditor({
extensions: [StarterKit],
});
return (
<div>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "is-active" : ""}
>
B
</button>
<EditorContent editor={editor} />
</div>
);
如何做出选择
选择 Tiptap 还是 ProseMirror,关键在于项目需求和开发团队的技术能力。
如果你的目标是快速构建一个功能丰富、用户友好的富文本编辑器,且不希望花费过多时间在底层细节上,Tiptap 是一个理想的选择。它提供了简洁的 API 和现成的 UI 组件,可以快速启动和开发。如果你的编辑器需要一些定制功能,但不需要完全控制每个底层细节,Tiptap 提供了足够的灵活性,同时保持了开发的简便性。如果需要实现多人实时协作,Tiptap 内建的对 Yjs 等库的支持可以简化实现过程。
如果你需要完全控制编辑器的行为、界面和性能,ProseMirror 提供了更高的自由度。它适合那些有特定需求的项目,比如自定义文档结构、输入行为或非常复杂的编辑操作。在处理非常大的文档或需要极高性能的场景下,ProseMirror 能提供更好的优化和性能。如果你的项目需要完全自定义插件,或者你想对编辑器进行深度定制,ProseMirror 提供了更高的灵活性。
性能考虑
对于大文档处理,ProseMirror 提供了更细粒度的控制:
// ProseMirror 中可以精确控制更新
const state = EditorState.create({
schema,
plugins: [
// 可以精确控制哪些插件启用
// 可以自定义更新逻辑
new Plugin({
state: {
init() {
return {};
},
apply(tr, value) {
// 自定义状态更新逻辑
return value;
},
},
}),
],
});
而 Tiptap 虽然性能良好,但在极端场景下可能不如直接使用 ProseMirror 优化:
// Tiptap 的性能优化选项
const editor = useEditor({
extensions: [StarterKit],
editorProps: {
attributes: {
class:
"prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none",
},
// 可以传递 ProseMirror 的原生配置
},
// 但仍然受到封装层的限制
});
生态系统和社区支持
Tiptap 拥有丰富的扩展生态系统:
# Tiptap 官方扩展
npm install @tiptap/extension-image
npm install @tiptap/extension-link
npm install @tiptap/extension-table
npm install @tiptap/extension-code-block-lowlight
npm install @tiptap/extension-placeholder
npm install @tiptap/extension-character-count
npm install @tiptap/extension-typography
而 ProseMirror 的插件需要通过 prosemirror-* 包系列来获取,或者自己实现。官方提供了基础插件,但高级功能需要社区插件或自行开发。
实际项目场景建议
对于博客平台、内容管理系统、笔记应用等常见场景,Tiptap 通常是最佳选择。它的快速开发和丰富的功能足以满足大多数需求。代码示例展示了如何在几分钟内搭建一个功能完整的编辑器。
对于需要特殊文档结构(如学术论文编辑器、代码编辑器、专业排版工具)或对性能有极致要求的场景,ProseMirror 提供了必要的底层控制能力。但需要投入更多时间学习其 API 和概念。
如果你的团队时间有限,或者希望快速迭代,Tiptap 是明智的选择。如果团队有富文本编辑器开发经验,或者有充足时间进行深度定制,ProseMirror 可以带来更高的灵活性和性能。
总结
Tiptap 是一个基于 ProseMirror 的富文本编辑器框架,适合需要快速开发、易用且功能丰富的场景。它封装了 ProseMirror 的复杂性,让开发者能够专注于业务逻辑,而无需关心底层实现细节。通过本文的代码示例可以看出,Tiptap 的 API 设计更加直观,学习曲线平缓,适合大多数项目需求。
ProseMirror 则是一个底层框架,适合那些需要完全控制文档结构、编辑行为和性能优化的高级开发者。它更灵活,但学习曲线较陡峭,适合复杂或定制化需求较强的项目。从代码示例中可以看到,使用 ProseMirror 需要处理更多的底层细节,但同时也获得了更高的控制权。
如果你的项目需要快速构建编辑器并具备一定的自定义能力,Tiptap 是一个更为理想的选择。而如果你的项目需要完全的定制化和高性能处理,ProseMirror 将更符合你的需求。最终的选择应基于你的开发需求、项目规模以及团队的技术能力。建议通过实际代码尝试两者,根据你的具体场景做出最适合的选择。
来源:juejin.cn/post/7593573617647796276
🌸 入职写了一个月全栈next.js 感想
背景介绍
- 最近组内要做0-1的新ai产品, 招我进来就是负责这个ai产品,启动的时候这个季度就剩下两个月了,天天开会对齐进度,一个月就已经把基础版本给做完了,想要接入到现有的业务上面,时间方面就特别紧张,技术选型怎么说呢, leader用ai写了一个版本 我们在现有的代码进行二次开发这样, 全栈next.js 要学习的东西太多了 又没有前端基础,没有ai coding很难完成任务(十几分钟干完我一天的工作 claude4.5效果还不错 进度推的特别快), 自从trae下架了claude,后面就一直cursor claude 4.5了。
- nextjs+ts+tailwindcss+shadcn ui现在是mvp套餐,startup在梭哈,时间就是生产力哪需要那么多差异化样式直接一把,有的💰才开始做细节,你会发现慢慢也💩化了。
- Nextjs 是全栈框架 可以很快把一个MVP从零到一完整跑起来。 你要是抬杠说什么高并发负载均衡啥的,你的用户数量真多到需要考虑性能的时候,你已经不需要自己考虑了(小红书看到的一段话 挺符合场景的)
- next.js 写后端 确实比较轻量 只能做一些curd的操作 socket之类的不太合适 其他api 还是随便开发 给我的感受就是前端能够直接操作db,前后端仓库可以不分离,业务逻辑还是一定要分离的 看看开源的next.js 项目的架构设计结构是怎么样的 学习/模仿/改造。
- 语言只是工具,适合最重要,技术没有银弹
- nextjs.org/ github.com/vercel/next…

项目的时间线
项目从启动到这周 大概是5周的时间
- 10/28-10/31 Week 1
- 项目初始化/需求讨论/设计文档/
- 后端next.js, typescript技术熟悉 项目运行/调试
- 基础框架搭建 设计表结构ddl, 集成mysql, 编写crud接口阶段
- 11/03-11/07 Week 2
- 产品PRD 提供
- xxxx等表设计
- 11/10-11/14 Week 3
- xxxxx 基本功能完结
- @xxxx 讲解项目结构/规范
- 11/17-11/21 Week 4
- 首页样式/逻辑 优化
- 集成统一登录调研
- 部署完成
- 11/24-11/28 Week 5
- 服务推理使用Authorization鉴权 对内接口使用Cookies (access_token) 鉴权 开发
- xxxx 表设计表设计 逻辑开发
- xxx设计 设计开发
- 联调xxxx
5周时间 功能基本完成了 剩下的就是部署到线上 进行场景实践了
前端技术栈
- Next.js 14:选择 App Router 架构,支持服务端渲染和 API Routes
- TypeScript 5.4:强类型语言提升代码质量和可维护性
- React 18:利用并发特性和 Suspense 提升用户体验
- Zustand:轻量级状态管理,替代 Redux 降低复杂度
- Ant Design + Radix UI:组件库组合,平衡美观性和可访问性
React + TypeScript react.dev/
- 优势:类型安全:TypeScript 提供编译时类型检查,减少运行时错误 ✅ 组件化开发:高度可复用的组件设计 ✅ 生态成熟:丰富的第三方库和工具链 ✅ 开发体验:优秀的 IDE 支持和调试工具
- 劣势: ❌ 学习曲线:TypeScript 对新手有一定门槛 ❌ 编译时间:大型项目编译可能较慢 ❌ 配置复杂:类型定义需要额外维护
UI 组件方案 Ant Design + Radix UI 混合方案
- 优势: ✅ 快速开发:Ant Design 提供完整的企业级组件 ✅ 无障碍性:Radix UI 提供符合 WAI-ARIA 标准的组件 ✅ 定制灵活:Radix UI 无样式组件便于自定义 ✅ 中文支持:Ant Design 对中文界面友好
- 劣势: ❌ 包体积大:两个 UI 库增加了打包体积 ❌ 样式冲突:需要注意两个库的样式隔离❌ 维护成本:需要同时维护两套组件系统
Tailwind CSS
- 优势: ✅ 开发效率高:原子化类名,快速构建 UI ✅ 体积优化:生产环境自动清除未使用的样式 ✅ 一致性:设计系统内置,确保视觉一致 ✅ 响应式:便捷的响应式设计工具
- 劣势: ❌ 类名冗长:HTML 可能变得难以阅读 ❌ 学习成本:需要记忆大量类名 ❌ 非语义化:类名不直观反映元素意义
ant design x
ahooks
后端技术栈
- Prisma 6.18:现代化 ORM,类型安全且支持 Migration
- MySQL:成熟的关系型数据库,满足复杂查询需求
- Redis (ioredis) :高性能缓存,支持多种数据结构
- Winston:企业级日志系统,支持日志轮转和结构化输出
- Zod:运行时类型验证,保障 API 数据安全
Next.js API Routes
- 优势: ✅ 统一代码库:前后端在同一项目中 ✅ 类型共享:TypeScript 类型可在前后端复用 ✅ 开发效率:无需配置跨域、代理等 ✅ 部署简单:单一应用部署
- 劣势: ❌ 扩展性限制:无法独立扩展后端服务 ❌ 性能瓶颈:Node.js 单线程可能成为瓶颈 ❌ 微服务困难:不适合复杂的微服务架构
Prisma ORM
- 优势: ✅ 类型安全:自动生成 TypeScript 类型 ✅ 迁移管理:声明式 schema,易于版本控制 ✅ 查询性能:生成优化的 SQL 查询 ✅ 关系处理:直观的关系查询 API ✅ 多数据库支持:支持 MySQL、PostgreSQL、SQLite 等
- 劣势: ❌ 复杂查询:某些复杂 SQL 可能需要原始查询 ❌ 生成代码体积:生成的 client 文件较大 ❌ 版本升级:大版本升级可能需要迁移
踩坑记录
主要是记录一些开发过程中踩坑 和设计问题
- node js 项目 jean部署
- 自定义配置/dockerfile配置 没有类似项目参考 健康检查问题 加上环境变量配置多环境 一步一步
- next.js 中 用middleware进行接口拦截鉴权 里面有prisma path import 直接出现了Edge Runtime 异常 自定义auth 解决
- npm build 项目 踩坑
- 静态渲染流程 动态api 警告 强制动态渲染
- 其他组件 document 不支持build问题
- 保存多场景模式+构建版本管理第一版考虑的太少了,发现有问题 后面又重构了一版本
- xxx日志目前还没有接入 要不就是日志文件 要不就是console.log 目前看日志的方式是去容器化运行日志看了 后续集群部署就比较麻烦了
- ant design 版本降低到6.0以下 ant-design x 用不了2.0.0 的一些对话组件
Next.js实践的项目记录
苏州 trae friends线下黑客松 📒
- 去Trae pro-Solo模式 苏州线下hackathon一趟, 基本都是一些独立开发者,一人一公司,三个小时做出一个产品用Trae-solo coder模式,不得不说trae内部集成的vercel部署很丝滑 react项目一键deploy访问 完全不用关系域名服务器, solo模式其实就是混合多种model使用进行输出 想要的效果还是得不断的调试 thiking太长,对于前后端分离项目 也能够同时关联进行思考规划。
- 1点多到4点 coding时间 从0-1生成项目 使用trae pro solo模式 就3个小时 做不了什么大的东西 那就做个日语50音的网站呗 现场酒店的网基本用不了 我数据也很卡 用的旁边干中学老师的热点 用next.js tailwindcss ant design deepseek搭建的网页 够用了 最后vercel部署 trae自带集成 挺方便的 solo模式还是太慢了 接受不了 网站地址是 traekanastudio1ssw.vercel.app/ 功能就是假名+ai生成例句和单词 我都没有路演 最后拿优秀奖可能是我部署了吧 大部分人没部署 优秀奖就是卫衣了 蹭了一天的饭加零食 爽吃
- http://www.xiaohongshu.com/explore/692… 小红书当时发的帖子 可以领奖品

Typescript的AI方向 langchain/langgraph支持ts
- 最近在看的ts的ai框架 发现langchain 是支持ts的, langchain-chat 主要是使用langchain+langgraph 对ts进行实践 traechat-apps4y6.vercel.app/
- 部署还踩坑了 MCP 在 Vercel 上不生效是因为 Vercel 是 serverless 环境,不支持运行持久的子进程。让我帮你解决这个问题:
- 主要是对最近项目组内要用的到mcp/function call 进行实践操作 使用modelscope 上面开源的mcp进行尝试 使用vercel进行部署。
- 最近看到小红书上面的3d 粒子 圣诞树有点火呀,自己也尝试下 效果很差 自己弄的提示词 可以去看看帖子上的提示词去试试 他们都是gemini pro 3玩的 我也去弄个gemini pro 3 账号去玩玩。
- 还有一个3d粒子 跟着音乐动的的效果 下面的提示词可以试试
帮我构建一个极简科幻风格的 3D 音乐可视化网页。
视觉上参考 SpaceX 的冷峻美学,全黑背景,去装饰化。核心是一个由数千个悬浮粒子组成的‘生命体’,它必须能与声音建立物理连接:低音要像心脏搏动一样冲击屏幕,高音要像电流一样瞬间穿过点阵。
重点实现一种‘ACID 流体’视觉引擎:让粒子表面的颜色不再是静态的,而是像两种粘稠的荧光液体一样,在失重环境下互相吞噬、搅拌、流动,且流速由音乐能量驱动。

- docs.langchain.com/oss/javascr…
- http://www.modelscope.ai/home
- vercel.com
- http://www.modelscope.ai/mcp


ai方向 总结
- a2a解决的是agent之间如何配合工作的问题 agent card定义名片 名称 版本 能力 语言 格式 task委托书 通信方式http 用户 客户端是主控 接受用户需求 制定具体任务 向服务器发出需求 任务分发 接受响应 服务器是各类部署好的agent 遵循一套结构化模式
- mcp 解决的llm自主调用功能和工具问题
- mcp 是解决 function call 协议的碎片化问题,多 agent 主要是为了做上下文隔离
- 比如说手机有一个system agent 然后各个app有一个agent,用户语音输入买咖啡,然后system agent调用瑞幸agent 这样就是非侵入式 让app暴露系统a2a接口,感觉比mcp要更合理一点,不是单纯让app暴露tools,系统agent只需要做路由
- 而且有一点我觉得挺有意思的,就是自己的agent花的token是自己的钱,如果自己的agent找别人的agent,让它执行任务啥的,花的不就是别人的钱……
- Dify:更像宜家的模块化家具,提供可视化工作流、预置模板,甚至支持“拖拽式”编排AI能力。比如,你想做一
个智能客服,只需在界面里连接对话模型、知识库和反馈按钮,无需写一行代码
python 和ts 在ai上面的比较
- Python 依然是 AI 训练和科研的王者,PyTorch、TensorFlow、scikit-learn 这些生态太厚实了,训练大模型你离不开它。
- TS 在底层 AI 能力上还没那么能打,GPU 加速、模型优化这些,暂时还得靠 Python 打底。
- Python 搞理论和模型,TypeScript卷体验和交付
个人学习记录
主要还是前端和ai方面的知识点学习的比较多吧
- Typescript 语法基础+进阶 / Next.js 开发指南/React 开发指南
- ahooks 组件 使用 ahooks.js.org/zh-CN/hooks…
- ant design x 使用 ant-design-x.antgroup.com/components/…
- prisma orm框架 +mysql github.com/prisma/pris…
- dotenv 读取配置文件 github.com/dotenvx/dot…
- fastmcp 项目构建使用 原理
- Agent2Agent google协议内部详情
- swagger.io/specificati… OpenAPI 规范 一个 OpenAPI 描述(OAD)可以由一个 JSON 或 YAML 文档组成
- github.com/yossi-lee/s… 根据Swagger3规范,一键将Web服务转换为MCP
- http://www.jsonrpc.org/specificati… JSON-RPC 是一种无状态、轻量级的远程过程调用(RPC)协议
- github.com/agno-agi/ag… 多智能体框架
- roadmap.sh/ai-engineer ai工程师的roadmap 很全
- github.com/ChromeDevTo… *可以集成到cursorz中 *AI 能够直接控制和调试真实的 Chrome 浏览器
- http://www.nano-banana.ai/ Nano Banana Pro (V2) 文生图 图生图
- aistudio.google.com/prompts/new… gemini ai studio
Vibe Coding
- 先叠甲, 我没有前端的开发经验,第一次写前端项目,项目里面90%的前端代码都是ai 生成的,能够让你一个不会前端的同学也快速完成mvp版本/需求任务。我虽然很推ai coding 很喜欢用, 即时反馈带来的成就感, 但是对于生成的代码是不是屎山 大概率可能是了, 因为前期 AI速度快,制造屎山的速度更快。无论架构设计多优秀,也难避免屎山代码的宿命: 需求一直在变,你的架构设计是针对老的需求,随着新的需求增加,老的架构慢慢的就无法满足了,需要重构。
- 一起开发的前端同事都说ai生成那些样式互相影响了,样式有tailwindcss 有自定义的css 每个模块又有不同 大概率出问题 有冲突,就是💩山。
- 最大的开发障碍就是内心的偏见 不愿意放弃现在所擅长的东西 带着这份偏见不愿意去学习
对于ai coding 的话 用过trae-pro/cursor/qoder/copilot/codex等等 最终还是cursor claude 4.5用的最舒服

- 基本一周一个cursor pro账号 买号都花了快1k了。

You have used up your included usage and are on pay-as-you-go which is charged based on model API rates. You have spent $0.00 in on-demand usage this month.


- 最后就是需要学好英语 前端的技术文档都是英文的 虽然有中文的翻译版本, 但没有自己直接去看官方的强 难免有差异, 我现在都是用插件进行web翻译去看的 很累。
- 现在时间是凌晨 11/30/02:36 喝了两瓶酒。这个周末我要重温甜甜的恋爱 给我也来一颗药丸 给时间是时间 让过去过去, 年底想去日本跨年了


来源:juejin.cn/post/7577713754562838580
为什么越来越多 Vue 项目用起了 UnoCSS?
Vue 开发者可能都注意到,UnoCSS 的讨论频率越来越高。它不像 Tailwind 那样有营销声势,不像 Windi 那样起得早,却在 2024 年之后逐渐“渗透”进越来越多的 Vue 项目中。很多团队从 Tailwind、Windi CSS、SCSS 等方案“迁徙”到了 UnoCSS。看似只是换了个工具,实际上却是一种更深层次的开发范式迁移。
为什么 UnoCSS 会被 Vue 项目偏爱?它到底解决了哪些问题?又会引发哪些新的思维变化?这篇文章,我们来拆开 UnoCSS 背后的真实诱因。
🎯 UnoCSS 到底是什么?一句话不够解释
如果你只把 UnoCSS 理解为“一个类 Tailwind 的原子化 CSS 工具”,那你可能漏掉了它真正颠覆的部分。
UnoCSS 是一个:
- 即写即用的原子 CSS 引擎,没有预定义 class(tailwind.config.js?你可以不用)
- 即时编译(on-demand generation) ,不扫描模板、不打包 CSS 文件,运行时动态生成样式表
- 支持任意规则组合,语义可扩展,能自动拼装
hover:bg-red-500/30 md:rounded-xl这种复杂 class - 插件式运行机制,样式规则 = 插件,想加功能不用改源码
简单说:UnoCSS 就像是原子 CSS 界的「Vite」,更轻,更快,更灵活。
🧩 Vue 项目迁移 UnoCSS 的几个主要诱因
1. 开箱即用,没有冗余配置
Tailwind 开发中一个不成文的痛点是配置文件维护成本:你几乎必须写一堆 tailwind.config.js 来扩展自己的颜色、字体、断点。
而 UnoCSS 有个“离谱”的特性:
你甚至可以不用写 config 文件。
举例:
<div class="text-lg font-bold text-[#3a7afe] hover:opacity-80">
颜色?随便写 HEX。你想用 shadow-[0_0_12px_rgba(0,0,0,0.2)]?它也认。基本告别 theme.extend。
这对 Vue 项目尤其友好 —— 组件就是 class 的封装,不需要额外定义 token。
2. 它更像 JS,而不是传统 CSS 工具
UnoCSS 本质上是一组「语法规则 + 解析器」,所有东西都是基于插件机制动态生成的。这点非常 Vue-ish。
比如你想扩展 btn-primary:
rules: [
['btn-primary', 'px-4 py-2 rounded bg-blue-500 text-white']
]
配合 Vue + Script Setup,甚至可以做到“功能指令式”的组件:
<button class="btn-primary hover:bg-blue-600">提交</button>
这是 Tailwind 无法比拟的灵活度,尤其当你想跨多个组件“语义复用”样式,而又不想搞复杂的 SCSS。
3. Vue SFC 中语法体验更佳
UnoCSS 不依赖 Preflight,不污染全局,也不会把所有 class 编译成一大坨 CSS 文件。
更关键的是,在 Vue SFC 中,它可以配合原子类的组合器变得非常语义化。
<div class="grid grid-cols-[1fr_auto] gap-4 items-center sm:(grid-cols-1 gap-2)">
括号组合、嵌套媒体查询、状态嵌套,全都写在 class 中,无需管理额外 CSS 文件,非常适合组件化开发。
4. 和 Vue 生态绑定更深
UnoCSS 的创作者之一是 Anthony Fu,也就是 VueUse、Vitesse、Vitest 的作者。
换句话说:UnoCSS 是为 Vue 项目天生设计的原子 CSS 工具,生态协同、理念统一。
你可以在 VitePress、Nuxt、Vitesse、VueUse 所有项目中一键集成 UnoCSS,毫不费力。插件如 @unocss/nuxt、@unocss/vite 也都官方维护,集成体验比 Tailwind 更丝滑。
📉 传统方案的反衬:你为什么“受够了 Tailwind”
- 写多了
text-sm text-neutral-700 font-medium leading-relaxed tracking-wide,你会厌烦堆 class - 为了统一样式,你又开始封装 btn、card、tag 等组件,但 Tailwind 里没法抽离 class 成变量
- 你想写一些自由样式(如
text-[rgba(0,0,0,0.75)]),却必须配置 tailwind.config.js,开发体验断层
UnoCSS 这时候就像一口“无限制自助餐”:你想吃什么,厨房就给你端上来。
🧪 真正让它爆红的项目:Nuxt 生态
Nuxt 3 和 UnoCSS 简直天作之合。
如果你用 Nuxt,安装 UnoCSS 就一行命令:
npm i -D @unocss/nuxt
甚至不需要配置,直接写:
<template>
<section class="text-center text-4xl text-gradient from-pink-500 to-yellow-500">
Hello, UnoCSS
</section>
</template>
想封装组件?直接写 variant 和 shortcuts,体验跟设计 token 一样自然:
shortcuts: {
'btn': 'px-4 py-2 font-bold rounded',
'btn-primary': 'btn bg-blue-500 text-white hover:bg-blue-600'
}
🧠 真正带来的范式转变
UnoCSS 不只是工具上的优化,它还改变了我们使用 CSS 的方式:
- 从维护样式表 → 动态生成样式
- 从配置颜色 → 直接在组件中定义 token
- 从 class 管理 → 到语义表达
传统做法是围绕“命名”,而 UnoCSS 更像是在写“表达式”。这种范式变化,决定了它会逐渐成为 Vue 项目的原子化首选。
📌 使用 UnoCSS 时的真实建议
- 如果你的项目刚启动,用 UnoCSS 会极大加快开发速度
- 如果你在维护大型 Vue 项目,建议先从局部引入,避免和 Tailwind 冲突
- 如果你对设计规范要求较高,UnoCSS 支持
theme、rules、shortcuts构建完全定制化体系 - 建议启用 VSCode 插件,否则开发体验会下降
✅为什么 UnoCSS 会流行?
因为它比 Tailwind 更轻,比 Windi 更快,比 SCSS 更灵活。而且,它是为 Vue 项目量身定制的。
不再“配置样式”,而是“表达样式”;不再围着类名转,而是围着组件转。
UnoCSS 不只是一个工具,而是一种更贴近 Vue 哲学的“开发语言”。
来源:juejin.cn/post/7512392168783659071
UI小姐姐要求有“Duang~Duang”的效果怎么办?

设计小姐姐: “搞一下这样的回弹效果,你行不行?”
我:“行!直接梭哈 50 行 keyframes + transform + 各种百分比,搞定 ”
设计小姐姐:“太硬(撇嘴),不够 Q 弹(鄙视)”
我:(裂开)
隔壁老王:这么简单你都不行,我来一行贝塞尔 cubic-bezier(0.3, 1.15, 0.33, 1.57) 秒了😎
设计小姐姐:哇哦!(兴奋)好帅!(星星眼🌟)好Q弹!(一脸崇拜😍)
我:“???”
🧠 一、为什么一行贝塞尔就能“Duang”起来?
1️⃣ cubic-bezier 是什么?
在 CSS 动画里,我们经常写:
transition: all 0.5s ease;
但其实 ease、linear、ease-in-out 这些都只是封装好的贝塞尔曲线。
底层原理是:
cubic-bezier(x1, y1, x2, y2)
这四个参数定义了时间函数曲线,控制动画速度的变化。
x:时间轴(必须在 0~1 之间)y:数值轴(可以超出 0~1!)
👉 当 y 超过 1 或小于 0 时,动画值就会冲过终点再回弹,
这就是“回弹感”的核心。
2️⃣ 回弹的本质:过冲 + 衰减
想象一个球掉下来:
- 过冲:球落地时会压扁(超出终点)
- 回弹:然后反弹回来,再逐渐稳定
在动画中,这个“过冲”就是 y>1 的部分,
而“回弹”就是曲线回到 y=1 的过程。
🧪 二、一行贝塞尔的魔法
✅ 火箭发射

<div class="bounce">🚀发射!</div>
<style>
.bounce {
transition: transform 0.8s cubic-bezier(0.68, -0.55, 0.27, 1.55);
}
.bounce:hover {
transform: translateY(-500px);
}
</style>
💡 参数解析:
- y1 = -0.55 → 先轻微反向缩小
- y2 = 1.55 → 再冲过头 55%,最后回弹到原位
🧩 四、常用贝塞尔参数
| 效果描述 | 贝塞尔参数 | 备注 |
|---|---|---|
| 微回弹(按钮) | cubic-bezier(0.34, 1.31, 0.7, 1) | 轻柔弹性 |
| 强回弹(卡片) | cubic-bezier(0.68, -0.55, 0.27, 1.55) | 爆发力强 |
| 柔和出入 | cubic-bezier(0.4, 0, 0.2, 1.4) | iOS 风 |
| 弹性放大 | cubic-bezier(0.175, 0.885, 0.32, 1.275) | 弹簧感 |
| 火箭猛冲 | cubic-bezier(0.68, -0.55, 0.27, 1.55) | 推背感 |
🧰 五、调试神器推荐
- 🎨 cubic-bezier.com
拖动手柄实时预览动画,复制参数一键搞定。 - ⚙️ easings.net
收录各种 easing 函数(含物理弹簧、阻尼等)。
来源:juejin.cn/post/7576264484688379944
WebRTC 实现视频通话的前端开发步骤
你好,我是木亦。我不知道你是否了解过 WebRTC(Web Real - Time Communication),但不得不承认,WebRTC 凭借其无需安装插件、支持浏览器间直接通信的显著优势,已成为实现网页端视频通话的不二之选。对于前端开发者而言,深入掌握 WebRTC 实现视频通话的开发流程,能够为用户打造出更加丰富多元、即时高效的互动体验。这篇文章将会向你介绍使用 WebRTC 实现视频通话的开发步骤。
一、项目初始化
在开启开发之旅前,首要任务是创建一个全新的前端项目。你可以借助常见的项目初始化工具,像create-react-app(适用于 React 项目)、vue-cli(适用于 Vue 项目),或者直接创建一个简洁的 HTML 页面。
使用 create-react-app 初始化项目
npx create-react-app webrtc-video-call
cd webrtc-video-call
使用 vue-cli 初始化项目
npm install - g @vue/cli
vue create webrtc-video-call
cd webrtc-video-call
如果选择直接创建 HTML 页面,其基本结构如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF - 8">
<title>WebRTC Video Call</title>
</head>
<body>
<!-- 后续添加视频通话相关元素 -->
</body>
</html>
二、引入 WebRTC 库
WebRTC 作为现代浏览器的内置功能,无需额外引入第三方库。在编写 JavaScript 代码时,可直接调用 WebRTC 提供的 API。
检测浏览器支持
if ('RTCPeerConnection' in window && 'RTCSessionDescription' in window && 'navigator.mediaDevices' in window) {
// 浏览器支持WebRTC
console.log('WebRTC is supported');
} else {
console.log('WebRTC is not supported in this browser');
}
通过上述代码,可快速判断当前浏览器是否支持 WebRTC,确保开发工作在兼容的环境下进行。
三、获取媒体设备权限
实现视频通话的第一步,是获取用户摄像头和麦克风的使用权限。
使用 navigator.mediaDevices.getUserMedia ()
const constraints = {
video: true,
audio: true
};
navigator.mediaDevices.getUserMedia(constraints)
.then((stream) => {
// 成功获取媒体流,可用于视频显示
const videoElement = document.createElement('video');
videoElement.srcObject = stream;
videoElement.autoplay = true;
document.body.appendChild(videoElement);
})
.catch((error) => {
console.error('Error accessing media devices:', error);
});
在这段代码中,constraints对象明确指定了需要获取视频和音频权限。getUserMedia()方法返回一个 Promise,当操作成功时,会返回包含媒体流的stream对象,随后便可将其绑定到video元素上,实现本地视频的实时显示。
四、建立对等连接
WebRTC 通过 RTCPeerConnection 对象建立对等连接,实现双方媒体数据的高效传输。
创建 RTCPeerConnection 对象
// 创建RTCPeerConnection对象
const peerConnection = new RTCPeerConnection({
iceServers: [
{ urls:'stun:stun.l.google.com:19302' }
]
});
这里借助了 STUN(Session Traversal Utilities for NAT)服务器辅助建立连接,stun.l.google.com:19302是 Google 提供的公共 STUN 服务器,能有效帮助穿越网络地址转换(NAT)设备。
处理 ICE 候选
在连接建立过程中,处理 ICE(Interactive Connectivity Establishment)候选至关重要,这有助于寻找到最佳的连接路径。
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
// 将ICE候选发送给对方
// 这里需要实现发送逻辑,例如通过信令服务器
console.log('ICE candidate:', event.candidate);
}
};
当有 ICE 候选生成时,需及时将其发送给对方,实际应用中通常借助信令服务器完成这一操作。
交换 SDP(Session Description Protocol)
SDP 用于详细描述媒体会话的各项参数,双方需交换 SDP 以协商媒体格式、编解码方式等关键信息。
// 创建Offer
peerConnection.createOffer()
.then((offer) => {
return peerConnection.setLocalDescription(offer);
})
.then(() => {
// 将本地的SDP发送给对方
// 这里需要实现发送逻辑,例如通过信令服务器
console.log('Local SDP:', peerConnection.localDescription);
})
.catch((error) => {
console.error('Error creating offer:', error);
});
// 接收对方的SDP并设置为远程描述
peerConnection.setRemoteDescription(new RTCSessionDescription(receivedSDP))
.then(() => {
// 接收对方的Offer后,创建Answer
return peerConnection.createAnswer();
})
.then((answer) => {
return peerConnection.setLocalDescription(answer);
})
.then(() => {
// 将本地的Answer发送给对方
// 这里需要实现发送逻辑,例如通过信令服务器
console.log('Local SDP (Answer):', peerConnection.localDescription);
})
.catch((error) => {
console.error('Error setting remote description or creating answer:', error);
});
这部分代码展示了创建 Offer、设置本地描述、发送本地 SDP,以及接收对方 SDP 并创建 Answer、设置本地描述、发送本地 Answer 的完整流程。
五、显示远程视频
当双方成功建立连接并完成 SDP 交换后,便可接收对方的媒体流,实现远程视频的显示。
监听 track 事件
peerConnection.ontrack = (event) => {
const remoteVideoElement = document.createElement('video');
remoteVideoElement.srcObject = event.streams[0];
remoteVideoElement.autoplay = true;
document.body.appendChild(remoteVideoElement);
};
一旦接收到对方的媒体流,ontrack事件就会被触发,此时将接收到的媒体流绑定到新创建的video元素上,即可实时显示远程视频画面。
六、信令服务器的作用与实现
在 WebRTC 视频通话中,信令服务器承担着交换 SDP 和 ICE 候选等关键信息的重要职责。尽管 WebRTC 实现了媒体数据的直接传输,但信令的交互仍需借助服务器来完成。
信令服务器的选择
可选用 WebSocket、Socket.IO 等技术搭建信令服务器。以 Socket.IO 为例,搭建一个简易信令服务器的步骤如下:
npm install socket.io
简单的 Socket.IO 信令服务器示例
const io = require('socket.io')(3000);
io.on('connection', (socket) => {
socket.on('offer', (offer) => {
// 这里可以实现将offer转发给目标客户端
console.log('Received offer:', offer);
});
socket.on('answer', (answer) => {
// 这里可以实现将answer转发给目标客户端
console.log('Received answer:', answer);
});
socket.on('ice - candidate', (candidate) => {
// 这里可以实现将ice - candidate转发给目标客户端
console.log('Received ice - candidate:', candidate);
});
});
在前端代码中,需引入 Socket.IO 客户端库,并精心编写与服务器的通信逻辑,实现将 SDP 和 ICE 候选发送至服务器,以及从服务器接收对方的 SDP 和 ICE 候选。
WebRTC 实现视频通话的前端开发涵盖多个关键环节,从项目初始化、获取媒体设备权限,到建立对等连接、交换 SDP 和 ICE 候选,再到显示远程视频和搭建信令服务器。通过逐步掌握这些核心步骤,前端开发者能够构建出功能完备的视频通话应用,为用户提供流畅、实时的视频通信体验。在实际开发过程中,还需依据具体需求和应用场景,对代码进行优化与扩展,以充分满足多样化的业务需求。
5@2x.png" loading="lazy" src="https://www.imgeek.net/uploads/article/20260118/718ea69cb7313b0faba4510956153837.jpg"/>
来源:juejin.cn/post/7474124938526900262
Vue 3 + Three.js 打造轻量级 3D 图表库 —— raychart.js
大家好,我是 一颗烂土豆。
最近在数据可视化领域进行了一些探索,基于 Vue 3 和 Three.js 开发了一款轻量级的 3D 图表库 —— raychart.js。
今天不谈晦涩的代码实现,主要和大家分享一下这个项目的设计初衷、目前进展以及未来的规划。
💻 在线体验:chart3js.netlify.app/

🌟 愿景 (Vision)
在实际开发中,我们往往面临两难的选择:要么使用传统的 2D 图表库(如 ECharts)通过“伪 3D”来实现效果,但缺乏立体感和自由视角;要么直接使用 Three.js 从零撸,成本高且难以复用。
chart3 的诞生就是为了解决这个问题,它的核心愿景是:
- 极简配置:延续 ECharts 的 "Option-based" 配置思维,让前端开发者无需深入了解 WebGL/Three.js 的底层细节,通过简单的 JSON 配置即可生成炫酷的 3D 图表。
- 真 3D 体验:全场景 3D 渲染,支持 360 度自由旋转、缩放、平移,提供真实的光影、材质和空间感。
- 轻量与现代:完全基于 Vue 3 Composition API 和 TypeScript 构建,模块化设计,无历史包袱。
🚀 现状 (Current Status)
目前项目处于快速迭代阶段,核心引擎已经搭建完毕,并实现了一套可视化的配置系统。你可以通过 在线 Demo 实时调整参数并预览效果。
已支持的功能特性:
- 基础图表组件:
- 📊 3D 柱状图 (Bar3D):支持多系列、不同颜色的柱体渲染。

- 🥧 3D 饼图 (Pie3D):支持扇区挤出高度、标签展示。

* 📈 3D 折线图 (Line3D):支持管状线条渲染。

* 🌌 3D 散点图 (Scatter3D):支持三维空间的数据点分布。

- 可视化配置系统:
- 数据源 (Data):支持静态数据配置。
- 主题与配色 (Theme):内置多套配色方案,支持自定义默认颜色。
- 坐标系 (Coordinate):可实时调整网格的宽度、深度、高度,以及各轴线、刻度、网格线的显示与隐藏。
- 材质系统 (Material):这是 3D 图表的灵魂。支持实时调节透明度、粗糙度 (Roughness)、金属度 (Metalness),轻松实现玻璃、金属等质感。
- 灯光系统 (Lighting):支持环境光和方向光的强度与位置调节,营造氛围感。
- 交互 (Interaction):支持鼠标悬停高亮、HTML 标签 (Label) 自动跟随。
📅 待实现的任务 (Roadmap)
为了让 chart3 真正成为生产可用的图表库,后续还有很多有趣的工作要做:
- 高级图表开发:
- 🌊 3D 曲面图 (Surface 3D):用于展示复杂的三维函数或地形数据(目前 Demo 中显示为“待开发”)。
- 🗺️ 3D 地图 (Map 3D):支持 GeoJSON 数据的三维挤出渲染。
- 性能优化:
- 引入
InstancedMesh技术,大幅提升大数据量(如 10w+ 散点或柱体)下的渲染性能。
- 引入
- 动画系统:
- 实现图表的入场动画(如柱子升起、饼图展开)。
- 数据更新时的平滑过渡动画。
- 工程化与文档:
- 完善 API 文档和使用指南。
- 提供 NPM 包发布,方便项目集成。
🤝 结语
这个项目是我对“数据可视化 x 3D”的一次尝试。
让我们一起把数据变得更酷一点!
来源:juejin.cn/post/7594040270502379558
这两个网站,一个可以当时间胶囊,一个充满了赛博菩萨。
你好呀,我是歪歪。
前两天不是发了这篇《可怕,看到一个如此冷血的算法。》嘛。
文章中有这样的一个链接:

我当时放这个链接的目的是为了方便大家直达吃瓜现场。
但是,由于这个帖子最终被证实是假的,所以被官方给“夹”了:

幸好,原文本来就不长,所以我在我的文章中把原文全部给截下来了。
也算是以另外一种形式保留了吃瓜现场。
如果这个“爆料”的帖子再长一点,按照我的习惯,我可能就不会把整个帖子搬运过来了,只会留取我认为关键的部分。
但是这种“我认为关键的部分”是非常主观的,有的人就是想看原贴长什么样,但是原贴又被删除了,怎么办?
我教你一招,老好用了。
时间胶囊
在万能的互联网上,有这样一个仿佛是时间胶囊一般存在的神奇的网站:

这个网站是叫做"互联网档案馆"(Internet Archive),于 1996 年成立的非营利组织维护的网站。
自 1996 年以来,互联网档案库与世界各地的图书馆和合作伙伴合作,建立了一个人类在线历史的共享数字图书馆。
这个网站有一个非常宏大的愿景:
捕捉大小不一的网站,从突发新闻到被遗忘的个人页面,使它们能够为子孙后代保持可访问性。
所以里面收藏了的内容有免费书籍、电影、软件、音乐、网站等。
截至目前,该网站收集了这么多的数据:

其中网站的数量是最多的,有 1T,超过 1T 的时候,官方还发文庆祝了一下:

这个 1T 中的 T 指的是什么呢?
Trillion。
一个非常小众的词汇啊,歪师傅也不认识,所以我去查了一下:

这个图片上一眼望去全是 0。
1 Trillion 就是 1,000,000,000,000
反正是数不过来了。
感觉成都都没有这么多 0。
这个网站怎么用呢?
很简单。
拿前面 reddit 中被“夹”了的帖子举例。
我不是给了吃瓜现场的链接嘛。
你把链接往“时光机”的这个地方一粘:

你就会看到这个有一个时间轴的页面:

把鼠标浮到有颜色的日期上,就能看到各个时间点的页面快照了。
颜色越深代表那一天的快照越多:

比如,我们看一下这个网站收集到的第一个快照:

点进去,就是我们要找的吃瓜现场。
发帖后的两小时就被收集到了,速度还是挺快的。
从数据上看,这个时候已经有 3.7k 个点赞和 255 个评论,已经有要起飞的预兆了。
换个时间的快照,还可以看到点赞和评论的数据变化,比如发帖一天后:

点赞量已经是 71k,评论数来到了 3.8K,直接就是一个起飞的大动作。
这里只是用这个帖子举个例子。
再举一个例子。
也是我的真实使用场景。
有一次我在研究平滑加权轮询负载均衡策略算法为什么是平滑的。
和各类 AI 讨论了半天,它们也给出了各种参考文献。
我在其中一个参考文献中看到了这样一个链接:
我知道这个链接的内容就是我要找的内容,但是这个链接跳转过去已经是 404 了:

于是,时间胶囊就派上用场了。
我直接把这个链接扔它:

找到了这个网页在 2019 年 12 月 10 日的快照:

通过这种方式就找到了原本已经被 404 的网页内容。
在看一些时间比较久远的文章的时候,参考链接打不开的情况,还是比较常见的。
所以这个方式是我最常用的一个场景。
此外,还有另外一个场景,就是偶尔去怀旧一下。
比如,中文互联网的一滴眼泪:天涯论坛。

这是 20 年前,2006 年 1 月的天涯论坛首页,一股浓烈的早期互联网风格:

在图片的右下角你还能看到“2006 天涯春晚”的字样。
另外,你不要觉得这只是一个静态页面。
里面的部分链接还是可以正常跳转的。
比如,这个链接:

点进去,你可以看到最最古早的一种直播形式:文字直播。

2006 年 1 月 2 日,《武林外传》开播。
天涯这个文字直播的时间是 2006 年 1 月 19 日,《武林外传》当时正在全国热播。
天涯网友在这个页面下提出自己关于《武林外传》的问题,作为天涯的知名写手,宁财神本人会选择部分问题进行回复。
我截取了几个我觉得有意思的回复:

这种行为这算不算是官方剧透了?

当年祝无双这个角色是真的不让人讨喜啊。幸好当时的网络还不发达,不然我觉得真有可能“网爆祝无双”。

DVD,一个多么具有年代感的词。


写文章的时候,我本来是想截几张图就走的,最多五分钟搞定。
结果我竟然一页页的翻完了这个帖子,看完之后才发现在这个帖子里面待了半个多小时。
时间过的还是很快的。
站在 2026 年,看 2006 的帖子,中间有 20 年的光阴。
但是就像是 2006 年佟掌柜对要给她干二十年工才能还清债务的小郭说的那样:不要怕,二十年快得很,弹指一挥间。

前几天小郭在微博上还回应了正式赎身这个梗。
去了六里桥、去了同福夹道、去了左家庄站、还去了祥蚨瑞,最后在人来人往的北京街头,一个猝不及防的回眸:

这是我的童年回头看了我一眼。
十几岁的不了解佟掌柜的这句话,三十出头了,一下就理解了:20 年,真的很快呀。
看到 2006 年的天涯的时候,我依稀想起了一些当年的往事。
那个时候我才 12 岁,看电视剧是真的在电视机上看,我还记得家里的电视机都是这样的“大屁股”电视机:

还记得《武林外传》每集开始,唱主题曲的时候,电视上面会显示一个电脑的桌面:

所以每次开头的时候,我就会叫表妹过来,对她说:你看,我等下把电视变成电脑。
那个时候表妹才 7 岁,我这个 12 岁的哥哥当然是把她唬的一愣一愣的。
那个时候电脑也还是一个稀奇的物品,虽然是乡下的学校,但是也还是有一个微机室,去微机室上课必须要带鞋套的那种。
所以 2006 年的天涯,我肯定是没有看过的,但是在 2026 年看到 2006 的天涯,我还是想起了很多童年往事。
对了,前几天才给表妹过完 27 岁的生日:

看着这张照片,再想起 7 岁时那个相信哥哥可以把电视变成电脑给她看《武林外传》的妹妹。
“二十年快得很,弹指一挥间”。
你说这不叫时间胶囊,叫什么?
再看一下 10 年前,2016 年 1 月 1 日的天涯,彼时的天涯可以说是如日中天,非常多的网友天天泡在论坛里面,谈古论今,激扬文字。
这是那天的天涯首页截图:

热帖榜第一的是一个关于纯电动汽车的帖子,我进去看了一下:

这个帖子的点击量是 10w,有 816 个回复。
可见这确实是当时的一个非常热门的话题。
按照作者的观点,纯电汽车代替燃油汽车,还很长的路要走。
站在 10 年后的今天,其实我们已经知道答案了。
但是,当我看到这个回复的时候,我还是佩服天涯网友的眼光:

除了天涯,还可以考古很多其他的网站。
比如,B 站:

从 2011 年开始有了网页快照,我随便点开一看,满满的历史感:

而这是 2016 年,10 年前的 B 站首页:

当时还有一个专门的鬼畜区:

而这里的一些视频甚至还是可以播放的。
比如这个“启蒙作品”:

现在在 B 站有 160w 的播放:

在这个视频的评论区,你能找到大量来“考古”的人:



二十年都弹指一挥间了,别说区区十年了。
从 B 站怀旧完成后,随便,我也去磨房、马蜂窝、穷游网看了一圈,随便选了 2012 年到 2016 年间的一些页面,感谢它们陪我度过了一整个美好的大学生活。
是我当时认识、感知、体验这个的广阔世界的一个重要窗口。
感谢磨房 4 年的陪伴:

感谢马蜂窝 4 年的陪伴:

感谢穷游网 4 年的陪伴:

如果你也有想要寻找的记忆,可以尝试在这个网站上去找一找。
存档
既然已经聊到“archive”了,那就顺便再分享一个“archive.today”。

这个网站和前面的“互联网档案馆”最大的一个差异是“互联网档案馆”是它主动去做“网页快照”,什么时候做,什么页面做,并不一定。
而“archive.today”是一个你可以去主动存档的网站。
比如,还是说回 reddit 上的那个帖子。
帖子下面有这样的一个回复:

这个回复中的超链接就是回复者找到的关于这个“爆料”是 AI 生成的证据。
点过去是这样的:

他提供的是一个网页存档。
为什么他要这么做呢?
你想想,如果他提供一个原始链接,但是这个原始链接突然有一天找不到了,岂不是很尴尬?
但是先在“archive.today”上存档一下,然后把这个存档后的链接贴出来,就稳当多了。
以后你要保存证据的话,你就可以使用这个网站。
另外,这个网站还有一个骚操作。
反而是骚操作让这个网站的打开率更高一点。
国外的一些网站可能有些文章是要付费才能看到的。
比如纽约时报:

但是,如果你一不小心把付费文章的链接贴在这个网站上去搜索。
有一些“好事之人”已经帮你把文章在这个网站上做了快照了,这些人可以称之为“赛博菩萨”,因为这些“菩萨”,你就可能看到免费的原文了:

在这里叠个甲啊,偶尔看到一两篇的话可以这样操作一下,就当时是试看了。
如果经常要看的话,还是充点钱吧。
对了,多说一句,上面提到的神奇的网站既然叫做时光胶囊,还有一些赛博菩萨,这些魔法世界中才有的东西,那肯定需要你会对应的魔法咒语才能访问到。如果你不会魔法,强行访问,那你肯定要撞到墙上。

来源:juejin.cn/post/7594266018304737343
2026 年 Web 前端开发的 8 个趋势!
1. 前言
2025 年是 Web 开发的分水岭。
之前 Web 开发领域一直发展迅速,几乎每天都有新的工具和框架涌现。
但到了 2025 年,这种发展速度直接呈指数级增长。
之所以有这种变化,很大程度上是因为 AI 工具的高效性,它们直接将生产力提升了 3 倍!
想想几年前,我们还在争论 GitHub Copilot 这样的 AI 工具是否可靠,如今,AI 已经能构建完整的全栈应用程序了!。
这也让不少人担忧,AI 是否真的能取代我们。
站在 2026 年的门槛上,让我们一起看看,今年会有哪些真正影响你我的技术趋势。
注意:这不是那种“5 年以后”的远景预测,而是今年你就有可能遇到的实实在在的变化。
2. AI 优先开发
AI 工具已经不再试一个简单的代码补全工具,它已经成为开发的核心组成部分。
开发人员更像是架构师的角色,监督 AI 智能体工作。毕竟 AI 智能体已经可以根据 Figma URL 或自然语言提示搭建完整的功能框架。
AI 也在重塑开发者探索和理解代码的方式。
团队不再需要手动阅读庞大的代码库,利用 AI 直接可以解释不熟悉的逻辑、追踪数据流并发现边缘 case。这极大地缩短了新用户上手时间,也让大型项目更易于操作。
因此,采用 AI 优先开发的团队将减少在机械性工作上花费的时间,而将更多精力投入到项目架构、用户体验的优化上。
这些工具虽然不能编写完美的代码,但它们会改变开发人员的精力投入方向。
3. 元框架成为默认设置
还记得当年选技术栈时的纠结吗?
路由用哪个?打包工具选什么?状态管理怎么办?
现在,这些问题都有了一个标准答案:用 Next.js 或 Nuxt 就完了。
因为这些元框架就是一个“全家桶套餐”,把你需要的所有东西都打包好了。
路由、数据获取、缓存、渲染策略、API 接口……统统内置。很多时候,后端就是前端项目里的一个文件夹。
AI 工具的兴起也加速了这一转变。现在大多数生成式 UI 构建器默认都会生成元框架项目。Vercel 自家的构建器 v0 就是一个很好的例子:开箱即用,直接输出 Next.js 应用程序。
对开发者来说,这是个好消息,意味着你可以把更多精力放在业务逻辑上,而不是纠结工具链的选择。
4. 前端开发 TanStack 化
虽然元框架提供了结构,但 TanStack 套件(查询、路由、表格、表单)已成为逻辑层的实际标准。
从最早的 TanStack Query(以前叫 React Query)处理数据获取和缓存,到现在的 Table、Form、Router、Store……它几乎覆盖了前端开发的方方面面。
2025 年,TanStack 又推出了 DB、AI 等新工具,从库升级成了一个完整生态。
TanStack 最大的优势就是框架无关、实用至上。
无论你用 React、Vue 还是其他框架,TanStack 都能无缝接入。而且它的设计理念很务实,解决的都是开发中的实际痛点。
TanStack 俨然成为前端界的“瑞士军刀”。
5. TypeScript + 服务端函数,告别传统后端
TypeScript 已经是标配,2026 年还在写 JavaScript 多少有些过时了。
而且随着服务端函数和托管后端的流行,前端和后端的界限将越来越模糊。
举个例子:
使用 tRPC,你可以在前端直接调用后端函数,而且类型完全同步。不需要手写 API 文档,不需要维护接口定义,改了后端,前端自动感知。
这就好比以前你要写信寄到邮局,现在直接打电话——即时、准确、零误差。
6. React 编译器越来越普及
还记得为了优化性能,到处写 useMemo、useCallback、React.memo 的日子吗?
React 编译器(React Compiler)在 2025 年 10 月发布 v1.0 后,已经开始大规模应用。它能在构建时自动处理性能优化,你只管写清晰的代码,编译器帮你搞定优化。
就像相机的自动对焦——以前要手动调,现在按快门就行。
如今 Next.js 16、Vite、Expo 等主流工具已经内置了 React 编译器。
创建新项目时,它就是默认配置的一部分。
这对新手特别友好。不用纠结性能问题,专注于功能实现就好,代码也更简洁易读。
7. 边缘计算开始普遍
以前部署应用,服务器可能在北京,广州的用户访问就慢半拍。
边缘计算的核心思路是:让代码跑在离用户最近的节点上。
你在上海?就用上海的服务器。你在成都?就用成都的。延迟大幅降低,响应速度更快。
而且现代框架的很多特性——比如服务端函数、流式响应——天生就适合边缘部署。再加上 AI 工具(像 v0、Lovable)一键生成边缘应用,这个趋势已经不是“要不要”的问题,而是“什么时候”的问题。
到 2026 年,边缘部署会成为默认选项。作为开发者,你需要习惯在设计时就考虑边缘环境的特点。
8. CSS:原生能力回归,实用工具辅助
原生 CSS 这些年在不断进化。
容器查询、层叠样式表、CSS 变量、现代颜色函数……这些新特性让 CSS 的表达能力大幅提升。
于是现在的趋势变成了混合使用:传统的实用类负责快速搭建,原生 CSS 负责精细控制。
比如特定样式以 CSS 变量的形式表示,变体和主题通过 layers 和选择器来处理,而不再依赖构建时处理。
9. React 安全性提升
202025 年,React 生态爆出了不少安全漏洞,比如 Next.js 中间件漏洞和 React2Shell。
这是因为前端承担的责任越来越重。
以前前端就负责展示,安全问题是后端的事。
现在 React 应用要处理身份验证、数据访问、业务逻辑……攻击面大大增加。
所以 2026 年预计框架会推出更多“防御性默认设置”,防止开发者犯错。
静态分析工具会更智能,开发时就能发现潜在安全隐患。框架和安全扫描器的集成会更紧密。
10. 结论
2026 年的前端开发,核心变化是角色转变。
你不再是“写代码的人”,而是“协调资源的人”。
AI 帮你写重复代码,编译器帮你优化性能,框架帮你搭好架构……
你要做的,是把精力放在更重要的事情上:
- 理解用户需求
- 设计系统架构
- 把控产品质量
- 优化用户体验
技术在进步,工具在演化,但解决问题的能力和对用户的关注——这些才是永远不会过时的核心竞争力。
2026 年,我们不是被工具取代,而是在工具的帮助下,做更有价值的事。
我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。
欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs) ,每天分享前端知识、AI 干货。
来源:juejin.cn/post/7594028166135250944
WebSocket,退!退!退!更简单的实时通信方案在此
多标签页实时消息同步方案:SSE + BroadcastChannel 完美解决!
你是否遇到过这样的问题:
用户同时打开多个标签页,每个标签页都建立了独立的 WebSocket 连接,导致服务器压力大、消息重复推送、资源浪费?本文将分享一个优雅的解决方案,通过 SSE + BroadcastChannel 的组合,实现单连接、多标签页实时消息同步,既节省资源又提升用户体验。
适用场景
推荐使用:
- 实时消息推送:系统通知、用户消息、业务提醒等
- 数据同步:多标签页状态同步、购物车同步、表单数据同步
- 任务状态更新:后台任务进度、数据处理状态、导出任务完成通知
- 系统公告:全局消息广播、系统维护通知、版本更新提示
实际案例:
在我们的 BI 系统中,该方案成功应用于:
- 消息中心:实时推送系统消息和业务通知
- 任务管理:后台数据处理任务的状态更新和完成通知(如素材批量上传任务)
- 国际化同步:多语言配置的实时更新
国际化同步这块配合 apollo 的配置中心实现多语言配置更新发布后,系统会无感自动实时更新翻译,超级爽
不推荐使用:
- 高频双向通信:如实时聊天、游戏等,建议使用 WebSocket
- 大量数据传输:如文件传输、大数据同步,建议使用 HTTP 轮询或分页
- 跨域通信:需要使用 postMessage 或其他跨域方案
前言
如果不想了解技术背景可点击直接跳转到实现方案👇
初衷
在现代 Web 应用中,实时消息推送、任务状态更新等是常见的需求。然而,当用户同时打开多个标签页时,如何确保消息能够正确同步到所有标签页,同时避免重复连接和资源浪费,是一个值得深入探讨的技术问题。
本文基于实际项目经验,分享如何通过 SSE(Server-Sent Events) 和 BroadcastChannel API 的组合方案,实现高效的多标签页实时消息同步。该方案不仅解决了单标签页消息推送的问题,还优雅地处理了多标签页场景下的连接管理和消息分发。
问题背景
多标签页消息同步的挑战
在实际业务场景中,我们遇到了以下问题:
场景一:用户打开多个标签页
当用户同时打开多个标签页访问同一个应用时,如果每个标签页都建立独立的 SSE 连接,会导致:
- 服务器资源浪费(多个长连接)
- 消息重复推送(每个标签页都收到相同消息)
- 用户体验不一致(不同标签页消息状态不同步)
打开多页签若系统采用 HTTP 1.0/1.1 协议,用户每打开一个页面就会建立一个长连接;当打开的标签页数量超过 6 个时,受浏览器并发连接数限制,第七个及之后的标签页将无法正常加载,出现卡顿。
场景二:标签页关闭与重连
当某个标签页关闭时,如果该标签页持有唯一的 SSE 连接,其他标签页将无法继续接收消息。需要:
- 检测连接断开
- 自动在其他标签页重新建立连接
- 保证消息不丢失
场景三:消息去重与状态同步
多个标签页需要:
- 避免重复显示相同的消息通知
- 保持消息已读/未读状态同步
- 统一更新 UI 状态(如未读消息数)
传统方案的局限性
| 方案 | 优点 | 缺点 |
|---|---|---|
| 纯 SSE | 实现简单,浏览器原生支持 | 多标签页会建立多个连接,资源浪费 |
| 纯 WebSocket | 双向通信,功能强大 | 实现复杂,需要心跳检测,多标签页问题同样存在 |
| LocalStorage 事件 | 跨标签页通信简单 | 只能传递字符串,性能较差,不适合频繁通信 |
| SharedWorker | 真正的单例连接 | 兼容性一般,调试困难 |
技术选型
为什么选择 SSE
SSE(Server-Sent Events) 是 HTML5 标准中的一种服务器推送技术,具有以下优势:
- 简单易用:基于 HTTP 协议,无需额外协议升级
- 自动重连:浏览器原生支持断线重连机制
- 单向推送:适合服务器主动推送消息的场景
- 文本友好:天然支持文本数据,JSON 解析方便
// SSE 基本使用
const eventSource = new EventSource('/api/sse');
eventSource.onmessage = (event) => {
console.log('收到消息:', event.data);
};
为什么选择 BroadcastChannel
BroadcastChannel API 是 HTML5 提供的跨标签页通信方案:
- 同源通信:同一域名下的所有标签页可以通信
- 简单高效:API 简洁,性能优秀
- 类型支持:支持传输对象、数组等复杂数据类型
- 事件驱动:基于事件机制,易于集成
// BroadcastChannel 基本使用
const channel = new BroadcastChannel('my-channel');
channel.postMessage({ type: 'MESSAGE', data: 'Hello' });
channel.onmessage = (event) => {
console.log('收到广播:', event.data);
};
组合方案的优势
将 SSE 和 BroadcastChannel 结合,可以实现:
- 单连接管理:只有一个标签页建立 SSE 连接
- 消息广播:SSE 接收的消息通过 BroadcastChannel 同步到所有标签页
- 连接恢复:标签页关闭时,其他标签页自动接管连接
- 状态同步:所有标签页的消息状态保持一致
实现方案
整体架构设计
sequenceDiagram
participant Server as 服务器端
participant TabA as 标签页 A<br/>(主连接)
participant BC as BroadcastChannel
participant TabB as 标签页 B<br/>(从连接)
Note over TabA: 初始化阶段
TabA->>TabA: 检查是否有 SSE 连接
alt 无连接
TabA->>Server: 建立 SSE 连接
Server-->>TabA: 连接成功
end
Note over Server,TabB: 消息接收阶段
Server->>TabA: 推送消息 (SSE)
TabA->>TabA: 处理消息<br/>(更新状态、显示通知)
TabA->>BC: 广播消息
BC->>TabB: 同步消息
TabB->>TabB: 处理消息<br/>(更新状态、显示通知)
Note over TabA,TabB: 连接管理阶段
TabA->>TabA: 标签页关闭
TabA->>BC: 发送关闭信号
BC->>TabB: 通知连接关闭
TabB->>TabB: 关闭旧连接
TabB->>Server: 重新建立 SSE 连接
Server-->>TabB: 连接成功
核心流程
- 初始化阶段
- 应用启动时,检查是否已有 SSE 连接
- 如果没有,当前标签页建立 SSE 连接
- 如果有,直接使用现有连接
- 消息接收阶段
- SSE 连接接收到服务器推送的消息
- 当前标签页处理消息(显示通知、更新状态)
- 通过 BroadcastChannel 广播消息到其他标签页
- 其他标签页接收广播,同步处理消息
- 连接管理阶段
- 标签页关闭时,发送关闭信号到 BroadcastChannel
- 其他标签页监听到关闭信号,关闭旧连接
- 重新建立 SSE 连接,确保消息不中断
注意这里服务端接入 SSE 的时候可以设置同一用户下只保持一个活跃连接即可,历史连接丢弃超时会自动断开
核心实现
1. SSE 连接封装
首先,我们需要封装一个支持重连和错误处理的 SSE 连接工具:
import { EventSourcePolyfill } from 'event-source-polyfill';
import util from '@/libs/util';
import Setting from "@/setting";
const MAX_RETRY_COUNT = 3;
const RETRY_DELAY = 3000;
const create = (url, payload) => {
let retryCount = 0;
const connect = () => {
const token = util.cookies.get("token")
if(!token){
return
}
const eventSource = new EventSourcePolyfill(
`${Setting.request.apiBaseURL}${url}`,
{
headers: {
token: util.cookies.get("token"),
pageUrl: window.location.pathname,
userId: util.cookies.get("userId"),
},
heartbeatTimeout: 28800000, // 8小时心跳超时
}
);
eventSource.addEventListener("open", function (e) {
console.log('SSE连接成功');
retryCount = 0; // 重置重试次数
});
eventSource.addEventListener("error", function (err) {
console.error('SSE连接错误:', err);
if (retryCount < MAX_RETRY_COUNT) {
retryCount++;
console.log(`尝试重新连接 (${retryCount}/${MAX_RETRY_COUNT})...`);
setTimeout(() => {
eventSource.close();
connect();
}, RETRY_DELAY);
} else {
console.error('SSE连接失败,已达到最大重试次数');
eventSource.close();
}
});
return eventSource;
};
return connect();
}
export default {
create
}
关键点解析:
- 使用
EventSourcePolyfill支持自定义 headers(原生 EventSource 不支持) - 实现自动重连机制,最多重试 3 次
- 设置心跳超时时间,防止长时间无响应导致连接假死
- 在 headers 中传递 token 和页面信息,便于服务端识别和路由
2. BroadcastChannel 封装
创建一个简洁的 BroadcastChannel 工具类:
export const createBroadcastChannel = (channelName: string) => {
const channel = new BroadcastChannel(channelName);
return {
channel,
sendMessage(data: any) {
channel.postMessage(data);
},
receiveMessage(callback: (data: any) => void) {
channel.onmessage = (event) => {
callback(event.data);
};
},
closeChannel() {
channel.close();
},
};
};
设计说明:
- 封装成工厂函数,便于创建多个通道(消息通道、连接管理通道)
- 提供简洁的 API:发送消息、接收消息、关闭通道
- 支持传递任意类型数据(对象、数组等)
3. SSE 连接管理
实现单例模式的 SSE 连接管理:
import sseRequest from "@/plugins/request/sse";
import store from "@/store";
export const fetchSSE = (payload?: { [key: string]: string }) => {
const eventSource = sseRequest.create("/sse/connect", {
...payload
});
return eventSource;
};
export const initSSEEvent = async () => {
console.log('sse-init');
// 检查是否已经有实例在当前标签页中创建,可用于项目中获取实例方法用
let eventSource = (store.state as any).admin.request.sseEvent;
if (!eventSource) {
// 如果没有实例,则创建一个新的
eventSource = fetchSSE();
// 存储到 Vuex 中
store.commit('admin/request/SET_SSE_EVENT', eventSource);
}
return eventSource;
};
核心逻辑:
- 通过 Vuex 全局状态管理 SSE 连接实例
- 实现单例模式:如果已有连接,直接复用
- 避免多个标签页同时建立连接
4. 消息处理与广播
实现消息接收、处理和跨标签页同步:
import { createBroadcastChannel } from "@/libs/broadcastChannel";
// 创建消息广播通道
const { sendMessage, receiveMessage } =
createBroadcastChannel("message-channel");
export const pushWatchAndShowNotifications = async (): Promise<any> => {
// 获取 SSE 连接实例
const eventSource = (store.state as any).admin.request.sseEvent;
if (!eventSource) {
return;
}
// 监听服务器推送的消息
eventSource.addEventListener("MESSAGE", function (e) {
const fmtData = JSON.parse(e.data);
// 1. 广播消息到其他标签页
sendMessage(fmtData);
// 2. 当前标签页处理消息
handleIncomingMessage(fmtData);
});
// 监听用户任务推送
eventSource.addEventListener("USER_TASK", function (e) {
const fmtData = JSON.parse(e.data);
// 广播任务消息到其他标签页
sendMessage({ type: "USER_TASK", data: fmtData });
// 当前标签页处理任务消息
handleIncomingUserTask(fmtData);
});
// 监听其他标签页广播的消息
receiveMessage((data) => {
if (data.type === "USER_TASK") {
handleIncomingUserTask(data.data);
} else {
handleIncomingMessage(data);
}
});
return eventSource;
};
function handleIncomingMessage(fmtData: any) {
const productId = (store.state as any).admin.user.info?.curProduct;
const productData = fmtData[productId];
if (!productData) {
return;
}
const { noReadCount, popupList } = productData;
// 更新未读消息数
store.commit("admin/layout/setUnreadMessage", noReadCount);
// 显示消息通知
if (popupList.length > 0) {
popupList.forEach((message, index) => {
showNotification(message, index);
});
}
}
处理流程:
- SSE 接收到消息后,立即通过 BroadcastChannel 广播
- 当前标签页处理消息(更新状态、显示通知)
- 其他标签页通过 BroadcastChannel 接收消息,同步处理
- 确保所有标签页状态一致
5. 连接恢复机制
实现标签页关闭时的连接恢复:
import { createBroadcastChannel } from '@/libs/broadcastChannel';
// 创建连接管理通道
const { sendMessage, receiveMessage } =
createBroadcastChannel('sse-close-channel');
export default defineComponent({
methods: {
handleCloseMessage() {
const sseEvent = (store.state as any).admin.request.sseEvent
if (sseEvent) {
sseEvent.close()
store.commit('admin/request/CLEAR_SSE_EVENT');
}
},
handleSSEClosed() {
// 监听其他标签页关闭 SSE 连接的消息
receiveMessage((data) => {
if (data === 'sse-closed') {
console.log('SSE connection closed in another tab. Re-establishing connection.');
// 关闭旧连接
this.handleCloseMessage()
// 重新建立连接
initSSEEvent();
this.handleGetMessage()
this.handleGetUserTasks()
}
});
}
},
mounted() {
// 页面卸载时,关闭 SSE 连接并通知其他标签页
on(window, 'beforeunload', () => {
const eventSource = (store.state as any).admin.request.sseEvent;
if (eventSource) {
eventSource.close();
store.commit('admin/request/CLEAR_SSE_EVENT');
}
// 广播关闭消息
sendMessage('sse-closed');
});
// 初始化 SSE 连接
const token = (store.state as any).admin.user.info?.curProduct
|| util.cookies.get("token");
if (token && !(store.state as any).admin.request.sseEvent) {
initSSEEvent();
pushWatchAndShowNotifications();
}
// 监听其他标签页的连接关闭事件
this.handleSSEClosed();
},
beforeUnmount() {
this.handleCloseMessage()
}
})
恢复机制:
- 标签页关闭时,发送
sse-closed消息到 BroadcastChannel - 其他标签页监听到消息,关闭旧连接并清理状态
- 重新初始化 SSE 连接和相关监听
- 确保至少有一个标签页保持连接
6. 状态管理
在 Vuex 中管理 SSE 连接状态:
export default {
namespaced: true,
state: {
sseEvent: null // SSE 连接实例
},
mutations: {
// 设置 SSE 事件
SET_SSE_EVENT(state, payload) {
state.sseEvent = payload
},
// 清除 SSE 事件
CLEAR_SSE_EVENT(state) {
state.sseEvent = null
}
}
}
方案总结
方案优势
- 资源优化
- 多个标签页共享一个 SSE 连接,减少服务器压力
- 降低网络带宽消耗
- 减少客户端内存占用
- 用户体验提升
- 所有标签页消息状态实时同步
- 避免重复通知,减少干扰
- 连接自动恢复,消息不丢失
- 实现简洁
- 基于浏览器原生 API,无需额外依赖
- 代码结构清晰,易于维护
- 兼容性好,现代浏览器全面支持
- 扩展性强
- 可以轻松添加新的消息类型
- 支持多个 BroadcastChannel 通道
- 便于集成到现有项目
局限性及注意事项
- 浏览器兼容性
- BroadcastChannel 不支持 IE 和部分旧版浏览器
- 需要提供降级方案(如 LocalStorage 事件)
- 同源限制
- BroadcastChannel 只能在同源页面间通信
- 跨域场景需要使用其他方案(如 postMessage)
- 连接管理
- 需要妥善处理标签页关闭和刷新场景
- 避免内存泄漏(及时清理事件监听)
- 错误处理
- SSE 连接断开时需要重连机制
- 网络异常时的降级策略
最佳实践建议
- 连接管理
- 建议:使用单例模式管理连接
- 建议:在应用入口统一初始化
- 建议:页面卸载时清理资源
- 消息去重
- 建议:为消息添加唯一 ID
- 建议:使用 Set 或 Map 记录已处理消息
- 建议:设置消息过期时间
- 性能优化
- 建议:限制 BroadcastChannel 消息大小
- 建议:使用防抖处理频繁消息
- 建议:批量处理消息更新
- 错误恢复
- 建议:实现指数退避重连策略
- 建议:添加连接状态监控
- 建议:提供手动重连功能
技术对比总结
| 特性 | SSE + BroadcastChannel | WebSocket | 轮询 |
|---|---|---|---|
| 实现复杂度 | ⭐⭐ 简单 | ⭐⭐⭐⭐ 复杂 | ⭐ 很简单 |
| 服务器压力 | ⭐⭐ 低(单连接) | ⭐⭐⭐ 中等 | ⭐⭐⭐⭐ 高 |
| 实时性 | ⭐⭐⭐⭐ 优秀 | ⭐⭐⭐⭐⭐ 极佳 | ⭐⭐ 一般 |
| 多标签页支持 | ⭐⭐⭐⭐⭐ 完美 | ⭐⭐ 需额外处理 | ⭐⭐⭐ 一般 |
| 浏览器兼容 | ⭐⭐⭐⭐ 良好 | ⭐⭐⭐⭐ 良好 | ⭐⭐⭐⭐⭐ 完美 |
未来优化方向
- 连接池管理:支持多个 SSE 连接,按业务类型分离
- 消息队列:离线消息缓存和重放机制
- 性能监控:连接质量监控和自动优化
- 降级方案:兼容旧浏览器的替代实现
参考文档
结语
SSE + BroadcastChannel 的组合方案为多标签页实时消息同步提供了一个优雅的解决方案。该方案在保证功能完整性的同时,兼顾了性能和用户体验。希望本文能够帮助你在实际项目中更好地应用这些技术。
写在最后
如果你在实际项目中应用了这个方案,欢迎分享你的经验和遇到的问题。如果你有更好的想法或优化建议,也欢迎在评论区交流讨论。
如果这篇文章对你有帮助,请点个赞支持一下,让更多开发者看到这个方案!
来源:juejin.cn/post/7588355695100854281
🤡什么鬼?两行代码就能适应任何屏幕?
你可能想不到,只用两行 CSS,就能让你的卡片、图片、内容块自动适应各种屏幕宽度,彻底摆脱复杂的媒体查询!
秘诀就是 CSS Grid 的 auto-fill 和 auto-fit。

马上教你用!✨
🧩 基础概念
假设你有这样一个需求:
- 一排展示很多卡片
- 每个卡片最小宽度 200px,剩余空间平均分配
- 屏幕变窄时自动换行
只需在父元素加两行 CSS 就能实现:
/* 父元素 */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
/* 子元素 */
.item {
height: 200px;
background-color: rgb(141, 141, 255);
border-radius: 10px;
}
下面详细解释这行代码的意思:
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
这是 CSS Grid 布局里定义列宽的常用写法,逐个拆解如下:
1. grid-template-columns
- 作用:定义网格容器里有多少列,以及每列的宽度。
2. repeat(auto-fit, ...)
repeat是个重复函数,表示后面的模式会被重复多次。auto-fit是一个特殊值,意思是:自动根据容器宽度,能放下几个就放几个,每列都用后面的规则。
- 容器宽度足够时,能多放就多放,放不下就自动换行。
3. minmax(200px, 1fr)
minmax也是一个函数,意思是:每列最小200px,最大可以占1fr(剩余空间的平分)- 具体来说:
- 当屏幕宽度很窄时,每列最小宽度是200px,再窄就会换行。
- 当屏幕宽度变宽,卡片会自动拉伸,每列最大可以占据剩余空间的等分(
1fr),让内容填满整行。
4. 综合起来
- 这行代码的意思就是:
- 网格会自动生成多列,每列最小200px,最大可以平分一行的剩余空间。
- 屏幕宽了就多显示几列,屏幕窄了就少显示几列,自动换行,自适应各种屏幕!
- 不需要媒体查询,布局就能灵活响应。
总结一句话:
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
让你的网格卡片最小200px,最大自动填满一行,自动适应任何屏幕,布局永远美观!
这里还能填 auto-fill,和 auto-fit 有啥区别?
🥇 auto-fill 和 auto-fit 有啥区别?
1. auto-fill
🧱 尽可能多地填充列,即使没有内容也会“占位”
- 会自动创建尽可能多的列轨道(包括空轨道),让网格尽量填满容器。
- 适合需要“列对齐”或“固定网格数”的场景。
2. auto-fit
🧱 自动适应内容,能合并多余空列,不占位
- 会自动“折叠”没有内容的轨道,让现有的内容尽量拉伸占满空间。
- 适合希望内容自适应填满整行的场景。
👀 直观对比
假设容器宽度能容纳 10 个 200px 的卡片,但你只放了 5 个卡片:
auto-fill会保留 10 列宽度,5 个卡片在前五列,后面五列是“空轨道”。auto-fit会折叠掉后面五列,让这 5 个卡片拉伸填满整行。

👇 Demo 代码:
<h2>auto-fill</h2>
<div class="grid-fill">
<div>item1</div>
<div>item2</div>
<div>item3</div>
<div>item4</div>
<div>item5</div>
</div>
<h2>auto-fit</h2>
<div class="grid-fit">
<div>item1</div>
<div>item2</div>
<div>item3</div>
<div>item4</div>
<div>item5</div>
</div>
.grid-fill {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 40px;
}
.grid-fit {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.grid-fill div {
background: #08f700;
}
.grid-fit div {
background: #f7b500;
}
.grid-fill div,
.grid-fit div {
padding: 24px;
font-size: 18px;
border-radius: 8px;
text-align: center;
}
兼容性

🎯 什么时候用 auto-fill,什么时候用 auto-fit?
- 希望每行“有多少内容就撑多宽”,用
auto-fit
适合卡片式布局、相册、响应式按钮等。 - 希望“固定列数/有占位”,用
auto-fill
比如表格、日历,或者你希望网格始终对齐,即使内容不满。
📝 总结
| 属性 | 空轨道 | 内容拉伸 | 适用场景 |
|---|---|---|---|
| auto-fill | 保留 | 否 | 固定列数、占位网格 |
| auto-fit | 折叠 | 是 | 流式布局、拉伸填充 |
🌟 小结
auto-fill更像“占位”,auto-fit更像“自适应”- 推荐大部分响应式卡片用
auto-fit - 善用
minmax配合,让列宽自适应得更自然
只需两行代码,你的页面就能优雅适配各种屏幕!
觉得有用就点赞收藏吧,更多前端干货持续更新中!🚀✨
来源:juejin.cn/post/7497895954101403688
H5唤醒APP技术方案入门级介绍
内容大纲

什么是H5唤醒App
“唤醒 App”指的是:
🐔🏀 从「另一个应用 / 系统环境」跳转并打开「你本地已安装的 App」
唤醒 App = 跨应用启动
典型来源端(“从哪来”)
- 🐔 浏览器(Safari / Chrome / 系统浏览器)
- 🏀 微信 / QQ / 钉钉 / 支付宝
- 🐔 其他第三方 App
- 🏀 短信 / 邮件
- 🐔 推送通知
- 🎤 二维码
目标端(“到哪去”)
- 🐉 你已经安装在手机里的原生 App
- 并且:
- 启动 App
- 还能跳到 指定页面
唤醒 App 的技术方案
deep link
在讲具体的技术选型方案之前
我们先要说什么是 deep link(唤端技术的本质)
deep link 本质上不是“打开 App” ,而是“让操作系统把一次跳转请求路由给某个 App 处理”
- 浏览器 / 微信 / 系统 并不是“主动打开 App”
- 而是 把一个“链接”交给系统
- 系统再决定:
- 1.有没有 App 能处理?
- 2.交给谁?
- 3.怎么交?
所以 deep link 是系统能力,不是 JS 技巧。
为什么会有这么多种唤醒方案?
- 1.iOS 和 Android 的系统模型不同
- 2.安全策略不同
- 3.浏览器、微信等容器又各自加了一层限制
于是结果就是:
“同一个目标(打开 App),在不同系统上只能用不同的入口”
这也是为什么你看到的主流方案是这三类:
- 1.URL Scheme(最原始)
- 2.Universal Link(iOS 官方)
- 3.App Link / Chrome Intents(Android 官方)
方案1.URL Scheme
在关于H5混合开发的通信中,我们就已经介绍了URL Scheme是JS bridge通信方式的一种
它的使用场景并不局限于“唤醒 App”,而是更广义的:
👉 通过一个特定格式的 URL,让系统或原生拦截并执行对应逻辑
一个典型的 URL Scheme 长这样:
myapp://page/detail?id=123
其中:
myapp:协议名(Scheme)page/detail:业务路径id=123:参数
对浏览器来说,它并不关心这个 URL 是否“合法”, 它唯一做的事是:把这个 URL 交给操作系统处理。
Scheme 方案唤醒app能生效的前提是:App 必须提前向系统注册这个协议名 。
在 App 安装阶段:
- iOS / Android 会在系统层记录
- “某个 App 能够处理哪些 Scheme”
系统会维护一张映射关系:
Scheme(协议名) → App
一旦这个映射存在,系统就具备了“路由能力”。
当系统再次遇到相同 Scheme 的 URL 时,流程会变成:
URL → 操作系统 → 查找注册关系 → 启动对应 App → 传递参数
整个过程发生在 系统层面,与 H5 是否运行在 WebView、是否使用 JS Bridge 本身并没有直接关系。
以 Safari → App 为例
Safari 点击链接
↓
系统识别这是 Universal Link / Scheme
↓
系统查找有没有 App 声明能处理
↓
有 → 启动 App(cold / warm)
↓
把参数交给 App
H5侧实现
① 通过 window.location.href 跳转
这是最直接、最直观的一种方式:
window.location.href = 'zhihu://'
它的行为非常明确:
- 1.当前页面发起一次 URL 跳转
- 2.浏览器发现这是一个非 http(s) 协议
- 3.将该 URL 交给操作系统处理
在早期移动浏览器和系统浏览器中,这种方式成功率较高,也是最常见的实现。
但它的问题也很明显:
- 1.会破坏当前页面状态
- 2.在强管控容器(如微信)中通常会被直接拦截
- 3.无法判断 App 是否已安装
② 通过隐藏 iframe 触发跳转
这种方式曾经被广泛用于 “无刷新唤醒” 的场景:
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = 'zhihu://'
document.body.appendChild(iframe)
其原理是:
- 1.利用 iframe 加载资源的行为
- 2.间接触发 Scheme
- 3.避免页面发生整体跳转
在一段时间内,这种方式被认为是:
比
location.href更“温和”的唤醒方式
但随着浏览器和容器安全策略的收紧:
- iframe 加载非标准协议被限制
- 微信、QQ 等环境几乎完全失效
目前这类方式更多只存在于历史代码或兼容逻辑中。
③ 通过 <a> 标签跳转
这是最“标准 HTML”的方式:
<a href="zhihu://">打开知乎 App</a>
它的特点是:
- 1.依赖用户真实点击
- 2.符合浏览器的交互安全模型
- 3.成功率通常高于自动跳转
在部分环境中:
“用户点击触发” 本身就是是否允许唤醒的重要判断条件
因此,<a> 标签在某些浏览器中的表现,反而比 JS 自动跳转更稳定。
④ 通过 JS Bridge 由原生侧发起
在 App 内 WebView 场景下,最稳定的方式其实是:
window.miduBridge.call('openAppByRouter', {
url: 'zhihu://'
})
这种方式的本质是:
- 1.H5 并不直接触发 Scheme
- 2.而是通过 JS Bridge 通知原生
- 3.由 原生代码主动发起跳转
这也是 混合开发中最推荐的做法,因为:
- 1.不受浏览器安全策略影响
- 2.成功率最高
- 3.可完全由 App 控制兜底逻辑
实际开发问题
在实际开发中,一个非常现实的问题是:
H5 发起 Scheme 跳转后,如何判断 App 是否真的被成功唤起?
但是事实上是对于 URL Scheme 这种系统级跳转机制 来说:
❗ 前端并不存在一个“可靠、官方、100% 准确”的判断方式
这是由 Scheme 的实现机制本身决定的。
为什么前端无法直接判断?
当 H5 触发 Scheme 跳转后:
- 1.浏览器将 URL 交给操作系统
- 2.系统尝试查找是否存在可处理该 Scheme 的 App
- 3.如果存在,则直接拉起 App
这个过程发生在:
浏览器 → 操作系统 → App
而 H5 所处的位置是:
浏览器沙箱内
浏览器不会告诉 H5:
- 1.是否找到了 App
- 2.是否成功启动
- 3.是否被系统或容器拦截
因此,H5 无法拿到任何明确的成功 / 失败回调。
目前的主流方案是【推测】
方式一:页面可见性变化(最常用)
let hidden = false
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
hidden = true
}
})
setTimeout(() => {
if (!hidden) {
// 大概率唤起失败
}
}, 1500)
原理是:
- 1.App 被拉起时
- 2.浏览器页面会进入后台
- 3.触发
visibilitychange
如果页面始终未进入隐藏状态,大概率唤醒失败
! 注意:
这是“概率判断”,不是绝对结论。
方式二:定时器兜底跳转
location.href = 'zhihu://'
setTimeout(() => {
location.href = 'https://appstore.xxx.com'
}, 2000)
逻辑是:
- 1.尝试唤醒 App
- 2.如果 2 秒内页面未被中断
- 3.认为 App 未安装或唤醒失败
- 4.自动跳转下载页
这是最常见的商业实现方式。\
以上方法均不可靠
因为它们都依赖于一个前提:
“App 被唤起,一定会导致页面进入后台”
但现实中:
- 系统弹窗
- 权限确认
- 容器拦截
- 多任务切换
都会导致误判。
所以结论非常明确:
Scheme 的唤醒结果,只能“推测”,不能“确认”
不过第 ④ 种方式,其实是一个例外。
window.miduBridge.call('openAppByRouter', { url: 'zhihu://' })
因为这一步是:
由原生主动发起跳转
所以:
- 原生知道自己是否成功处理了跳转
- 可以通过 JS Bridge 回调结果给 H5
window.miduBridge.call(
'openAppByRouter',
{ url: 'zhihu://' },
(result) => {
if (result.success) {
// 唤起成功
} else {
// 唤起失败
}
}
)
- ❌ 纯 H5 + Scheme
- 无法准确判断唤醒是否成功
- 只能通过行为推测
- ✅ JS Bridge + 原生发起
- 可以获得明确结果
- 成功率与可控性最高
也正是这个差异,导致了今天的现实:
Scheme 更适合作为“兜底工具”,而不是主方案
scheme方案的其他缺点
除了前面提到的 安全性差、用户体验不佳、无法准确判断唤起结果 外,URL Scheme 还有几个现实工程中必须考虑的缺点:
① 协议名可能被重复注册或占用
- 1.URL Scheme 依赖的是 协议名(如
myapp://) 来标识 App - 2.系统层面并没有强制保证唯一性
- 3.如果不同 App 注册了相同协议名:
- 用户点击 Scheme 时,系统可能唤醒错误的 App
- 导致业务逻辑混乱,甚至产生安全隐患
② 部分 App 或容器主动屏蔽
- 微信、QQ、支付宝等强管控容器对 Scheme 跳转有严格限制
- 常见表现:
- 1.自动跳转失效
- 2.iframe / location.href 被直接拦截
- 3.用户点击
<a>标签也可能无法唤醒
- 原因:
- 1.防止恶意跳转、劫持安装流
- 2.控制容器内的用户体验
换句话说,即便你的协议名注册正确,Scheme 在这些环境下往往失效。
③ 无统一管理和安全约束
- 1.URL Scheme 本身没有域名验证或证书绑定机制
- 2.任何 App 都可以注册
- 3.没有办法验证调用者或跳转来源
- 4.容易被用作“恶意唤醒”或劫持入口
<br>
方案2.Universal Link / App Link
随着 URL Scheme 的局限性暴露出来:
- 1.协议名可能冲突
- 2.容器或浏览器屏蔽
- 3.无法安全验证来源
Apple 和 Google 分别提出了官方解决方案:
- iOS → Universal Link
- Android → App Link / Chrome Intents
它们的核心理念很一致:
通过 HTTPS 链接 + 系统校验,让 App 唤醒更安全、更可靠
2.1 Universal Link(iOS)
Universal Link 是 iOS 9 之后新增的功能,它允许开发者 直接通过 HTTPS 链接唤醒 App。
相比 URL Scheme,它有几个明显优势:
- 自然降级:如果 App 没有安装,点击链接会直接打开网页,无需前端判断唤起是否成功。
- 用户体验更好:不会弹出“是否打开 App”的确认框,唤端效率更高。
- 安全可靠:链接必须绑定到 App 的域名,避免协议名冲突或被劫持。
核心原理
Universal Link 的实现原理可以概括为两步:
- 1.App 注册域名
- 在 iOS 项目中,需要声明 App 支持的域名。
- 系统通过这个绑定来识别哪些链接可以交给 App 处理。
- 2.域名配置 apple-app-site-association 文件
- 在对应域名的根目录下放置 apple-app-site-association 文件,声明 App 支持哪些路径。
- 当用户点击该域名的链接时,iOS 会检查该文件,并判断 App 是否可以处理。
- 如果 App 安装了,就直接唤起;否则,打开网页。
对前端同学来说,不需要关注文件的具体配置,只需与 iOS 同学确认好支持的域名即可。
- 系统在点击链接时,会偷偷做三件事:
- 1.验证域名是否和 App 绑定(Apple 服务器文件 + App 配置)
- 2.检查 App 是否已安装
- 3.匹配 App 内路由,如果符合则直接唤起 App 指定页面
- 未安装 App,则自然打开网页页面,不会报错或失效
<br>
相对于 URL Scheme,Universal Link 的优势非常明显:
- 1.无弹窗提示
- 唤端时不会弹出“是否打开 App”的确认框
- 用户体验更顺畅,可以减少用户流失
- 2.自然降级能力
- 无需关心用户是否安装 App
- 对于未安装 App 的用户,点击链接会直接打开对应网页
- 这也解决了 URL Scheme 无法准确判断唤端失败的问题
- 3.平台限制
- Universal Link 目前只能在 iOS 系统使用
- Android 需要使用 App Link 或 Chrome Intents
- 4.用户触发要求
- 必须由用户主动点击触发
- 自动跳转、iframe 触发等方式无法保证唤起成功
H5侧代码
在 H5 页面中,触发 Universal Link 非常简单,就像普通的网页链接一样
function openByUniversal() {
// 打开知乎问题页
window.location.href = 'https://oia.zhihu.com/questions/64966868';
}
或者使用 <a> 标签:
<a href="https://oia.zhihu.com/questions/64966868">打开 App</a>
特点:
- 1.与普通网页跳转一致,前端不需要做额外判断
- 2.如果 App 安装了,系统会直接拉起 App 并跳转到对应页面
- 3.如果 App 未安装,则打开网页,兜底自然
🔹 对前端同学来说,Universal Link 的操作非常简单,不需要关心底层配置,只需确认域名和路径由 iOS 同学支持即可。
⚠️ 但是它在 iOS 容器中仍然有限制:
- 微信、QQ 等仍然可能拦截
- 因为容器本身不允许把链接交给系统
2.2 App Link / Chrome Intents(Android)
Android 的解决方案和 iOS 类似,但实现上更“开放”:
- 1.App Link:和 Universal Link 一样,通过 HTTPS + 域名校验来保证安全
- 2.Chrome Intents:允许开发者直接指定 包名 + Scheme + 路由,用于兜底或精确跳转
示例:
https://www.example.com/product/123
或者使用 Intent:
intent://product/123#Intent;scheme=myapp;package=com.example.app;end
- 系统会检查 App 是否安装
- 安装则唤起指定页面
- 未安装则跳转应用商店
H5 侧触发方式
①通过普通 HTTPS 链接触发 App Link
function openByAppLink() {
// 打开商品详情页
window.location.href = 'https://www.example.com/product/123';
}
或者直接用 <a> 标签:
<a href="https://www.example.com/product/123">打开 App</a>
原理:
- 1.系统检测链接对应域名是否绑定 App
- 2.App 安装了 → 唤起并跳转指定页面
- 3.App 未安装 → 自动打开网页,兜底自然
② 通过 Intent URL 触发 Chrome Intents
function openByIntent() {
window.location.href = 'intent://product/123#Intent;scheme=myapp;package=com.example.app;end';
}
特点:
- 1.可以指定 App 包名和 Scheme
- 2.App 安装 → 唤起指定页面
- 3.App 未安装 → 跳转应用商店,确保用户可获取 App
2.3 相比 Scheme 的优势
| 优势 | 说明 |
|---|---|
| 安全 | 域名验证避免被劫持或重复注册 |
| 成功率高 | 系统直接控制唤醒流程 |
| 可自然降级 | App 未安装时自动跳网页或应用商店 |
| 用户体验好 | 不弹确认框,跳转顺畅 |
2.4 需要注意的点
- 1.Universal Link / App Link 仍然会被部分 容器拦截 (尤其是微信)
- 2.域名和 App 的绑定必须在 服务端 + App 配置 同步
- 3.Android 上不同浏览器行为可能略有差异,需要在测试时覆盖主流浏览器
方案3:微信环境下的唤醒方案
微信环境下的 H5 唤醒 App,和普通浏览器相比有几个显著特点:
- 1.绝大部分 Scheme 被拦截
- 无论是
location.href、iframe 还是<a>标签 - 微信会直接阻止跳转,防止外部 App 劫持
- 无论是
- 2.Universal Link / App Link 成功率有限
- iOS 的 Universal Link 在微信里也可能被拦截
- Android 的 App Link / Chrome Intents 在微信内同样可能无效
🔹 也就是说,在微信环境下,“传统唤端方案”几乎失效。
3.1可行方案
① 通过 跳转到 App Store / 应用商店
- 对于未安装 App 的用户,是最安全、最通用的兜底方案
- 缺点:用户必须手动下载,体验不如直接唤端
window.location.href = 'https://apps.apple.com/cn/app/idxxxxxx';
② 使用 中转页 / 提示页
- 先打开一个中转 H5 页面(WebView 或浏览器打开),提示用户点击按钮唤醒 App
- 按钮可以触发 Scheme 或 Universal Link
- 优势:
- 1.提示用户手动操作,提高唤醒成功率
- 2.可以结合埋点统计唤醒行为
- 缺点:
- 额外增加一个页面,增加跳转成本
H5侧
<!-- 中转提示页 -->
<button id="openAppBtn">打开 App</button>
<script>
document.getElementById('openAppBtn').addEventListener('click', function() {
// 方式 1:使用 URL Scheme(兜底方案)
window.location.href = 'myapp://page/detail?id=123';
// 方式 2:使用 Universal Link(iOS)
// window.location.href = 'https://www.example.com/page/detail?id=123';
// 可选:2 秒后兜底到应用商店
setTimeout(() => {
window.location.href = 'https://apps.apple.com/cn/app/idxxxxxx'; // iOS 应用商店
// 或 Android 下载链接
}, 2000);
});
</script>
特点:
- 1.必须用户点击才能触发
- 2.可以结合 setTimeout 兜底下载
- 3.可以在按钮点击时触发埋点统计唤醒成功率
③ 小程序或企业号协作
- 对于企业内部或自家 App:
- 可以通过 小程序 / 企业微信接口 调起 App
- 优点:成功率高,可控
- 缺点:仅限特定生态
H5 侧示例(假设使用企业微信 JS-SDK)
<button id="openAppBtn">打开 App</button>
<script>
// 假设已经引入企业微信 JS-SDK 并完成 config
document.getElementById('openAppBtn').addEventListener('click', function() {
if (window.wx && wx.invoke) {
wx.invoke('openEnterpriseChat', { // 示例接口
useridlist: 'user_id',
chatType: 1
}, function(res) {
if(res.err_msg == "openEnterpriseChat:ok") {
console.log('App 唤起成功');
} else {
console.log('唤起失败,兜底逻辑');
window.location.href = 'https://apps.apple.com/cn/app/idxxxxxx';
}
});
}
});
</script>
特点:
- 1.成功率高,原生接口可明确回调
- 2.适合企业内部 / 自家生态
- 3.不适用于普通微信用户
④ 微信开放标签 <wx-open-launch-app>(Android)
微信为了改善 Android H5 唤醒体验,提供了 开放标签 wx-open-launch-app,可以让前端 H5 直接在微信里唤醒 App。
使用示例
<wx-open-launch-app
appid="wx123" <!-- 你注册的 App ID -->
extinfo="page=home&id=123"> <!-- 透传参数,可在 App 内使用 -->
<script type="text/wxtag-template">
<button>打开 App</button>
</script>
</wx-open-launch-app>
原理:
- 1.标签本身是微信官方提供的组件
- 2.内部会调用 微信客户端唤醒 App 的能力
- 3.可以透传参数给 App,直接跳到指定页面
⚠️ 使用前提
- 1.微信认证
- 公众号或小程序必须经过微信认证
- 2.App 在白名单内
- 需要申请微信开放能力并配置白名单
- 只有在白名单内的 App 才能被唤醒
- 3.仅限微信环境
- 该标签在普通浏览器或非微信环境下无法使用
特点
- 1.成功率高:比传统 Scheme / Universal Link 在微信中稳定
- 2.前端简单:不需要写 JS 复杂逻辑,只需包一层标签即可
- 3.可透传参数:可直接带参数跳到指定页面
限制
- 1.仅适用于 Android
- 2.必须满足认证 + 白名单条件
- 3.仅能在微信内使用
⑤微信环境下 iOS 唤醒:Universal Link
微信中,前面提到的 URL Scheme、iframe 等方式几乎都被拦截,无法自动唤起 App。
iOS 唯一可行且推荐的方案是 Universal Link:
- 1.用户点击 H5 页面里的 HTTPS 链接
- 2.iOS 系统检查该域名是否绑定了 App
- 3.App 已安装 → 直接唤起并跳转指定页面
- 4.App 未安装 → 打开网页,自然兜底
H5 触发方式
<a href="https://oia.zhihu.com/questions/64966868">打开 App</a>
<script>
function openByUniversal() {
window.location.href = 'https://oia.zhihu.com/questions/64966868';
}
</script>
特点:
- 1.成功率最高
- iOS 系统直接判断是否唤起 App
- 不受微信容器拦截 Scheme 的影响
- 2.用户体验好
- 不弹出“是否打开 App”的确认框
- 点击即可直接唤起 App
- 3.自然降级
- App 未安装时,自动打开网页
- 前端无需额外逻辑判断唤端成功与否
注意:
- 1.仅适用于 iOS 微信
- 2.Android 微信仍需中转页或
<wx-open-launch-app>等方案 - 3.必须事先和 iOS 同学确认支持的域名和 Universal Link 配置
来源:juejin.cn/post/7594087108594237503
React + Tailwind CSS 实战:打造一个“会呼吸”的登录页面
哈喽,各位掘金的“打工人”们,大家好!👋
还记得咱们上一篇聊过的 Tailwind CSS 入门(在这里详细讲解了如何配置TailwindCss) 吗?当时我们不仅揭开了原子化 CSS 的神秘面纱,还稍微带了一嘴“受控组件”的概念。
今天,咱们不玩虚的,直接实战!🚀
我们要用 React 配合 Tailwind CSS,从零打造一个现代、优雅、且交互细腻的登录页面。
别担心,虽然说是“实战”,但我的风格你懂的:轻松愉快,知识硬核。我会把代码掰开了、揉碎了讲给你听,保证你不仅能学会写,还能懂得为什么要这么写。
准备好了吗?系好安全带,老司机要发车了!🚌💨
🎯 我们的目标
我们要做的不是一个死板的 HTML 页面,而是一个有灵魂的 React 组件。它包含:
- 响应式布局:手机、平板、电脑通吃。
- 优雅的 UI:圆角、阴影、柔和的配色(Tailwind 拿手好戏)。
- 极致的交互:聚焦时图标变色、平滑的过渡动画。
- React 逻辑:受控组件、状态管理、密码显隐切换。
- 图标库:使用
lucide-react这一当下最火的图标库。
最终效果?就像你每天用的那些大厂 App 一样丝滑。✨
🛠️ 准备工作:兵马未动,粮草先行
首先,确保你的环境里有 React 和 Tailwind CSS。如果你是 Vite 用户,这简直是分分钟的事。
在这个项目中,我们还需要一个特别好用的图标库:lucide-react。
npm install lucide-react
# 或者
pnpm add lucide-react
它体积小、图标全、风格统一,绝对是开发利器。
🏗️ 第一步:骨架与画布 —— 布局的艺术
一切从 App.jsx 开始。
我们先看最外层的结构。想象一下,你是个画家,得先铺好画布。
export default function App() {
// ... 逻辑部分稍后讲 ...
return (
// 1. 外层容器:全屏背景,居中布局
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
{/* ... 卡片 ... */}
</div>
)
}
📝 代码详解
min-h-screen: 核心! 这让容器的高度至少为屏幕高度(100vh)。如果内容不够多,背景也能铺满全屏;内容多了,它能自动延伸。告别尴尬的“白底漏出”。bg-slate-50: 给背景来点极其淡雅的灰。纯白(#fff)太刺眼,Slate-50 刚刚好,高级感这就来了。flex items-center justify-center: Flexbox 三连。这是最经典的垂直水平居中方案。不管你的屏幕多大,登录框永远稳坐 C 位。p-4: 给四周留点余地,防止在小屏幕手机上内容贴边。
📦 第二步:卡片设计 —— 拟物感的回归
接下来是那个漂浮在屏幕中央的白色卡片。
<div className="relative z-10 w-full max-w-md bg-white rounded-3xl shadow-xl shadow-slate-200/60 border-slate-100 p-8 md:p-10">
{/* ... 内容 ... */}
</div>
📝 代码详解
这里面的学问可大了:
- 尺寸控制:
w-full: 宽度占满父容器(但在 padding 的作用下不会贴边)。max-w-md: 关键限制。在大屏幕上,我们不希望登录框无限拉长,max-w-md(28rem / 448px) 是一个非常舒适的阅读宽度。
- 质感营造:
bg-white: 卡片主体白色。rounded-3xl: 超大圆角!现在流行这种亲和力强的设计,比直角或小圆角更 Modern。shadow-xl shadow-slate-200/60: Tailwind 的黑魔法。shadow-xl给出一个大投影,而shadow-slate-200/60则是修改了这个投影的颜色!默认的黑色投影太脏了,用带点蓝紫调的灰色(slate),并且设置透明度(/60),会让卡片看起来像是“悬浮”在空气中,通透感满分。border-slate-100: 极淡的边框,增强边界感,细节决定成败。
- 响应式内边距:
p-8: 默认情况(手机)内边距是 2rem。md:p-10: Mobile First 策略。当屏幕宽度大于 md(768px)时,内边距增加到 2.5rem。大屏大留白,呼吸感就有了。
🧠 第三步:注入灵魂 —— React 状态管理
界面写得再好看,不能动也是白搭。我们要用 React 的 Hooks 来赋予它生命。
import { useState } from 'react';
export default function App() {
// 1. 表单数据状态:单一数据源
const [formData, setFormData] = useState({
email: '',
password: '',
remember: false // 虽然 UI 里没画,但逻辑我们要预留好
});
// 2. UI 交互状态
const [showPassword, setShowPassword] = useState(false); // 密码显隐
const [isLoading, setIsLoading] = useState(false); // 加载中状态
// ...
}
💡 为什么这么设计?
我们没有为 email 和 password 分别创建 state(比如 email, setEmail),而是用一个对象 formData 统一管理。
这样做的好处是:当表单字段变多时(比如注册页有10个空),我们不需要写10个 useState,代码更整洁,扩展性更强。
⚡ 第四步:抽象事件处理 —— 优雅的 handleChange
这是很多新手容易写乱的地方。看仔细了,这一段代码非常通用,建议背诵!
// 抽象的表单变更处理函数
const handleChange = (e) => {
// 解构出我们需要的信息
// name: 哪个输入框变了?
// value: 变成了什么值?
// type/checked: 专门处理 checkbox
const { name, value, type, checked } = e.target;
// 状态更新
setFormData((prev) => ({
...prev, // 保留之前的其他字段
// 动态属性名:[name]
// 如果是 checkbox 用 checked,否则用 value
[name]: type === 'checkbox' ? checked : value,
}))
}
📝 深度解析
- 对象解构:
const {name, value, ...} = e.target让代码更清晰。 - 函数式更新:
setFormData((prev) => ...)。注意! 永远推荐用这种回调函数的方式更新依赖于旧状态的新状态。这能确保在复杂的异步更新中,你拿到的prev永远是最新的。 - 计算属性名:
[name]: ...。ES6 的语法糖,让我们可以用变量name作为对象的 key。这意味着这一个函数,可以同时处理 email、password、username 等无数个输入框!这就叫复用。
🎨 第五步:表单组件 —— 细节狂魔
接下来是重头戏:输入框。这里我们用到了 Tailwind 极其强大的 group 和 peer 特性。
邮箱输入框
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Email:</label>
{/* group: 父容器标记 */}
<div className="relative group">
{/* 图标:绝对定位 */}
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-indigo-600 transition-colors">
<Mail size={18} />
</div>
{/* 输入框 */}
<input
type="email"
name="email"
required
value={formData.email}
onChange={handleChange}
placeholder="name@company.com"
className="block w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600 transition-all"
/>
</div>
</div>
🤯 这里的 CSS 技巧太炸裂了!
- 图标变色魔法 (
group-focus-within):
- 我们在父级
div加了group类。 - 在图标
div加了group-focus-within:text-indigo-600。 - 效果:当子元素(input)被聚焦(focus)时,父级检测到 focus-within,通知图标改变颜色!
- 体验:用户一点输入框,前面的小信封瞬间变成亮紫色,这种交互反馈极大地提升了用户的掌控感。
- 我们在父级
- Input 的精细打磨:
pl-11: 左边距留大点(2.75rem),因为那里放了图标。focus:ring-2 focus:ring-indigo-600/20: 聚焦时,不要浏览器默认的丑边框,我们要一个 2px 宽、带透明度的紫色光环。focus:border-indigo-600: 同时边框颜色变深。transition-all: 所有的变化(颜色、阴影)都要有过渡动画,拒绝生硬。
🔐 第六步:密码框与显隐切换
密码框多了一个“眼睛”按钮,逻辑稍微复杂一点点。
<div className="relative group">
{/* 左侧锁图标 (同上,略) */}
<input
// 动态类型:根据状态决定是明文还是密文
type={showPassword ? "text" : "password"}
name="password"
// ...
/>
{/* 右侧切换按钮 */}
<button
type="button" // 必须写!否则默认是 submit 会触发表单提交
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-slate-600 transition-colors"
>
{/* 根据状态切换图标 */}
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
📝 关键点
- 动态 Type:
type={showPassword ? "text" : "password"}。这是 React 控制 DOM 属性最直接的体现。数据驱动视图,我们不需要手动去操作 DOM 节点的 type 属性。 - Button Type:在
<form>内部的<button>,如果没有指定type,默认行为是submit。如果你点击眼睛图标,页面突然刷新了,肯定是因为你忘了写type="button"。 - 图标切换:利用三元运算符
{showPassword ? <EyeOff /> : <Eye />}在两个图标组件间切换。
🚀 总结
看到这里,你应该已经发现,使用 Tailwind CSS + React 开发界面,实际上是一种搭积木的体验。
- Tailwind 提供了极其丰富的原子积木(Utility Classes),让你不用写一行 CSS 就能堆砌出精美的样式。
- React 提供了胶水和传动装置(State & Props),让这些积木动起来,响应用户的操作。
我们学到了什么?
- 布局:
min-h-screen,flex,justify-center是万能起手式。 - 美学:利用
shadow-slate-200/60这种带颜色的透明阴影制造高级感。 - 交互:
group-focus-within是处理父子联动交互的神器。 - 逻辑:单个
handleChange处理多个输入框,高效且优雅。 - 细节:
ring,transition,placeholder等伪类修饰符的组合使用。
课后作业 📝
现在的登录点击后还没有实际效果。你可以尝试完善 handleSubmit 函数,加一个 setTimeout 模拟网络请求,把 isLoading 状态用起来,给按钮加一个“加载中”的转圈圈动画。
前端开发很有趣,Tailwind 让它变得更有趣。希望这篇文章能让你感受到原子化 CSS 的魅力!
喜欢的话,点个赞再走吧!我们下期见!👋
本文代码基于 React 18 + Tailwind CSS 3.x + Lucide React 编写。
来源:juejin.cn/post/7591708519449198601
autohue.js:让你的图片和背景融为一体,绝了!
需求
先来看这样一个场景,拿一个网站举例

这里有一个常见的网站 banner 图容器,大小为为1910*560,看起来背景图完美的充满了宽度,但是图片原始大小时,却是:

它的宽度只有 1440,且 background-size 设置的是 contain ,即等比例缩放,那么可以断定它两边的蓝色是依靠背景色填充的。
那么问题来了,这是一个 轮播banner,如果希望添加一张不是蓝色的图片呢?难道要给每张图片提前标注好背景颜色吗?这显然是非常死板的做法。
所以需要从图片中提取到图片的主题色,当然这对于 js 来说,也不是什么难事,市面上已经有众多的开源库供我们使用。
探索
首先在网络上找到了以下几个库:
- color-thief 这是一款基于 JavaScript 和 Canvas 的工具,能够从图像中提取主要颜色或代表性的调色板
- vibrant.js 该插件是 Android 支持库中 Palette 类的 JavaScript 版本,可以从图像中提取突出的颜色
- rgbaster.js 这是一段小型脚本,可以获取图片的主色、次色等信息,方便实现一些精彩的 Web 交互效果
我取最轻量化的 rgbaster.js(此库非常搞笑,用TS编写,npm 包却没有指定 types) 来测试后发现,它给我在一个渐变色图片中,返回了七万多个色值,当然,它准确的提取出了面积最大的色值,但是这个色值不是图片边缘的颜色,导致设置为背景色后,并不能完美的融合。
另外的插件各位可以参考这几篇文章:
- 文章1:blog.csdn.net/weixin_4299…
- 文章2:juejin.cn/post/684490…
- 文章3:http://www.zhangxinxu.com/wordpress/2…
可以发现,这些插件主要功能就是取色,并没有考虑实际的应用场景,对于一个图片颜色分析工具来说,他们做的很到位,但是在大多数场景中,他们往往是不适用的。
在文章 2 中,作者对比了三款插件对于图片容器背景色的应用,看起来还是 rgbaster 效果好一点,但是我们刚刚也拿他试了,它并不能适用于颜色复杂度高的、渐变色的图片。
思考
既然又又又没有人做这件事,正所谓我不入地狱谁入地狱,我手写一个
整理一下需求,我发现我希望得到的是:
- 图片的主题色(面积占比最大)
- 次主题色(面积占比第二大)
- 合适的背景色(即图片边缘颜色,渐变时,需要边缘颜色来设置背景色)
这样一来,就已经可以覆盖大部分需求了,1+2 可以生成相关的 主题 TAG、主题背景,3 可以使留白的图片容器完美融合。
开搞
⚠⚠ 本小节内容非常硬核,如果不想深究原理可以直接跳过,文章末尾有用法和效果图 ⚠⚠
思路
首先需要避免上面提到的插件的缺点,即对渐变图片要做好处理,不能取出成千上万的颜色,体验太差且实用性不强,对于渐变色还有一点,即在渐变路径上,每一点的颜色都是不一样的,所以需要将他们以一个阈值分类,挑选出一众相近色,并计算出一个平均色,这样就不会导致主题色太精准进而没有代表性。
对于背景色,需要按情况分析,如果只是希望做一个协调的页面,那么大可以直接使用主题色做渐变过渡或蒙层,也就是类似于这种效果

但是如果希望背景与图片完美衔接,让人看不出图片边界的感觉,就需要单独对边缘颜色取色了。
最后一个问题,如果图片分辨率过大,在遍历像素点时会非常消耗性能,所以需要降低采样率,虽然会导致一些精度上的丢失,但是调整为一个合适的值后应该基本可用。
剩余的细节问题,我会在下面的代码中解释
使用 JaveScript 编码
接下来我将详细描述 autohue.js 的实现过程,由于本人对色彩科学不甚了解,如有解释不到位或错误,还请指出。
首先编写一个入口主函数,我目前考虑到的参数应该有:
export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions)
type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }
interface autoColorPickerOptions {
/**
* - 降采样后的最大尺寸(默认 100px)
* - 降采样后的图片尺寸不会超过该值,可根据需求调整
* - 降采样后的图片尺寸越小,处理速度越快,但可能会影响颜色提取的准确性
**/
maxSize?: number
/**
* - Lab 距离阈值(默认 10)
* - 低于此值的颜色归为同一簇,建议 8~12
* - 值越大,颜色越容易被合并,提取的颜色越少
* - 值越小,颜色越容易被区分,提取的颜色越多
**/
threshold?: number | thresholdObj
}
概念解释 Lab ,全称:
CIE L*a*b,CIE L*a*b*是CIE XYZ色彩模式的改进型。它的“L”(明亮度),“a”(绿色到红色)和“b”(蓝色到黄色)代表许多的值。与XYZ比较,CIE L*a*b*的色彩更适合于人眼感觉的色彩,正所谓感知均匀
然后需要实现一个正常的 loadImg 方法,使用 canvas 异步加载图片
function loadImage(imageSource: HTMLImageElement | string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
let img: HTMLImageElement
if (typeof imageSource === 'string') {
img = new Image()
img.crossOrigin = 'Anonymous'
img.src = imageSource
} else {
img = imageSource
}
if (img.complete) {
resolve(img)
} else {
img.onload = () => resolve(img)
img.onerror = (err) => reject(err)
}
})
}
这样我们就获取到了图片对象。
然后为了图片过大,我们需要进行降采样处理
// 利用 Canvas 对图片进行降采样,返回 ImageData 对象
function getImageDataFromImage(img: HTMLImageElement, maxSize: number = 100): ImageData {
const canvas = document.createElement('canvas')
let width = img.naturalWidth
let height = img.naturalHeight
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height)
width = Math.floor(width * scale)
height = Math.floor(height * scale)
}
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法获取 Canvas 上下文')
}
ctx.drawImage(img, 0, 0, width, height)
return ctx.getImageData(0, 0, width, height)
}
概念解释,降采样:降采样(Downsampling)是指在图像处理中,通过减少数据的采样率或分辨率来降低数据量的过程。具体来说,就是在保持原始信息大致特征的情况下,减少数据的复杂度和存储需求。这里简单理解为将图片强制压缩为 100*100 以内,也是 canvas 压缩图片的常见做法。
得到图像信息后,就可以对图片进行像素遍历处理了,正如思考中提到的,我们需要对相近色提取并取平均色,并最终获取到主题色、次主题色。
那么问题来了,什么才算相近色,对于这个问题,在 常规的 rgb 中直接计算是不行的,因为它涉及到一个感知均匀的问题
概念解释,感知均匀:XYZ系统和在它的色度图上表示的两种颜色之间的距离与颜色观察者感知的变化不一致,这个问题叫做感知均匀性(perceptual uniformity)问题,也就是颜色之间数字上的差别与视觉感知不一致。由于我们需要在颜色簇中计算出平均色,那么对于人眼来说哪些颜色是相近的?此时,我们需要把 sRGB 转化为 Lab 色彩空间(感知均匀的),再计算其欧氏距离,在某一阈值内的颜色,即可认为是相近色。
所以我们首先需要将 rgb 转化为 Lab 色彩空间
// 将 sRGB 转换为 Lab 色彩空间
function rgbToLab(r: number, g: number, b: number): [number, number, number] {
let R = r / 255,
G = g / 255,
B = b / 255
R = R > 0.04045 ? Math.pow((R + 0.055) / 1.055, 2.4) : R / 12.92
G = G > 0.04045 ? Math.pow((G + 0.055) / 1.055, 2.4) : G / 12.92
B = B > 0.04045 ? Math.pow((B + 0.055) / 1.055, 2.4) : B / 12.92
let X = R * 0.4124 + G * 0.3576 + B * 0.1805
let Y = R * 0.2126 + G * 0.7152 + B * 0.0722
let Z = R * 0.0193 + G * 0.1192 + B * 0.9505
X = X / 0.95047
Y = Y / 1.0
Z = Z / 1.08883
const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)
const fx = f(X)
const fy = f(Y)
const fz = f(Z)
const L = 116 * fy - 16
const a = 500 * (fx - fy)
const bVal = 200 * (fy - fz)
return [L, a, bVal]
}
这个函数使用了看起来很复杂的算法,不必深究,这是它的大概解释:
- 获取到 rgb 参数
- 转化为线性 rgb(移除 gamma矫正),常量 0.04045 是sRGB(标准TGB)颜色空间中的一个阈值,用于区分非线性和线性的sRGB值,具体来说,当sRGB颜色分量大于0.04045时,需要通过 gamma 校正(即采用
((R + 0.055) / 1.055) ^ 2.4)来得到线性RGB;如果小于等于0.04045,则直接进行线性转换(即R / 12.92) - 线性RGB到XYZ空间的转换,转换公式如下:
X = R * 0.4124 + G * 0.3576 + B * 0.1805Y = R * 0.2126 + G * 0.7152 + B * 0.0722Z = R * 0.0193 + G * 0.1192 + B * 0.9505
- 归一化XYZ值,为了参考白点(D65),标准白点的XYZ值是
(0.95047, 1.0, 1.08883)。所以需要通过除以这些常数来进行归一化 - XYZ到Lab的转换,公式函数:const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)
- 计算L, a, b 分量
L:亮度分量(表示颜色的明暗程度)
L = 116 * fy - 16
a:绿色到红色的色差分量
a = 500 * (fx - fy)
b:蓝色到黄色的色差分量
b = 200 * (fy - fz)
接下来实现聚类算法
/**
* 对满足条件的像素进行聚类
* @param imageData 图片像素数据
* @param condition 判断像素是否属于指定区域的条件函数(参数 x, y)
* @param threshold Lab 距离阈值,低于此值的颜色归为同一簇,建议 8~12
*/
function clusterPixelsByCondition(imageData: ImageData, condition: (x: number, y: number) => boolean, threshold: number = 10): Cluster[] {
const clusters: Cluster[] = []
const data = imageData.data
const width = imageData.width
const height = imageData.height
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (!condition(x, y)) continue
const index = (y * width + x) * 4
if (data[index + 3] === 0) continue // 忽略透明像素
const r = data[index]
const g = data[index + 1]
const b = data[index + 2]
const lab = rgbToLab(r, g, b)
let added = false
for (const cluster of clusters) {
const d = labDistance(lab, cluster.averageLab)
if (d < threshold) {
cluster.count++
cluster.sumRgb[0] += r
cluster.sumRgb[1] += g
cluster.sumRgb[2] += b
cluster.sumLab[0] += lab[0]
cluster.sumLab[1] += lab[1]
cluster.sumLab[2] += lab[2]
cluster.averageRgb = [cluster.sumRgb[0] / cluster.count, cluster.sumRgb[1] / cluster.count, cluster.sumRgb[2] / cluster.count]
cluster.averageLab = [cluster.sumLab[0] / cluster.count, cluster.sumLab[1] / cluster.count, cluster.sumLab[2] / cluster.count]
added = true
break
}
}
if (!added) {
clusters.push({
count: 1,
sumRgb: [r, g, b],
sumLab: [lab[0], lab[1], lab[2]],
averageRgb: [r, g, b],
averageLab: [lab[0], lab[1], lab[2]]
})
}
}
}
return clusters
}
函数内部有一个 labDistance 的调用,labDistance 是计算 Lab 颜色空间中的欧氏距离的
// 计算 Lab 空间的欧氏距离
function labDistance(lab1: [number, number, number], lab2: [number, number, number]): number {
const dL = lab1[0] - lab2[0]
const da = lab1[1] - lab2[1]
const db = lab1[2] - lab2[2]
return Math.sqrt(dL * dL + da * da + db * db)
}
概念解释,欧氏距离:Euclidean Distance,是一种在多维空间中测量两个点之间“直线”距离的方法。这种距离的计算基于欧几里得几何中两点之间的距离公式,通过计算两点在各个维度上的差的平方和,然后取平方根得到。欧氏距离是指n维空间中两个点之间的真实距离,或者向量的自然长度(即该点到原点的距离)。
总的来说,这个函数采用了类似 K-means 的聚类方式,将小于用户传入阈值的颜色归为一簇,并取平均色(使用 Lab 值)。
概念解释,聚类算法:Clustering Algorithm 是一种无监督学习方法,其目的是将数据集中的元素分成不同的组(簇),使得同一组内的元素相似度较高,而不同组之间的元素相似度较低。这里是将相近色归为一簇。
概念解释,颜色簇:簇是聚类算法中一个常见的概念,可以大致理解为 "一类"
得到了颜色簇集合后,就可以按照count大小来判断哪个是主题色了
// 对全图所有像素进行聚类
let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
clusters.sort((a, b) => b.count - a.count)
const primaryCluster = clusters[0]
const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
const primaryColor = rgbToHex(primaryCluster.averageRgb)
const secondaryColor = rgbToHex(secondaryCluster.averageRgb)
现在我们已经获取到了主题色、次主题色 🎉🎉🎉
接下来,我们继续计算边缘颜色
按照同样的方法,只是把阈值设小一点,我这里直接设置为 1 (threshold.top 等都是1)
// 分别对上、右、下、左边缘进行聚类
const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
topClusters.sort((a, b) => b.count - a.count)
const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor
const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
bottomClusters.sort((a, b) => b.count - a.count)
const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor
const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
leftClusters.sort((a, b) => b.count - a.count)
const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor
const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
rightClusters.sort((a, b) => b.count - a.count)
const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor
这样我们就获取到了上下左右四条边的颜色 🎉🎉🎉
这样大致的工作就完成了,最后我们将需要的属性导出给用户,我们的主函数最终长这样:
/**
* 主函数:根据图片自动提取颜色
* @param imageSource 图片 URL 或 HTMLImageElement
* @returns 返回包含主要颜色、次要颜色和背景色对象(上、右、下、左)的结果
*/
export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions): Promise<AutoHueResult> {
const { maxSize, threshold } = __handleAutoHueOptions(options)
const img = await loadImage(imageSource)
// 降采样(最大尺寸 100px,可根据需求调整)
const imageData = getImageDataFromImage(img, maxSize)
// 对全图所有像素进行聚类
let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
clusters.sort((a, b) => b.count - a.count)
const primaryCluster = clusters[0]
const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
const primaryColor = rgbToHex(primaryCluster.averageRgb)
const secondaryColor = rgbToHex(secondaryCluster.averageRgb)
// 定义边缘宽度(单位像素)
const margin = 10
const width = imageData.width
const height = imageData.height
// 分别对上、右、下、左边缘进行聚类
const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
topClusters.sort((a, b) => b.count - a.count)
const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor
const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
bottomClusters.sort((a, b) => b.count - a.count)
const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor
const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
leftClusters.sort((a, b) => b.count - a.count)
const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor
const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
rightClusters.sort((a, b) => b.count - a.count)
const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor
return {
primaryColor,
secondaryColor,
backgroundColor: {
top: topColor,
right: rightColor,
bottom: bottomColor,
left: leftColor
}
}
}
还记得本小节一开始提到的参数吗,你可以自定义 maxSize(压缩大小,用于降采样)、threshold(阈值,用于设置簇大小)
为了用户友好,我还编写了 threshold 参数的可选类型:number | thresholdObj
type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }
可以单独设置主阈值、上下左右四边阈值,以适应更个性化的情况。
autohue.js 诞生了
名字的由来:秉承一贯命名习惯,auto 家族成员又多一个,与颜色有关的单词有好多个,我取了最短最好记的一个 hue(色相),也比较契合插件用途。
此插件已在 github 开源:GitHub autohue.js
npm 主页:NPM autohue.js
在线体验:autohue.js 官方首页
安装与使用
pnpm i autohue.js
import autohue from 'autohue.js'
autohue(url, {
threshold: {
primary: 10,
left: 1,
bottom: 12
},
maxSize: 50
})
.then((result) => {
// 使用 console.log 打印出色块元素s
console.log(`%c${result.primaryColor}`, 'color: #fff; background: ' + result.primaryColor, 'main')
console.log(`%c${result.secondaryColor}`, 'color: #fff; background: ' + result.secondaryColor, 'sub')
console.log(`%c${result.backgroundColor.left}`, 'color: #fff; background: ' + result.backgroundColor.left, 'bg-left')
console.log(`%c${result.backgroundColor.right}`, 'color: #fff; background: ' + result.backgroundColor.right, 'bg-right')
console.log(`%clinear-gradient to right`, 'color: #fff; background: linear-gradient(to right, ' + result.backgroundColor.left + ', ' + result.backgroundColor.right + ')', 'bg')
bg.value = `linear-gradient(to right, ${result.backgroundColor.left}, ${result.backgroundColor.right})`
})
.catch((err) => console.error(err))
最终效果

复杂边缘效果

纵向渐变效果(这里使用的是 left 和 right 边的值,可能使用 top 和 bottom 效果更佳)

纯色效果(因为单独对边缘采样,所以无论图片内容多复杂,纯色基本看不出边界)

突变边缘效果(此时用css做渐变蒙层应该效果会更好)

横向渐变效果(使用的是 left 和 right 的色值),基本看不出边界
参考资料
- zhuanlan.zhihu.com/p/370371059
- baike.baidu.com/item/%E5%9B…
- baike.baidu.com/item/%E6%A0…
- zh.wikipedia.org/wiki/%E6%AC…
- blog.csdn.net/weixin_4256…
- zh.wikipedia.org/wiki/K-%E5%…
- blog.csdn.net/weixin_4299…
- juejin.cn/post/684490…
番外
Auto 家族的其他成员
- Auto-Plugin/autofit.js autofit.js 迄今为止最易用的自适应工具
- Auto-Plugin/autolog.js autolog.js 轻量化小弹窗
- Auto-Plugin/autouno autouno 直觉的UnoCSS预设方案
- Auto-Plugin/autohue.js 本品 一个自动提取图片主题色让图片和背景融为一体的工具
来源:juejin.cn/post/7471919714292105270
TensorFlow.js 和 Brain.js 全面对比:哪款 JavaScript AI 库更适合你?
温馨提示
由于篇幅较长,为方便阅读,建议按需选择章节,也可收藏备用,分段消化更高效哦!希望本文能为你的前端
AI开发之旅提供实用参考。 😊
引言:前端 AI 的崛起
在过去的十年里,人工智能(AI)技术的飞速发展已经深刻改变了各行各业。从智能助手到自动驾驶,从图像识别到自然语言处理,AI 的应用场景几乎无处不在。而对于前端开发者来说,AI 的魅力不仅在于其强大的功能,更在于它已经走进了浏览器,让客户端也能够轻松承担起机器学习的任务。
试想一下,当你开发一个 Web 应用,需要进行图像识别、文本分析、语音识别或其他 AI 任务时,你是否希望直接在浏览器中处理这些数据,而无需依赖远程服务器?如果能在用户的设备上本地运行这些任务,不仅可以大幅提升响应速度,还能减少服务器资源的消耗,为用户提供更流畅的体验。
这正是 TensorFlow.js 和 Brain.js 两款库所带来的变革。它们使开发者能够在浏览器中轻松实现机器学习任务,甚至支持训练和推理深度学习模型。虽然这两款库在某些功能上有相似之处,但它们的定位和特点却各有侧重。
TensorFlow.js 是由 Google 推出的深度学习框架,它为浏览器端的机器学习提供了强大的支持,能够处理从图像识别到自然语言处理的复杂任务。基于 WebGL 提供加速,TensorFlow.js 可以充分利用硬件性能,实现大规模数据处理和复杂模型推理。
TensorFlow.js 不仅功能强大,还能直接在浏览器中运行复杂的机器学习任务,例如图像识别和处理。如果你想深入了解如何使用 TensorFlow.js 构建智能图像处理应用,可以参考我的另一篇文章:纯前端用 TensorFlow.js 实现智能图像处理应用(一)。
相比之下,Brain.js 是一款轻量级神经网络库,专注于简单易用的神经网络模型。它的设计目标是降低机器学习的入门门槛,适合快速原型开发和小型应用场景。尽管 Brain.js 不具备 TensorFlow.js 那样强大的深度学习能力,但它的简洁性和易用性使其成为许多开发者快速实验和实现基础 AI 功能的优选工具。
然而,选择哪款库作为前端 AI 的工具并不简单,这取决于项目的需求、性能要求以及学习成本等多个因素。本文将详细对比两款库的功能、优缺点及适用场景,帮助你根据需求选择最适合的工具。
无论你是 AI 初学者还是有经验的开发者,相信你都能从这篇文章中找到有价值的指导,助力你在浏览器端实现机器学习。准备好了吗?让我们一起探索 TensorFlow.js 和 Brain.js 的世界,发现它们的不同之处,了解哪一个更适合你的项目。
一、TensorFlow.js - 强大而复杂的深度学习库

1.1 TensorFlow.js 概述
TensorFlow.js 是由 Google 推出的开源 JavaScript 库,用于在浏览器和 Node.js 环境中执行机器学习任务,包括深度学习模型的推理和训练。它是 TensorFlow 生态的一部分,TensorFlow 是全球最受欢迎的深度学习框架之一,广泛应用于计算机视觉、自然语言处理等领域。
TensorFlow.js 的核心亮点在于其 跨平台支持。你可以在浏览器端运行,也可以在 Node.js 环境下执行,灵活满足不同开发需求。此外,它支持导入已训练好的 TensorFlow 或 Keras 模型,在浏览器或 Node.js 中进行推理,无需重新训练。这使得 AI 的开发更加高效和便捷。
1.2 TensorFlow.js 的功能特点
TensorFlow.js 提供了丰富的功能,覆盖从简单的机器学习到复杂的深度学习任务。以下是它的核心特点:
- 浏览器端深度学习推理:通过
WebGL加速,TensorFlow.js可以高效地在浏览器中加载和运行深度学习模型,无需依赖服务器,大幅提升用户体验和响应速度。 - 训练与推理一体化:
TensorFlow.js支持在前端环境直接训练神经网络,这对于动态数据更新和快速迭代非常有用。即使是复杂的深度学习模型,也能通过优化技术确保高效的训练过程。 - 支持复杂神经网络架构:包括卷积神经网络(
CNN)、循环神经网络(RNN)、以及高级模型如Transformer,适用于图像、语音、文本等多领域任务。 - 模型导入与转换:支持从其他
TensorFlow或Keras环境导入已训练的模型,并在浏览器或Node.js中高效运行,降低了开发门槛。 - 跨平台支持:无论是前端浏览器还是后端
Node.js,TensorFlow.js都可以灵活适配,特别适合需要多环境协作的项目。
1.3 TensorFlow.js 的优势与应用场景
优势:
- 本地化计算:无需数据传输到服务器,所有计算均在用户设备上完成,提升速度并保障隐私。
- 强大的生态支持:依托
TensorFlow的生态系统,TensorFlow.js可以轻松访问预训练模型、教程和工具。 - 灵活性与高性能:支持低级别
API和WebGL加速,可根据需求灵活调整模型和计算流程。 - 无需后台服务器:在浏览器中即可完成复杂的训练和推理任务,显著简化系统架构。
应用场景:
- 图像识别:例如手写数字识别、人脸检测、物体分类等实时图像处理任务。
- 自然语言处理:支持情感分析、文本分类、语言翻译等复杂 NLP 任务。
- 实时数据分析:适用于
IoT或其他需要即时数据处理和反馈的应用场景。 - 推荐系统:通过用户行为数据构建个性化推荐,例如电商、新闻或社交媒体应用。
1.4 TensorFlow.js 基本用法示例
以下是一个简单示例,展示如何使用 TensorFlow.js 构建并训练神经网络模型。
安装与引入 TensorFlow.js
- 通过
CDN引入:
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs"></script>
- 通过
npm安装(适用于Node.js环境):
npm install @tensorflow/tfjs
创建简单神经网络
以下示例创建了一个简单的前馈神经网络,用于处理二分类问题:
// 导入 TensorFlow.js
const tf = require('@tensorflow/tfjs');
// 创建一个神经网络模型
const model = tf.sequential();
// 添加隐藏层(10 个神经元)
model.add(tf.layers.dense({ units: 10, activation: 'relu', inputShape: [5] }));
// 添加输出层(2 类分类问题)
model.add(tf.layers.dense({ units: 2, activation: 'softmax' }));
// 编译模型
model.compile({
optimizer: 'adam',
loss: 'categoricalCrossentropy',
metrics: ['accuracy'],
});
训练和推理过程
训练模型需要提供输入数据(特征)和标签(目标值):
// 创建训练数据
const trainData = tf.tensor2d([[0, 1, 2, 3, 4], [1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]);
const trainLabels = tf.tensor2d([[1, 0], [0, 1], [1, 0]]);
// 训练模型
model.fit(trainData, trainLabels, { epochs: 10 }).then(() => {
// 使用新数据进行推理
const input = tf.tensor2d([[1, 2, 3, 4, 5]]);
model.predict(input).print();
});
二、Brain.js - 轻量级且易于使用的神经网络库

2.1 Brain.js 概述
Brain.js 是一个轻量级的开源 JavaScript 神经网络库,专为开发者提供快速、简单的机器学习工具。它的设计理念是易用性和轻量化,适合那些希望快速构建和训练神经网络的开发者,尤其是机器学习的新手。
与功能丰富的 TensorFlow.js 不同,Brain.js 更注重于直观和简单,能够帮助开发者快速完成从构建到推理的基本机器学习任务。虽然它不支持复杂的深度学习模型,但其易用性和小巧的特性,使其成为小型项目和快速原型开发的理想选择。
2.2 Brain.js 的功能特点
Brain.js 的功能主要集中在简化神经网络的构建与训练上,以下是其核心特点:
- 简单易用的
API:Brain.js提供了直观的接口,开发者无需复杂的机器学习知识,也能轻松上手并实现神经网络任务。 - 轻量级:相较于体积较大的
TensorFlow.js,Brain.js的核心库更为小巧,非常适合嵌入前端应用,且不会显著影响加载速度。 - 支持多种网络结构:前馈神经网络(
Feedforward Neural Network)、LSTM网络(Long Short-Term Memory)等。这些模型已足够应对大多数基础的机器学习需求。 - 快速训练与推理:通过几行代码即可完成训练与推理任务,适用于快速原型设计和验证。
- 同步与异步训练支持:
Brain.js同时支持同步和异步的训练过程,开发者可以根据项目需求选择合适的方式。
2.3 Brain.js 的优势与应用场景
优势:
- 快速原型开发:开发者可以用最少的代码完成神经网络的构建和训练,特别适合需要快速验证想法的场景。
- 轻量级与高效率:库的体积较小,能快速加载,适合资源有限的环境。
- 易于集成:
Brain.js非常适合嵌入Web应用或小型Node.js服务,集成简单。 - 适合初学者:
Brain.js的设计对机器学习新手友好,无需深入了解复杂的深度学习算法即可上手。
应用场景:
- 基础分类与预测任务:适合实现简单的分类任务或数值预测,例如时间序列预测、情感分析等。
- 教学与实验:对于机器学习教学或学习过程中的快速实验,
Brain.js是一个很好的工具。 - 轻量化应用:例如小型交互式
Web应用中实时处理用户输入。
2.4 Brain.js 基本用法示例
以下示例展示了如何使用 Brain.js 构建并训练一个简单的神经网络模型。
安装与引入
- 通过 npm 安装:
npm install brain.js
- 通过 CDN 引入:
<script src="https://cdn.jsdelivr.net/npm/brain.js"></script>
创建简单神经网络
以下代码创建了一个用于解决 XOR 问题的前馈神经网络:
// 引入 Brain.js
const brain = require('brain.js');
// 创建一个简单的神经网络实例
const net = new brain.NeuralNetwork();
// 提供训练数据
const trainingData = [
{ input: [0, 0], output: [0] },
{ input: [0, 1], output: [1] },
{ input: [1, 0], output: [1] },
{ input: [1, 1], output: [0] }
];
// 训练网络
net.train(trainingData);
// 测试推理
const output = net.run([1, 0]);
console.log(`预测结果: ${output}`); // 输出接近 1 的值
训练与推理参数调整
Brain.js 提供了一些可选参数,用于优化训练过程,例如:
- 迭代次数(
iterations) :设置训练的最大轮数。 - 学习率(
learningRate) :控制每次更新的步长。
以下示例展示了如何自定义训练参数:
net.train(trainingData, {
iterations: 1000, // 最大训练轮数
learningRate: 0.01, // 学习率
log: true, // 显示训练过程
logPeriod: 100 // 每 100 次迭代打印一次日志
});
// 推理新数据
const testInput = [0, 1];
const testOutput = net.run(testInput);
console.log(`输入: ${testInput}, 预测结果: ${testOutput}`);
三、TensorFlow.js 和 Brain.js 的全面对比
在这一章中,我们将从多个维度对 TensorFlow.js 和 Brain.js 进行详细对比,帮助开发者根据自己的需求选择合适的工具。对比内容涵盖技术实现差异、学习曲线、适用场景、性能表现以及生态系统和社区支持。
3.1 技术实现差异
TensorFlow.js 和 Brain.js 的技术实现差异显著,主要体现在功能复杂度、支持的模型类型和底层架构上:
TensorFlow.js是一个功能全面的深度学习框架,基于TensorFlow的设计思想,提供了复杂的神经网络架构和高效的数学计算支持。它支持卷积神经网络(CNN)、循环神经网络(RNN)、生成对抗网络(GAN)等多种模型类型,能够完成从图像识别到自然语言处理的复杂任务。借助WebGL技术,TensorFlow.js可在浏览器中高效进行高性能计算,尤其适合大规模数据和复杂模型。Brain.js则更加轻量,主要面向快速开发和简单任务。它支持前馈神经网络(Feedforward Neural Network)、长短期记忆网络(LSTM)等基础模型,适合处理简单的分类或预测问题。尽管功能不如TensorFlow.js广泛,但其简洁的设计使开发者能够快速上手,完成实验和小型项目。
总结:TensorFlow.js 更加强大,适用于复杂任务;Brain.js 简单轻便,适合快速开发和小型应用。
3.2 学习曲线与开发者体验
在学习曲线和开发体验方面,两者差异明显:
TensorFlow.js学习曲线较为陡峭。其功能强大且覆盖面广,但开发者需要了解深度学习的基础知识,包括模型训练、数据预处理等环节。尽管文档和教程丰富,但对初学者而言,掌握这些内容可能需要投入更多的时间和精力。Brain.js则以简洁直观的 API 著称,初学者可以通过几行代码实现神经网络的搭建与训练。它对复杂概念的抽象程度高,无需深入理解深度学习理论,便能快速完成任务。
总结:如果你是新手或需要快速实现一个简单模型,选择 Brain.js 更友好;而如果你已有一定经验,并计划处理复杂任务,则 TensorFlow.js 更适合。
3.3 适用场景与功能选择
根据应用场景,选择合适的库可以大大提高开发效率:
TensorFlow.js:适用于复杂任务,如图像识别、自然语言处理、视频分析或推荐系统。由于其强大的深度学习功能和高性能计算能力,TensorFlow.js特别适合大规模数据处理和精度要求高的场景。Brain.js:适合轻量级任务,例如简单的分类、回归、时间序列预测等。对于快速验证模型或开发原型,Brain.js提供了简单高效的解决方案,尤其是在浏览器端运行时无需依赖复杂的服务器计算。
总结:TensorFlow.js 面向复杂场景和大规模任务;Brain.js 更适合轻量化需求和快速开发。
3.4 性能对比
在性能方面,TensorFlow.js 和 Brain.js 存在显著差异:
TensorFlow.js借助WebGL实现高效的硬件加速,支持 GPU 并行计算。在处理大规模数据集和复杂模型时,其性能优势显著,适用于高负载、高计算量的场景。Brain.js性能较为有限,主要针对小型数据集和简单任务。由于其轻量级设计,虽然在小规模任务中表现出色,但无法与TensorFlow.js的硬件加速能力相媲美。
总结:对于需要高性能计算的场景,TensorFlow.js 是更优选择;而对于小型任务,Brain.js 的性能已足够。
3.5 生态系统与社区支持
TensorFlow.js:作为TensorFlow生态的一部分,TensorFlow.js享有丰富的社区资源和支持,包括大量的开源项目、教程、论坛和工具。开发者可以从官方文档和预训练模型中快速找到所需资源,支持复杂应用的开发。Brain.js:社区较小,但活跃度高。文档简洁,适合初学者。虽然资源和支持不如TensorFlow.js丰富,但足以满足小型项目的需求。
总结:TensorFlow.js 的生态更强大,适合需要长期维护和扩展的项目;Brain.js 更适合轻量化开发和快速上手。
四、如何选择最适合你的库?
在 TensorFlow.js 和 Brain.js 之间做出选择时,开发者需要综合考虑项目需求、技术背景和性能要求。这两款库各有特色:TensorFlow.js 功能强大,适用于复杂任务;Brain.js 简单易用,适合快速开发。以下从选择标准和实际场景出发,帮助开发者找到最合适的工具。
4.1 选择标准
在选择 TensorFlow.js 或 Brain.js 时,可参考以下几个关键标准:
- 功能需求:
- 复杂任务:如果项目涉及深度学习任务(如大规模图像分类、语音识别或自然语言处理),选择
TensorFlow.js更为合适。它支持复杂的神经网络模型,具备高效的数据处理能力。 - 基础任务:如果需求相对简单,例如小型神经网络模型、时间序列预测或分类任务,
Brain.js是更轻量的选择。
- 复杂任务:如果项目涉及深度学习任务(如大规模图像分类、语音识别或自然语言处理),选择
- 开发者经验:
- 有机器学习背景:
TensorFlow.js提供高度灵活的 API,但学习曲线较陡。熟悉机器学习的开发者可以充分利用其强大功能。 - 初学者:
Brain.js更适合新手,提供简洁的接口和直观的使用体验。
- 有机器学习背景:
- 性能需求:
- 高性能计算:如果项目需要硬件加速(如
GPU支持)以处理大规模数据,TensorFlow.js的WebGL支持是理想选择。 - 轻量化应用:对于性能要求较低的场景,
Brain.js的轻量级设计足够满足需求。
- 高性能计算:如果项目需要硬件加速(如
- 项目规模与复杂度:
- 大型项目:
TensorFlow.js提供复杂功能和强大的扩展性,适合长期维护和生产级应用。 - 快速开发:
Brain.js专注于快速实现小型项目,适合验证想法或开发MVP(最小可行产品)。
- 大型项目:
4.2 基于项目需求的选择建议
以下是根据常见场景的具体选择建议:
场景一:图像分类应用
- 需求:对大规模图像进行分类或识别,涉及复杂的卷积神经网络(
CNN)。 - 推荐选择:
TensorFlow.js。支持复杂模型架构,通过WebGL提供高效的硬件加速,适合处理大量图像数据。
场景二:实时数据分析与预测
- 需求:对传感器数据进行实时监测和分析,预测未来趋势(如气象预测、股票走势)。
- 推荐选择:
Brain.js。其轻量化和快速实现的特性非常适合实时数据处理和快速部署。
场景三:自然语言处理(NLP)应用
- 需求:需要对文本数据进行分类、情感分析或对话生成。
- 推荐选择:
TensorFlow.js。支持循环神经网络(RNN)、Transformer等复杂模型,能处理 NLP 任务的高维数据和复杂结构。
场景四:个性化推荐系统
- 需求:根据用户行为推荐商品或内容。
- 推荐选择:
- 如果推荐系统复杂,涉及神经协同过滤或深度学习模型,选择
TensorFlow.js。 - 如果系统较为简单,仅需基于用户行为的规则实现,
Brain.js是更高效的选择。
- 如果推荐系统复杂,涉及神经协同过滤或深度学习模型,选择
场景五:快速原型开发与实验
- 需求:验证机器学习模型效果或快速开发实验性产品。
- 推荐选择:
Brain.js。它提供简洁的接口和快速训练功能,适合快速搭建和迭代。
结论:最终选择
通过对 TensorFlow.js 和 Brain.js 的详细对比,可以帮助开发者根据项目需求和个人技能做出最佳选择。以下是两者的优缺点总结及适用场景的建议。
TensorFlow.js 优缺点
优点:
- 功能全面:支持复杂的深度学习模型(如
CNN、RNN、GAN),适用于广泛的机器学习任务,包括图像识别、自然语言处理和语音处理等。 - 跨平台支持:可运行于浏览器和
Node.js环境,灵活部署于多种平台。 - 性能卓越:利用
WebGL实现硬件加速,适合高性能需求,尤其是大规模数据处理。 - 强大的生态系统:依托
TensorFlow生态,拥有丰富的预训练模型、教程和社区支持,为开发者提供充足资源。
缺点:
- 学习门槛较高:功能复杂,适合有机器学习基础的开发者,初学者可能需要投入较多时间学习。
- 库体积较大:功能的多样性导致库体积偏大,可能影响浏览器加载速度和资源消耗。
Brain.js 优缺点
优点:
- 轻量级与易用性:设计简单,API 直观,非常适合快速开发和机器学习初学者。
- 小巧体积:库文件体积小,适合嵌入前端应用,对网页加载影响小。
- 支持基础模型:支持前馈神经网络和
LSTM,能满足大多数基础机器学习任务。 - 快速上手:开发者无需深厚的机器学习知识,能够快速实现简单神经网络应用。
缺点:
- 功能较为局限:不支持复杂深度学习模型,难以满足高阶任务需求。
- 性能有限:轻量设计决定其在大规模数据处理中的性能不如
TensorFlow.js。
适用场景与开发者建议
初学者或简单任务:
- 选择:
Brain.js - 理由:适合刚接触机器学习的开发者,或处理简单分类、时间序列预测等基础任务。其平缓的学习曲线和快速开发特性,帮助初学者快速上手。
经验丰富的开发者或复杂任务:
- 选择:
TensorFlow.js - 理由:适合处理复杂的深度学习任务,如大规模图像识别、自然语言处理或实时视频分析。提供灵活的 API 和强大的计算能力,满足高性能需求。
小型项目与快速开发:
- 选择:
Brain.js - 理由:适合快速构建原型和简单的神经网络任务,易于维护,开发效率高。
大规模应用与高性能需求:
- 选择:
TensorFlow.js - 理由:其强大的加速能力和复杂模型支持,使其成为生产级应用的理想选择,尤其适合需要 GPU 加速的大规模任务。
结语
通过本文的对比,读者可以清晰了解 TensorFlow.js 和 Brain.js 在功能、性能、学习曲线、适用场景等方面的显著差异。选择最适合的库时,需要综合考虑项目的复杂度、团队的技术背景以及性能需求。
如果你的项目需要处理复杂的深度学习任务,并且需要高性能计算与广泛的社区支持,TensorFlow.js 是不二之选。它功能强大、生态丰富,适合图像识别、自然语言处理等高需求场景。而如果你只是进行小型神经网络实验,或需要快速原型开发,Brain.js 提供了更简洁易用的解决方案,是初学者和小型项目开发者的理想选择。
无论选择哪个库,充分了解它们的优势与限制,将帮助你在项目开发中高效使用这些工具,成功实现你的前端 AI 开发目标。
附录:对比表格
以下对比表格总结了 TensorFlow.js 和 Brain.js 在关键维度上的差异,帮助读者快速决策:
| 特性 | TensorFlow.js | Brain.js |
|---|---|---|
GitHub 星标数量 | 18.6K | 14.5K |
| 功能复杂度 | 高,支持复杂的深度学习模型(CNN, RNN, GAN等) | 低,支持基础前馈神经网络和LSTM网络 |
| 学习曲线 | 陡峭,适合有深度学习经验的开发者 | 平缓,适合初学者和快速原型开发 |
| 使用场景 | 复杂场景,如大规模数据处理、图像识别、语音处理等 | 小型项目,如简单分类任务、时间序列预测 |
| 支持的模型类型 | 多种类型(CNN, RNN, GAN等复杂模型) | 基础类型(前馈神经网络、LSTM等) |
| 性能优化 | 支持 WebGL 加速和 GPU 并行计算,适合高性能需求 | 不支持硬件加速,适合小规模数据处理 |
| 开发平台 | 浏览器和 Node.js 环境,跨平台支持 | 主要用于浏览器,也支持 Node.js |
| 社区支持与文档 | 丰富的生态系统,拥有大量教程、示例和预训练模型资源 | 社区较小但活跃,文档简单直观 |
| 易用性 | API 较复杂,适合有深度学习背景的开发者 | API 简洁,适合初学者和快速开发 |
| 适用开发者 | 高阶开发者,有深度学习基础 | 初学者及快速实现简单任务的开发者 |
| 体积与资源消耗 | 库文件较大,可能影响加载速度 | 体积小,对网页性能影响较小 |
| 训练与推理能力 | 支持复杂模型的训练与推理,适合高需求场景 | 适合简单任务的训练与推理 |
| 预训练模型支持 | 支持从 TensorFlow Hub 加载预训练模型 | 不支持广泛预训练模型,主要用于自定义训练 |
同系列文章推荐
如果你觉得本文对你有所帮助,不妨看看以下同系列文章,深入了解 AI 开发的更多可能性:
欢迎点击链接阅读,开启你的前端 AI 学习之旅,让开发更高效、更有趣! 🚀
我是 “一点一木”
专注分享,因为分享能让更多人专注。
生命只有一次,人应这样度过:当回首往事时,不因虚度年华而悔恨,不因碌碌无为而羞愧。在有限的时间里,用善意与热情拥抱世界,不求回报,只为当回忆起曾经的点滴时,能无愧于心,温暖他人。
来源:juejin.cn/post/7459285932092211238
高德地图与Three.js结合实现3D大屏可视化
高德地图与Three.js结合实现3D大屏可视化
文末源码地址及视频演示
前言
在智慧城市安全管理场景中,如何将真实的地理信息与3D模型完美结合,实现沉浸式的可视化监控体验?本文将以巡逻犬管理系统的大屏预览功能为例,详细介绍如何通过高德地图API与Three.js深度结合,实现3D机械狗模型在地图上的实时巡逻展示。

该系统实现了以下核心功能:
- 在高德地图上加载并渲染3D机械狗模型
- 实现模型沿预设路线的自动巡逻动画
- 镜头自动跟随模型移动,提供沉浸式监控体验
- 实时显示巡逻进度、告警信息等业务数据
技术栈
- 高德地图 JS API 2.0:提供地图底图和空间定位能力
- Three.js r157:3D模型渲染和动画控制
- Loca 2.0:高德地图数据可视化API,用于镜头跟随
- React + TypeScript:前端框架和类型支持
- TWEEN.js:补间动画库,用于平滑的模型移动
一、高德地图初始化
1.1 地图配置
首先需要配置高德地图的加载参数,包括API Key、版本号等:
// src/utils/amapConfig.ts
export const mapConfig = {
key: 'your-amap-key',
version: '2.0',
Loca: {
version: '2.0.0', // Loca版本需与地图版本一致
},
};
// 初始化安全配置(必须在AMapLoader.load之前调用)
export const initAmapSecurity = () => {
if (typeof window !== 'undefined') {
(window as any)._AMapSecurityConfig = {
securityJsCode: 'your-security-code',
};
}
};
1.2 创建地图实例
使用AMapLoader.load加载地图API,然后创建地图实例:
// 设置安全密钥
initAmapSecurity();
// 加载高德地图
const AMap = await AMapLoader.load(mapConfig);
// 创建地图实例,开启3D视图模式
const mapInstance = new AMap.Map(mapContainerRef.current, {
zoom: 13,
center: defaultCenter,
viewMode: '3D', // 关键:必须开启3D模式
resizeEnable: true,
});

关键点:
viewMode: '3D'必须设置,否则无法使用3D相关功能- 需要提前设置安全密钥,否则会报错
1.3 初始化Loca容器
Loca是高德地图的数据可视化容器,用于实现镜头跟随等功能:
const loca = new (window as any).Loca.Container({
map: mapInstance,
zIndex: 9
});
二、创建GLCustomLayer自定义图层
GLCustomLayer是高德地图提供的WebGL自定义图层,允许我们在地图上渲染Three.js内容。
2.1 图层结构
const customLayer = new AMap.GLCustomLayer({
zIndex: 200, // 图层层级,确保模型在最上层
init: async (gl: any) => {
// 在这里初始化Three.js场景、相机、渲染器等
},
render: () => {
// 在这里执行每帧的渲染逻辑
},
});
mapInstance.add(customLayer);
2.2 初始化Three.js场景
在init方法中创建Three.js的核心组件:
init: async (gl: any) => {
// 1. 创建透视相机
const camera = new THREE.PerspectiveCamera(
60, // 视野角度
window.innerWidth / window.innerHeight, // 宽高比
100, // 近裁剪面
1 << 30 // 远裁剪面(使用位运算表示大数值)
);
// 2. 创建WebGL渲染器
const renderer = new THREE.WebGLRenderer({
context: gl, // 使用地图提供的WebGL上下文
antialias: false, // 禁用抗锯齿,减少WebGL扩展需求
powerPreference: 'default',
});
renderer.autoClear = false; // 必须设置为false,否则地图底图无法显示
renderer.shadowMap.enabled = false; // 禁用阴影,避免WebGL扩展问题
// 3. 创建场景
const scene = new THREE.Scene();
// 4. 添加光源
const ambientLight = new THREE.AmbientLight(0xffffff, 1.0);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
directionalLight.position.set(1000, -100, 900);
scene.add(directionalLight);
}
关键点:
renderer.autoClear = false必须设置,否则会清除地图底图- 使用地图提供的
gl上下文创建渲染器,实现资源共享

三、坐标系统转换
高德地图使用经纬度坐标(WGS84),而Three.js使用3D世界坐标,两者之间的转换是关键。
3.1 获取自定义坐标系统
地图实例提供了customCoords工具,用于坐标转换:
// 获取自定义坐标系统
const customCoords = mapInstance.customCoords;
// 设置坐标系统中心点(重要:必须在设置模型位置前设置)
const center = mapInstance.getCenter();
customCoords.setCenter([center.lng, center.lat]);
3.2 经纬度转3D坐标
使用lngLatsToCoords方法将经纬度转换为Three.js坐标:
// 将经纬度 [lng, lat] 转换为Three.js坐标 [x, z, y?]
const position = customCoords.lngLatsToCoords([
[120.188767, 30.193832]
])[0];
// 注意:返回的数组格式为 [x, z, y?]
// position[0] 对应 Three.js 的 z 轴(纬度)
// position[1] 对应 Three.js 的 x 轴(经度)
// position[2] 对应 Three.js 的 y 轴(高度,可选)
robotGr0up.position.setX(position[1]); // x坐标(经度)
robotGr0up.position.setZ(position[0]); // z坐标(纬度)
robotGr0up.position.setY(position.length > 2 ? position[2] : 0); // y坐标(高度)
坐标轴对应关系:
- 高德地图:X轴(经度),Y轴(纬度),Z轴(高度)
- Three.js:X轴(右),Y轴(上),Z轴(前)
- 转换后:
position[1]→ Three.js X轴,position[0]→ Three.js Z轴
3.3 同步相机参数
在render方法中,需要同步高德地图的相机参数到Three.js相机:
render: () => {
const { near, far, fov, up, lookAt, position } = customCoords.getCameraParams();
// 同步相机参数
camera.near = near;
camera.far = far;
camera.fov = fov;
camera.position.set(position[0], position[1], position[2]);
camera.up.set(up[0], up[1], up[2]);
camera.lookAt(lookAt[0], lookAt[1], lookAt[2]);
camera.updateProjectionMatrix();
// 渲染场景
renderer.render(scene, camera);
// 必须执行:重新设置three的gl上下文状态
renderer.resetState();
}
四、加载3D模型
4.1 使用GLTFLoader加载模型
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
const loader = new GLTFLoader();
const modelPath = '/assets/modules/robot_dog/scene.gltf';
const gltf = await new Promise<any>((resolve, reject) => {
loader.load(
modelPath,
(gltf: any) => resolve(gltf),
(progress: any) => {
if (progress.total > 0) {
const percent = (progress.loaded / progress.total) * 100;
console.log('模型加载进度:', percent.toFixed(2) + '%');
}
},
reject
);
});
const robotModel = gltf.scene;
4.2 模型预处理
加载模型后需要进行预处理,包括材质优化、位置调整等:
// 遍历模型所有子对象
robotModel.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
// 禁用阴影相关功能
child.castShadow = false;
child.receiveShadow = false;
// 简化材质,避免使用需要WebGL扩展的高级特性
if (child.material) {
const materials = Array.isArray(child.material)
? child.material
: [child.material];
materials.forEach((mat: any) => {
// 禁用transmission等高级特性
if (mat.transmission !== undefined) {
mat.transmission = 0;
}
});
}
}
});
// 计算模型边界框并居中
const box = new THREE.Box3().setFromObject(robotModel);
const center = box.getCenter(new THREE.Vector3());
// 将模型居中(X和Z轴)
robotModel.position.x = -center.x;
robotModel.position.z = -center.z;
// 将模型底部放在y=0
robotModel.position.y = -box.min.y;
// 设置模型缩放
const scale = 15;
robotModel.scale.set(scale, scale, scale);
4.3 创建模型组并设置初始旋转
由于高德地图和Three.js的坐标系差异,需要调整模型的初始旋转:
// 创建外层Gr0up用于位置和旋转控制
const robotGr0up = new THREE.Gr0up();
robotGr0up.add(robotModel);
// 设置初始旋转(90, 90, 0)度转换为弧度
const initialRotationX = (Math.PI / 180) * 90;
const initialRotationY = (Math.PI / 180) * 90;
const initialRotationZ = (Math.PI / 180) * 0;
robotGr0up.rotation.set(initialRotationX, initialRotationY, initialRotationZ);
scene.add(robotGr0up);
五、实现镜头跟随
5.1 使用Loca实现镜头跟随
高德地图的Loca API提供了viewControl.addTrackAnimate方法,可以实现镜头自动跟随路径移动:
// 计算路径总距离
let totalDistance = 0;
for (let i = 0; i < paths.length - 1; i++) {
totalDistance += AMap.GeometryUtil.distance(paths[i], paths[i + 1]);
}
// 假设速度是 1.5 m/s
const speed = 1.5;
const duration = (totalDistance / speed) * 1000; // 转换为毫秒
loca.viewControl.addTrackAnimate({
path: paths, // 镜头轨迹,二维数组
duration: duration, // 时长(毫秒)
timing: [[0, 0.3], [1, 0.7]], // 速率控制器
rotationSpeed: 180, // 每秒旋转多少度
}, function () {
console.log('单程巡逻完成');
// 可以在这里处理往返逻辑
});
loca.animate.start(); // 启动动画
5.2 模型位置同步
在render方法中,根据地图中心点实时更新模型位置:
render: () => {
// ... 同步相机参数代码 ...
if (robotGr0up && mapInstance && !patrolFinishedRef.current) {
// 获取当前地图中心(镜头跟随会改变地图中心)
const center = mapInstance.getCenter();
if (center) {
// 更新坐标系统中心点为地图中心点
customCoords.setCenter([center.lng, center.lat]);
// 将地图中心转换为Three.js坐标
const position = customCoords.lngLatsToCoords([
[center.lng, center.lat]
])[0];
// 更新模型位置
robotGr0up.position.setX(position[1]);
robotGr0up.position.setZ(position[0]);
robotGr0up.position.setY(position.length > 2 ? position[2] : 0);
// 更新模型旋转(根据地图旋转)
const rotation = mapInstance.getRotation();
if (rotation !== undefined) {
const initialRotationY = (Math.PI / 180) * 90;
robotGr0up.rotation.y = initialRotationY + (rotation * Math.PI / 180);
}
}
}
// 渲染场景
renderer.render(scene, camera);
renderer.resetState();
}
关键点:
- 使用地图中心点作为模型位置,实现精确跟随
- 在每次render中更新坐标系统中心点,确保坐标转换准确
- 同步地图旋转角度到模型Y轴旋转

六、巡逻动画实现
6.1 启动巡逻
当模型加载完成并设置好初始位置后,可以启动巡逻动画:
const startPatrol = (paths: number[][], mapInstance: any, AMap: any) => {
// 停止之前的巡逻
TWEEN.removeAll();
patrolFinishedRef.current = false;
// 保存路径
patrolPathsRef.current = paths;
patrolIndexRef.current = 0;
// 播放前进动画
playAnimation('1LYP'); // 播放行走动画
// 设置坐标系统中心点为路径起点
const firstPoint = paths[0];
customCoordsRef.current.setCenter([firstPoint[0], firstPoint[1]]);
// 使用Loca实现镜头跟随
const loca = locaRef.current;
if (loca) {
// ... addTrackAnimate 代码 ...
}
// 启动模型移动动画
changeObject();
};
6.2 模型移动动画
使用TWEEN.js实现模型在路径点之间的平滑移动:
const changeObject = () => {
if (patrolFinishedRef.current || patrolIndexRef.current >= patrolPathsRef.current.length - 1) {
return;
}
const sp = patrolPathsRef.current[patrolIndexRef.current];
const ep = patrolPathsRef.current[patrolIndexRef.current + 1];
const s = new THREE.Vector2(sp[0], sp[1]);
const e = new THREE.Vector2(ep[0], ep[1]);
const speed = 0.03;
const dis = AMap.GeometryUtil.distance(sp, ep);
if (dis <= 0) {
patrolIndexRef.current++;
changeObject();
return;
}
// 使用TWEEN实现平滑移动
new TWEEN.Tween(s)
.to(e.clone(), dis / speed / speedFactor)
.start()
.onUpdate((v) => {
// 更新模型经纬度引用
modelLngLatRef.current = [v.x, v.y];
// 节流更新状态(每100ms更新一次)
const now = Date.now();
if (now - lastUpdateTimeRef.current > 100) {
setCurrentLngLat([v.x, v.y]);
checkSamplePoint([v.x, v.y], AMap); // 检测取样点
// 计算已巡逻长度
updatePatrolledLength(v);
lastUpdateTimeRef.current = now;
}
})
.onComplete(() => {
accumulatedLengthRef.current += dis;
if (patrolIndexRef.current < patrolPathsRef.current.length - 2) {
patrolIndexRef.current++;
changeObject(); // 继续下一段
} else {
// 单程完成
if (patrolMode !== '往返') {
patrolFinishedRef.current = true;
playAnimation('1Idle'); // 播放静止动画
}
}
});
};
6.3 动画系统
模型支持多种动画(行走、静止、跳舞等),使用AnimationMixer管理:
// 设置动画系统
if (gltf.animations && gltf.animations.length > 0) {
const mixer = new THREE.AnimationMixer(robotModel);
// 创建所有动画动作
const actions = new Map<string, THREE.AnimationAction>();
gltf.animations.forEach((clip: THREE.AnimationClip) => {
const action = mixer.clipAction(clip);
action.setLoop(THREE.LoopRepeat); // 循环播放
actions.set(clip.name, action);
});
// 播放默认静止动画
const defaultAction = actions.get('1Idle');
if (defaultAction) {
defaultAction.setEffectiveTimeScale(0.6); // 设置播放速度
defaultAction.fadeIn(0.3);
defaultAction.play();
}
}
// 在render循环中更新动画
const render = () => {
requestAnimationFrame(() => {
render();
});
// 更新动画混合器
if (mixer) {
const currentTime = performance.now();
const delta = (currentTime - lastAnimationTime) / 1000;
mixer.update(delta);
lastAnimationTime = currentTime;
}
// 更新TWEEN动画
TWEEN.update();
// 渲染地图
mapInstance.render();
};
图片略大,耐心等候

七、AI安全隐患自动检测与告警
系统集成了Coze AI大模型,实现了巡逻过程中的自动安全隐患检测和告警功能。当机械狗沿路线巡逻时,系统会在预设的取样点自动触发AI分析,识别潜在的安全隐患。
7.1 取样点计算
系统支持基于路线间隔的自动取样点计算,根据巡逻犬配置的取样间隔(如每50米、100米等),在路线上均匀分布取样点:
// 计算取样点(基于路线间隔)
const calculateSamplePoints = (
paths: number[][],
sampleInterval: number,
AMap: any
): Array<{ lng: number; lat: number; distance: number }> => {
const samplePoints: Array<{ lng: number; lat: number; distance: number }> = [];
let accumulatedDistance = 0;
// 从第一个点开始(0米处)
samplePoints.push({
lng: paths[0][0],
lat: paths[0][1],
distance: 0,
});
// 遍历路径,计算每个取样点
for (let i = 0; i < paths.length - 1; i++) {
const currentPoint = paths[i];
const nextPoint = paths[i + 1];
const segmentDistance = AMap.GeometryUtil.distance(currentPoint, nextPoint);
// 检查当前段是否包含取样点
while (accumulatedDistance + segmentDistance >= (samplePoints.length * sampleInterval)) {
const targetDistance = samplePoints.length * sampleInterval;
const distanceInSegment = targetDistance - accumulatedDistance;
// 计算取样点在当前段中的位置(线性插值)
const ratio = distanceInSegment / segmentDistance;
const sampleLng = currentPoint[0] + (nextPoint[0] - currentPoint[0]) * ratio;
const sampleLat = currentPoint[1] + (nextPoint[1] - currentPoint[1]) * ratio;
samplePoints.push({
lng: sampleLng,
lat: sampleLat,
distance: targetDistance,
});
}
accumulatedDistance += segmentDistance;
}
return samplePoints;
};
关键点:
- 使用高德地图的
GeometryUtil.distance计算路径段距离 - 通过线性插值计算取样点的精确位置
- 取样点从路线起点开始,按固定间隔均匀分布
7.2 自动触发检测
在巡逻过程中,系统实时检测模型位置是否到达取样点附近(±10米范围内):
// 检测是否到达取样点
const checkSamplePoint = (currentLngLat: [number, number], AMap: any) => {
const patrolDog = currentPatrolDogRef.current;
const route = currentRouteRefForSample.current;
const area = currentAreaRefForSample.current;
if (!patrolDog || !route || !patrolDog.cameraDeviceId) {
return; // 没有绑定摄像头,不进行取样
}
// 检查取样方式(必须是"路线间隔"模式)
if (patrolDog.sampleMode !== '路线间隔' || !patrolDog.sampleInterval) {
return;
}
// 检查是否在取样点附近(±10米范围内)
for (let i = 0; i < samplePointsRef.current.length; i++) {
if (processedSamplePointsRef.current.has(i)) {
continue; // 已处理过,跳过
}
const samplePoint = samplePointsRef.current[i];
const distance = AMap.GeometryUtil.distance(
[currentLngLat[0], currentLngLat[1]],
[samplePoint.lng, samplePoint.lat]
);
// 在 ±10 米范围内,触发取样
if (distance <= 10) {
console.log(`✅ 到达取样点 ${i + 1}/${samplePointsRef.current.length}`);
processedSamplePointsRef.current.add(i);
// 异步调用 Coze API(不阻塞巡逻)
analyzeSecurity(
patrolDog,
route,
area,
currentLngLat,
AMap
).catch(error => {
console.error('安全隐患分析失败:', error);
});
break; // 一次只处理一个取样点
}
}
};
关键点:
- 使用距离判断,避免重复触发
- 异步调用AI分析,不阻塞巡逻动画
- 使用
Set记录已处理的取样点,确保每个点只处理一次
7.3 调用Coze API进行安全隐患分析
系统使用Coze平台的大模型工作流进行图像安全隐患分析:
// 调用 Coze API 进行安全隐患分析
const analyzeSecurity = async (
patrolDog: PatrolDog,
route: Route,
area: Area | null,
currentLngLat: [number, number],
AMap: any
): Promise<void> => {
try {
// 1. 获取默认令牌
await initDB();
const tokens = await db.token.getAll();
const validTokens = tokens.filter(token => Date.now() <= token.expireDate);
if (validTokens.length === 0) {
console.warn('没有可用的令牌,跳过安全隐患分析');
return;
}
const defaultToken = validTokens.find(t => t.isDefault) || validTokens[0];
// 2. 准备分析数据
// 随机选择一张测试图片(实际应用中应使用摄像头实时抓拍)
const randomImageUrl = imageUrlr[Math.floor(Math.random() * imageUrlr.length)];
// 构建输入文本,描述当前巡逻场景
const inputText = `${patrolDog.name}当前在${area?.name || '未知'}区域${route.name}巡逻时抓拍了一张照片。分析是否存在安全隐患`;
// 3. 创建 Coze API 客户端
const apiClient = new CozeAPI({
token: defaultToken.token,
baseURL: 'https://api.coze.cn',
allowPersonalAccessTokenInBrowser: true,
});
// 4. 调用工作流
const workflow_id = '7585585625312034858';
const res = await apiClient.workflows.runs.create({
workflow_id: workflow_id,
parameters: {
input: inputText,
mediaUrl: randomImageUrl,
},
});
// 5. 解析返回结果
let analysisResult: { securityType: number; score: number; desc: string } | null = null;
if (res.data) {
const dataObj = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
if (dataObj.output && typeof dataObj.output === 'string') {
// 提取 markdown 代码块中的 JSON
const jsonMatch = dataObj.output.match(/```json\s*([\s\S]*?)\s*```/) ||
dataObj.output.match(/```\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
analysisResult = JSON.parse(jsonMatch[1].trim());
} else {
// 尝试直接解析 output 为 JSON
analysisResult = JSON.parse(dataObj.output);
}
} else {
analysisResult = dataObj;
}
}
// 6. 判断是否是报警(securityType !== 0 且 score !== 0)
if (analysisResult && analysisResult.securityType !== 0 && analysisResult.score !== 0) {
// 保存到分析报警表
const analysisAlert: Omit<AnalysisAlert, 'id' | 'createTime' | 'updateTime'> = {
alertTime: Date.now(),
patrolDogId: patrolDog.id!,
patrolDogName: patrolDog.name,
cameraDeviceId: patrolDog.cameraDeviceId,
cameraDeviceName: patrolDog.cameraDeviceName,
routeId: route.id!,
routeName: route.name,
areaId: area?.id,
areaName: area?.name,
securityType: analysisResult.securityType as 0 | 1 | 2 | 3 | 4 | 5,
score: analysisResult.score,
desc: analysisResult.desc,
mediaUrl: randomImageUrl,
input: inputText,
status: '未处理',
};
await db.analysisAlert.add(analysisAlert);
console.log('✅ 安全隐患告警已保存');
// 更新告警列表(实时显示在大屏右侧)
updateAlertList(patrolDog.id!, route.id!, area?.id);
} else {
console.log('未发现安全隐患,不保存报警');
}
} catch (error) {
console.error('调用 Coze API 失败:', error);
}
};
API返回结果格式:
{
"securityType": 1, // 0=无隐患, 1=明火燃烟, 2=打架斗殴, 3=违章停车, 4=杂物堆放, 5=私搭乱建
"score": 85, // 严重程度评分 (0-100)
"desc": "检测到明火,存在严重安全隐患" // 详细描述
}
关键点:
- 使用
@coze/api官方SDK调用工作流API - 支持多种安全隐患类型识别(明火燃烟、打架斗殴、违章停车等)
- 自动保存告警记录,支持后续查询和处理
- 告警信息实时显示在大屏右侧告警列表中

7.4 Coze测试页面
系统提供了专门的Coze测试页面,方便开发者测试和调试AI分析功能。在Coze测试页面中,可以:
- 选择令牌:从已配置的Coze API令牌中选择(支持多个令牌管理)
- 输入分析文本:描述需要分析的场景
- 上传图片URL:提供需要分析的图片地址
- 自动填充功能:点击"自动填充"按钮,快速填充默认的测试数据
- 查看完整响应:显示Coze API的完整返回结果,包括解析后的JSON和原始响应
// Coze测试页面核心功能
const handleTest = async () => {
const values = await form.validateFields();
// 创建 Coze API 客户端
const apiClient = new CozeAPI({
token: values.token,
baseURL: 'https://api.coze.cn',
allowPersonalAccessTokenInBrowser: true,
});
// 调用工作流
const workflow_id = '7585585625312034858';
const res = await apiClient.workflows.runs.create({
workflow_id: workflow_id,
parameters: {
input: values.input,
mediaUrl: values.mediaUrl,
},
});
// 解析并显示结果
// ... 解析逻辑 ...
};
测试页面特性:
- 自动填充数据:提供默认的测试图片和文本,方便快速测试
- 图片预览:实时预览输入的图片URL
- 完整响应展示:显示API的完整响应,便于调试
- 错误处理:友好的错误提示,帮助定位问题
请截图 Coze测试页面 自动填充功能 测试结果展示
使用场景:
- 测试新的安全隐患识别算法
- 验证Coze API令牌是否有效
- 调试API返回结果格式
- 验证图片URL是否可被Coze解析

八、性能优化建议
7.1 渲染优化
- 禁用不必要的WebGL扩展(如阴影、抗锯齿)
- 使用
requestAnimationFrame统一管理渲染循环 - 合理设置模型LOD(细节层次)
7.2 内存管理
- 及时清理不需要的TWEEN动画:
TWEEN.removeAll() - 组件卸载时销毁Three.js资源
- 模型加载后缓存,避免重复加载
7.3 坐标转换优化
- 坐标系统中心点跟随地图中心,减少转换误差
- 使用节流控制状态更新频率
- 避免在render中进行复杂计算
九、常见问题解决
8.1 模型不显示
问题:模型加载成功但在地图上不可见
解决方案:
- 检查
renderer.autoClear是否设置为false - 确认坐标转换是否正确(注意数组索引对应关系)
- 检查模型缩放是否合适(可能太小或太大)
8.2 模型位置偏移
问题:模型位置与预期不符
解决方案:
- 确保在设置模型位置前调用
customCoords.setCenter() - 检查坐标轴对应关系(
position[1]对应X轴,position[0]对应Z轴) - 使用
AxesHelper辅助调试坐标轴方向
8.3 镜头跟随不流畅
问题:镜头跟随有延迟或卡顿
解决方案:
- 调整
rotationSpeed参数,控制旋转速度 - 优化
timing速率控制器,实现更平滑的加速减速 - 检查render循环是否正常执行
十、总结
通过高德地图与Three.js的深度结合,我们成功实现了3D模型在地图上的实时展示和动画效果,并集成了AI大模型实现智能安全隐患检测。核心要点包括:
- GLCustomLayer是关键桥梁:通过自定义图层实现Three.js与高德地图的融合
- 坐标转换是核心:正确理解和使用
customCoords进行坐标转换 - 镜头跟随提升体验:使用Loca API实现平滑的镜头跟随效果
- AI智能检测增强功能:集成Coze大模型实现自动安全隐患识别和告警
- 性能优化不可忽视:合理配置渲染参数,避免不必要的WebGL扩展
技术亮点:
- 虚实结合:真实地理信息与3D模型的完美融合
- 智能检测:基于AI大模型的自动安全隐患识别
- 实时告警:巡逻过程中的实时检测和告警推送
- 可视化展示:沉浸式大屏监控体验
这种技术方案不仅适用于巡逻犬管理系统,还可以扩展到智慧城市、物流追踪、车辆监控、园区安防等多个场景,为空间数据可视化提供了强大的技术支撑。通过AI能力的集成,系统从传统的可视化展示升级为智能化的安全监控平台,实现了"看得见、管得住、能预警"的完整闭环。
参考资源
来源:juejin.cn/post/7589482741759819803











