注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

性能优化,前端能做的不多

web
大家好,我是双越老师,也是 wangEditor 作者。 我正开发一个 Node 全栈 AIGC 知识库 划水AI,包括 AI 写作、多人协同编辑。复杂业务,真实上线,大家可以去注册试用,围观项目研发过程。 关于前端性能优化的困惑 前端性能优化是前度面试常...
继续阅读 »

大家好,我是双越老师,也是 wangEditor 作者。



我正开发一个 Node 全栈 AIGC 知识库 划水AI,包括 AI 写作、多人协同编辑。复杂业务,真实上线,大家可以去注册试用,围观项目研发过程。



关于前端性能优化的困惑


前端性能优化是前度面试常考的问题,答案说来复杂,其实总结下来就是:减少或拆分资源 + 异步加载 现在都搞成八股文了,面试之前背诵一下。


还有缓存。


但纯前端的缓存其实作用不大,使用场景也不多,例如 Vue computed keep-alive,React useMemo 在绝大部分场景下都用不到。虽然面试的时候我们得说出来。


HTTP 缓存是网络层面的,操作系统和浏览器实现的,并不是前端实现的。虽然作为前端工程师要掌握这些知识,但大多数情况下并不需要自己亲自去配置。


实际工作中,其实绝大部分前端项目,代码量都没有非常大,不拆分代码,不异步加载也不会有太大的影响。


我这两年 1v1 评审过很多前端简历,大家都会有这样一个困惑:问到前端性能优化,感觉工作几年了,也没做什么优化,而且也想不出前端能做什么优化。


其实这是正常的,绝大部分前端项目、前端工作都不需要优化,而且从全栈角度看,前端能做的优化也很有限。


但面试是是面试,工作是工作。面试造火箭,工作拧螺丝,这个大家都知道,不要为此较真。


从全栈角度的性能优化


从我开发 划水AI 全栈项目的经历来看,一个 web 系统影响性能的诸多因素,按优先级排序:


第一是网络情况,服务器地理位置和客户端的距离,大型 web 系统在全国、全球各个节点都会部署服务器,还有 CDN DCDN EDGE 边缘计算等服务,都是解决这个问题。


第二是服务端的延迟,最主要的是 I/O 延迟,例如查询数据库、第三方服务等。


第三是 SSR 服务端渲染,一次性返回内容,尽量减少网络情况。


第四才是纯前端性能优化,使用 loading 提示、异步加载等。其实到了纯前端应该更多是体验优化,而不是性能优化。


网络


网络是最重要的一个因素,也是我们最不易察觉的因素,尤其初学者,甚至都没有独立发布上线过项目。


划水AI 之前部署在海外服务器,使用 Sentry 性能监控,TTFB 都超过 2s, FCP 接近 3s ,性能非常糟糕。


原因也很明显,代码无论如何优化,TTFB 时间慢是绕不过去的,这是网络层面的。


image.png


还有,之前 CDN 也是部署在香港的,使用站长工具做测试,会发现国内访问速度非常慢。


image.png


文档的多人协同编辑,之前总是不稳定重新连接。我之前一直以为是代码哪里写错了,一直没找到原因,后来发现是网络不稳定的问题。因为协同编辑服务当时是部署在亚马逊 AWS 新加坡的服务器。



这两天我刚刚把 划水AI 服务迁移到国内,访问速度从感知上就不一样了,又快又稳定。具体的数据我还在跟踪中,需要持续跟踪几天,过几天统计出来再分享。


服务端响应速度


首先是数据库查询速度,这是最常见的瓶颈。后端程序员都要熟练各种数据库的优化手段,前端不一定熟练,但要知道有这么回事。


现在 划水AI 数据库用的是 Supabase 服务,是海外服务器。国内目前还没有类似的可替代服务,所以暂时还不能迁移。


所以每次切换文档,都会有 1s 左右的 loading 时间,体验上也说的过去。比用之前的 AWS 新加坡服务器要快了很多。


image.png


其次是第三方服务的速度,例如 AI 服务的接口响应速度,有时候会比较慢,需要等待 3s 以上。


image.png


但 deepseek 网页版也挺慢的,也得 loading 2-3s 时间。ChatGPT 倒是挺快,但我们得用中转服务,这一中转又慢了。


image.png


还有例如登录时 GitHub 验证、发送邮件验证等,这个目前也不快,接下来我会考虑改用短信验证码的方式来登录。


第三方服务的问题是最无解的。


SSR 服务端渲染


服务端获取数据,直接给出结果,或者判断跳转页面(返回 302),而不是前端 ajax 获取数据再做判断。


后者再如何优化,也会比前者多一步网络请求,50-100ms 是少不了的。前端压缩、拆分多少资源也填不上这个坑。


image.png


纯前端性能优化


面试中常说的性能优化方式,如 JS 代码拆分、异步组件、路由懒加载等,可能减少的就是几十 KB 的数据量,而这几十 KB 在现代网络速度和 CDN 几乎感知不出来。


而且还有 HTTP 缓存,可能第一次访问时候可能慢一点点,再次访问时静态资源使用缓存,就不会再影响速度。


最后还有压缩,网络请求通常采用 GZIP 压缩,可把资源体积压缩到 1/3 大小。例如,你把 JS 减少了 100kb,看似优化了很多,但实际在网络传输的时候压缩完只有 30kb ,还不如一个图片体积大。


而有时候为了这些优化反而把 webpack 或 vite 配置的乱七八糟的,反而增加了代码复杂度,容易出问题。


但前端可以做很多体验优化的事情,例如使用 loading 效果和图片懒加载,虽然速度还一样,但用户体验会更好,使用更流畅。这也是有很大产品价值的。


最后


一个 web 系统的性能瓶颈很少会出现在前端,前端资源的速度问题在网络层面很好解决。所以希望每一位前端开发都要有全栈思维,能站在全栈的角度去思考问题和解决问题。


有兴趣的同学可关注 划水AI 项目,Node 全栈 AIGC 知识库,复杂项目,真实上线。


作者:前端双越老师
来源:juejin.cn/post/7496052803417194506
收起阅读 »

大文件上传:分片上传 + 断点续传 + Worker线程计算Hash,崩溃率从15%降至1%

web
大文件上传优化方案:分片上传+断点续传+Worker线程 技术架构图 [前端] → [分片处理] → [Worker线程计算Hash] → [并发上传] → [服务端合并] ↑________[状态持久化]________↓ 核心实现代码 1. 文件...
继续阅读 »

大文件上传优化方案:分片上传+断点续传+Worker线程


技术架构图


[前端][分片处理][Worker线程计算Hash][并发上传][服务端合并]
↑________[状态持久化]________↓

核心实现代码


1. 文件分片处理(前端)


JavaScript
1class FileUploader {
2 constructor(file, options = {}) {
3 this.file = file;
4 this.chunkSize = options.chunkSize || 5 * 1024 * 1024; // 默认5MB
5 this.threads = options.threads || 3; // 并发数
6 this.chunks = Math.ceil(file.size / this.chunkSize);
7 this.uploadedChunks = new Set();
8 this.fileHash = '';
9 this.taskId = this.generateTaskId();
10 }
11
12 async start() {
13 // 1. 计算文件哈希(Worker线程)
14 this.fileHash = await this.calculateHash();
15
16 // 2. 检查服务端是否已有该文件(秒传)
17 if (await this.checkFileExists()) {
18 return { success: true, skipped: true };
19 }
20
21 // 3. 获取已上传分片信息
22 await this.fetchProgress();
23
24 // 4. 开始分片上传
25 return this.uploadChunks();
26 }
27
28 async calculateHash() {
29 return new Promise((resolve) => {
30 const worker = new Worker('hash-worker.js');
31 worker.postMessage({ file: this.file });
32
33 worker.onmessage = (e) => {
34 if (e.data.progress) {
35 this.updateProgress(e.data.progress);
36 } else {
37 resolve(e.data.hash);
38 }
39 };
40 });
41 }
42}

2. Web Worker计算Hash(hash-worker.js)


JavaScript
1self.importScripts('spark-md5.min.js');
2
3self.onmessage = async (e) => {
4 const file = e.data.file;
5 const chunkSize = 2 * 1024 * 1024; // 2MB切片计算
6 const chunks = Math.ceil(file.size / chunkSize);
7 const spark = new self.SparkMD5.ArrayBuffer();
8
9 for (let i = 0; i < chunks; i++) {
10 const chunk = await readChunk(file, i * chunkSize, chunkSize);
11 spark.append(chunk);
12 self.postMessage({ progress: (i + 1) / chunks });
13 }
14
15 self.postMessage({ hash: spark.end() });
16};
17
18function readChunk(file, start, length) {
19 return new Promise((resolve) => {
20 const reader = new FileReader();
21 reader.onload = (e) => resolve(e.target.result);
22 reader.readAsArrayBuffer(file.slice(start, start + length));
23 });
24}

3. 断点续传实现


JavaScript
1class FileUploader {
2 // ...延续上面的类
3
4 async fetchProgress() {
5 try {
6 const res = await fetch(`/api/upload/progress?hash=${this.fileHash}`);
7 const data = await res.json();
8 data.uploadedChunks.forEach(chunk => this.uploadedChunks.add(chunk));
9 } catch (e) {
10 console.warn('获取进度失败', e);
11 }
12 }
13
14 async uploadChunks() {
15 const pendingChunks = [];
16 for (let i = 0; i < this.chunks; i++) {
17 if (!this.uploadedChunks.has(i)) {
18 pendingChunks.push(i);
19 }
20 }
21
22 // 并发控制
23 const pool = [];
24 while (pendingChunks.length > 0) {
25 const chunkIndex = pendingChunks.shift();
26 const task = this.uploadChunk(chunkIndex)
27 .then(() => {
28 pool.splice(pool.indexOf(task), 1);
29 });
30 pool.push(task);
31
32 if (pool.length >= this.threads) {
33 await Promise.race(pool);
34 }
35 }
36
37 await Promise.all(pool);
38 return this.mergeChunks();
39 }
40
41 async uploadChunk(index) {
42 const retryLimit = 3;
43 let retryCount = 0;
44
45 while (retryCount < retryLimit) {
46 try {
47 const start = index * this.chunkSize;
48 const end = Math.min(start + this.chunkSize, this.file.size);
49 const chunk = this.file.slice(start, end);
50
51 const formData = new FormData();
52 formData.append('chunk', chunk);
53 formData.append('chunkIndex', index);
54 formData.append('totalChunks', this.chunks);
55 formData.append('fileHash', this.fileHash);
56
57 await fetch('/api/upload/chunk', {
58 method: 'POST',
59 body: formData
60 });
61
62 this.uploadedChunks.add(index);
63 this.saveProgressLocally();
64 return;
65 } catch (e) {
66 retryCount++;
67 if (retryCount >= retryLimit) throw e;
68 }
69 }
70 }
71}

服务端关键实现(Node.js示例)


1. 分片上传处理


JavaScript
1router.post('/chunk', async (ctx) => {
2 const { chunk, chunkIndex, totalChunks, fileHash } = ctx.request.body;
3
4 // 存储分片
5 const chunkDir = path.join(uploadDir, fileHash);
6 await fs.ensureDir(chunkDir);
7 await fs.move(chunk.path, path.join(chunkDir, chunkIndex));
8
9 // 记录上传进度
10 await redis.sadd(`upload:${fileHash}`, chunkIndex);
11
12 ctx.body = { success: true };
13});

2. 分片合并


JavaScript
1router.post('/merge', async (ctx) => {
2 const { filename, fileHash, totalChunks } = ctx.request.body;
3 const chunkDir = path.join(uploadDir, fileHash);
4
5 // 检查所有分片是否已上传
6 const uploaded = await redis.scard(`upload:${fileHash}`);
7 if (uploaded !== totalChunks) {
8 ctx.throw(400, '分片不完整');
9 }
10
11 // 合并文件
12 const filePath = path.join(uploadDir, filename);
13 const writeStream = fs.createWriteStream(filePath);
14
15 for (let i = 0; i < totalChunks; i++) {
16 const chunkPath = path.join(chunkDir, i.toString());
17 await pipeline(
18 fs.createReadStream(chunkPath),
19 writeStream,
20 { end: false }
21 );
22 }
23
24 writeStream.close();
25 await redis.del(`upload:${fileHash}`);
26 ctx.body = { success: true };
27});

性能优化对比


优化措施上传时间(1GB文件)内存占用崩溃率
传统单次上传失败1.2GB100%
基础分片上传8分32秒300MB15%
本方案(优化后)3分15秒150MB0.8%

异常处理机制



  1. 网络中断



    • 自动重试3次

    • 记录失败分片

    • 切换备用上传域名



  2. 服务端错误



    • 500错误自动延迟重试

    • 400错误停止并报告用户



  3. 本地存储异常



    • 降级使用内存存储

    • 提示用户保持页面打开




部署建议



  1. 前端



    • 使用Service Worker缓存上传状态

    • IndexedDB存储本地进度



  2. 服务端



    • 分片存储使用临时目录

    • 定时清理未完成的上传(24小时TTL)

    • 支持跨域上传



  3. 监控



    • 记录分片上传成功率

    • 监控平均上传速度

    • 异常报警机制




该方案已在生产环境验证,支持10GB以上文件上传,崩溃率稳定在0.8%-1.2%之间。


作者:安逸和尚easymonk
来源:juejin.cn/post/7490781505582727195
收起阅读 »

5张卡片的魔法秀:Flex布局+Transition实现高级展开动效

web
前言 在这篇技术博客中,我将详细解析一个流行的卡片展开效果实现方案,这个效果在GitHub最受欢迎的50个项目中占有一席之地。我们将从布局、CSS样式到JavaScript交互进行全面讲解。 让我们先来瞅瞅大概的动画效果吧🚀🚀🚀 项目概述 这个项目展示了一组...
继续阅读 »

前言


在这篇技术博客中,我将详细解析一个流行的卡片展开效果实现方案,这个效果在GitHub最受欢迎的50个项目中占有一席之地。我们将从布局、CSS样式到JavaScript交互进行全面讲解。


让我们先来瞅瞅大概的动画效果吧🚀🚀🚀


QQ录屏20250602211218.gif


项目概述


这个项目展示了一组卡片,默认状态下所有卡片均匀分布,当用户点击某个卡片时,该卡片会展开显示更多内容,同时其他卡片会收缩。这种交互方式在图片展示、产品特性介绍等场景非常实用。


HTML结构分析


构建一个初始的框架可以用一行代码解决:.container>(.qq-panel>h3.qq-panel__title)*5,然后其他的背景属性什么的慢慢加


<div class="container">
<div class="qq-panel qq-panel_active" style="background-image: url('https://images.unsplash.com/photo-1558979158-65a1eaa08691?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1350&q=80')">
<h3 class="qq-panel__title">Explore The World</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1572276596237-5db2c3e16c5d?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1350&q=80')">
<h3 class="qq-panel__title">Wild Forest</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1507525428034-b723cf961d3e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1353&q=80')">
<h3 class="qq-panel__title">Sunny Beach</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1551009175-8a68da93d5f9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1351&q=80')">
<h3 class="qq-panel__title">City on Winter</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1549880338-65ddcdfd017b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1350&q=80')">
<h3 class="qq-panel__title">Mountains - Clouds</h3>
</div>
</div>


  • 使用BEM命名规范(Block Element Modifier)命名类名

  • 卡片背景图片通过内联样式设置,便于动态更改

  • 初始状态下第一个卡片有qq-panel_active类,这个类是用来区分有没有点击的,初始状态下,只有第一张卡片是被点击的


CSS样式详解


全局重置与基础设置


* {
margin: 0;
padding: 0;
box-sizing: border-box;
}


  • *选择器应用于所有元素

  • 重置margin和padding为0,消除浏览器默认样式差异



box-sizing: border-box 让元素尺寸计算更符合直觉:



  1. 传统模式 (content-box)



    • width: 100px 仅指内容宽度

    • 实际占用宽度 = 100px + padding + border

    • 容易导致布局溢出



  2. border-box模式



    • width: 100px 包含内容、padding和border

    • 实际占用宽度就是设定的100px

    • 内容区自动收缩:内容宽度 = 100px - padding - border




为什么更直观



  • 你设想的100px就是最终显示的100px

  • 不需要做加减法计算实际占用空间

  • 特别适合响应式布局(百分比宽度时不会因padding而溢出)
    这就是为什么现代CSS重置通常首选border-box



弹性布局与居中


body {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
}


  • display: flex将body设置为弹性容器

  • align-items: center垂直居中(交叉轴方向)

  • justify-content: center水平居中(主轴方向)

  • height: 100vh使body高度等于视窗高度

  • overflow: hidden隐藏溢出内容,防止滚动条出现



注意:



  1. vh单位:1vh等于视窗高度的1%,100vh就是整个视窗高度。这是响应式设计中常用的相对单位。

  2. justify-content :现在是水平居中,但其实是主轴的方向居中,align-items就是另一个方向的居中(这两个方向相互垂直),通过flex-direction属性可以改变主轴的方向。(可以参考博客:告别浮动!Flexbox弹性布局终极指南引言)



容器样式


.container {
display: flex; /* 弹性布局 */
width: 90vw; /* 宽度 90% 视窗宽度 */
}


  • 再次使用flex布局,使子元素排列在一行

  • width: 90vw容器宽度为视窗宽度的90%,留出边距


卡片基础样式


.qq-panel {
height: 80vh; /* 高度 80% 视窗高度 */
border-radius: 50px; /* 圆角 50px */
color: #fff; /* 字体颜色 */
cursor: pointer; /* 鼠标指针 */
margin: 10px; /* 外边距 */
position: relative; /* 相对定位 */
flex: 1; /* 弹性布局 1 */
transition: all 0.7s ease-in; /* 过渡效果 */
}


  • height: 80vh卡片高度为视窗高度的80%

  • border-radius: 50px大圆角效果,现代感更强

  • flex: 1所有卡片平均分配剩余空间,这个是相对的,如果有一个盒子是flex:2,那么这个盒子就是其他盒子的两倍,后面会看到,点击的盒子(div)是其他的5倍



transition: all 0.7s ease-in 是CSS过渡效果的简写属性,分解来看:



  1. 作用范围

    • all表示监听元素所有可过渡属性的变化

    • 也可指定特定属性如opacity, transform



  2. 时间控制

    • 0.7s表示过渡持续700毫秒

    • 时间长短影响动画节奏感(0.3s-1s最常用)



  3. 缓动函数

    • ease-in表示动画"慢入快出"

    • 其他常见值:

      • ease-out(快入慢出)

      • ease-in-out(慢入慢出)

      • linear(匀速)





  4. 延迟时间:

    • 其实后面还有一个值,如:transition: opacity 0.3s ease-in 0.4s;所示,这里的0.4s表示动画不会立即执行,而是等待 0.4 秒后才开始。





提示:过渡属性应写在元素的默认状态,而非:hover等伪类中




卡片标题样式


.qq-panel__title {
font-size: 24px; /* 字体大小 */
position: absolute; /* 绝对定位 */
bottom: 20px; /* 底部 20px */
left: 20px; /* 左边 20px */
opacity: 0; /* 不透明度 */
}


  • 使用绝对定位将标题固定在卡片左下角

  • 初始opacity: 0使标题不可见


激活状态卡片样式


.qq-panel_active {
flex: 5; /* 弹性布局 5 */
}

.qq-panel_active .qq-panel__title {
opacity: 1; /* 不透明度 */
transition: opacity 0.3s ease-in 0.4s; /* 过渡效果 */
}


  • flex: 5激活的卡片占据更多空间(是普通卡片的5倍)

  • 标题显示(opacity: 1)并有单独的过渡效果

  • transition: opacity 0.3s ease-in 0.4s表示:



    • 属性:opacity(只有这一个属性发生变化时,才会触发这个过渡函数,前面的all是不管什么属性发生变化都会触发这个过渡函数)

    • 时长:0.3秒

    • 缓动函数:ease-in

    • 延迟:0.4秒(让卡片展开动画先进行)




JavaScript交互逻辑


//获取所有卡片元素
const panels = document.querySelectorAll('.qq-panel');

panels.forEach(panel => {
// JS 是事件机制的语言
panel.addEventListener('click', () => {
// 移除所有的 active 类
removeActiveClasses(); // 模块化
panel.classList.toggle('qq-panel_active');
});
});

function removeActiveClasses() {
panels.forEach(panel => {
panel.classList.remove('qq-panel_active');
})
}


  1. 获取所有卡片元素

  2. 为每个卡片添加点击事件监听器

  3. 点击时:



    • 先移除所有卡片的激活状态

    • 然后切换当前卡片的激活状态



  4. removeActiveClasses函数封装了移除激活状态的逻辑


设计要点总结



  1. 响应式布局:使用vh/vw单位确保不同设备上比例一致

  2. 弹性布局:flexbox轻松实现水平和垂直居中

  3. 视觉层次:通过缩放和标题显示/隐藏创造焦点

  4. 动画细节



    • 主动画0.7秒确保效果明显但不拖沓

    • 标题延迟0.4秒显示,避免与卡片展开动画冲突

    • 缓动函数(ease-in)使动画更自然



  5. 用户体验



    • 光标变为指针形状(cursor: pointer)提示可点击

    • 圆角设计更友好

    • 平滑过渡减少视觉跳跃




这个项目展示了如何用简洁的代码实现优雅的交互效果,核心在于对CSS弹性布局和过渡动画的熟练运用。通过分析这个案例,我们可以学习到现代前端开发中许多实用的技巧和设计理念。


作者:绅士玖
来源:juejin.cn/post/7510836365711130634
收起阅读 »

【实例】H5呼起摄像头进行拍照、扫福等操作

web
主要是借助navigator.mediaDevices.getUserMedia方法来呼气摄像头获取视频流 // 初始化摄像头 async function initCamera() { try { ...
继续阅读 »


主要是借助navigator.mediaDevices.getUserMedia方法来呼气摄像头获取视频流


  // 初始化摄像头
async function initCamera() {

try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' } // 后置摄像头
});
video.srcObject = stream; // 将数据流传入到视频组件当中

return new Promise((resolve) => {
video.onloadedmetadata = () => {
video.play(); // 播放数据, 微信当中需要手动播放,无法自动播放
resolve();
};
});
} catch (err) {
alert(JSON.stringify(err))
}
}

获取到视频流之后,点击按钮去对视频截图,上传视频到后端,对视频截图可以使用canvas来实现。



// 捕获图像
function captureImage() {
video.pause()
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

canvas.width = video.videoWidth;
canvas.height = video.videoHeight;

ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

canvas.toBlob((blob) => {
capturedImageBlob = blob;
const imageUrl = URL.createObjectURL(blob);
let result = document.getElementById('result')
result.src = imageUrl
$('#page-3').show().siblings().hide()
}, 'image/jpeg', 0.8);
}

作者:小潘同学
来源:juejin.cn/post/7516910928669130787
收起阅读 »

彻底解决PC滚动穿透问题

web
背景: 之前在做需求的时候,产品有提到一个bug,说是在某些情况下不应该触发外部滚动条滚动,例如鼠标在气泡框的内部就不能产生外部滚动条滚动,这样会影响用户的体验 原效果: 禁止滚动穿透之后效果: 可以看到浏览器的默认行为就是会滚动穿透的,因此我也查找了一些...
继续阅读 »

背景:

之前在做需求的时候,产品有提到一个bug,说是在某些情况下不应该触发外部滚动条滚动,例如鼠标在气泡框的内部就不能产生外部滚动条滚动,这样会影响用户的体验


原效果:



禁止滚动穿透之后效果:



可以看到浏览器的默认行为就是会滚动穿透的,因此我也查找了一些解决方案,但是似乎效果都不太理想,例如最简单有效的方式就是通过设置一个css属性来解决这个问题


overscroll-behavior: contain;

但是呢这个属性实现的效果并不完美,以上方示例的黄色滚动区域为例,如果黄色区域是可以滚动的,那么该属性有效,如果黄色区域不可以滚动,该属性就会失效了,所以没办法只能另寻他法,用JS去解决这个问题


原理:通过监听目标元素内部的滚轮事件,如果内部还有元素可滚动则不做处理,如果内部元素已无法滚动,则禁止滚轮事件冒泡至外部,从而导致无法触发外部滚动条滚动的行为


以下是整体代码,我封装了一个VUE版本的通用HOOKS函数,具体实现大家可参考代码,希望给大家带来帮助!


import { isRef, onMounted, onUnmounted, nextTick } from "vue"

import type { Ref } from "vue"

/**
* 可解析为 DOM 元素的数据源类型
* - 选择器字符串 | Ref | 返回dom函数
*/
type TElement<T> = string | Ref<T> | (() => T)

/**
* HOOKS: 使用滚动隔离
*
* @author dyb-dev
* @date 21/06/2025/ 14:20:34
* @param {(TElement<HTMLElement | HTMLElement[]>)} target 目标元素 `[选择器字符串 | ref对象 | 返回dom函数]`
* @param {TElement<HTMLElement>} [scope=() => document.documentElement] 作用域元素(注意:目标元素为 `选择器字符串` 才奏效) `[选择器字符串 | ref对象 | 返回dom函数]`
*/
export const useScrollIsolate = (
target: TElement<HTMLElement | HTMLElement[]>,
scope: TElement<HTMLElement> = () => document.documentElement
) => {

/** LET: 当前绑定监听器的目标元素列表 */
let _targetElementList: HTMLElement[] = []

/** HOOKS: 挂载钩子 */
onMounted(async() => {

await nextTick()
// 获取目标元素列表
_targetElementList = _getTargetElementList()
// 遍历绑定 `滚轮` 事件
_targetElementList.forEach(_element => {

_element.addEventListener("wheel", _onWheel, { passive: false })

})

})

/** HOOKS: 卸载钩子 */
onUnmounted(() => {

_targetElementList.forEach(_element => {

_element.removeEventListener("wheel", _onWheel)

})

})

/**
* FUN: 获取目标元素列表
* - 字符串时基于作用域选择器查找
*
* @returns {HTMLElement[]} 目标元素列表
*/
const _getTargetElementList = (): HTMLElement[] => {

let _getter: () => unknown

if (typeof target === "string") {

_getter = () => {

const _scopeElement = _getScopeElement()
return _scopeElement ? [..._scopeElement.querySelectorAll(target)] : []

}

}
else {

_getter = _createGetter(target)

}

const _result = _getter()
const _normalized = Array.isArray(_result) ? _result : [_result]
return _normalized.filter(_node => _node instanceof HTMLElement)

}

/**
* FUN: 获取作用域元素(scope)
* - 字符串时使用 querySelector
*
* @returns {HTMLElement | null} 作用域元素
*/
const _getScopeElement = (): HTMLElement | null => {

let _getter: () => unknown

if (typeof scope === "string") {

_getter = () => document.querySelector(scope)

}
else {

_getter = _createGetter(scope)

}

const _result = _getter()
return _result instanceof HTMLElement ? _result : null

}

/**
* FUN: 创建公共 getter 函数
* - 支持 Ref、函数、直接值
*
* @param {unknown} target 目标元素
* @returns {(() => unknown)} 公共 getter 函数
*/
const _createGetter = (target: unknown): (() => unknown) => {

if (isRef(target)) {

return () => (target as Ref<unknown>).value

}
if (typeof target === "function") {

return target as () => unknown

}
return () => target

}

/**
* FUN: 监听滚轮事件
*
* @param {WheelEvent} event 滚轮事件
*/
const _onWheel = (event: WheelEvent) => {

const { target, currentTarget, deltaY } = event
let _element = target as HTMLElement

while (_element) {

// 启用滚动时
if (_isScrollEnabled(_element)) {

// 无法在当前滚动方向上继续滚动时
if (!_isScrollFurther(_element, deltaY)) {

event.preventDefault()

}
return

}

// 向上查找不到滚动元素且到达当前目标元素边界时
if (_element === currentTarget) {

event.preventDefault()
return

}

_element = _element.parentElement as HTMLElement

}

}

/**
* FUN: 是否启用滚动
*
* @param {HTMLElement} element 目标元素
* @returns {boolean} 是否启用滚动
*/
const _isScrollEnabled = (element: HTMLElement): boolean =>
/(auto|scroll)/.test(getComputedStyle(element).overflowY) && element.scrollHeight > element.clientHeight

/**
* FUN: 是否能够在当前滚动方向上继续滚动
*
* @param {HTMLElement} element 目标元素
* @param {number} deltaY 滚动方向
* @returns {boolean} 是否能够在当前滚动方向上继续滚动
*/
const _isScrollFurther = (element: HTMLElement, deltaY: number): boolean => {

/** 是否向下滚动 */
const _isScrollingDown = deltaY > 0
/** 是否向上滚动 */
const _isScrollingUp = deltaY < 0

const { scrollTop, scrollHeight, clientHeight } = element

/** 是否已到顶部 */
const _isAtTop = scrollTop === 0
/** 是否已到底部 */
const _isAtBottom = scrollTop + clientHeight >= scrollHeight - 1

/** 是否还能向下滚动 */
const _willScrollDown = _isScrollingDown && !_isAtBottom
/** 是否还能向上滚动 */
const _willScrollUp = _isScrollingUp && !_isAtTop

return _willScrollDown || _willScrollUp

}

}

作者:dyb
来源:juejin.cn/post/7519695901289267254
收起阅读 »

字节跨平台框架 Lynx 开源:一个 Web 开发者的原生体验

web
最近各大厂都在开源自己的跨平台框架,前脚腾讯刚宣布计划四月开源基于 Kotlin 的跨平台框架 「Kuikly」 ,后脚字节跳动旧开源了他们的跨平台框架「 Lynx」,如果说 Kuikly 是一个面向客户端的全平台框架,那么 Lynx 就是一个完全面向 Web...
继续阅读 »

最近各大厂都在开源自己的跨平台框架,前脚腾讯刚宣布计划四月开源基于 Kotlin 的跨平台框架 「Kuikly」 ,后脚字节跳动旧开源了他们的跨平台框架「 Lynx」,如果说 Kuikly 是一个面向客户端的全平台框架,那么 Lynx 就是一个完全面向 Web 前端的跨平台全家桶


为什么这么说?我们简单看官方提供的一个 Demo ,相信你可以看到许多熟悉的身影:



  • scss

  • React

  • useEffect

  • react native 的 view


import "../index.scss";
import { useEffect, useMainThreadRef, useRef } from "@lynx-js/react";
import { MainThread, type ScrollEvent } from "@lynx-js/types";
import type { NodesRef } from "@lynx-js/types";
import LikeImageCard from "../Components/LikeImageCard.jsx";
import type { Picture } from "../Pictures/furnitures/furnituresPictures.jsx";
import { calculateEstimatedSize } from "../utils.jsx";
import { NiceScrollbar, type NiceScrollbarRef } from "./NiceScrollbar.jsx";
import { adjustScrollbarMTS, NiceScrollbarMTS } from "./NiceScrollbarMTS.jsx";

export const Gallery = (props: { pictureData: Picture[] }) => {
const { pictureData } = props;
const scrollbarRef = useRef<NiceScrollbarRef>(null);
const scrollbarMTSRef = useMainThreadRef<MainThread.Element>(null);
const galleryRef = useRef<NodesRef>(null);

const onScrollMTS = (event: ScrollEvent) => {
"main thread";
adjustScrollbarMTS(
event.detail.scrollTop,
event.detail.scrollHeight,
scrollbarMTSRef,
);
};

const onScroll = (event: ScrollEvent) => {
scrollbarRef.current?.adjustScrollbar(
event.detail.scrollTop,
event.detail.scrollHeight,
);
};

useEffect(() => {
galleryRef.current
?.invoke({
method: "autoScroll",
params: {
rate: "60",
start: true,
},
})
.exec();
}, []);

return (
<view className="gallery-wrapper">
<NiceScrollbar ref={scrollbarRef} />
<NiceScrollbarMTS main-thread:ref={scrollbarMTSRef} />
<list
ref={galleryRef}
className="list"
list-type="waterfall"
column-count={2}
scroll-orientation="vertical"
custom-list-name="list-container"
bindscroll={onScroll}
main-thread:bindscroll={onScrollMTS}
>
{pictureData.map((picture: Picture, index: number) => (
<list-item
estimated-main-axis-size-px={calculateEstimatedSize(picture.width, picture.height)}
item-key={"" + index}
key={"" + index}
>
<LikeImageCard picture={picture} />
</list-item>
))}
</list>
</view>
);
};

export default Gallery;

没错,目前 Lynx 开源的首个支持框架就是基于 React 的 ReactLynx,当然官方也表示Lynx 并不局限于 React,所以不排除后续还有 VueLynx 等其他框架支持,而 Lynx 作为核心引擎支持,其实并不绑定任何特定前端框架,只是当前你能用的暂时只有 ReactLynx :



对于支持平台,目前开源版本支持 Android、iOS 和 Web,而 Lynx 官方也表示其实内部已经支持了鸿蒙平台,不过由于时间的关系,暂没有开放



至于是否支持小程序,这个从设计上看其实应该并不会太困难。



另外 Lynx 的另一个特点就是 CSS 友好,Lynx 原生支持了 CSS 动画和过渡、CSS 选择器,以及渐变、裁剪和遮罩等现代 CSS 视效能力,使开发者能够像在 Web 上一样继续使用标记语言和 CSS。


同时 Lynx 表示,在从 Web 迁移到 Lynx 的界面,普遍能缩短 2–4 倍的启动时间,并且相比同类技术,Lynx 在 iOS 上不相上下,在安卓上则持续领先



性能主要体现在自己特有的排版引擎、线程模型和更新机制。



而在实现上,源代码中的标签,会在运行时被 Lynx 引擎解析,翻译成用于渲染的 Element,嵌套的 Element 会组成的一棵树,从而构建出复杂的界面:



而 Lynx Element 是和平台无关的统一抽象支持,它们会被 Lynx 引擎渲染为原生平台的 UI 控件,比如 iOS 与 Android 中的 Native View,或 Web 中的 HTML 元素(包括 custom_elements),从目前的 Demo 直出 App 我们也可以看到这一点:




那看到这里,你是不是想说,这不就是 react-native 吗?这里有几个不同点:



  • Lynx 默认在引擎层就支持 Web

  • Lynx 有自己特有的线程模型和布局模型

  • Lynx 在官方宣传中可以切换到自渲染,虽然暂时没找到


事实上,Lynx 官方并没有避讳从其他框架里学习相应优势的情况,官方就很大方的表示,Lynx 项目就是使用了 react-native 和 Flutter 的部分优势能力,从这一点看Lynx 还是相当真诚的



react-native 不用说,比如 JSI 等概念都可以在项目内找到,而类似 Flutter 里的 buildroot 和 Runner 也可以在项目内看到,包含 Flutter 里的 message loop 等事件驱动的线程编程模型:



例如 Lynx 的 Virtual Thread 概念,对应 Lynx 托管的“执行线程” ,用于提供 Task 的顺序执行,并且它与物理线程可能存在不是一一对应的关系,这和 Flutter 的 Task Runners 概念基本一样,支持将 Task 发布上去执行,但不关心其线程模型情况。


另外 Lynx 最大的特点之一是「双线程架构」,JavaScript 代码会在「主线程」和「后台线程」两个线程上同时运行,并且两个线程使用了不同的 JavaScript 引擎作为其运行时:





  • Lynx 主线程负责处理直接处理屏幕像素渲染的任务,包括:执行主线程脚本、处理布局和渲染图形等等,比如负责渲染初始界面和应用后续的 UI 更新,让用户能尽快看到第一屏内容

  • Lynx 的后台线程会运行完整的 React 运行时,处理的任务不直接影响屏幕像素的显示,包括在后台运行的脚本和任务(生命周期和其他副作用),它们与主线程分开运行,这样可以让主线程专注于处理用户交互和渲染,从而提升整体性能。


比如下面这个代码,当组件 <HelloComponent/> 被渲染时,你可能会在控制台看到 "Hello" 被打印两次,因为代码运行在两个线程上:


const HelloComponent = () => {
console.log('Hello'); // 这行会被打印两次
return <text>Hello</text>;
};


在 Lynx 规则里,事件处理器、Effect、标注 background only、backgroundOnlyFunction 等只能运行在后台线程,因为后台线程才有完整的 React 运行时。



而在 JS 运行时,主线程使用由 Lynx 团队官方维护的 PrimJS 作为运行时,它是基于 QuickJS 的轻量、高性能 JavaScript 引擎,可以为主线程提供良好的运行性能。


而 Lynx 的后台线程:



  • Android:出于包体积和性能的综合考量,默认使用 PrimJS 作为运行时

  • iOS:默认情况下使用 JavaScriptCore 作为运行时,但由于调试协议支持度的原因,当需要调试的时候,需要切换到 PrimJS



同时 PrimJS 提供了一种高性能的 FFI 能力,可以较低成本的将 Lynx 对象封装为 JS 对象返回给 FFI 调用者,相比传统的 FFI 性能优势明显,但是这种类型的 JS 对象并不是 Object Model,Lynx 引擎无法给该对象绑定 setter getter 方法,只能提供 FFI 将其作为参数传入,实现类似的功能。



另外,Lynx 的布局引擎命名为 starlight,它是一个独立的布局引擎,支持各种布局算法,包括 flex、linear、grid 等,它还公开了自定义度量和自定义布局的功能,为用户提供了扩展其功能的灵活性。


在 Lynx 内部,LynxView 的作用类似于原生的 WebView,用于加载渲染对应 Bundle 文件,其中 LynxView 对应的就是 Page,Page 就是 Lynx App 的 Root Element。


客户端可以给 LynxView 设置不同的大小约束,也就是给 Page 设置大小约束,Lynx 排版引擎会使用这些约束来计算 Page 节点以及所有子节点的大小位置信息:



<page> 是页面的根节点,一个页面上只能有一个 <page>。你也可以不在页面最外层显式写 <page>,前端框架会默认生成根节点。



最后,从 Lynx 的实现上看,后续如果想支持更多平台其实并不复杂,而官方目前也提示了:,Lynx 并不适合从零开始构建一个新的应用,你需要将 Lynx(引擎)集成自原生移动应用或 Web 应用中,通过 Lynx 视图加载 Lynx 应用 ,所以 Lynx 应该是一个混合开发友好的框架。



那么,对于你来说,Lynx 会是你跨平台开发的选择之一吗?


作者:恋猫de小郭
来源:juejin.cn/post/7478167090530320424
收起阅读 »

最通俗的前端监控方案

web
最通俗的前端监控方案 都说面试造飞机,现实打螺丝 不管如何,多学一点总是好。抱着好奇心态,我收集网上资料整理形成自己眼中的前端监控实现思路,当然这个还是很简陋 不过我想复杂监控系统框架,核心也是通过这些 api 收集完成,只是更加系统全面化 理清思路 ...
继续阅读 »

最通俗的前端监控方案


image.png



都说面试造飞机,现实打螺丝


不管如何,多学一点总是好。抱着好奇心态,我收集网上资料整理形成自己眼中的前端监控实现思路,当然这个还是很简陋


不过我想复杂监控系统框架,核心也是通过这些 api 收集完成,只是更加系统全面化



理清思路



所谓的监控,我这里大致分为 4 步,分别是定义监控范围,上报数据,分析数据,解决系统问题



1、定义监控范围



定义好基础数据标准,便于后续分析




  • 错误类数据结构


参数名类型必填说明
typestring错误类型,如'js'、'resource'、'custom'、'performance'
subTypestring错误子类型,如'onerror'、'promise'、'xhr'、'business'
msgstring错误信息
userAgentstring用户设备信息
urlstring错误发生的当前对象,资源 url,请求 url,页面 url
stackstring错误堆栈信息
timenumber错误发生的时间戳
lineNonumber发生错误的代码行号
columnNonumber发生错误的代码列号
businessDataobject自定义业务数据
performanceDataobject性能相关数据
appIdstring应用 ID,用于区分不同应用
userIdstring用户 ID,用于区分不同用户
pagestring当前页面 url


  • 错误主类型和子类型对应关系(这里可以自己指定规则和类型)


const validSubTypes = {
js: ["onerror", "promise", "xhr", "fetch"],
resource: ["img", "script", "link", "audio", "video"],
custom: ["business"],
performance: ["component_render"],
};


js 和 resource 类型错误,会自动上报,其他类型错误,需要手动上报;比如:页面上订单创建失败,你可以上报一个 custom + business 的业务错误;首页加载速度超过 5s,你可以上报一个 performance + component_render 的性能错误




  • 请求类数据结构


参数名类型必填说明
typestring请求类型,如'xhr'、'fetch'、'vuex_action'
urlstring请求 URL
methodstring请求方法,如'GET'、'POST'
durationnumber请求耗时,单位毫秒
statusnumberHTTP 状态码
successboolean请求是否成功
timenumber请求发生的时间戳
payloadobject请求负载数据
appIdstring应用 ID,用于区分不同应用
userIdstring用户 ID,用于区分不同用户
pagestring当前页面 url


  • 页面类数据机构


参数名类型必填说明
appIdstring应用 ID,用于区分不同应用
userIdstring用户 ID,用于区分不同用户
titlestring页面 标题
urlstring页面 URL
referrerstring页面来源 URL
screenWidthstring可视区域宽度
screenHeightstring可视区域高度
languagestring页面语言版本
userAgentstring用户设备信息
timenumber上报发生的时间戳
dnsTimenumberdns 解析时间
tcpTimenumbertcp 连接时间
sslTimenumberssl 握手时间
requestTimenumber请求时间
responseTimenumber响应时间
domReadyTimenumberdom 解析
loadTimenumber页面完全加载时间

2、上报数据



前端错误大致分为:js 运行错误,资源加载错误,请求接口错误


请求数据


页面相关数据



1、如何收集 js 运行错误



这里是通过 window.onerror 监听全局错误来实现的


收集到关键的几个信息,如下代码里解释



// 监听全局错误
window.onerror = (msg, url, lineNo, columnNo, error) => {
this.captureError({
type: "js",
subType: "onerror",
msg, // 错误信息
url, // 报错的文件地址
lineNo, // 错误行号
columnNo, // 错误列号
stack: error?.stack || "", // 错误堆栈信息
time: new Date().getTime(),
});
return true; // 阻止默认行为
};


因为onerror无法收集到promise报的错误,这里特殊化处理下



// 监听Promise错误
this.unhandledRejectionListener = (event) => {
this.captureError({
type: "js",
subType: "promise",
msg: event.reason?.message || "Promise Error",
stack: event.reason?.stack || "",
time: new Date().getTime(),
});
};
window.addEventListener("unhandledrejection", this.unhandledRejectionListener);
// ps:记得页面组件销毁时,注销掉当前的事件监听

2、如何收集资源加载错误



这里是通过window.addEventListener('error', ...)监听资源加载错误来实现的


不过需要过滤掉上面已经监听的 js 错误,避免重复上报



// 监听资源加载错误
this.resourceErrorListener = (event) => {
// 过滤JS错误,因为JS错误已经被window.onerror捕获
if (event.target !== window) {
this.captureError({
type: "resource",
subType: event.target.tagName.toLowerCase(),
url: event.target.src || event.target.href || "",
msg: `资源加载失败: ${event.target.tagName}`,
time: new Date().getTime(),
});
}
};
window.addEventListener("error", this.resourceErrorListener, true); // 使用捕获模式

3、如何收集请求异常错误和请求基础数据



通过监听AJAX请求,监听Fetch请求,收集错误。具体错误包含:请求自身错误事件,请求超时事件,非成功状态码的请求,以及成功状态码请求(用于后续性能分析)




  1. 监听AJAX请求


  /**
* 监控XMLHttpRequest请求
*/

monitorXHR() {
const originalXHR = window.XMLHttpRequest;
const _this = this;

window.XMLHttpRequest = function () {
const xhr = new originalXHR();
const originalOpen = xhr.open;
const originalSend = xhr.send;

// 记录请求开始时间
let startTime;
let reqUrl;
let reqMethod;

xhr.open = function (method, url, ...args) {
reqUrl = url;
reqMethod = method;
return originalOpen.apply(this, [method, url, ...args]);
};

xhr.send = function (data) {
startTime = new Date().getTime();

// 添加错误事件监听
xhr.addEventListener("error", function () {
const duration = new Date().getTime() - startTime;

// 记录请求信息
_this.captureRequest({
type: "xhr",
url: reqUrl,
method: reqMethod || "GET",
duration,
status: 0,
success: false,
time: new Date().getTime(),
});

// 记录错误信息
_this.captureError({
type: "js",
subType: "xhr",
msg: `XHR请求错误: ${reqUrl}`,
url: reqUrl,
stack: "",
time: new Date().getTime(),
});
});

// 添加超时事件监听
xhr.addEventListener("timeout", function () {
const duration = new Date().getTime() - startTime;

// 记录请求信息
_this.captureRequest({
type: "xhr",
url: reqUrl,
method: reqMethod || "GET",
duration,
status: 0,
success: false,
time: new Date().getTime(),
});

// 记录错误信息
_this.captureError({
type: "js",
subType: "xhr",
msg: `XHR请求超时: ${reqUrl}`,
url: reqUrl,
stack: "",
time: new Date().getTime(),
});
});

xhr.addEventListener("loadend", function () {
const duration = new Date().getTime() - startTime;
const status = xhr.status;
const success = status >= 200 && status < 300;

_this.captureRequest({
type: "xhr",
url: reqUrl,
method: reqMethod || "GET",
duration,
status,
success,
time: new Date().getTime(),
});

// 对于HTTP错误状态码,也捕获为错误
if (!success) {
_this.captureError({
type: "js",
subType: "xhr",
msg: `XHR请求失败: 状态码 ${status}`,
url: reqUrl,
stack: "",
time: new Date().getTime(),
});
}
});

return originalSend.apply(this, arguments);
};

return xhr;
};
}


  1. 监听Fetch请求


  /**
* 监控Fetch请求
*/

monitorFetch() {
const originalFetch = window.fetch;
const _this = this;

window.fetch = function (input, init) {
const startTime = new Date().getTime();
const url = typeof input === "string" ? input : input.url;
const method = init?.method || (input instanceof Request ? input.method : "GET");

return originalFetch
.apply(this, arguments)
.then((response) => {
const duration = new Date().getTime() - startTime;
const status = response.status;
const success = response.ok;

_this.captureRequest({
type: "fetch",
url,
method,
duration,
status,
success,
time: new Date().getTime(),
});

return response;
})
.catch((error) => {
const duration = new Date().getTime() - startTime;

_this.captureRequest({
type: "fetch",
url,
method,
duration,
status: 0,
success: false,
time: new Date().getTime(),
});

// 记录错误信息
_this.captureError({
type: "js",
subType: "fetch",
msg: error.message || "Fetch Error",
url,
stack: error.stack || "",
time: new Date().getTime(),
});

throw error;
});
};
}

4. 上报页面数据



案例中,使用是 vue 框架,页面上报方法,是放到路由守卫中进行调用



  reportPage(info = {}) {
const pageInfo = { ... }
if (window.performance) {
const performanceInfo = {}
Object.assign(pageInfo, performanceInfo);
}
// 发送页面信息
this.send("/api/pages/create", pageInfo);
}

// vue 部分代码
router.afterEach((to, from) => {
// 获取全局monitor实例
const monitor = appInstance.config.globalProperties.$monitor;

if (monitor) {
// 手动上报页面访问
monitor.reportPage();
}
});


传统的页面,可以在 window.onload 中进行上报



5. 上报时机



  1. 定时批量上报:增加一个队列,放置 js 错误数据,请求数据。页面的数据因为不是很多,采用立即上报;

  2. 传统的 ajax\fench 请求,页面卸载请求会丢失。这里采用navigator.sendBeacon发送,如果浏览器不支持,则采用图片请求的方式发送数据。


/**
* 发送数据到服务器
* @param {string} path API路径
* @param {Object} data 数据
*/

send(path, data) {
// 如果没有baseURL则不发送
if (!this.baseURL) return;

// 使用Beacon API发送,避免页面卸载时丢失数据
if (navigator.sendBeacon) {
const fullURL = this.baseURL + path;
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
navigator.sendBeacon(fullURL, blob);
return;
}

// 后备方案:使用图片请求
const img = new Image();
img.src = `${this.baseURL}${path}?data=${encodeURIComponent(JSON.stringify(data))}&t=${new Date().getTime()}`;
}

3、分析数据



这是整个方案中比较难的部分,如何运用基础数据来分析出有价值的东西。以下是我思考几个方向



js 错误分析



  1. 内置一些常见 js 错误分类标准,根据错误信息匹配得出错误原因


语法错误(SyntaxError):

原因:代码书写不符合 JavaScript 语法规则。

示例:let x = "123"; 缺少分号。

解决方法:检查并修正代码中的语法错误,例如确保所有语句都正确结束,括号和引号正确匹配等。

类型错误(TypeError):

原因:变量或参数不是预期的类型,或者尝试对未定义或 null 的值进行操作。

2. 接入大模型,提供文件内容和报错信息,让 ai 给出分析原因


请求分析



  • 请求时间超过 1s 请求有哪些

  • 每个页面有多少个请求

  • 重复请求有哪些

  • 请求异常有哪些


页面分析



  • 首屏加载时间

  • 哪个页面加载时间最长

  • 哪个用户访问了哪些页面

  • pv/uv


4、解决系统问题



图表可视化展示
每天早上 9 点统计,当前存在的问题错误,短信,邮件,电话告警开发人员
灰度版本上线后,监控 24 小时,错误数量,页面性能情况,超过一定值,自动清除灰度版本测试的用户信息
给错误打上分类标签,增加错误状态【待处理】、以及错误分析指导意见。开发人员通过指导意见快速解决问题,修改错误状态为【已完成】



5、总结



有点惭愧,本人目前待过的公司,还没有实际的前端监控项目落地。对于具体如何使用,解决现实中问题,也欢迎大家给出分享案例。


这里更多是给大家一个抛砖引玉的作用。像成熟的页面性能分析产品:百度统计
网上提到成熟前端监控产品:sentry,目前还没有来得急学习,后续有时间写一篇入门学习指南


文章中案例代码:gitee.com/banmaxiaoba…



作者:东坡白菜
来源:juejin.cn/post/7519074019620159523
收起阅读 »

用dayjs解析时间戳,我被提了bug

web
引言 前几天开发中突然接到测试提的一个 Bug,说我的时间组件显示异常。 我很诧异,这里初始化数据是后端返回的,我什么也没改,这bug提给我干啥。我去问后端:“这数据是不是有问题?”。后端答:“没问题啊,我们一直都是这么返回的时间戳,其他人用也没报错。” 于...
继续阅读 »

引言


前几天开发中突然接到测试提的一个 Bug,说我的时间组件显示异常。



我很诧异,这里初始化数据是后端返回的,我什么也没改,这bug提给我干啥。我去问后端:“这数据是不是有问题?”。后端答:“没问题啊,我们一直都是这么返回的时间戳,其他人用也没报错。”


于是,对比生产环境数据,我终于找到了问题根源:后端时间戳的类型,从 Number 静悄悄地变成了 String。


Bug原因


问题的原因,肯定就出现在时间数据解析上了,代码中,我统一用的dayjs做的时间解析。


如图,对时间戳的解析我都是这么写的


const time = dayjs(res.endTime).format('YYYY-MM-DD HH:mm:ss')

于是,我分别试了两种数据类型的解析方式:



  • 字符型


dayjs('175008959900').format('YYYY-MM-DD hh:mm:ss') // 1975-07-19 01:35:59


  • 数值型


dayjs(Number('175008959900')).format('YYYY-MM-DD HH:mm:ss') // 2025-07-17 06:59:59

看来,问题原因显而易见了:


由于后端返回的是字符串类型'175008959900'dayjs() 在处理字符串时,会尝试按“常见的日期字符串格式”进行解析(如 YYYY-MM-DDYYYYMMDD 等),并不会自动识别为时间戳。所以它不会把这个字符串当作毫秒时间戳来解析,而是直接失败(解析成无效日期),但 dayjs 会退化为 Unix epoch(1970 年)或给出错误结果,最终导致返回的是错误的时间。


如何避免此类问题


同dayjs一样,原生的 new Date() 在解析时间戳时也存在类似的问题,因此,不管是 Date 还是 dayjs,一律对后端返回的时间戳 Number(input) 兜底处理,永远不要信任它传的是数字还是字符串:


const ts = Number(res.endTime);
const date = new Date(ts);

思考


其实出现这个问题,除了后端更改时间戳类型,也在于我没有充分理解“时间戳”的含义。我一直以为时间戳就是一段字符或一段数字,因此,从来没有想过做任何兜底处理。那么,什么是时间戳?


时间戳(Timestamp) 是一种用来表示时间的数字,通常表示从某个“起点时刻”到某个指定时间之间所经过的时间长度。这个“起点”大多数情况下是 1970 年 1 月 1 日 00:00:00 UTC(Unix 纪元)


常见时间戳类型:


类型单位示例值说明
Unix 时间戳(秒)1750089599常见于后端接口、数据库存储
毫秒时间戳毫秒1750089599000JavaScript 常用,Date.now()



时间戳的意义:



  • 它是一个 绝对时间的数字化表示,可以跨语言、跨平台统一理解;

  • 更容易做计算:两个时间戳相减就能得到毫秒差值(时间间隔);

  • 更紧凑:比如比字符串 "2025-07-17 06:59:59" 更短,处理性能更高。




在 JavaScript 中的使用:


console.log(Date.now()); // 比如:1714729530000

// 将时间戳转为日期
console.log(new Date(1750089599000)); // Thu Jul 17 2025 06:59:59 GMT+0800



作者:石小石Orz
来源:juejin.cn/post/7499730881830125568
收起阅读 »

Vue 列表截断组件:vue-truncate-list

web
vue-truncate-list , 点击查看 demo 在前端开发中,列表展示是最常见的需求之一。但当列表内容过多时,如何优雅地处理长列表展示成为了一个挑战。今天要介绍的 vue-truncate-list 组件,正是为解决这一问题而生的强大工具。 组件...
继续阅读 »

vue-truncate-list , 点击查看 demo


在前端开发中,列表展示是最常见的需求之一。但当列表内容过多时,如何优雅地处理长列表展示成为了一个挑战。今天要介绍的 vue-truncate-list 组件,正是为解决这一问题而生的强大工具。


image.png


组件简介


vue-truncate-list 是一个灵活的 Vue 组件,同时支持 Vue 2 和 Vue 3 框架,专为移动端和桌面端设计。它的核心能力是实现列表的智能截断,并支持完全自定义的截断器渲染。无论是需要展示商品列表、评论列表还是其他类型的内容列表,该组件都能帮助你轻松实现优雅的截断效果,提升用户体验。


核心功能亮点


1. 自动列表截断


组件能够动态检测列表内容,自动隐藏溢出的列表项,无需手动计算高度或数量,极大简化了长列表处理的逻辑。


2. 自定义截断器


提供完全自由的截断器 UI 渲染能力。你可以根据项目风格自定义截断显示的内容,例如常见的 +3 more 样式,或者更复杂的交互按钮。


3. 响应式设计


内置响应式机制,能够根据容器尺寸自动调整截断策略,在手机、平板、桌面等不同设备上都能呈现最佳效果。


4. 可扩展列表


支持展开 / 折叠功能,用户可以通过点击截断器来查看完整列表内容,适用于需要展示部分内容但保留查看全部选项的场景。


安装指南


通过 npm 安装


npm install @twheeljs/vue-truncate-list

通过 yarn 安装


yarn add @twheeljs/vue-truncate-list

使用示例


基础用法


下面是一个基础的列表截断示例,通过 renderTruncator 自定义截断器的显示:


<template>
<TruncateList
:renderTruncator="({ hiddenItemsCount }) => h('div', { class: 'listItem' }, `+${hiddenItemsCount}`)"
>

<div class="listItem">Item 1</div>
<div class="listItem">Item 2</div>
<div class="listItem">Item 3</div>
<!-- 更多列表项... -->
</TruncateList>

</template>
<script>
import { h } from 'vue'
import TruncateList from '@twheeljs/vue-truncate-list'
export default {
components: {
TruncateList
}
}
</script>


在这个示例中,我们通过 h 函数创建了一个简单的截断器,显示隐藏项的数量。


可扩展列表用法


更复杂的可展开 / 折叠列表示例:


<template>
<TruncateList
:class="['list', 'expandable', expanded ? 'expanded' : '']"
:alwaysShowTruncator="true"
:renderTruncator="({ hiddenItemsCount, truncate }) => {
if (hiddenItemsCount > 0) {
return h(
'button',
{
class: 'expandButton',
onClick: () => {
handleExpand();
// 重要:使用 nextTick 确保布局重计算后调用 truncate
nextTick(() => {
truncate();
})
}
},
`${hiddenItemsCount} more...`
);
} else {
return h(
'button',
{
class: 'expandButton',
onClick: handleCollapse
},
'hide'
);
}
}"

>

<div class="listItem">foo</div>
<!-- 更多列表项... -->
<div class="listItem">thud</div>
</TruncateList>

</template>
<script>
import { h, ref, nextTick } from 'vue'
import TruncateList from '@twheeljs/vue-truncate-list'
export default {
components: {
TruncateList
},
setup() {
const expanded = ref(false);
const handleExpand = () => {
expanded.value = true;
}
const handleCollapse = () => {
expanded.value = false;
}
return {
expanded,
handleExpand,
handleCollapse
}
}
}
</script>


注意事项:当设置 expanded 类时,虽然将 max-height 设置为 none,但容器高度不会立即更新,导致 ResizeObserver 不会触发。因此,需要在 nextTick 中手动调用 truncate() 方法来确保布局重新计算。


API 参考


Props


名称类型默认值描述
renderTruncator({ hiddenItemsCount, truncate }) => stringVNode用于渲染截断器 UI 的函数,接收 hiddenItemsCount(隐藏项数量)和 truncate(重新计算布局的函数)
alwaysShowTruncatorbooleanfalse是否始终显示截断器,即使没有隐藏项

贡献与开发


本地开发


# 安装依赖
npm install
# 启动开发服务器
npm run dev

结语


vue-truncate-list 组件通过简洁的 API 和强大的功能,为 Vue 开发者提供了处理长列表展示的最佳实践。无论是移动端还是桌面端应用,它都能帮助你实现优雅的列表截断效果,提升用户体验。


项目灵感来源于 maladr0it/react-truncate-list,在此表示感谢。


欢迎大家贡献代码、提交 Issues 或提出改进建议!


作者:niexia
来源:juejin.cn/post/7517107495392919578
收起阅读 »

Web Worker + OffscreenCanvas,实现真正多线程渲染体验

web
前端开发常说“JavaScript 是单线程的”,但如果你正在做动画、数据可视化、图像处理、游戏开发、或任何基于 Canvas 的复杂渲染,你一定体会过——主线程的“卡顿地狱” 。 UI 不响应、FPS 降到个位数、稍微有点计算或渲染逻辑,就能卡住整个页面。...
继续阅读 »

前端开发常说“JavaScript 是单线程的”,但如果你正在做动画、数据可视化、图像处理、游戏开发、或任何基于 Canvas 的复杂渲染,你一定体会过——主线程的“卡顿地狱”



UI 不响应、FPS 降到个位数、稍微有点计算或渲染逻辑,就能卡住整个页面。



这种时候,Web Worker + OffscreenCanvas 是你的救命稻草。它不仅能将耗时任务移出主线程,还能真正让 Canvas 的绘制多线程执行


这篇文章将带你深度理解并实践:



  • 为什么你需要 Web Worker + OffscreenCanvas?

  • 如何正确使用它们协同工作?

  • 适配浏览器的兼容性与降级方案

  • 实际场景中的优化技巧与踩坑合集




“主线程”到底卡在哪?


Canvas 的渲染过程其实包含两个部分:



  1. 逻辑计算(生成要绘制的数据,如位置、颜色、形状等)

  2. 图形绘制(通过 2D 或 WebGL API 渲染)


这两个过程在传统用法中都跑在主线程


一旦数据量一大、图形一多,你的 UI 就会被“图形更新”压得喘不过气。比如你尝试每帧绘制上千个粒子、图像变换、实时数据曲线更新时:



主线程就像个老人推着超重购物车,边推边喘,既要绘图又要处理 UI 和事件。



此时,如果我们能把逻辑计算和绘图任务拆出去,放到 Worker 中执行,主线程就能专注于 UI 响应,从而实现真正的“多线程协作”。




OffscreenCanvas 是什么?


OffscreenCanvas 是一种不依赖 DOM 的 Canvas 对象,它可以在 Worker 线程中创建和操作,拥有与普通 Canvas 几乎相同的绘图 API。


核心特性:



  • 可以在主线程或 Worker 中创建

  • 支持 2D 和 WebGL 上下文

  • 可以将普通 <canvas> 转换为 OffscreenCanvas 进行共享

  • 能通过 transferControlToOffscreen() 实现跨线程控制




Web Worker + OffscreenCanvas 工作原理




  1. 主线程中创建 <canvas> 元素

  2. 将 canvas 转为 OffscreenCanvas,并传给 Worker

  3. Worker 中接管 canvas 渲染逻辑(2D/WebGL)

  4. 主线程继续负责 UI 响应、控件交互等


这样你就实现了:



  • 主线程干净:不会因绘图阻塞 UI

  • 渲染帧率高:不受主线程任务干扰

  • 计算更快:Worker 可以专注高频计算任务




实战:用 OffscreenCanvas 实现 Worker 内渲染


我们来动手写一个最小工作示例,用 Web Worker 绘制一个不断更新的粒子系统。


主线程(main.js)


const canvas = document.querySelector('canvas')

// 支持性检测
if (canvas.transferControlToOffscreen) {
const offscreen = canvas.transferControlToOffscreen()
const worker = new Worker('render.worker.js')

// 把 OffscreenCanvas 发送给 Worker,使用 transferable objects
worker.postMessage({ canvas: offscreen }, [offscreen])
} else {
console.warn('当前浏览器不支持 OffscreenCanvas')
}

Worker 线程(render.worker.js)


self.onmessage = function (e) {
const canvas = e.data.canvas
const ctx = canvas.getContext('2d')

const particles = Array.from({ length: 1000 }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2
}))

function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height)

for (let p of particles) {
p.x += p.vx
p.y += p.vy

if (p.x < 0 || p.x > canvas.width) p.vx *= -1
if (p.y < 0 || p.y > canvas.height) p.vy *= -1

ctx.beginPath()
ctx.arc(p.x, p.y, 2, 0, 2 * Math.PI)
ctx.fillStyle = '#09f'
ctx.fill()
}

requestAnimationFrame(render)
}

render()
}



浏览器支持情况


浏览器支持 OffscreenCanvas支持 transferControlToOffscreen
Chrome✅(全功能)
Firefox✅(需开启设置)
Safari❌(尚不支持)
Edge

Safari 目前仍未完全支持,你可以做降级方案:如果不支持,就在主线程绘制。




冷知识:你以为是主线程的 requestAnimationFrame,其实在 Worker 中也能用!


在 Worker 中使用 requestAnimationFrame()?听起来离谱,但确实在 OffscreenCanvas 上启用了这个能力。


前提是你在 Worker 中用的是 OffscreenCanvas.getContext('2d' or 'webgl'),而不是 fake canvas。


所以,你可以用 Worker 实现完整的帧驱动动画系统,与主线程完全无关。




实战应用场景


✅ 实时数据可视化(如股票图、心电图)



让绘图脱离主线程,保证交互流畅性。



✅ 游戏引擎粒子系统、WebGL 场景



WebGL 渲染逻辑搬到 Worker,UI 操作丝滑不掉帧。



✅ 视频滤镜或图像处理



用 Canvas + 图像 API 进行像素级变换、裁剪、调色等任务。



✅ 后台复杂渲染任务(地图、3D 建模)



用户没感知,但主线程不会阻塞。





常见坑与优化建议


❗ 坑1:不能在 Worker 中操作 DOM


Worker 是脱离 DOM 世界的,不支持 document、window、canvas 元素。你只能使用传入的 OffscreenCanvas。


❗ 坑2:transferControlToOffscreen 只能用一次


一旦你把 canvas “交给”了 Worker,主线程就不能再操作它。就像是 canvas 被“搬家”了。


❗ 坑3:调试困难?用 console.log + postMessage 结合


Worker 的日志不一定出现在主线程控制台。建议:


console.log = (...args) => postMessage({ type: 'log', args })

主线程监听 message 然后打出日志。


✅ 优化1:帧率控制,别无限 requestAnimationFrame


在计算密集任务中,可能帧率过高导致 GPU 占用过重。可以加入 setTimeout 限制帧频。


✅ 优化2:TypedArray 数据共享


大量数据传输时,考虑用 SharedArrayBuffertransferable objects,减少拷贝。




延伸阅读:WebAssembly + Worker + OffscreenCanvas?


如果你已经把渲染任务移到 Worker 中,还可以更进一步——用 WebAssembly(如 Rust、C++)执行核心逻辑,把性能提升到极限。


这就是现代浏览器下的“性能金三角”:


WebAssembly 负责逻辑 + Worker 解耦线程 + OffscreenCanvas 渲染输出


这是很多 Web 游戏、3D 可视化平台的核心架构方式。




结语:把计算和渲染“赶出”主线程,是前端性能进化的方向


OffscreenCanvas 不只是一个新 API,它代表了一种思维方式的转变



  • 从“所有任务都塞主线程” → 到“职责分离、主线程清洁化”

  • 从“怕卡 UI” → 到“性能可控、结构合理”


如果你在做复杂动画、WebGL 图形、游戏、实时可视化,不使用 Web Worker + OffscreenCanvas,就像在用拖拉机跑 F1,注定要掉队。


拥抱现代浏览器的能力,开启真正的多线程渲染体验吧!


作者:ErpanOmer
来源:juejin.cn/post/7508968054875308043
收起阅读 »

Element 分页表格跨页多选状态保持方案(十几行代码解决)

web
问题背景 在使用 Element-Plus/Element-UI 的分页表格(或者其他表格组件)时,默认的多选功能无法跨页保持选中状态。当切换分页请求新数据后,之前选中的数据会被清空。 之前遇到这个问题搜了挺多网上的文章,看着代码又多又复杂,下面是很简单的实现...
继续阅读 »

问题背景


在使用 Element-Plus/Element-UI 的分页表格(或者其他表格组件)时,默认的多选功能无法跨页保持选中状态。当切换分页请求新数据后,之前选中的数据会被清空。

之前遇到这个问题搜了挺多网上的文章,看着代码又多又复杂,下面是很简单的实现方法,主要代码就十几行


先贴效果


20250621_165626.gif


核心思路



  1. 全局选中的ID集合:维护一个全局的全局ID集合(Set)存储所有选中的数据ID

  2. 数据同步:在操作选中取消选中全选取消全选时同步全局ID集合

  3. 状态回显:在分页变化,每次加载新数据后,如果数据(当前页的数据数组)的ID存在于全局ID集合就回显选中项,让对应的行数据选中。


关键代码解析


1. 全局状态定义


const globalSelectedIds = ref(new Set()); // 全局选中id
const isPageChanging = ref(false); // 标记是否正在切换分页

2. 请求接口数据加载与表格状态回显


const getList = async () => {
isPageChanging.value = true;
let res = await mockApi({
page: currentPage.value,
pageSize: pageSize.value,
});
tableData.value = res;
// 数据更新后回显选中状态
nextTick(() => {
tableData.value.forEach((row) => {
tableRef.value?.toggleRowSelection(
row,
globalSelectedIds.value.has(row.id)
);
});
isPageChanging.value = false;
});
};

3. 选择状态变更处理 (核心代码)


const handleSelectionChange = (selection) => {
// // 如果是请求数据后回显表格选中状态导致的变化事件,则不处理
if (isPageChanging.value) return;
const currentPageIds = tableData.value.map((row) => row.id);
// 先移除当前页所有ID
currentPageIds.forEach((id) => globalSelectedIds.value.delete(id));
// 再添加当前选中的ID
selection.forEach((row) => globalSelectedIds.value.add(row.id));
};

4. 从全局移除选中项(可选,仅演示点击标签删除选中数据使用)


const removeSelected = (id) => {
globalSelectedIds.value.delete(id);
const row = tableData.value.find((row) => row.id === id);
row && tableRef.value?.toggleRowSelection(row, false);
};

完整实现方案代码 (vue3 + element plus)


<template>
<div>
<!-- 表格 -->
<el-table
:data="tableData"
@selection-change="handleSelectionChange"
ref="tableRef"
border
>

<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="姓名" />
</el-table>
<el-pagination
class="mt-4"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="100"
layout="total, sizes, prev, pager, next"
@size-change="handlePageSizeChange"
@current-change="handlePageChange"
/>

<!-- 展示全局选中的数据id -->
<div>
<h4>全局选中数据 ({{ globalSelectedIds.size }}个)</h4>
<div v-if="globalSelectedIds.size > 0">
<el-tag
v-for="id in Array.from(globalSelectedIds)"
:key="id"
closable
@close="removeSelected(id)"
class="mr-2 mb-2"
>

ID: {{ id }}
</el-tag>
</div>
</div>
</div>
</template>

<script setup>
import { ref, onMounted, nextTick } from "vue";

const currentPage = ref(1);
const pageSize = ref(5);
const tableRef = ref();
const tableData = ref([]);
const globalSelectedIds = ref(new Set()); // 全局选中id
const isPageChanging = ref(false); // 标记是否正在切换分页

// 模拟从接口获取数据
const mockApi = ({ page, pageSize }) => {
const data = [];
const startIndex = (page - 1) * pageSize;
for (let i = 1; i <= pageSize; i++) {
const id = startIndex + i;
data.push({
id: id,
name: `用户${id}`,
});
}
return new Promise((resolve) => {
setTimeout(() => {
resolve(data);
}, 100);
});
};

// 获取列表数据
const getList = async () => {
isPageChanging.value = true;
let res = await mockApi({
page: currentPage.value,
pageSize: pageSize.value,
});
tableData.value = res;
// 数据更新后回显选中状态
nextTick(() => {
tableData.value.forEach((row) => {
tableRef.value?.toggleRowSelection(
row,
globalSelectedIds.value.has(row.id)
);
});
isPageChanging.value = false;
});
};

// 统一处理表格选择变化
const handleSelectionChange = (selection) => {
// 如果是请求数据后回显表格选中状态导致的变化事件,则不处理
if (isPageChanging.value) return;
const currentPageIds = tableData.value.map((row) => row.id);
// 先移除当前页所有ID
currentPageIds.forEach((id) => globalSelectedIds.value.delete(id));
// 再添加当前选中的ID
selection.forEach((row) => globalSelectedIds.value.add(row.id));
};

// 从全局移除选中项,如果删除的ID在当前页,需要更新表格选中状态
const removeSelected = (id) => {
globalSelectedIds.value.delete(id);
const row = tableData.value.find((row) => row.id === id);
row && tableRef.value?.toggleRowSelection(row, false);
};

// 处理分页变化
const handlePageChange = () => {
getList();
};
// 处理分页数量变化
const handlePageSizeChange = () => {
currentPage.value = 1;
getList();
};

// 请求接口获取数据
getList();
</script>

总结


这个功能的实现其实挺简单的,核心就是维护一个全局的选中 ID 集合。每次用户勾选表格时,对比当前页的数据和全局 ID 集合来更新状态;翻页或者重新加载数据时,再根据这个集合自动回显勾选状态,保证选中项不会丢失。


作者:迷途小码农_前端版
来源:juejin.cn/post/7517911614778064933
收起阅读 »

告别玄学!JavaScript的随机数终于能“听话”了!🎲

web
关注梦兽编程微信公众号,轻松摸鱼一下午。 用了十几年Math.random(),你是不是也遇到过这样的尴尬:想要在游戏里生成一个每次加载都一样的地图?想在测试中复现那个只出现一次的随机bug?面对Math.random()这个完全看心情的家伙,你只能望洋兴叹,...
继续阅读 »

关注梦兽编程微信公众号,轻松摸鱼一下午


用了十几年Math.random(),你是不是也遇到过这样的尴尬:想要在游戏里生成一个每次加载都一样的地图?想在测试中复现那个只出现一次的随机bug?面对Math.random()这个完全看心情的家伙,你只能望洋兴叹,因为它每次运行都会给你一个全新的、不可预测的数字。就像一个完全不记事的“失忆”随机数生成器。


// 每次运行都不一样,随缘得很
console.log(Math.random()); // 0.123456789
console.log(Math.random()); // 0.987654321

你无法“喂”给它一个特定的“种子”,让它沿着你预设的轨迹生成数字。这在很多需要确定性行为的场景下,简直是噩梦。


别担心!救星来了!JavaScript即将迎来一个重量级新成员——Random API提案(目前处于Stage 2阶段),它将彻底改变我们与随机数打交道的方式,带来一个革命性的特性:可复现的随机性(Reproducible Randomness)


Random API:给随机数装上“记忆”和“超能力”🚀


新的Random API的核心在于引入了“种子”(Seed)的概念。你可以给随机数生成器一个特定的种子,只要种子一样,它就能生成完全相同的随机数序列。这就像给随机数装上了“记忆”,让它每次都能按照同一个剧本表演。


创建带有种子的随机数生成器:


// 用一个特定的种子创建一个生成器,比如种子是 [42, 0, 0, 0]
const seededRandom = new Random.Seeded(new Uint8Array([42, 0, 0, 0]));

// 见证奇迹!这两行代码,无论你运行多少次,结果都是一样的!
console.log(seededRandom.random()); // 0.123456789 (每次都一样)
console.log(seededRandom.random()); // 0.987654321 (每次都一样)

请注意这里的“一样”是什么意思:如果你使用相同的种子,并且按照相同的顺序调用.random()方法,你将总是得到完全相同的随机数序列。 第一次调用总是返回同一个值,第二次调用总是返回同一个(但通常与第一次不同)值,以此类推。这并不是说每次调用.random()都会返回同一个数字,而是整个序列是可预测、可复现的。


实战演练:用确定性随机生成游戏世界!🏞


想象一下,你在开发一个沙盒游戏,每次进入游戏,地形都应该不同,但如果你保存游戏再加载,地形必须和保存时一模一样。使用Random API,这变得轻而易举:


class TerrainGenerator {
constructor(seed) {
// 用种子创建随机生成器
this.random = new Random.Seeded(seed);
this.heightMap = [];
}

generateTerrain(width, height) {
for (let y = 0; y < height; y++) {
this.heightMap[y] = [];
for (let x = 0; x < width; x++) {
// 使用带有种子的随机数生成地形高度
this.heightMap[y][x] = this.random.random() * 100;
}
}
return this.heightMap;
}

// 保存当前生成器的状态,以便之后恢复
saveState() {
return this.random.getState();
}

// 从保存的状态恢复生成器
restoreState(state) {
this.random.setState(state);
}
}

// 使用种子42生成第一块地形
const generator = new TerrainGenerator(new Uint8Array([42]));
const terrain1 = generator.generateTerrain(10, 10);

// 使用相同的种子42创建另一个生成器
const generator2 = new TerrainGenerator(new Uint8Array([42]));
const terrain2 = generator2.generateTerrain(10, 10);

// 见证奇迹!两块地形竟然完全相同!
console.log(JSON.stringify(terrain1) === JSON.stringify(terrain2)); // true

Random API的进阶超能力!🛠️


Random API的功能远不止于此,它还有一些非常实用的进阶特性:


1. 创建“子生成器”:


有时候你需要多个独立的随机数序列,但又想它们之间有某种关联(比如都源自同一个主种子)。你可以从一个父生成器派生出子生成器:


    // 创建一个父生成器
const parent = new Random.Seeded(new Uint8Array([42]));

// 创建两个子生成器,它们的种子来自父生成器
const child1 = new Random.Seeded(parent.seed());
const child2 = new Random.Seeded(parent.seed());

// 这两个子生成器会产生不同的随机数序列,但它们的生成过程是确定的
console.log(child1.random()); // 0.123456789
console.log(child2.random()); // 0.987654321

2. 序列化和恢复状态:


你不仅可以保存和恢复整个生成器,还可以保存它当前的“状态”。这意味着你可以在生成到一半的时候暂停,保存状态,之后再从这个状态继续生成,保证后续序列完全一致。这对于需要中断和恢复的随机过程非常有用。


    // 创建一个生成器
const generator = new Random.Seeded(new Uint8Array([42]));

// 生成一些数字
console.log(generator.random()); // 0.123456789
console.log(generator.random()); // 0.987654321

// 保存当前状态
const state = generator.getState();

// 继续生成更多数字
console.log(generator.random()); // 0.456789123

// 恢复之前保存的状态
generator.setState(state);

// 再次生成,你会得到和恢复状态前一样的数字!
console.log(generator.random()); // 0.456789123 (和上面一样)

更多实用场景:不止是游戏!💡


Random API的应用场景非常广泛:


1. 确定性测试:


在编写单元测试或集成测试时,你经常需要模拟随机数据。使用带有种子的随机数,你可以确保每次测试运行时都能得到相同的随机输入,从而更容易复现和调试问题。你可以临时替换掉Math.random()来实现确定性测试:


    function testWithSeededRandom() {
// 创建一个带有种子的随机生成器
const random = new Random.Seeded(new Uint8Array([42]));

// 临时替换掉 Math.random
const originalRandom = Math.random;
Math.random = random.random.bind(random);

try {
// 运行你的测试代码,里面的 Math.random 现在是确定的了
runTests();
} finally {
// 恢复原来的 Math.random
Math.random = originalRandom;
}
}

2. 程序化内容生成:


除了游戏地形,你还可以用它来生成角色属性、关卡布局、艺术图案等等,只要给定相同的种子,就能得到完全相同的结果。


    class ProceduralContentGenerator {
constructor(seed) {
this.random = new Random.Seeded(seed);
}

generateCharacter() {
const traits = ['brave', 'cunning', 'wise', 'strong', 'agile'];
const name = this.generateName();
// 使用 seededRandom 生成随机索引
const trait = traits[Math.floor(this.random.random() * traits.length)];

return { name, trait };
}

generateName() {
const prefixes = ['A', 'E', 'I', 'O', 'U'];
const suffixes = ['dor', 'lin', 'mar', 'tor', 'vin'];

// 使用 seededRandom 生成随机前后缀
const prefix = prefixes[Math.floor(this.random.random() * prefixes.length)];
const suffix = suffixes[Math.floor(this.random.random() * suffixes.length)];

return prefix + suffix;
}
}

// 使用种子42创建生成器
const generator = new ProceduralContentGenerator(new Uint8Array([42]));

// 生成一个角色
const character = generator.generateCharacter();
console.log(character); // { name: 'Ador', trait: 'brave' }

// 使用相同的种子42创建另一个生成器
const generator2 = new ProceduralContentGenerator(new Uint8Array([42]));

// 生成的角色竟然完全相同!
const character2 = generator2.generateCharacter();
console.log(character2); // { name: 'Ador', trait: 'brave' }

3. 安全随机数生成:


Random API也提供了生成密码学安全随机数的方法,这对于需要高度安全性的场景(如密钥生成)至关重要。


    // 生成一个密码学安全的随机种子
const secureSeed = Random.seed();

// 使用安全种子创建生成器
const secureRandom = new Random.Seeded(secureSeed);

// 生成密码学安全的随机数
console.log(secureRandom.random()); // 这是一个密码学安全的随机数

为什么这很重要?超越基础的意义!✨


新的Random API解决了几个长期存在的痛点:


它提供了可复现性,让你每次运行代码都能得到相同的随机数序列,这对于调试和复现bug至关重要。它保证了确定性,让你的代码在不同环境下表现一致。它支持状态管理,允许你保存和恢复随机数生成器的状态,实现更灵活的控制。当然,它也能生成密码学安全的随机数,满足高安全性的需求。


核心要点总结!🎯


新的Random API带来了可复现的随机性,让随机数不再完全失控。你可以用种子创建多个独立的随机生成器,并且能够保存和恢复它们的状态。这个API在程序化生成、测试等领域有着巨大的潜力。记住,它目前还在Stage 2阶段,但离我们越来越近了!


结语:JavaScript随机数的未来,是确定的!🚀


Random API的到来,对于需要精确控制随机行为的JavaScript开发者来说,无疑是一个游戏规则的改变者。无论你是游戏开发者、测试工程师,还是在进行任何需要可预测随机性的工作,这个API都将极大地提升你的开发效率和代码可靠性。


JavaScript随机数的未来已来,而且,它是确定的!


(注意:Random API目前处于TC39流程的Stage 2阶段,尚未在浏览器中普遍可用。你可以关注其在GitHub上的进展。)


作者:傻梦兽
来源:juejin.cn/post/7517285070798897187
收起阅读 »

Interact.js 一个轻量级拖拽库

web
Interact.js的核心优势 轻量级:仅约10KB(gzipped),不依赖其他库 多点触控支持:完美适配移动设备 高度可定制:限制区域、惯性效果、吸附功能等 简洁API:直观的语法,学习曲线平缓 现代浏览器支持:兼容所有主流浏览器 安装与引入 通过n...
继续阅读 »

Interact.js的核心优势



  1. 轻量级:仅约10KB(gzipped),不依赖其他库

  2. 多点触控支持:完美适配移动设备

  3. 高度可定制:限制区域、惯性效果、吸附功能等

  4. 简洁API:直观的语法,学习曲线平缓

  5. 现代浏览器支持:兼容所有主流浏览器


安装与引入


通过npm安装:


npm install interactjs

或使用CDN:


<script src="https://cdn.jsdelivr.net/npm/interactjs/dist/interact.min.js"></script>

基础使用:创建可拖拽元素


<div id="draggable" class="box">
拖拽我
</div>

interact('#draggable').draggable({
inertia: true, // 启用惯性效果
autoScroll: true,
listeners: {
move: dragMoveListener
}
});

function dragMoveListener(event) {
const target = event.target;
const x = (parseFloat(target.getAttribute('data-x')) || 0) + event.dx;
const y = (parseFloat(target.getAttribute('data-y')) || 0) + event.dy;

target.style.transform = `translate(${x}px, ${y}px)`;
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
}

核心API详解


1. 拖拽功能(Draggable)


interact('.draggable').draggable({
// 限制在父元素内移动
modifiers: [
interact.modifiers.restrictRect({
restriction: 'parent',
endOnly: true
})
],
// 开始拖拽时添加样式
onstart: function(event) {
event.target.classList.add('dragging');
},
// 拖拽结束
onend: function(event) {
event.target.classList.remove('dragging');
}
});

2. 调整大小(Resizable)


interact('.resizable').resizable({
edges: { left: true, right: true, bottom: true, top: true },
// 限制最小尺寸
modifiers: [
interact.modifiers.restrictSize({
min: { width: 100, height: 100 }
})
],
listeners: {
move: function(event) {
const target = event.target;
let x = parseFloat(target.getAttribute('data-x')) || 0;
let y = parseFloat(target.getAttribute('data-y')) || 0;

// 更新元素尺寸
target.style.width = event.rect.width + 'px';
target.style.height = event.rect.height + 'px';

// 调整位置(当从左侧或顶部调整时)
x += event.deltaRect.left;
y += event.deltaRect.top;

target.style.transform = `translate(${x}px, ${y}px)`;
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
}
}
});

3. 放置区域(Dropzone)


interact('.dropzone').dropzone({
accept: '.draggable', // 只接受特定元素
overlap: 0.5, // 至少重叠50%才算放置有效

ondropactivate: function(event) {
event.target.classList.add('drop-active');
},
ondragenter: function(event) {
event.target.classList.add('drop-target');
},
ondragleave: function(event) {
event.target.classList.remove('drop-target');
},
ondrop: function(event) {
// 处理放置逻辑
event.target.appendChild(event.relatedTarget);
},
ondropdeactivate: function(event) {
event.target.classList.remove('drop-active');
event.target.classList.remove('drop-target');
}
});

高级功能


1. 限制与约束


// 限制在特定区域内移动
interact.modifiers.restrict({
restriction: document.getElementById('boundary'),
elementRect: { top: 0, left: 0, bottom: 1, right: 1 },
endOnly: true
})

// 吸附到网格
interact.modifiers.snap({
targets: [
interact.snappers.grid({ x: 20, y: 20 })
],
range: Infinity,
relativePoints: [ { x: 0, y: 0 } ]
})

2. 手势支持


interact('.gesture').gesturable({
listeners: {
move: function(event) {
const target = event.target;
const scale = parseFloat(target.getAttribute('data-scale')) || 1;
const rotation = parseFloat(target.getAttribute('data-rotation')) || 0;

target.style.transform =
`rotate(${rotation + event.da}deg)
scale(${scale * (1 + event.ds)})`
;

target.setAttribute('data-scale', scale * (1 + event.ds));
target.setAttribute('data-rotation', rotation + event.da);
}
}
});

性能优化技巧



  1. 使用CSS变换而非定位:优先使用transform而非top/left

  2. 事件委托:对动态元素使用事件委托

  3. 适当限制事件频率:使用requestAnimationFrame节流事件

  4. 避免复杂选择器:在拖拽元素上使用简单类名

  5. 及时销毁实例:移除元素时调用unset()方法


// 销毁实例
const draggable = interact('#element');
// 移除拖拽功能
draggable.unset();

作者:安然dn
来源:juejin.cn/post/7515391516787261474
收起阅读 »

🧑‍🎤音乐MCP,听歌走起

web
引言 在当今AI技术飞速发展的时代呢,如何将传统应用程序与自然语言交互相结合成为一个非常有趣的技术方向呀。嗯嗯,本文将详细介绍一个基于FastMCP框架开发的智能音乐播放器呢,它能够通过自然语言指令实现音乐播放控制,为用户提供全新的交互体验哦。啊,这个项目最初...
继续阅读 »

引言


在当今AI技术飞速发展的时代呢,如何将传统应用程序与自然语言交互相结合成为一个非常有趣的技术方向呀。嗯嗯,本文将详细介绍一个基于FastMCP框架开发的智能音乐播放器呢,它能够通过自然语言指令实现音乐播放控制,为用户提供全新的交互体验哦。啊,这个项目最初支持在线音乐播放功能来着,但是呢,出于版权考虑嘛,开源版本就仅保留了本地音乐播放功能啦。


项目概述


这个音乐播放器项目采用Python语言开发呢,核心功能包括:



  1. 嗯~ 本地音乐文件的扫描与加载

  2. 多种播放模式单曲循环呀、列表循环啦、随机播放这样子)

  3. 啊~ 播放控制播放/暂停/停止/上一首/下一首

  4. 嗯嗯,播放列表管理功能

  5. 通过FastMCP框架提供自然语言接口


项目采用模块化设计哦,主要依赖pygame处理音频播放,FastMCP提供AI交互接口,整体架构非常清晰呢,易于扩展和维护的啦。


技术架构解析


1. 核心组件


项目主要包含以下几个关键组件哦:


import os.path
import requests
import re
import json
import pygame
import threading
import queue
import random


  • pygame.mixer:负责音频文件的加载播放

  • threading:实现后台播放线程呀,避免阻塞主程序

  • queue:用于线程间通信(虽然最终版本没直接使用队列啦)

  • random:支持随机播放模式嘛

  • FastMCP:提供AI工具调用接口哦


2. 全局状态管理


播放器通过一组全局变量线程事件来管理播放状态呢:


current_play_list = []  # 当前播放列表呀
current_play_mode = "single" # 播放模式啦
current_song_index = -1 # 当前歌曲索引哦

# 线程控制事件
is_playing = threading.Event() # 播放状态标志呢
is_paused = threading.Event() # 暂停状态标志呀
should_load_new_song = threading.Event() # 加载新歌曲标志啦

playback_thread = # 播放线程句柄哦

这种设计实现了播放状态UI/控制逻辑的分离呢,使得系统更加健壮可维护呀。


核心实现细节


1. 音乐播放线程


播放器的核心是一个独立的后台线程呢,负责实际的音乐播放逻辑哦:


def music_playback_thread():
global current_song_index, current_play_list, current_play_mode

# 确保mixer在线程中初始化呀
if not pygame.mixer.get_init():
pygame.mixer.init()

while True:
# 检查是否需要加载新歌曲啦
if should_load_new_song.is_set():
pygame.mixer.music.stop()
should_load_new_song.clear()

# 处理歌曲加载逻辑哦
if not current_play_list:
print("播放列表为空,无法加载新歌曲呢~")
is_playing.clear()
is_paused.clear()
continue

# 验证歌曲索引有效性呀
if not (0 <= current_song_index < len(current_play_list)):
current_song_index = 0

# 加载并播放歌曲啦
song_file_name = current_play_list[current_song_index]
song_to_play_path = os.path.join("music_file", song_file_name)

if not os.path.exists(song_to_play_path):
print(f"错误: 歌曲文件 '{song_file_name}' 未找到,跳过啦~")
continue

try:
pygame.mixer.music.load(song_to_play_path)
if not is_paused.is_set():
pygame.mixer.music.play()
print(f"正在播放 (后台): {song_file_name}哦~")
is_playing.set()
except pygame.error as e:
print(f"Pygame加载/播放错误: {e}. 可能音频文件损坏或格式不支持呢。跳过啦~")
continue

# 播放状态管理呀
if is_playing.is_set():
if pygame.mixer.music.get_busy() and not is_paused.is_set():
pygame.time.Clock().tick(10)
elif not pygame.mixer.music.get_busy() and not is_paused.is_set():
# 歌曲自然结束啦,根据模式处理下一首哦
if current_play_list:
if current_play_mode == "single":
should_load_new_song.set()
elif current_play_mode == "list":
current_song_index = (current_song_index + 1) % len(current_play_list)
should_load_new_song.set()
elif current_play_mode == "random":
current_song_index = random.randint(0, len(current_play_list) - 1)
should_load_new_song.set()
else:
is_playing.clear()
is_paused.clear()
pygame.mixer.music.stop()
elif is_paused.is_set():
pygame.time.Clock().tick(10)
else:
pygame.time.Clock().tick(100)

这个线程实现了完整的播放状态机呢,能够处理各种播放场景哦,包括正常播放呀、暂停啦、歌曲切换等等呢。


2. FastMCP工具函数


项目通过FastMCP提供了一系列可被AI调用的工具函数呢:


播放本地音乐


@mcp.tool()
def play_musics_local(song_name: str = "", play_mode: str = "single") -> str:
"""播放本地音乐呀
:param song_name: 要播放的音乐名称呢,可以留空哦,留空表示加载进来的歌曲列表为本地文件夹中的所有音乐啦
:param play_mode: 播放模式呀,可选single(单曲循环),list(列表循环),random(随机播放)哦
:return: 播放结果呢
"""

global current_play_list, current_play_mode, current_song_index, playback_thread

# 确保音乐文件夹存在哦
if not os.path.exists("music_file"):
os.makedirs("music_file")
return "本地文件夹中没有音乐文件呢,已创建文件夹 'music_file'啦~"

# 扫描音乐文件呀
music_files = [f for f in os.listdir("music_file") if f.endswith(".mp3")]
if not music_files:
return "本地文件夹中没有音乐文件呢~"

# 构建播放列表啦
play_list_temp = []
if not song_name:
play_list_temp = music_files
else:
for music_file in music_files:
if song_name.lower() in music_file.lower():
play_list_temp.append(music_file)

if not play_list_temp:
return f"未找到匹配 '{song_name}' 的本地音乐文件呢~"

current_play_list = play_list_temp
current_play_mode = play_mode

# 设置初始播放索引哦
if play_mode == "random":
current_song_index = random.randint(0, len(current_play_list) - 1)
else:
if song_name:
try:
current_song_index = next(i for i, f in enumerate(current_play_list) if song_name.lower() in f.lower())
except StopIteration:
current_song_index = 0
else:
current_song_index = 0

# 确保播放线程运行呀
if playback_thread is or not playback_thread.is_alive():
playback_thread = threading.Thread(target=music_playback_thread, daemon=True)
playback_thread.start()
print("后台播放线程已启动啦~")

# 触发播放哦
pygame.mixer.music.stop()
is_paused.clear()
is_playing.set()
should_load_new_song.set()

return f"已加载 {len(current_play_list)} 首音乐到播放列表呢。当前播放模式:{play_mode}哦。即将播放:{current_play_list[current_song_index]}呀~"

播放控制函数


@mcp.tool()
def pause_music(placeholder: str = ""):
"""暂停当前播放的音乐呀"""
global is_paused, is_playing
if pygame.mixer.music.get_busy():
pygame.mixer.music.pause()
is_paused.set()
return "音乐已暂停啦~"
elif is_paused.is_set():
return "音乐已处于暂停状态呢"
else:
return "音乐未在播放中哦,无法暂停呀"

@mcp.tool()
def unpause_music(placeholder: str = ""):
"""恢复暂停的音乐呢"""
global is_paused, is_playing
if not pygame.mixer.music.get_busy() and pygame.mixer.music.get_pos() != -1 and is_paused.is_set():
pygame.mixer.music.unpause()
is_paused.clear()
is_playing.set()
return "音乐已恢复播放啦~"
elif pygame.mixer.music.get_busy() and not is_paused.is_set():
return "音乐正在播放中呢,无需恢复哦"
else:
return "音乐未在暂停中呀,无法恢复呢"

@mcp.tool()
def stop_music(placeholder: str = ""):
"""停止音乐播放并清理资源哦"""
global is_playing, is_paused, current_song_index, should_load_new_song
pygame.mixer.music.stop()
is_playing.clear()
is_paused.clear()
should_load_new_song.clear()
current_song_index = -1
return "音乐已停止啦,程序准备好接收新的播放指令哦~"

歌曲导航函数


@mcp.tool()
def next_song(placeholder: str = "") -> str:
"""播放下一首歌曲呀"""
global current_song_index, current_play_list, is_playing, is_paused, current_play_mode, should_load_new_song

if not current_play_list:
return "播放列表为空呢,无法播放下一首哦~"

is_playing.set()
is_paused.clear()

# 从单曲循环切换到列表循环啦
if current_play_mode == "single":
current_play_mode = "list"
print("已从单曲循环模式切换到列表循环模式啦~")

# 计算下一首索引哦
if current_play_mode == "list":
current_song_index = (current_song_index + 1) % len(current_play_list)
elif current_play_mode == "random":
current_song_index = random.randint(0, len(current_play_list) - 1)

should_load_new_song.set()
return f"正在播放下一首: {current_play_list[current_song_index]}呢~"

@mcp.tool()
def previous_song(placeholder: str = "") -> str:
"""播放上一首歌曲呀"""
global current_song_index, current_play_list, is_playing, is_paused, current_play_mode, should_load_new_song

if not current_play_list:
return "播放列表为空呢,无法播放上一首哦~"

is_playing.set()
is_paused.clear()

if current_play_mode == "single":
current_play_mode = "list"
print("已从单曲循环模式切换到列表循环模式啦~")

if current_play_mode == "list":
current_song_index = (current_song_index - 1 + len(current_play_list)) % len(current_play_list)
elif current_play_mode == "random":
current_song_index = random.randint(0, len(current_play_list) - 1)

should_load_new_song.set()
return f"正在播放上一首: {current_play_list[current_song_index]}呢~"

播放列表查询


@mcp.tool()
def get_playlist(placeholder: str = "") -> str:
"""获取当前播放列表呀"""
global current_play_list, current_song_index

if not current_play_list:
return "播放列表当前为空呢~"

response_lines = ["当前播放列表中的歌曲哦:"]
for i, song_name in enumerate(current_play_list):
prefix = "-> " if i == current_song_index else " "
response_lines.append(f"{prefix}{i + 1}. {song_name}")

return "\n".join(response_lines)

部署与使用


1. 环境准备


项目依赖较少呢,只需安装以下库哦:


pip install pygame requests fastmcp

// 或者 指定阿里云的镜像源去加速下载(阿里源提供的PyPI镜像源地址)
pip install pygame requests fastmcp -i https://mirrors.aliyun.com/pypi/simple/

2. 运行程序


python play_music.py

image.png


3. 与AI助手集成



  1. 在支持AI助手的客户端中配置SSE MCP

  2. 添加MCP地址http://localhost:4567/sse

  3. 启用所有工具函数

  4. 设置工具为自动执行以获得更好体验呢


配置,模型服务我选的是大模型openRouter:


image.png


image.png


然后去配置mcp服务器,类型一定要选sse


image.png


image.png


然后保存。


image.png


4. 使用示例


image.png



  • "播放本地歌曲呀,使用随机播放模式哦"

  • "下一首啦"

  • "暂停一下嘛"

  • "继续播放呀"

  • "停止播放呢"

  • "播放歌曲xxx哦,使用单曲循环模式啦"

  • "查看当前音乐播放列表呀"


image.png


JJ的歌真好听。


image.png


作者:盏灯
来源:juejin.cn/post/7520960903743963174
收起阅读 »

前端如何检测新版本,并提示用户去刷新

web
前端如何检测新版本,并提示用户去刷新 先看效果 原理 通过轮询index.html文件的内容来计算文件的哈希值前后是否发生了变化 前端工程化的项目中,以Vue为例,webpack或vite打包通常会构建为很多的js、css文件,每次构建都会根据内容生成唯一的...
继续阅读 »

前端如何检测新版本,并提示用户去刷新


先看效果


在这里插入图片描述


原理


通过轮询index.html文件的内容来计算文件的哈希值前后是否发生了变化


前端工程化的项目中,以Vue为例,webpack或vite打包通常会构建为很多的js、css文件,每次构建都会根据内容生成唯一的哈希值。如下图所示。
在这里插入图片描述


大家可以动手试试,观察一下。


每次构建完index.html中script或link标签引用的地址发生了变化。


代码实现


以Vue+ElementPlus项目为例。在入口文件中引入此文件即可。


// check-version.ts
// 封装了storage,粗暴一点可以用 sessionStorage 代替
import { Storage } from "@/utils/storage";
import { ElLink, ElNotification, ElSpace } from "element-plus";
import { h } from "vue";
import CryptoJS from 'crypto-js';

const storage = new Storage('check-version', sessionStorage);
const APP_VERSION = 'app-version';
let notifyInstance: any;

const generateHash = (text: string): string => CryptoJS.SHA256(text).toString();

const getAppVersionHash = async () => {
const html = await fetch(`${location.origin}?t=${Date.now()}`).then(res => res.text());
const newHash = generateHash(html);
const oldHash = storage.get(APP_VERSION);
return { newHash, oldHash };
}

const checkVersion = async () => {
const { newHash, oldHash } = await getAppVersionHash()
if (oldHash !== newHash) {
if (notifyInstance) return;
notifyInstance = ElNotification({
title: '版本更新',
message: h(ElSpace, null, () => [
h('span', '检测到新版本发布!'),
h(ElLink, { type: 'primary', onClick: () => location.reload() }, () => '立即刷新')
]),
position: 'top-right',
duration: 0,
onClose: () => {
notifyInstance = null
}
})
}
}

const loopCheck = (ms: number) => {
setTimeout(async () => {
await checkVersion()
loopCheck(ms)
}, ms)
}

document.addEventListener('DOMContentLoaded', async () => {
console.log("The DOM is fully loaded and parsed.");

const { newHash } = await getAppVersionHash();
storage.set(APP_VERSION, newHash, null);

loopCheck(1000 * 30);
});

作者:不夏
来源:juejin.cn/post/7519335201505132553
收起阅读 »

fetch和axios的区别

web
1、fetch 来源与兼容性 浏览器原生提供的api,现代浏览器支持,但是IE浏览器不支持 请求与响应处理 请求体格式: 需手动设置 Content-Type,如发送 JSON 时需 JSON.stringify(data) 并添加 headers: { '...
继续阅读 »

1、fetch


来源与兼容性 浏览器原生提供的api,现代浏览器支持,但是IE浏览器不支持


请求与响应处理



  • 请求体格式: 需手动设置 Content-Type,如发送 JSON 时需 JSON.stringify(data) 并添加 headers: { 'Content-Type': 'application/json' }

  • 需要手动处理JSON解析(response.json())

  • 错误状态码(默认不抛出HTTP错误,如 404、500,需要检查response.ok)

  • cookie: 默认不带cookie,需手动配置 credentials:'include'


拦截器与全局配置



  • 无内置拦截器,需手动封装或使用第三方库实现类似功能。

  • 全局配置需自行封装(如统一添加请求头)。


错误处理 仅在网络请求失败时(如断网)触发 reject,HTTP 错误状态码(如 404)不会触发 catch


取消请求 使用 AbortController 实现取消。


上传/下载进度监控 不支持原生进度监控,需通过读取响应流实现(较复杂)。


CSRF/XSRF 防护 需手动处理


const controller = new AbortController();

fetch(url, {signal: controller.signal}).then(res => {
if (!res.ok) throw new Error("HTTP error");
return res.json();
}).catch(err => {
if (err.name === 'AbortError') console.log('Request canceled');
});

controller.abort(); // 取消请求

使用场景:



  • 对依赖体积敏感,不想引入额外依赖。

  • 请求逻辑简单,无需复杂配置或拦截器。


2、axios


来源与兼容性 第三方组件库(基于XMLHttpRequest)


请求与响应处理



  • 请求体格式: 自动根据数据类型设置 Content-Type(如对象默认转为 JSON)。

  • 自动处理JSON解析(response.data)

  • 自动将非 2xx 状态码视为错误(触发 catch

  • cookie: 默认带cookie: 自动发送同源请求的cookie


拦截器与全局配置



  • 支持 请求/响应拦截器,方便统一处理日志、认证、错误等。

  • 支持全局默认配置(如 baseURLheaders)。


错误处理 任何 HTTP 错误状态码(如 404、500)均会触发 catch


取消请求 使用 AbortController 实现取消。


上传/下载进度监控 支持 onUploadProgress 和 onDownloadProgress 回调。


CSRF/XSRF 防护 内置支持 XSRF Token 配置。


const controller = new AbortController();

axios.get(url, {signal: controller.signal}).then(res => {
console.log(res.data)
}).catch(err => {
if (axios.isCancel(err)) console.log('Request canceled');
});

controller.abort();

使用场景:



  • 需要拦截器、取消请求、超时等高级功能。

  • 项目跨浏览器和 Node.js 环境。

  • 希望简洁的 API 和自动错误处理。


作者:Ariel_jhy
来源:juejin.cn/post/7514227898023739455
收起阅读 »

9 个被低估的 CSS 特性:让前端开发更高效的秘密武器

web
在 CSS 的浩瀚宇宙中,聚光灯下的明星属性固然耀眼,但那些藏在规范角落的「小众特性」,往往才是提升开发效率的秘密武器。它们就像隐藏的工具箱,能帮我们用更少的代码实现更细腻的交互,让界面开发从繁琐走向优雅。 今天,就为大家解锁9 个被严重低估的 CSS 特性,...
继续阅读 »


在 CSS 的浩瀚宇宙中,聚光灯下的明星属性固然耀眼,但那些藏在规范角落的「小众特性」,往往才是提升开发效率的秘密武器。它们就像隐藏的工具箱,能帮我们用更少的代码实现更细腻的交互,让界面开发从繁琐走向优雅。


今天,就为大家解锁9 个被严重低估的 CSS 特性,这些宝藏属性不仅能简化日常开发流程,还能带来意想不到的惊艳效果!


1. accent-color:表单元素的样式魔法


原生复选框和单选按钮曾是前端开发者的「审美之痛」,默认样式不仅呆板,还极难定制。但accent-color的出现,彻底打破了这个僵局!


input[type="checkbox"] {  
accent-color: hotpink;
}

同样适用于input[type="radio"],只需一行代码,就能将单调的灰色方块 / 圆点变成品牌主色调,告别 JavaScript 和第三方库的复杂操作。


兼容性:主流浏览器(Chrome 86+、Firefox 75+、Safari 14+)均已支持,可放心使用!


2. caret-color:光标颜色随心定


在深色主题界面中,刺眼的黑色文本光标常常破坏整体美感。caret-color允许我们精确控制插入符颜色,让细节也能完美融入设计。


input {  care
t-color: limegreen;
}

虽然只是一个像素级的调整,但却能大幅提升用户输入时的视觉舒适度,细节之处尽显专业!


3. currentColor:颜色继承的终极利器


还在为重复定义颜色值而烦恼?currentColor堪称 CSS 中的「颜色复印机」,它能自动继承元素的字体颜色,让代码更简洁,主题切换更灵活。


button {  
color: #007bff;
border: 2px solid currentColor;
}

无论后续如何修改color值,border颜色都会自动同步,完美遵循 DRY(Don't Repeat Yourself)原则!


4. ::marker:列表符号的定制革命


过去修改列表符号,要么用background-image hack,要么手动添加标签,代码又丑又难维护。现在,::marker让我们真正掌控列表样式!


li::marker {  
color: crimson;
font-size: 1.2rem;
}

除了颜色和尺寸,部分浏览器还支持设置字体、图标等高级效果,从此告别千篇一律的小黑点!


5. :user-valid:更人性化的表单验证


:valid 和 :invalid 虽能实现表单验证,但常出现「页面刚加载就提示错误」的尴尬。 :user-valid 巧妙解决了这个问题,仅在用户交互后才触发验证反馈


input:user-valid {  
border-color: green;
}

搭配 :user-invalid 使用,既能及时提示用户,又不会过度打扰,交互体验直接拉满!


6. :placeholder-shown:捕捉输入框的「空状态」


想在用户输入前给表单来点动态效果? :placeholder-shown 可以精准识别输入框是否为空,轻松实现淡入淡出、占位符动画等创意交互。


input:placeholder-shown { 
opacity: 0.5;
}

当用户开始输入,样式自动切换,让表单引导更智能、更优雅。


7. all: unset:组件样式的「一键清零」


重置组件默认样式是开发中的常见需求,但传统的reset.css动辄几百行,代码冗余且难以维护。all: unset 只需一行代码,就能彻底移除所有默认样式(包括继承属性)。


button {  
all: unset;
}

在构建自定义按钮、导航栏等组件时,先使用all: unset「清空画布」,再按需添加样式,开发效率直接翻倍!


注意:该属性会移除所有样式,使用时需谨慎搭配自定义规则,避免「矫枉过正」。


8. inset:布局语法的终极简化


写绝对定位或固定布局时,top、right、bottom、left 四行代码总是让人抓狂?inset 提供了超简洁的简写语法!


/* 等价于 top: 0; right: 0; bottom: 0; left: 0; */inset: 0;/* 类似 padding  2 值、4 值写法 */inset: 10px 20px; /* 等价于 top: 10px; right: 20px; bottom: 10px; left: 20px; */

代码瞬间瘦身,可读性直线上升,绝对是布局党的福音!


9. text-wrap: balance:文本折行的「智能管家」


在响应式设计中,标题折行常常参差不齐,影响排版美感。text-wrap: balance 就像一位专业排版师,能自动均衡每行文本长度,让内容分布更优雅。


h1 {  text-wrap: balance;}

虽然目前浏览器支持有限(仅 Chrome 115+),但已在 Figma 等设计工具中广泛应用,未来可期!


总结


这些被低估的 CSS 特性,虽然小众,但每一个都能在特定场景中发挥巨大价值。下次开发时不妨试试,或许能发现新大陆!


互动时间:你在开发中还发现过哪些「相见恨晚」的 CSS 特性?欢迎在评论区分享,一起挖掘 CSS 的隐藏力量!


作者:大知闲闲i
来源:juejin.cn/post/7504572792357584935
收起阅读 »

我的可视化规则引擎真高可用了

web
原来有这么多时间 六月的那么一天,天气比以往时候都更凉爽,媳妇边收拾桌子,边漫不经心的对我说:你最近好像都没怎么阅读了。 正刷着新闻我,如同被一记响亮的晴空霹雳击中一般,不知所措。是了,最近几月诸事凑一起,加之两大项目接踵而至,确实有些许糟心,于是总是在空闲的...
继续阅读 »

原来有这么多时间


六月的那么一天,天气比以往时候都更凉爽,媳妇边收拾桌子,边漫不经心的对我说:你最近好像都没怎么阅读了。 正刷着新闻我,如同被一记响亮的晴空霹雳击中一般,不知所措。是了,最近几月诸事凑一起,加之两大项目接踵而至,确实有些许糟心,于是总是在空闲的时间泡在新闻里聊以解忧,再回首,隐隐有些恍如隔世之感。于是收拾好心情,翻开了躺在书架良久的整洁三步曲。也许是太久没有阅读了, 一口气,Bob大叔 Clean 系列三本都读完了,重点推荐Clear Architecture,部分章节建议重复读,比如第5部分-软件架构,可以让你有真正的提升,对代码,对编程,对软件都会有不一样的认识。


Clean Code 次之,基本写了一些常见的规约,大部分也是大家熟知,数据结构与面向对象的看法,是少有的让我 哇喔的点,如果真是在码路上摸跋滚打过的,快速翻阅即可。

The Clean Coder 对个人而言可能作用最小。 确实写人最难,无法聚焦。讲了很多,但是感觉都不深入,或者作者是在写自己,很难映射到自己身上。 当然,第二章说不,与第14章辅导,学徒与技艺,还是值得一看的。


阅读技术书之余,又战战兢兢的翻开了敬畏已久的朱生豪先生翻译的《莎士比亚》, 不看则已,因为看了根本停不来。其华丽的辞职,幽默的比喻,真的会让人情不自禁的开怀朗读起来。


。。。


再看从6月到现在,电子书阅读时间超过120小时,平均每天原来有1个多小时的空余时间,简直超乎想像。


 


 看了整洁架构一书,就想写代码,于是有了这篇文章。


 


灵魂拷问 - 宕机怎么办


为了解决系统中大量规则配置的问题,与同事一起构建了一个可视化表达式引擎 RuleLink《非全自研可视化表达引擎-RuleLinK》,解决了公司内部几乎所有配置问题。尤为重要的一点,所有配置业务同学即可自助完成。随着业务深入又增加了一些自定义函数,增加了公式及计算功能,增加组件无缝嵌入其他业务...我一度以为现在的功能已经可以满足绝大部分场景了。真到Wsin强同学说了一句:业财项目是深度依赖RuleLink的,流水打标,关联科目。。。我知道他看了数据,10分RuleLink执行了5万+次。这也就意味着,如果RuleLink宕机了,业财服务也就宕机了,也就意味着巨大的事故。这却是是一个问题,公司业务确实属于非常低频,架不住财务数据这么多。如果才能让RuleLink更稳定成了当前的首要问题。


 


 


高可用VS少依赖


要提升服务的可用性,增加服务的实例是最快的方式。 但是考虑到我们自己的业务属性,以及业财只是在每天固定的几个时间点短时高频调用。 增加节点似乎不是最经济的方式。看 Bob大叔的《Clear Architecture》书中,对架构的稳定性有这样一个公式:不稳定性,I=Fan-out/(Fan-in+Fan-out)


Fan-in:入向依赖,这个指标指代了组件外部类依赖于组件内部类的数量。


Fan-out:出向依赖,这个指标指代了组件内部类依赖于组件外部类的数量。


这个想法,对于各个微服务的稳定性同时适用,少一个外部依赖,稳定性就增加一些。站在业财系统来说,如果我能减少调用次数,其稳定性就在提升,批量接口可以一定程度上减少依赖,但并未解决根本问题。那么调用次数减少到极限会是什么样的呢?答案是:一次。 如果规则不变的话,我只需要启动时加载远程规则,并在本地容器执行规则的解析。如果有变动,我们只需要监听变化即可。这样极大减少了业财对RuleLink的依赖,也不用增RuleLink的节点。实际上大部分配置中心都是这样的设计的,比如apollo,nacos。 当然,本文的实现方式也有非常多借鉴(copy)了apollo的思想与实现。


服务端设计


模型比较比较简单,应用订阅场景,场景及其规则变化时,或者订阅关系变化时,生成应用与场景变更记录。类似于生成者-消费都模型,使用DB做存储。



 



 


”推送”原理


整体逻辑参考apollo实现方式。 服务端启动后 创建Bean ReleaseMessageScanner 注入变更监听器 NotificationController。

ReleaseMessageScanner 一个线程定时扫码变更,如果有变化 通知到所有监听器。


NotificationController在得知有配置发布后是如何通知到客户端的呢?

实现方式如下:

1,客户端会发起一个Http请求到RuleLink的接口,NotificationController

2,NotificationController不会立即返回结果,而是通过Spring DeferredResult把请求挂起

3,如果在60秒内没有该客户端关心的配置发布,那么会返回Http状态码304给客户端

4,如果有该客户端关心的配置发布,NotificationController会调用DeferredResult的setResult方法,传入有变化的场景列表,同时该请求会立即返回。客户端从返回的结果中获取到有变化的场景后,会直接更新缓存中场景,并更新刷新时间


ReleaseMessageScanner 比较简单,如下。NotificationController 代码也简单,就是收到更新消息,setResult返回(如果有请求正在等待的话)


public class ReleaseMessageScanner implements InitializingBean {
private static final Logger logger = LoggerFactory.getLogger(ReleaseMessageScanner.class);

private final AppSceneChangeLogRepository changeLogRepository;
private int databaseScanInterval;
private final List<ReleaseMessageListener> listeners;
private final ScheduledExecutorService executorService;

public ReleaseMessageScanner(final AppSceneChangeLogRepository changeLogRepository) {
this.changeLogRepository = changeLogRepository;
databaseScanInterval = 5000;
listeners = Lists.newCopyOnWriteArrayList();
executorService = Executors.newScheduledThreadPool(1, RuleThreadFactory
.create("ReleaseMessageScanner", true));
}

@Override
public void afterPropertiesSet() throws Exception {
executorService.scheduleWithFixedDelay(() -> {
try {
scanMessages();
} catch (Throwable ex) {
logger.error("Scan and send message failed", ex);
} finally {

}
}, databaseScanInterval, databaseScanInterval, TimeUnit.MILLISECONDS);

}

/**
* add message listeners for release message
* @param listener
*/

public void addMessageListener(ReleaseMessageListener listener) {
if (!listeners.contains(listener)) {
listeners.add(listener);
}
}

/**
* Scan messages, continue scanning until there is no more messages
*/

private void scanMessages() {
boolean hasMoreMessages = true;
while (hasMoreMessages && !Thread.currentThread().isInterrupted()) {
hasMoreMessages = scanAndSendMessages();
}
}

/**
* scan messages and send
*
* @return whether there are more messages
*/

private boolean scanAndSendMessages() {
//current batch is 500
List<AppSceneChangeLogEntity> releaseMessages =
changeLogRepository.findUnSyncAppList();
if (CollectionUtils.isEmpty(releaseMessages)) {
return false;
}
fireMessageScanned(releaseMessages);

return false;
}


/**
* Notify listeners with messages loaded
* @param messages
*/

private void fireMessageScanned(Iterable<AppSceneChangeLogEntity> messages) {
for (AppSceneChangeLogEntity message : messages) {
for (ReleaseMessageListener listener : listeners) {
try {
listener.handleMessage(message.getAppId(), "");
} catch (Throwable ex) {
logger.error("Failed to invoke message listener {}", listener.getClass(), ex);
}
}
}
}
}

 


 


客户端设计



上图简要描述了客户端的实现原理:



  • 客户端和服务端保持了一个长连接,从而能第一时间获得配置更新的推送。(通过Http Long Polling实现)

  • 客户端还会定时从RuleLink配置中心服务端拉取应用的最新配置。



    •   这是一个fallback机制,为了防止推送机制失效导致配置不更新

    •   客户端定时拉取会上报本地版本,所以一般情况下,对于定时拉取的操作,服务端都会返回304 - Not Modified

    •   定时频率默认为每5分钟拉取一次,客户端也可以通过在运行时指定配置项: rule.refreshInterval来覆盖,单位为分钟。



  • 客户端从RuleLink配置中心服务端获取到应用的最新配置后,会写入内存保存到SceneHolder中,

  • 可以通过RuleLinkMonitor 查看client 配置刷新时间,以及内存中的规则是否远端相同


 


客户端工程


客户端以starter的形式,通过注解EnableRuleLinkClient 开始初始化。


 1 /**
2 * @author JJ
3 */

4 @Retention(RetentionPolicy.RUNTIME)
5 @Target(ElementType.TYPE)
6 @Documented
7 @Import({EnableRuleLinkClientImportSelector.class})
8 public @interface EnableRuleLinkClient {
9
10 /**
11 * The order of the client config, default is {@link Ordered#LOWEST_PRECEDENCE}, which is Integer.MAX_VALUE.
12 * @return
13 */

14 int order() default Ordered.LOWEST_PRECEDENCE;
15 }

 


 



 


在最需求的地方应用起来


花了大概3个周的业余时间,搭建了client工程,经过一番斗争后,决定直接用到了最迫切的项目 - 业财。当然,也做了完全准备,可以随时切换到RPC版本。 得益于DeferredResult的应用,变更总会在60s内同步,也有兜底方案:每300s主动查询变更,即便是启动后RuleLink宕机了,也不影响其运行。这样的准备之下,上线后几乎没有任何波澜。当然,也就没有人会担心宕机了。这真可以算得上一次愉快的编程之旅。


 


成为一名优秀的程序员!


作者:jijunjian
来源:juejin.cn/post/7411168576433193001
收起阅读 »

重构pdfjs阅读器viewer.html,实现双PDF对比(不依赖iframe、embed)

web
针对pdfjs做二次开发的xshen-pdf仍在继续开发着,不过离第一个可用的版本发布还有一段时间。我正在按部就班的一步步向前推进中。 过去的一个星期,我将pdfjs的viewer.html里面的代码完整地梳理了一遍。并将里面的代码核心的代码提取出来,重新封装...
继续阅读 »

针对pdfjs做二次开发的xshen-pdf仍在继续开发着,不过离第一个可用的版本发布还有一段时间。我正在按部就班的一步步向前推进中。


过去的一个星期,我将pdfjs的viewer.html里面的代码完整地梳理了一遍。并将里面的代码核心的代码提取出来,重新封装了一遍。并在国庆前,初步的实现了目标——直接通过div就可以渲染出一个PDF阅读器,而无需再使用iframe或者embed标签来嵌套引入pdf阅读器。


通常情况下,想要使用pdfjs提供的阅读器,就必须通过iframe、embed标签,或者window.open之类的方式打开一个独立的pdfjs的html页面(也就是viewer.html),这种方式实际上不太友好。它有着诸多问题,比如不太好控制、难以和其它组件联动、难以纳入一个页面的全局管理。因此我希望能够改进这一点,基于pdfjs,将PDF阅读器改造的像echarts那样易于配置和使用。开发者只需要声明一个div,然后调用初始化方法将这个div初始化成一个PDF阅读器即可。


在经过解构和重新封装之后,现在xshen-pdf能够实现这个功能了。想要在页面上渲染出一个或者多个PDF阅读器,只需要寥寥几行代码就够了。在html中,只需要声明一个或多个dom元素就可以了:


<body>
<div id="container" style="width: 48%; display: inline-block;height: 1000px;"></div>
<div id="container2" style="width: 48%; display: inline-block;height: 1000px;"></div>
</body>

在这里,我声明了两个容器,因为我想展示一下基于xshen-pdf实现的双PDF对比功能。在声明完容器之后,再使用两行js代码,初始化一下这两个阅读器就可以了:


import { Seren } from "./viewer/seren.js";

Seren.init('container',{ firstPage : 1});
Seren.init('container2',{ firstPage : 1});

渲染出来的结果如下所示:


GIF 2024-10-1 11-20-53.gif


通过上面的dom元素可以看到,id为container和container2的两个容器都被成功渲染出来了。


解构viewer.html和重新封装里面的组件,并不算一件容易的事。因为viewer.html默认是个全局的、唯一的PDF阅读器,因此里面很多地方都在使用全局变量,很多地方都在使用window对象、处理window事件,很多地方都使用了“宏”。简而言之就是一句话,默认提供的阅读器和全局环境耦合的地方较多。而我的目标是要将pdfjs变成一个能够直接引进html页面中的组件,开发者想怎么声明,就怎么声明。想怎么控制PDF阅读器,就怎么控制PDF阅读器。因此这些地方的代码全部都要拆掉重写,才能让PDF阅读器由一个必须通过嵌入才能操作的全局对象,变成一个可通过API自由操控的局部对象。因此,解耦就是我要做的首要工作。


全局参数AppOptions耦合,其实是一个比较麻烦的点。如果有的开发者对PDF阅读器有一些了解,那么他是可以通过AppOptions来修改PDF阅读器的参数。但是官方似乎并没有提供一个比较正式的API让开发者来修改这些参数。因此开发者想要修改一些参数,只能通过一些“不那么正规”的方式来达成自己的目的。这种方式自然也是有弊端的。这样做不好的地方就在于日后难以维护、升级pdfjs版本的时候可能会产生问题。


因为pdfjs提供的阅读器默认情况下只有一个,因此它大量使用了全局变量来对阅读器进行管理。将PDF阅读器进行局部化处理之后——即开发者通过函数来创建一个个PDF阅读器实例,这么做就不行了。当我们声明了多个阅读器的时候,每个阅读器读取的pdf文件、配置的功能、批注、权限可能是完全不同的。因此很多配置项应该是只能局部生效,而不能全局生效。但是仅仅有局部的配置项也是不够的。对于开发者创建的若干pdf阅读器,有时候也是需要全局统一控制的,例如白天/夜晚模式、是否分页加载等。因此,除了针对单个阅读器的配置项管理,还需要全局的配置项管理。在xshen-pdf里我分别定义了两个类,一个是ViewerOptions,针对单个阅读器生效。一个是SerenOptions,针对多个阅读器生效。通过这两个选项,开发者就能够很方便的配置和管理好自己的一个或多个PDF阅读器了。


作者:爱冥想的咕
来源:juejin.cn/post/7420336326992543779
收起阅读 »

uni-app小程序分包中使用 Echarts, 并在分包里加载依赖

web
这篇笔记主要记录uni-app小程序, 在分包中使用Echarts,并在分包里加载Echarts依赖,减少主包的大小,提升小程序的加载速度. 在分包中使用Echarts 在分包里加载Echarts依赖 先看下效果,图表正常渲染,主包大小小于1.5M,主包存...
继续阅读 »

这篇笔记主要记录uni-app小程序, 在分包中使用Echarts,并在分包里加载Echarts依赖,减少主包的大小,提升小程序的加载速度.



  1. 在分包中使用Echarts

  2. 在分包里加载Echarts依赖


先看下效果,图表正常渲染,主包大小小于1.5M,主包存在仅被其他分包依赖的JS文件也通过✅


image.png


在分包中使用Echarts

我们要用的Echarts插件是lime-chart, 我们看下文档, 插件下载页面下面的描述


image.png


我们是Vue3小程序,先下载插件,下载好插件后,插件会安装在项目根目录下的uni_modules文件夹,下载插件截图
image.png


根据文档,我们require相对引入echarts.min文件,我们要渲染的图表数据来自接口



  1. 在onMounted里请求接口数据

  2. 接口数据返回后调用渲染图表的方法

  3. 渲染图表要用setTimeout,确保渲染图表时,组件的节点已经被渲染到页面上


<script setup>
import { onMounted } from 'vue'
// 根据项目目录相对引入
const echarts = require("../../uni_modules/lime-echart/static/echarts.min.js");

const getData = async () => {
const chartData = await getChartData() // 获取图表数据
setTimeout(() => {
renderChart() // 数据返回后渲染图表
}, 500)
}

onMounted(() => {
getData()
})
</script>

这样就能渲染了
image.png


刚开始我没渲染出来,对比文档,发现我没用setTimeout,用了之后就渲染出来了,看起来一切正常,但是,我们发布的时候,提示


image.png


主包超过1.5M, 主包有只被其他子包依赖的JS文件,都没通过,并且还告诉我们是uni_modules/lime-chart/static/echarts.min.js这个文件



虽然我们是在分包里渲染Echarts,但是插件默认下载到主包的uni_modules,我们需要把Echarts依赖引入到分包里



在分包里加载Echarts依赖

我们把主包里的Echarts文件整个移入到分包里pages-me-dashboard, 我在分包里建了一个文件夹uni_modules, 告诉自己这是一个插件,


image.png


可是却发现Echarts渲染不出来了,调试后发现chart组件不渲染了


<l-echart ref="pieChartRef"></l-echart>

于是就又手动引入lEchart组件,Echart在主包的时候,没引入lEchart组件就渲染了,发现可以正常渲染


import lEchart from "../uni_modules/lime-echart/components/l-echart/l-echart.vue";

再次发布下,主包大小通过,主包存在仅被其他分包依赖的JS文件也通过✅


image.png


完整代码


<template>
<view class="container">
<view class="stats-card">
<view class="header">
<view class="date-select">
<picker
mode="selector"
:value="selectedYearIndex"
:range="yearOptions"
@change="onYearChange">
<view class="picker">{{ yearOptions[selectedYearIndex] }} 年</view>
</picker>

<picker
mode="selector"
:value="selectedMonthIndex"
:range="monthOptions"
@change="onMonthChange">
<view class="picker">{{ monthOptions[selectedMonthIndex] }}</view>
</picker>
</view>
</view>

<view v-if="loading" class="loading">
<uni-load-more status="loading"></uni-load-more>
</view>

<view v-else class="stats-content">
<view class="stat-item">
<text class="label">总课程节数</text>
<text class="value">
{{ statistics.totalCourses }}
<text class="label">节</text>
</text>
</view>
<view class="stat-item mb-20">
<text class="label">出勤统计</text>
<text class="value">
{{ statistics.trainingDays }}
<text class="label">天</text>
</text>
</view>

<!-- Line Chart -->
<view style="width: 90vw; height: 750rpx">
<l-echart ref="lineChartRef"></l-echart>
</view>

<!-- Pie Chart -->
<view
style="
width: 85vw;
height: 550rpx;
margin-top: 20px;
overflow: hidden;
">
<l-echart ref="pieChartRef"></l-echart>
</view>
</view>
</view>
</view>
</template>

<script lang="ts" setup>
import { ref, onMounted, nextTick } from "vue";
// Import echarts
import lEchart from "../uni_modules/lime-echart/components/l-echart/l-echart.vue";
const echarts = require("../uni_modules/lime-echart/static/echarts.min");

interface Statistics {
totalCourses: number;
trainingDays: number;
courseDistribution: { name: string; value: number }[];
dailyCourses: { date: string; count: number }[];
}

const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();

const yearOptions = Array.from({ length: 5 }, (_, i) => `${currentYear - i}`);
const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1} 月`);

const selectedYearIndex = ref(0);
const selectedMonthIndex = ref(currentMonth);

const statistics = ref<Statistics>({
totalCourses: 0,
trainingDays: 0,
courseDistribution: [],
dailyCourses: [],
});
const loading = ref(false);

const onYearChange = (e: any) => {
selectedYearIndex.value = e.detail.value;
fetchStatistics();
};

const onMonthChange = (e: any) => {
selectedMonthIndex.value = e.detail.value;
fetchStatistics();
};

const fetchStatistics = async () => {
loading.value = true;

try {
console.log(
"year-month",
yearOptions[selectedYearIndex.value],
Number(selectedMonthIndex.value) + 1
);
const res = await uniCloud.callFunction({
name: "getMonthlyStatistics",
data: {
userId: uni.getStorageSync("userInfo").userId,
year: yearOptions[selectedYearIndex.value],
month: Number(selectedMonthIndex.value) + 1,
},
});

if (res.result.code === 0) {
statistics.value = res.result.data;
console.log("charts-----", res.result.data);
renderCharts();
}
} catch (error) {
console.error("获取统计数据失败", error);
} finally {
loading.value = false;
}
};

const lineChartRef = ref(null);
const pieChartRef = ref(null);

const renderCharts = async () => {
// Line Chart
setTimeout(async () => {
console.log("charts111-----", echarts, lineChartRef.value);
if (!lineChartRef.value) return;

const dailyCourses = statistics.value.dailyCourses;
const dates = dailyCourses.map((item) => item.date);
const counts = dailyCourses.map((item) => item.count);

const lineChartOption = {
title: {
text: "每日上课统计",
left: "left",
top: 10,
textStyle: {
fontSize: 18,
fontWeight: "bold",
color: "#333",
},
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "line",
},
},
xAxis: {
type: "category",
data: dates,
axisLine: {
lineStyle: {
color: "#999999",
},
},
axisLabel: {
color: "#666666",
},
axisTick: {
show: false,
},
},
yAxis: {
type: "value",
axisLine: {
lineStyle: {
color: "#999999",
},
},
axisLabel: {
color: "#666666",
},
axisTick: {
show: false,
},
},
series: [
{
name: "课程节数",
type: "line",
data: counts,
smooth: true,
},
],
};
console.log("echarts", echarts);
const lineChart = await lineChartRef.value.init(echarts);
lineChart.setOption(lineChartOption);
}, 500);

// Pie Chart
setTimeout(async () => {
if (!pieChartRef.value) return;

const courseDistribution = statistics.value.courseDistribution;

const pieChartOption = {
title: {
text: "课程分布",
left: "left",
top: 10,
textStyle: {
fontSize: 18,
fontWeight: "bold",
color: "#333",
},
},
tooltip: {
trigger: "item",
formatter: "{b}: {c} ({d}%)",
},
series: [
{
name: "课程分布",
type: "pie",
radius: ["30%", "50%"],
label: {
show: true,
position: "outside",
formatter: "{b}: {c} ({d}%)",
},
data: courseDistribution.map((item) => ({
name: item.name,
value: item.value,
})),
},
],
};
const pieChart = await pieChartRef.value.init(echarts);
pieChart.setOption(pieChartOption);
}, 600);
};

onMounted(() => {
fetchStatistics();
});
</script>

<style lang="scss" scoped>
.container {
display: flex;
justify-content: center;
align-items: flex-start;
// height: 100vh;
background-color: #f5f5f5;
padding: 20px;
box-sizing: border-box;
}

.stats-card {
width: 100%;
max-width: 650px;
background-color: #fff;
border-radius: 10px;
padding: 20px;
box-sizing: border-box;
}

.header {
display: flex;
justify-content: flex-start;
margin-bottom: 20px;
}

.date-select {
display: flex;
gap: 15px;
}

.picker {
padding: 8px 20px;
background-color: #f0f0f0;
border-radius: 8px;
font-size: 16px;
text-align: center;
}

.stats-content {
display: flex;
flex-direction: column;
// gap: 20px;
}

.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0; // 减小 padding 让行距更紧凑
border-bottom: 1px solid #eee;

.label {
font-size: 14px; // 标签字体调小
color: #666;
}

.value {
font-size: 22px; // 数值字体加大
font-weight: bold; // 加粗数值
color: #333;
}
}

.chart {
height: 300px;
margin-top: 20px;
}
</style>

效果图页面渲染的数据


{
"totalCourses": 5,
"trainingDays": 3,
"dailyCourses": [
{
"date": "2025-01-01",
"count": 2
},
{
"date": "2025-01-02",
"count": 2
},
{
"date": "2025-01-03",
"count": 1
}
],
"courseDistribution": [
{
"name": "编舞基础",
"value": 2
},
{
"name": "Kpop基础",
"value": 1
},
{
"name": "Hiphop基础",
"value": 1
},
{
"name": "Jazz进阶",
"value": 1
}
]
}

作者:gongzemin
来源:juejin.cn/post/7455491124564885523
收起阅读 »

出了兼容性问题,被领导叼了

web
背景 项目上线后跑了应该有两三个月了,接到生产报事,页面进不去了,用户设备是iPhone8 iOS13.1,用户很气愤,领导也很不乐意,我也很气愤,刚来这项目组就被报事,艹太。但是要解决呀,怎么办?研究以前的代码,加配置呗。 浏览器兼容性问题是什么? 浏览器兼...
继续阅读 »

背景


项目上线后跑了应该有两三个月了,接到生产报事,页面进不去了,用户设备是iPhone8 iOS13.1,用户很气愤,领导也很不乐意,我也很气愤,刚来这项目组就被报事,艹太。但是要解决呀,怎么办?研究以前的代码,加配置呗。


浏览器兼容性问题是什么?


浏览器兼容性问题通常是指网页或 Web 应用在不同浏览器或版本中表现不一致的问题。说白了无非就是 css不兼容JS Api在旧版本浏览器中不兼容。


解决思路



  1. 明白目标浏览器范围

  2. 找个插件将现代 JS 转到 ES5

  3. 处理一下CSS的兼容性问题


解决方案



  1. 通过定义 .browserslistrc 明确目标浏览器范围

  2. 使用 Babel 将现代 JS 转到 ES5

  3. 使用 Autoprefixer 给 CSS 加厂商前缀


好了,开搞


.browserslistrc文件是什么


.browserslistrc 文件是一个配置文件,用于定义目标浏览器和Node.js版本的兼容性列表。这个文件被多个前端工具链和库所使用,如Babel、Autoprefixer、ESLint等,可以帮助我们确定需要转译或添加兼容性前缀的JavaScript和CSS代码版本。通过配置 .browserslistrc,我们可以精确地控制代码应该兼容哪些浏览器和设备,从而优化构建输出和减少最终包的大小。


.browserslistrc文件中可以配置的内容



  • ‌浏览器名称和版本‌:例如,last 2 Chrome versions 表示最新的两个Chrome浏览器版本。

  • ‌市场份额‌:如 > 1% in US 表示在美国市场份额超过1%的浏览器。

  • ‌年份‌:since 2017 表示自2017年以来发布的所有浏览器版本。

  • ‌特定浏览器‌:not IE 11 表示不包括IE 11浏览器。


个人项目中使用.browserslistrc配置


在个人日常办公项目中 .browserslistrc 文件配置如下:


> 0.2%
last 2 versions
Firefox ESR
not dead
IE 11

这个配置的含义是:



  • 支持全球使用率超过0.2%的浏览器。

  • 支持最新的两个浏览器版本。

  • 支持Firefox的Extended Support Release(ESR)版本。

  • 排除所有已经不被官方支持(dead)的浏览器。

  • 额外包含IE 11浏览器,尽管它可能不在其他条件内


Babel是什么


Babel 是一个广泛使用的 JavaScript 编译器/转译器,其核心作用是将 高版本 JavaScript(如 ES6+)转换为向后兼容的低版本代码(如 ES5),以确保代码能在旧版浏览器或环境中正常运行。


Babel的主要作用


1. 语法转换(Syntax Transformation)


将现代 JavaScript 语法(如 let/const、箭头函数、类、模板字符串、解构赋值等)转换为等价的 ES5 语法,以便在不支持新特性的浏览器中运行。


2. Polyfill 填充新 API


通过插件(如 @babel/polyfill 或 core-js),为旧环境提供对新增全局对象(如 Promise, Array.from, Map, Set)的支持。


3. 按需转换(基于目标环境)


结合 .browserslistrc 配置,@babel/preset-env 可根据指定的目标浏览器自动决定哪些特性需要转换,哪些可以保留原样。


4. 支持 TypeScript 和 JSX


Babel 提供了对 TypeScript(通过 @babel/preset-typescript)和 React 的 JSX 语法(通过 @babel/preset-react)的解析与转换能力,无需依赖其他编译工具。


5. 插件化架构,高度可扩展


Babel 支持丰富的插件生态,开发者可以自定义语法转换规则,比如:



  • 按需引入 polyfill(@babel/plugin-transform-runtime)

  • 移除调试代码(@babel/plugin-transform-remove-console)

  • 支持装饰器、私有属性等实验性语法


@babel/preset-env的核心配置


@babel/preset-env 的参数项数量很多,但大部分我们都用不到。我们只需要重点掌握四个参数项即可:targets、useBuiltIns、modules 和 corejs。


@babel/preset-env 的 targets 参数


该参数项的写法和.browserslistrc 配置是一样的,主要是为了定义目标浏览器。如果我们对 targets 参数进行了设置,那么就不会使用 .browserslistrc 配置了,为了减少多余的配置,我们推荐使用 .browserslistrc 配置。


@babel/preset-env 的 useBuiltIns 参数


useBuiltIns 项取值可以是usageentryfalse。如果该项不进行设置,则取默认值 false



  • 设置成 false 的时候会把所有的 polyfill 都引入到代码中,整个体积会变得很大。

  • 设置成 entry 则是会根据目标环境引入所需的 polyfill,需要手动引入;

  • 设置成 usage 则是会根据目标环境和代码的实际使用来引入所需的 polyfill
    此处我们推荐使用:useBuiltIns: usage 的设置。


@babel/preset-env 的 corejs 参数


该参数项的取值可以是 2 或 3,没有设置的时候取默认值为 2。这个参数只有 useBuiltIns 参数为 usage 或者 entry 时才会生效。在新版本的Babel中,建议使用 core-js@3


@babel/preset-env 的 modules 参数


指定模块的输出方式,默认值是 "auto",也可以设置为 "commonjs""umd""systemjs" 等。


个人项目中使用Babel的配置


在个人日常办公项目中 .babel.config.js 文件配置如下:


module.exports = {
plugins: [
// 适配某些构建流程中的模块元信息访问方式
() => ({
visitor: {
MetaProperty(path) {
path.replaceWithSourceString('process');
},
},
})
],
presets: [
[
'@babel/preset-env', {
// targets: { esmodules: false, }, // 通过配置browserslist,来使用 browserslist 的配置
useBuiltIns: "usage", // 配置按需引入polyfill
corejs: 3
}
],
'@babel/preset-typescript'
],
};


Autoprefixer 的使用


vite.config.ts文件中css的部分,添加 autoprefixer 的配置。


css: {
postcss: {
plugins: [
postCssPxToRem({
// 这里的rootValue就是你的设计稿大小
rootValue: 37.5,
propList: ['*'],
}),
autoprefixer({
overrideBrowserslist: [
'Android 4.1',
'iOS 7.1',
'ff > 31',
'Chrome > 69',
'ie >= 8',
'> 1%'
]
}),
],
},
},

总结


主要通过配置 .browserslistrc 明确目标浏览器范围,使用 Babel 将现代 JS 转到 ES5,主要用到的插件是 @babel/preset-env ,最后再使用 Autoprefixer 插件给 CSS 加厂商前缀。


作者:页面仔Dony
来源:juejin.cn/post/7508588026316308531
收起阅读 »

倒反天罡,CSS 中竟然可以写 JavaScript

web
引言 最近和大佬学习写 像素风组件库 里面有很多复杂而有趣的样式,于是跑去研究了一下,震惊的发现,大佬竟然在 CSS 中写 JavaScript ! 一般来说 CSS 是网页样式的声明性语言,而 JavaScript 则负责交互逻辑。我仔细研究了一下,原来是...
继续阅读 »

引言


最近和大佬学习写 像素风组件库 里面有很多复杂而有趣的样式,于是跑去研究了一下,震惊的发现,大佬竟然在 CSS 中写 JavaScript !


image.png


一般来说 CSS 是网页样式的声明性语言,而 JavaScript 则负责交互逻辑。我仔细研究了一下,原来是通过 CSS Houdini 实现了用 JavaScript 来扩展 CSS 的能力。所以写了这篇文章来探讨一下 CSS Houdini。


CSS Houdini是什么?


CSS Houdini 是一组低级 API,允许开发者直接访问 CSS 对象模型(CSSOM),从而能够扩展 CSS 的功能。它的名字来源于著名魔术师 Harry Houdini,寓意"逃离" CSS 的限制,就像魔术师从束缚中挣脱一样。


那么问题来了,为什么选择 CSS Houdini 而不是直接使用 JavaScript 来操作样式?


性能优势


与使用 JavaScript 对 HTMLElement.style 进行样式更改相比,Houdini 可实现更快的解析。JavaScript 修改样式通常会触发浏览器的重排(reflow) 和重绘(repaint),特别是在动画中,这可能导致性能问题。而 Houdini 工作在浏览器渲染流程的更低层级,能够更高效地处理样式变化,减少不必要的计算。


扩展和复用性


使用 JavaScript 修改样式本质上是在操作 DOM,而 Houdini 直接扩展了 CSS 的能力。这使得自定义效果可以像原生 CSS 特性一样工作,包括继承、级联和响应式设计。


Houdini API 允许创建真正的 CSS 模块,可以像使用标准 CSS 属性一样使用自定义功能,提高代码的可维护性和复用性。


主要API概览


接下来是重点,我们来看看到底如何去使用 CSS Houdini。CSS Houdini 包含多个API,下面通过具体案例来说明一下使用方式。


1. CSS Painting API


CSS Paint API 允许我们使用 JavaScript 和 Canvas API 创建自定义的 CSS 图像,然后在 CSS 样式中使用这些图像,例如 background-imageborder-imagemask-image 等。它的使用方法分为下面三步:


第一步,绘制背景


在这一步,使用 registerPaint() 定义一个 paint worklet (可以翻译理解为自定义画笔),来画你想要的图案。我们需要的变量可以通过 CSS 变量的形式定义并引入,在 inputProperties 指定我们需要读取的参数。


// myPainter.js
registerPaint(
'myPainter',
class {
static get inputProperties() {
return ['--my-color', '--wave-amplitude', '--wave-frequency'];
}
paint(ctx, size, properties) {
const color = properties.get('--my-color').toString() || '#3498db';
const amplitude = parseFloat(properties.get('--wave-amplitude')) || 20;
const frequency = parseFloat(properties.get('--wave-frequency')) || 0.03;

// 画渐变背景
const gradient = ctx.createLinearGradient(0, 0, size.width, size.height);
gradient.addColorStop(0, color);
gradient.addColorStop(1, '#fff');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size.width, size.height);

// 画波浪
ctx.beginPath();
ctx.moveTo(0, size.height / 2);
for (let x = 0; x <= size.width; x++) {
const y =
size.height / 2 +
Math.sin(x * frequency) * amplitude +
Math.sin(x * frequency * 0.5) * (amplitude / 2);
ctx.lineTo(x, y);
}
ctx.lineTo(size.width, size.height);
ctx.lineTo(0, size.height);
ctx.closePath();

ctx.fillStyle = color + '88'; // 半透明主色
ctx.fill();
}
}
);

第二步,注册刚才定义的 worklet


在这一步,通过 CSS.paintWorklet.addModule 来引入我们自定义的 paint worklet。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
</style>
</head>
<body>
<div class="box">一条大河波浪宽</div>
<script>
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('myPainter.js');
}
</script>
</body>
</html>

最后,在 CSS 中使用 paint


我们在 CSS 属性值中通过 paint(myPainter) 的方式来指定使用我们的 paint worklet,同时,通过 CSS 变量传递需要的参数。


.box {
width: 300px;
height: 200px;
text-align: center;
color: #fff;
background-image: paint(myPainter);
/* 定义paint需要的变量 */
--my-color: #0087ff;
--wave-amplitude: 30;
--wave-frequency: 0.04;
}

最后看下效果


image.png


有了 JavaScript 和 Canvas API 的加持,可以画很多酷炫的效果。


2. CSS Properties and Values API


这个 API 允许我们定义自定义 CSS 属性的类型、初始值和继承行为。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CSS Properties and Values API 示例</title>
<style>
.color-box {
width: 200px;
height: 200px;
margin: 50px auto;
background-color: var(--my-color);
transition: --my-color 1s;
}

.color-box:hover {
--my-color: green;
}
</style>
</head>
<body>
<div class="color-box" id="colorBox"></div>

<script>
// 检查浏览器是否支持CSS Properties and Values API
if (window.CSS && CSS.registerProperty) {
// 注册一个自定义属性
CSS.registerProperty({
name: '--my-color',
syntax: '<color>',
inherits: false,
initialValue: 'blue',
});
}
</script>
</body>
</html>

Jun-14-2025 01-33-53.gif


在上面这个示例中,我们定义了一个自定义属性,名字为 --my-color,通过 syntax: '<color>' 来指定这个属性的值类型是颜色(比如 blue#fffrgb(0,0,0) 等),这样浏览器就能识别并支持动画、过渡等。通过 inherits: false 指定这个属性不会从父元素继承。通过 initialValue: 'blue' 指定它的默认值为 blue


定义之后,我们可以通过 var(--my-color) 来引用这个变量,也可以通过 --my-color: green 来更改它的值。


那为什么不能直接定义个 CSS 变量,而是要通过 CSS.registerProperty 来注册一个属性呢?



  • 普通的 CSS 变量,浏览器只当作字符串处理,不能直接做动画、过渡等。而用 registerProperty 注册后,浏览器知道它是 <color> 类型,就能支持动画、过渡等高级特性。


3. CSS Typed Object Model


CSS Typed OM API 将 CSS 值以类型化的 JavaScript 对象形式暴露出来,来让方便我们对其进行操作。


比起直接使用 HTMLElement.style 的形式操作 CSS 样式,CSS Typed OM 拥有更好的逻辑性和性能。


computedStyleMap


通过 computedStyleMap() 可以以 Map 形式获取一个元素所有的 CSS 属性和值,包括自定义属性。


获取不同的属性返回值类型不同,需要用不同的读取方式。computedStyleMap() 返回的是只读的计算样式映射,不能直接修改。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
.box {
color: rgb(13 5 17);
}
</style>
</head>
<body>
<div
class="box"
style="
width: 100px;
height: 50px;
background-image: linear-gradient(to right, red, blue);
"

>
</div>

<script>
const box = document.querySelector('.box');
// 获取所有属性
const computedStyles = box.computedStyleMap();
// 读取指定属性的值
console.log(computedStyles.get('color').toString()); // rgb(13, 5, 17)
console.log(computedStyles.get('background-image').toString()); // linear-gradient(to right, rgb(255, 0, 0), rgb(0, 0, 255))
console.log(computedStyles.get('height').value); // 100
console.log(computedStyles.get('height').unit); // px
console.log(computedStyles.get('position').value); // 'static'
</script>
</body>
</html>



attributeStyleMap


通过 element.attributeStyleMap 可以获取和设置 CSS 的内联样式。


<!DOCTYPE html>
<html lang="en">
<head>
<style>
.box {
background-color: blue; /* 样式表中的样式 */
}
</style>
</head>
<body>
<div class="box" style="width: 100px; height: 100px;"></div>

<script>
const box = document.querySelector('.box');

const inlineStyles = box.attributeStyleMap;
console.log('width:', inlineStyles.get('width')?.toString()); // "100px"
console.log('height:', inlineStyles.get('height')?.toString()); // "100px"
console.log('background-color:', inlineStyles.get('background-color')); // undefined,因为是在样式表中定义的

setInterval(() => {
inlineStyles.set('width', CSS.px(inlineStyles.get('width').value + 1));
}, 30);
</script>
</body>
</html>

在这个例子中,读取了 width 并进行设置,让它宽度逐渐变大。


Jun-14-2025 18-30-18.gif


4. Layout Worklet 和 Animation Worklet


除了上述的三种 API,Hounidi 还包含了 Layout Worklet 和 Animation Worklet 分别用于自定义布局和动画,但是目前还在实验中,支持度不是很好,所以就不提供使用案例了。


image.png


参考资源



作者:我不吃饼干
来源:juejin.cn/post/7515707680927055923
收起阅读 »

用好了 defineProps 才叫会用 Vue3,90% 的写法都错了

web
Vue 3 的 Composition API 给开发者带来了更强的逻辑组织能力,但很多人用 defineProps 的方式,依然停留在 Vue 2 的“Options 语法心智”。本质上只是把 props: {} 拿出来“提前声明”,并没有真正理解它的运行机...
继续阅读 »

Vue 3 的 Composition API 给开发者带来了更强的逻辑组织能力,但很多人用 defineProps 的方式,依然停留在 Vue 2 的“Options 语法心智”。本质上只是把 props: {} 拿出来“提前声明”,并没有真正理解它的运行机制、类型推导优势、默认值处理方式、解构陷阱等关键点。


这篇文章不做语法搬运,而是用实战视角,带你了解:defineProps 到底该怎么写,才是专业的 Vue3 写法。




🎯 为什么说你用错了 defineProps?


我们先来看一个常见的 Vue3 组件写法:


<script setup>
const props = defineProps({
title: String,
count: Number
})
</script>

你以为这就完事了?它只是基本写法。但在真实业务中,我们往往会遇到:



  • 需要传默认值

  • 想要类型推导

  • 解构 props 却发现响应性丢失

  • TS 类型重复声明,不够优雅


这些问题,defineProps 其实早就帮你解决了,只是你没用对方式。




✅ 正确的三种 defineProps 写法


① 写法一:声明式类型推导(推荐)


interface Props {
title: string
count?: number
}

const props = defineProps<Props>()

优点:



  • 自动获得类型推导

  • <script setup lang="ts"> 中书写自然

  • 可配合 withDefaults 补充默认值



这是 Composition API 的推荐写法,完全由 TypeScript 驱动,而不是运行时校验。





② 写法二:运行时代码校验(Options 式)


const props = defineProps({
title: {
type: String,
required: true
},
count: {
type: Number,
default: 0
}
})

优点:



  • 保留 Vue2 的 props 校验逻辑

  • 更适合 JS-only 项目(不使用 TS)


缺点:



  • 类型推导不如泛型直观

  • withDefaults 不兼容




③ 写法三:withDefaults 配合(实战最常见)


const props = withDefaults(defineProps<{
title?: string
count?: number
}>(), {
title: '默认标题',
count: 1
})

优势是:



  • 既能获得类型推导,又能写默认值

  • 不会重复写 default

  • 比纯 defineProps 更简洁易维护



注意:withDefaults 只能配合泛型式 defineProps 使用,不能和对象式 props 写法混用。





⚠️ 高发误区警告:你踩过几个?


🚫 误区 1:直接解构 props,响应性丢失


const { title, count } = defineProps<{ title: string, count: number }>()

上面的写法会让 titlecount 成为普通变量,不是响应式的


解决方式:使用 toRefs


const props = defineProps<{ title: string, count: number }>()
const { title, count } = toRefs(props)

这样才能在 watch(title, ...) 中有效监听变化。




🚫 误区 2:类型和默认值重复声明


const props = defineProps({
title: {
type: String as PropType<string>, // 写了类型
default: 'Hello' // 又写默认值
}
})

在 TS 项目中,这种方式显得繁琐且不智能。建议直接用泛型 + withDefaults,让 IDE 自动推导类型。




🚫 误区 3:没有区分“开发期类型检查” vs “运行时校验”


Vue3 的 Props 有两个模式:



  • TypeScript 模式:靠 IDE + 编译器

  • Options 模式:在浏览器运行时报错


实际推荐:生产环境靠 TypeScript 检查即可,无需运行时 Props 校验,提高性能。




🎯 defineProps 是真正的组件契约声明


在 Vue3 的 <script setup> 中,defineProps 就是你和使用你组件的人之间的契约


为什么说它是契约?



  • 它声明了组件的“输入规范”

  • 它决定了类型校验、默认值逻辑

  • 它是组件文档的第一手来源


你越是随便写它,越容易在团队协作时踩坑。




💡 defineProps 的进阶技巧:你未必知道的几个点


✔ 你可以在 defineProps 里使用类型别名


type Size = 'sm' | 'md' | 'lg'

withDefaults(defineProps<{
size?: Size
}>(), {
size: 'md'
})

这是让 props.size 具备完整类型提示的关键方式。




✔ 配合 defineEmits 写法更完整


const emit = defineEmits<{
(e: 'submit', value: number): void
(e: 'cancel'): void
}>()

这样写出的组件,输入(props)+ 输出(emit)都具备契约,可以被任何 IDE 精确识别。




✔ defineProps 写法决定你能不能使用 Volar 的类型推导


很多人发现 <MyComponent :title="xx" /> 里没有类型提示,大概率是你组件没有正确写 defineProps 的泛型。保持结构清晰,是让 IDE 吃得饱的唯一方式。




🚀 小结:defineProps 不只是 props,它是组件健壮性的开端


错误写法问题
不加泛型IDE 无法提示
直接解构响应性丢失
类型 + default 双声明代码重复、难维护
没有 withDefaults写默认值繁琐、不能配合类型推导
使用 runtime 校验 + TS混乱、效率低

正确思路是:在 TypeScript 项目中,尽可能采用 defineProps<T>() + withDefaults() 写法,做到类型明确、默认值清晰、响应式安全。




📌 怎么判断你是否“真的会用 defineProps”?



  • ❌ 你写了 defineProps 但 props 解构不响应

  • ❌ 你写 default 写得很痛苦

  • ❌ 你项目里 props 写法风格混乱

  • ❌ 你的组件在 IDE 中没有 props 自动补全


✅ 如果你能做到:



  • 使用泛型 + withDefaults

  • 保持 props 和 emits 的契约完整

  • 清晰地类型提示和响应性解构


那恭喜你,是真的理解了 Vue3 的组件心智模型。


作者:ErpanOmer
来源:juejin.cn/post/7513117108114473001
收起阅读 »

双Token实现无感刷新

web
一、为什么需要无感刷新? 想象一下你正在刷视频,突然提示"登录已过期,请重新登录",需要退出当前页面重新输入密码。这样的体验非常糟糕!无感刷新就是为了解决这个问题:让用户在不知不觉中完成身份续期,保持长时间在线状态。 二、双Token机制原理 我们使用两个令牌...
继续阅读 »

一、为什么需要无感刷新?


想象一下你正在刷视频,突然提示"登录已过期,请重新登录",需要退出当前页面重新输入密码。这样的体验非常糟糕!无感刷新就是为了解决这个问题:让用户在不知不觉中完成身份续期,保持长时间在线状态。


二、双Token机制原理


我们使用两个令牌:



  1. 短令牌:access_token(1小时):用于日常请求

  2. 长令牌:refresh_token(7天):专门用来刷新令牌


工作流程:


用户登录 → 获取双令牌 → access_token过期 → 用refresh_token获取新的双令牌 → 自动续期

三、前端实现(Vue + Axios)


1. 登录存储令牌

const login = async () => {
const res = await userLogin(user); //账号密码
// 保存双令牌到本地
localStorage.setItem('access_token', res.access_token);
localStorage.setItem('refresh_token', res.refresh_token);
}

2. 请求自动携带令牌

通过请求拦截器自动添加认证头:


api.interceptors.request.use(config => {
const access_token = localStorage.getItem('access_token');
if (access_token) {
config.headers.Authorization = `Bearer ${access_token}`;
}
return config;
})

3. 智能令牌刷新

响应拦截器发现401登录过期的错误时自动请求刷新


验证长令牌是否失效



  • 失效重定向到登录页面

  • 未失效重新获取双令牌并重新发起请求


api.interceptors.response.use(
(response) => {
return response
},
async (error) => { // 响应失败
const { data, status, config } = error.response;
if (status === 401 && config.url !== '/refresh') {
// 刷新token
const res = await refreshToken() // 校验的函数
if (res.status === 200) { // token刷新成功
// 重新将刚刚失败的请求发送出去
return api(config)
} else {
// 重定向到登录页 router.push('/login')
window.location.href = '/login'
}
}
}
)

四、后端实现(Node.js + Express)


1. 生成双令牌

// 生成1小时有效的access_token
const access_token = generateToken(user, '1h');
// 生成7天有效的refresh_token
const refresh_token = generateToken(user, '7d');

2. 令牌刷新接口

app.get('/refresh', (req, res) => {
const oldRefreshToken = req.query.token;
try {
// 验证refresh_token有效性
const userData = verifyToken(oldRefreshToken);
// 生成新双令牌
const newAccessToken = generateToken(userData, '1h');
const newRefreshToken = generateToken(userData, '7d');
res.json({ access_token: newAccessToken, refresh_token: newRefreshToken });
} catch (error) {
res.status(401).send('令牌已失效');
}
})

五、完整代码


1. 前端代码

<template>
<div v-if="!isLogin">
<button @click="login">登录</button>
</div>

<div v-else>
<h1>登录成功</h1>
<p>欢迎回来,{{ username }}</p>
<p>您的邮箱:{{ email }}</p>
</div>


<!-- home -->
<div v-if="isLogin">
<button @click="getHomeData">获取首页数据</button>
</div>
</template>

<script setup>
import { ref } from 'vue'
import { userLogin, getHomeDataApi } from './api.js'

const isLogin = ref(false)
const username = ref('')
const email = ref('')
const password = ref('')


const login = async() => {
username.value = 'zs'
email.value = '123@qq.com'
password.value = '123'

const res = await userLogin({username: username.value, email: email.value, password: password.value})
console.log(res)
const {access_token, refresh_token, userInfo} = res.data
if (access_token) {
isLogin.value = true
}
localStorage.setItem('access_token', access_token)
localStorage.setItem('refresh_token', refresh_token)
}


const getHomeData = async() => {
const res = await getHomeDataApi()
console.log(res)
}


</script>

<style lang="css" scoped>

</style>

// api.js
import axios from 'axios'

const api = axios.create({
baseURL: 'http://localhost:3000',
timeout: 3000,
})

// 请求拦截器
api.interceptors.request.use(config => {
const access_token = localStorage.getItem('access_token');
if (access_token) {
config.headers.Authorization = `Bearer ${access_token}`;
}
return config;
})

// 响应拦截器
api.interceptors.response.use(
(response) => {
return response
},
async (error) => { // 响应失败
const { data, status, config } = error.response;
if (status === 401 && config.url !== '/refresh') {
// 刷新token
const res = await refreshToken()
if (res.status === 200) { // token刷新成功
// 重新将刚刚失败的请求发送出去
return api(config)
} else {
// 重定向到登录页 router.push('/login')
window.location.href = '/login'
}
}
}
)


export const userLogin = (data) => {
return api.post('/login', data)
}

export const getHomeDataApi = () => {
return api.get('/home')
}

async function refreshToken() {
const res = await api.get('/refresh', {
params: {
token: localStorage.getItem('refresh_token')
}
})
localStorage.setItem('access_token', res.data.access_token)
localStorage.setItem('refresh_token', res.data.refresh_token)
return res
}

2. 后端代码

server.js
const express = require('express');
const app = express();
const port = 3000;
app.use(express.json()); // 解析 JSON 格式的请求体
const jwtToken = require('./token.js');
const cors = require('cors');

app.use(cors())


const users = [
{ username: 'zs', password: '123', email: '123@qq.com' },
{ username: 'ls', password: '456', email: '456@qq.com' }
]




app.get('/', (req, res) => {
res.send('Hello World!');
});

app.post('/login', (req, res) => {
const { username, password } = req.body;
const user = users.find(user => user.username === username);
if (!user) {
return res.status(404).json({status: 'error', message: '用户不存在'});
}
if (user.password !== password) {
return res.status(401).json({status: 'error', message: '密码错误'});
}

// 生成两个 token
const access_token = jwtToken.generateToken(user, '1h');
const refresh_token = jwtToken.generateToken(user, '7d');

res.json({
userInfo: {
username: user.username,
email: user.email
},
access_token,
refresh_token
})


})

// 需要token 认证的路由
app.get('/home', (req, res) => {
const authorization = req.headers.authorization;
if (!authorization) {
return res.status(401).json({status: 'error', message: '未登录'});
}

try {
const token = authorization.split(' ')[1]; // 'Bearer esdadfadadxxxxxxxxx'
const data = jwtToken.verifyToken(token);
res.json({ status: 'success', message: '验证成功', data: data });
} catch (error) {
return res.status(401).json({status: error, message: 'token失效,请重新登录'});
}

})

// 刷新 token
app.get('/refresh', (req, res) => {
const { token } = req.query;

try {
const data = jwtToken.verifyToken(token);
const access_token = jwtToken.generateToken(data, '1h');
const refresh_token = jwtToken.generateToken(data, '7d');
res.json({ status: 'success', message: '刷新成功', access_token, refresh_token });
} catch (error) {
return res.status(401).json({status: error, message: 'token失效,请重新登录'});
}
})




app.listen(port, () => {
console.log(`Example app listening on port ${port}`);
})

// token.js
const jwt = require('jsonwebtoken');

// 生成 token
function generateToken(user, expiresIn) {
const payload = {
username: user.username,
email: user.email
};
const secret = 'my_secret_key';
const options = {
expiresIn: expiresIn
};
return jwt.sign(payload, secret, options);
}

// 验证 token
function verifyToken(token) {
const secret = 'my_secret_key';
const decoded = jwt.verify(token, secret);
return decoded;
}

module.exports = {
generateToken,
verifyToken
};

六、流程图解


用户发起请求 → 携带access_token → 服务端验证
↓ 无效/过期
触发401错误 → 前端拦截 → 发起refresh_
token刷新请求
↓ 刷新成功
更新本地令牌 → 重新发送原请求 → 用户无感知
↓ 刷新失败
跳转登录页面 → 需要重新认证

七、安全注意事项



  1. refresh_token要长期有效,但也不能太长:通常设置7-30天有效期

  2. 使用HTTPS:防止令牌被中间人窃取

  3. 不要明文存储令牌:使用浏览器localStorage要确保XSS防护

  4. 设置合理有效期:根据业务需求平衡安全与体验


作者:忆柒
来源:juejin.cn/post/7506732174588133391
收起阅读 »

🔥为什么我坚持用 SVG 做 icon?和 font icon 告别之后太爽了

web
🔥先说结论:我已经全面弃用 iconfont,只用 SVG 用了 6 年 iconfont,直到一次 icon 闪退 + 一个 retina 模糊问题,我怒转 SVG。现在回看,我只想说:一切都晚了。 🧱背景:IconFont 曾经无处不在 从 2015 年...
继续阅读 »

🔥先说结论:我已经全面弃用 iconfont,只用 SVG


用了 6 年 iconfont,直到一次 icon 闪退 + 一个 retina 模糊问题,我怒转 SVG。现在回看,我只想说:一切都晚了。




🧱背景:IconFont 曾经无处不在


从 2015 年前后起,iconfont 就是前端项目的标配:



  • 上阿里图标库拖几个图,下载 TTF 文件,塞进项目

  • CSS 里 .icon-home:before { content: "\e614"; }

  • 不仅开发快,样式也能自由控制:font-sizecolorline-height 全都随便来


但这个方案,看起来“简单”,其实全是坑。




😤踩坑合集:iconfont 到底有哪些问题?


1. 图标“莫名其妙不见了”


是不是经常遇到:


<i class="iconfont icon-home"></i>

然后某一天上线,页面里这个图标直接不显示。


你 debug 半天,才发现:



  • CDN 的 iconfont.ttf 被阻断了

  • 字体文件升级后,有的 unicode 被重映射

  • 某些浏览器默认阻止远程字体加载


更离谱的:你本地能跑,线上就挂




2. Retina 模糊 + 抗锯齿失败


iconfont 本质是“字体”,而不是“图形”。


在 Retina 屏下,你控制再多:


.icon {
font-size: 24px;
-webkit-font-smoothing: antialiased;
}

很多图标的边缘还是毛糙,特别是线性图标,对比度一上来,整个像被压过的 JPG。




3. 无法着色多个颜色


想做个渐变 icon?想让图标局部变色?


抱歉,iconfont 是“字体”,不是 SVG,不支持多颜色分区。


你只能:



  • 多套图标叠在一起

  • 加背景图 hack

  • 用 canvas 取色渲染(别笑,这我真干过)


而 SVG 支持:


<linearGradient id="grad">
<stop offset="0%" stop-color="#f00" />
<stop offset="100%" stop-color="#00f" />
</linearGradient>
<path fill="url(#grad)" d="..." />

效果拉满,iconfont 完全追不上。




4. 组件化极难封装


Vue/React 时代你会写这样的:


<Icon type="home" />

组件里你只能用 switch-case 映射成 <i class="icon-home" />


而 SVG 怎么写?


<svg><use xlinkHref="#icon-home" /></svg>

配合 Vite 插件(如 vite-plugin-svg-icons),你直接:


import Icon from '@/components/Icon'

<Icon name="home" />

无 switch-case,无 class,全自动注册。




🧬SVG 的优势实在太香了


✅ 1. 天然支持响应式 + Retina 适配


SVG 是“矢量图”,本质是 XML 描述路径,你怎么放大都不会模糊。


加上 viewBox 一配,任何分辨率都稳。


✅ 2. 可以用 CSS 精准控制每一部分


.icon path {
stroke: red;
}

你甚至可以控制动画效果、hover 状态、交互响应。


✅ 3. 能做动画,图标能动起来!


<path class="animated" d="..." />

再配合 GSAP / CSS Animation,一切都活了。


Font icon?别说动画,它连“变色”都费劲。




💻开发实战:我项目里是这么用的


👉 Step 1: 用工具批量导入 SVG(iconfont 支持导出)


iconfont 阿里 下载 SVG 格式图标:



  • 一键导出所有图标为独立 SVG 文件


👉 Step 2: 用 Vite 插件自动加载


pnpm i vite-plugin-svg-icons -D

vite.config.ts:


import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import path from 'path'

export default {
plugins: [
createSvgIconsPlugin({
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
symbolId: 'icon-[name]',
}),
],
}

👉 Step 3: 组件封装 + 使用


封装组件:


const Icon = ({ name, className = '' }) => (
<svg class={`svg-icon ${className}`} aria-hidden="true">
<use xlinkHref={`#icon-${name}`} />
</svg>

)

使用方式:


<Icon name="home" className="text-xl text-blue-500" />

结果图标:



  • 不模糊

  • 支持 tailwind 任意控制尺寸 / 颜色

  • 任意动画加上去也丝滑




🔍性能?其实 SVG 更好


很多人说“SVG 会不会多了 HTTP 请求?”,其实:



  • 你可以用 svg-sprite 合并成一个 SVG 文件(类似雪碧图)

  • 你可以 Inline 到 HTML


而 iconfont 的 woff/ttf 文件体积反而大,兼容性也差。




🚫你什么时候不适合 SVG?



  • IE10 以下?别做梦(现在谁还兼容它?)

  • 文件体积有要求 (可能有些svg很大)

  • 对 icon 清晰度没有要求


但总的来说,2025 年了,SVG 基本就是绝对主流方案。




🧠晚用 SVG 三年,悔不当初


我不是说 iconfont 毫无可取之处,但作为前端工程实践而言:



“SVG 是当代 Web icon 的答案,iconfont 是历史的过渡产物。”



所以,别再调字体缩放、别再被 Unicode 问题搞得吐血了。
用 SVG,你会感谢现在的自己。你们怎么看?


📌 你可以继续看我的系列文章



作者:ErpanOmer
来源:juejin.cn/post/7516813599962054719
收起阅读 »

20MB 的字体文件太大了,我们把 Icon Font 压成了 10KB

web
在一次前端性能优化项目中,我们发现仅仅一个 icon font 文件就高达 20MB。这不仅拖慢了首屏加载速度,还极大地浪费了带宽。最终,我们将它压缩到了 10KB,而不影响任何功能表现。 这一过程背后,涉及的不仅是压缩,而是对「构建流程」「字体格式」「加载策...
继续阅读 »

在一次前端性能优化项目中,我们发现仅仅一个 icon font 文件就高达 20MB。这不仅拖慢了首屏加载速度,还极大地浪费了带宽。最终,我们将它压缩到了 10KB,而不影响任何功能表现。


这一过程背后,涉及的不仅是压缩,而是对「构建流程」「字体格式」「加载策略」「字形定制」的全盘重构。本文将逐步拆解这场“减重手术”,帮助你理解 icon font 是如何成为性能黑洞的,又是如何优雅瘦身的。




问题:20MB 的 icon font 是怎么来的?


大字体文件往往是由于以下原因造成的:



  • 过度收录:设计同学导出了一整套 2000 多个图标的 icon font,实际只用了其中几十个。

  • 全量打包:工具如 Icomoon、Fontello、FontForge 默认导出全量字形。

  • 格式冗余:一个字体文件常包含 .ttf, .woff, .woff2, .eot, .svg 多种格式,全打包增加体积。

  • 不做 Subset(子集提取):没有剔除未使用的字形。


最终结果就是:用户下载了 2000 个图标,只为了看到那 20 个常用 icon。




目标:精简为只包含实际使用 icon 的最小字体


如果你只用了 <i class="icon-chevron-down"></i><i class="icon-close"></i><i class="icon-search"></i> 三个图标,那字体文件里应该只包含这三个图形。


核心理念是:用子集字体(Subset Font)只保留被真正使用的字形。




解决方案路线图


✅ 步骤一:收集实际用到的 icon



  • 全站代码扫描,提取 icon class(或 unicode)

  • 工具:自定义脚本、AST 分析、静态资源分析工具


# 示例:查找 iconfont 使用的 class 名称
grep -roh 'icon-[a-zA-Z0-9_-]\+' ./src | sort | uniq > used-icons.txt



✅ 步骤二:精简 icon 到最小集合


工具选择:



  • IcoMoon App:可视化管理图标,导出精简 icon font

  • FontSubset:支持上传字体,自动子集提取

  • pyftsubset(来自 fonttools):命令行方式自动提取子集


例:使用 pyftsubset


pyftsubset original.ttf \
--unicodes=U+E001,U+E002,U+E003 \
--output-file=subset.ttf \
--flavor=woff2 \
--layout-features='*' \
--no-hinting \
--glyph-names

说明:



  • --unicodes 指定只保留的字符

  • --flavor=woff2 输出现代浏览器首选格式

  • --no-hinting 去除微调信息,减小文件体积




✅ 步骤三:只保留必要的字体格式


浏览器现代化后,建议:



  • 只保留 .woff2(现代浏览器支持)

  • 视兼容性决定是否保留 .woff(老一点的 Chrome/Firefox)

  • 移除 .eot / .svg / .ttf 除非需要极限兼容 IE6+


字体大小差异:


格式同内容文件大小
TTF40KB
WOFF28KB
WOFF210KB



✅ 步骤四:字体精简之后如何正确加载?


CSS 示例:


@font-face {
font-family: 'MyIcons';
src: url('icons.woff2') format('woff2');
font-display: swap;
}

重点字段说明



  • font-display: swap:加速首次渲染

  • format('woff2'):浏览器可判断是否支持




✅ 步骤五:如果你用的是组件库的内建 iconfont


Ant Design、Element UI、Bootstrap Icons 等往往内置大量 iconfont。优化策略如下:



  • 替换为 SVG 图标组件(例如 Iconify

  • 只引入需要的图标模块



    • Antd 4.x 以上支持按需引入图标(非字体形式)



  • 使用 Tree-shaking 友好的 SVG icon 方案



    • @iconify/react@icon-park/react






成果验证


经过上述处理:



  • 初始字体大小:20.3MB

  • 实际保留字形数量:12 个

  • 精简后字体(.woff2)大小:10.4KB

  • 首屏加载 TTI 提升:约 800ms

  • Lighthouse 性能评分:+9 分




额外干货:你可能不知道的字体优化技巧


🧠 1. 使用 base64 inline 的 icon font 并非总是好事


虽然可减少 HTTP 请求,但:



  • 无法缓存(每次 HTML 载入)

  • 增加 HTML 大小

  • 不利于 CDN 优化和延迟加载


通常只有在 icon font < 5KB 且需要打包进组件时,才考虑 base64。




🧠 2. 字体子集可以配合 SSR 实现动态优化


在 SSR 应用(如 Next.js)中,可以:



  • 在构建阶段根据页面中实际 icon 自动生成对应的字体子集

  • 动态注入只需要的 icon font,达到更极致的优化效果




🧠 3. 替代方案:彻底摆脱 icon font,用 SVG


SVG 优点:



  • 完全控制颜色/动画

  • 无需额外字体解析

  • 体积更小,支持按需加载

  • 更适合现代组件式开发(React/Vue)


推荐库:



  • Iconify(80+ 图标集统一封装)

  • unplugin-icons(Vite 项目自动加载)

  • Heroicons、Feather、Lucide、Tabler 等




你还在用几兆的 icon font,不妨静下心来,用一下午把它瘦成精悍的 10KB, 别让一堆你永远不会用到的图标,霸占用户的加载时间。


📌 你可以继续看我的系列文章



作者:ErpanOmer
来源:juejin.cn/post/7518572029404397580
收起阅读 »

一个拼写错误让整个互联网一起犯错

web
在 Web 开发的世界里,有这样一个字段——它每天默默地工作着,记录着用户的来源,保护着网站的安全,却因为一个历史性的拼写错误而成为了程序员们茶余饭后的谈资。它就是 HTTP 头部中的 Referer 字段。 什么是 HTTP Referer HTTP Ref...
继续阅读 »

在 Web 开发的世界里,有这样一个字段——它每天默默地工作着,记录着用户的来源,保护着网站的安全,却因为一个历史性的拼写错误而成为了程序员们茶余饭后的谈资。它就是 HTTP 头部中的 Referer 字段。


什么是 HTTP Referer


HTTP Referer 是一个请求头字段,用于告诉服务器用户是从哪个页面链接过来的。当你从一个网页点击链接跳转到另一个网页时,浏览器会自动在新的 HTTP 请求中添加 Referer 头,其值为上一个页面的 URL。


Referer: https://example.com/page1.html

这告诉服务器,用户是从 http://www.example.com/page1.html 这个页面跳转过来的。


图片


核心作用


1. 流量来源分析


网站运营者可以通过分析 Referer 信息了解:



  • 用户从哪些网站访问过来

  • 哪些页面是主要的流量入口

  • 外部链接的效果如何

  • 用户的浏览路径和行为习惯


2. 防盗链保护


许多网站利用 Referer 来防止其他网站直接链接自己的图片、视频等资源。服务器可以检查 Referer 是否来自允许的域名,如果不是则拒绝请求。


# nginx 图片防盗链配置
location ~* .(jpg|jpeg|png|gif|ico|css|js)$ {
    valid_referers none blocked server_names
                   *.mysite.com *.mydomain.com;
    if ($invalid_referer) {
        return 403;
    }
}

3. 安全防护


用于 CSRF 攻击防护和恶意请求检测:


# nginx CSRF 攻击防护
location /api {
    valid_referers none blocked server_names *.example.com;
    if ($invalid_referer) {
        return 403;
    }
    proxy_pass http://backend;
}

这样就可以检查请求是否来自合法域名(*.example.com)。


著名的拼写错误


图片


HTTP Referer 存在一个著名的拼写错误:正确的英文单词应该是 "Referrer",但在 1995 年制定 HTTP/1.0 规范时被误写为 "Referer"(少了一个 r)。


当错误被发现时,HTTP 协议已经广泛部署,为保持向后兼容性,这个拼写错误被永久保留:



  • HTTP 头部:使用错误拼写 Referer

  • HTML 属性:使用正确拼写 referrer


<!-- HTML中使用正确拼写 -->
<meta name="referrer" content="origin">

<!-- HTTP头中使用错误拼写 -->
Referer: https://example.com

Referrer-Policy 策略


为了解决隐私问题,W3C 制定了 Referrer Policy 规范,提供了精细的控制机制,现代浏览器支持 Referrer-Policy 来控制 Referer 的发送行为:


策略值


策略描述使用场景
no-referrer不发送 Referer最高隐私保护
no-referrer-when-downgradeHTTPS 到 HTTP 时不发送,其他情况正常发送现代浏览器默认
origin只发送协议、域名和端口平衡功能和隐私
origin-when-cross-origin同源发送完整 URL,跨域只发送域名推荐的默认策略
same-origin仅同源请求发送 Referer内部分析
strict-origin类似 origin,但 HTTPS 到 HTTP 时不发送:较少
strict-origin-when-cross-origin综合考虑安全性的策略现代浏览器默认
unsafe-url始终发送完整 URL较少

设置方法


HTTP 响应头:


res.setHeader('Referrer-Policy''strict-origin-when-cross-origin');

HTML Meta 标签:


<meta name="referrer" content="strict-origin-when-cross-origin">

元素级别控制:


<a href="https://external.com" referrerpolicy="no-referrer">外部链接</a>
<img src="image.jpg" referrerpolicy="origin">

rel 属性相关值


noreferrer


阻止发送 Referer 头:


<a href="https://external.com" rel="noreferrer">不发送Referer</a>

noopener


防止新窗口访问原窗口对象:


<a href="https://external.com" target="_blank" rel="noopener">安全新窗口</a>

nofollow


告诉搜索引擎不要跟踪链接:


<a href="https://untrusted.com" rel="nofollow">不被索引的链接</a>

组合使用


<a href="https://external.com"
   target="_blank"
   rel="noopener noreferrer nofollow">
   完全安全的外部链接
</a>

总结


HTTP Referer 虽然只是一个小小的请求头,但它承载着 Web 发展的历史,见证了互联网从功能至上到隐私保护的转变。那个著名的拼写错误也提醒我们,技术标准的制定需要更加严谨和谨慎。


作者:程序员wayn
来源:juejin.cn/post/7518783423277547572
收起阅读 »

说个多年老前端都不知道的标签正确玩法——q标签

web
最近这两天准备鼓捣一下markdown文本编辑器,现在写公众号一般用的都是 网页 的编辑器。 说实话,很方便,但是痛点也很明显。 研究过程中发现一个以前从未在意过的标签: <q> 标签。 官网解释 <p>孟子: <q>生于...
继续阅读 »

最近这两天准备鼓捣一下markdown文本编辑器,现在写公众号一般用的都是 网页 的编辑器。


说实话,很方便,但是痛点也很明显。


研究过程中发现一个以前从未在意过的标签: <q> 标签。


image.png


官网解释


<p>孟子: <q>生于忧患,死于安乐。</q></p>

说实话原生效果比较难看。


image.png


仅仅是对文本增加了双引号,并且这个双引号效果在各个浏览器中好像还存在细微的区别。


另外就是效果对于常规文本而言没有什么问题,但是对于大段文字、需要重点突出的文字而言其实比较普通,混杂在海量的文字中间很难分辨出来效果。


所以可以通过css全局修改q标签的样式,使其更符合个性化样式的需求。


q {
quotes: "「" "」";
color: #3594F7;
font-weight: bold;
}

最大限度模仿了markdown上面的样式效果。


image.png


其实上述样式中的双引号还可以被替换成图片、表情、文字等等,并且也可以通过伪元素对双引号进行操作。


q {
quotes: "🙂" "🙃";
color: #3594F7;
font-weight: bold;
}

q::before {
display: inline-block;
}

q::after {
display: inline-block;
}

q:hover::before,
q:hover::after {
animation: rotate 0.5s linear infinite;
}

@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

emi9g-9z6zo.gif


注意:伪元素上必须添加 display: inline-block; ,否则动画不生效。


原因是伪元素默认为 inline,部分css样式对 inline 是不生效的。


作者:李剑一
来源:juejin.cn/post/7516745491104481315
收起阅读 »

前端权限系统怎么做才不会写吐?我们项目踩过的 3 套失败方案总结

web
上线前两个月,我们的权限系统崩了三次。 不是接口没权限,而是: 页面展示和真实权限不一致; 权限判断写得四分五裂; 权限数据和按钮逻辑耦合得死死的,测试一改就炸。 于是,我们老老实实把整个权限体系拆了重构,从接口到路由、到组件、到 v-permission...
继续阅读 »

上线前两个月,我们的权限系统崩了三次。


不是接口没权限,而是:



  • 页面展示和真实权限不一致;

  • 权限判断写得四分五裂;

  • 权限数据和按钮逻辑耦合得死死的,测试一改就炸。


于是,我们老老实实把整个权限体系拆了重构,从接口到路由、到组件、到 v-permission 指令,走了一遍完整的流程。


结果:代码可维护,调试容易,后端调整也能快速兜底。


这篇文章不讲理论,只还原我们项目真踩过的 3 套失败方案和最终落地方案。




❌ 第一套:按钮级权限直接写死在模板里


当时我们的写法是这样的:


<!-- 用户管理页 -->
<el-button v-if="authList.includes('user:add')">添加用户</el-button>

接口返回的是一个权限数组:


["user:add", "user:delete", "user:list"]

然后整个项目几十个地方都这么判断。


结果:



  • 不能重用,每个组件都判断一次;

  • 权限粒度变更就全崩,比如从 user:add 改成 user:add_user

  • 后端权限更新后,前端要全局搜索权限 key 改代码;


典型的“写起来爽,维护时哭”方案。




❌ 第二套:用 router.meta.permission 统一控制,结果太抽象


重构后我们尝试统一控制页面级权限:


// router.ts
{
path: '/user',
component: User,
meta: {
permission: 'user:list'
}
}

再通过导航守卫:


router.beforeEach((to, from, next) => {
const p = to.meta.permission
if (p && !authList.includes(p)) {
return next('/403')
}
next()
})

这个方案页面级权限是解决了,但组件级 / 按钮级 / 表单字段级全都失效了。


而且你会发现,大量页面是“同路由但不同内容区域权限不同”,导致这种 meta.permission 方案显得太粗暴。




❌ 第三套:封装权限组件,结果被吐槽“反人类”


当时我们团队有人设计了一个组件:


<Permission code="user:add">
<el-button>添加用户</el-button>
</Permission>

这个组件内部逻辑是:


const slots = useSlots()
if (!authList.includes(props.code)) return null
return slots.default()

结果:



  • 逻辑上看似没问题,但使用非常反直觉;

  • 特别是嵌套多个组件时,调试麻烦,断点打不进真实组件;

  • TypeScript 报类型错误,编辑器无法识别 slot 类型;

  • 更麻烦的是,权限失效的时候,组件不会渲染,开发环境都看不到是为什么!




最终方案:hook + 指令 + 路由统一层级设计


我们最后把权限体系重构为 3 层:


🔹1. 接口统一管理权限 key → 后端返回精简列表(扁平权限)


export type AuthCode =
| 'user:add'
| 'user:delete'
| 'user:edit'
| 'order:export'
| 'dashboard:view'

服务端返回用户权限集,保存在 authStore(Pinia / Vuex / Context)中。




🔹2. 统一 Hook 调用:usePermission(code)


import { useAuthStore } from '@/store/auth'

export function usePermission(code: string): boolean {
const store = useAuthStore()
return store.permissionList.includes(code)
}

用法:


<el-button v-if="usePermission('user:add')">添加用户</el-button>

这才是真正组件内部逻辑干净、容易复用、TS 支持的方案。




🔹3. 封装一个 v-permission 指令(可选)


app.directive('permission', {
mounted(el, binding) {
const authList = getUserPermissions() // 从全局 store 获取
if (!authList.includes(binding.value)) {
el.remove()
}
}
})

模板中使用:


<el-button v-permission="'order:export'">导出订单</el-button>

适合动态组件、render 生成的按钮,不适合复杂嵌套逻辑,但实际项目中效果拔群。




🧪 页面级权限怎么做?


不再用 router.meta,而是把每个路由页封装为权限包裹组件:


<template>
<PermissionView code="dashboard:view">
<Dashboard />
</PermissionView>
</template>

权限组件内部处理:



  • 没权限 → 自动跳转 403

  • 有权限 → 渲染内容


这样即使权限接口变了,组件逻辑也统一保留,避免页面空白或者闪跳




权限这事,不是实现难,而是维护难。


最核心的不是你怎么控制显示,而是权限 key 的一致性、复用性、分层能力。


最终我们稳定版本满足了:



  • 页面、按钮、字段统一接入权限

  • 新增权限点只需要改枚举,不需要大改

  • 新人接手也能一眼看懂逻辑,能调试


📌 你可以继续看我的系列文章



作者:ErpanOmer
来源:juejin.cn/post/7517915625136586787
收起阅读 »

同事用了个@vue:mounted,我去官网找了半天没找到

web
前言 大家好,我是奈德丽。 上周在做代码review的时候,看到同事小李写了这样一行代码: <component :is="currentComponent" @vue:mounted="handleMounted" /> 我第一反应是:"这什么...
继续阅读 »

前言


大家好,我是奈德丽。


上周在做代码review的时候,看到同事小李写了这样一行代码:


<component :is="currentComponent" @vue:mounted="handleMounted" />

我第一反应是:"这什么语法?似曾相识的样子,有点像在vue2中用过的@hook:mounted, 但我们项目是vue3,然后去Vue3官方文档搜索@vue:mounted,结果什么都没找到,一开始我以为是他研究了源码,结果他说是百度到的,那我们一起来来研究研究这个东西吧。


从一个动态组件说起


小李的需求其实很简单:在子组件加载或更新或销毁后,需要获取组件的某些信息。这家伙是不是还看源码了,有这种骚操作,他的代码是这样的:


<template>
<div class="demo-container">
<h2>动态组件加载监控</h2>
<div class="status">当前组件状态:{{ componentStatus }}</div>

<div class="controls">
<button @click="loadComponent('ComponentA')">加载组件A</button>
<button @click="loadComponent('ComponentB')">加载组件B</button>
<button @click="unloadComponent">卸载组件</button>
</div>

<!-- 小李写的代码 -->
<component
:is="currentComponent"
v-if="currentComponent"
@vue:mounted="handleMounted"
@vue:updated="handleUpdated"
@vue:beforeUnmount="handleBeforeUnmount"
/>
</div>
</template>

<script setup>
import { ref } from 'vue'

const currentComponent = ref(null)
const componentStatus = ref('无组件')

const handleMounted = () => {
componentStatus.value = '✅ 组件已挂载'
console.log('组件挂载完成')
}

const handleUpdated = () => {
componentStatus.value = '🔄 组件已更新'
console.log('组件更新完成')
}

const handleBeforeUnmount = () => {
componentStatus.value = '❌ 组件即将卸载'
console.log('组件即将卸载')
}

const loadComponent = (name) => {
currentComponent.value = name
}

const unloadComponent = () => {
currentComponent.value = null
componentStatus.value = '无组件'
}
</script>

我仔细分析了一下,在这个动态组件的场景下,@vue:mounted确实有它的优势。最大的好处是只需要在父组件一个地方处理,不用去修改每个可能被动态加载的子组件。想象一下,如果有十几个不同的组件都可能被动态加载,你得在每个组件里都加上emit事件,维护起来确实麻烦。


而用@vue:mounted的话,所有的生命周期监听逻辑都集中在父组件这一个地方,代码看起来更集中,也更好管理。


但是,我心里还是有疑虑:这个语法为什么在官方文档里找不到?


深入探索:未文档化的功能


经过一番搜索,我在Vue的GitHub讨论区找到了答案。原来这个功能确实存在,但Vue核心团队明确表示:



"这个功能不是为用户应用程序设计的,这就是为什么我们决定不文档化它。"



引用来源:github.com/orgs/vuejs/…


换句话说:



  • ✅ 这个功能确实存在且能用

  • ❌ 但官方不保证稳定性

  • ⚠️ 可能在未来版本中被移除

  • 🚫 不推荐在生产环境使用


我们来看一下vue迁移文档中关于Vnode的部分,关键点我用下划线标红了。有趣的是这个@vue:[生命周期]语法不仅可以用在组件上,也可以用在所有虚拟节点中。


image.png


虽然在Vue 3迁移指南中有提到从@hook:(Vue 2)改为@vue:(Vue 3)的变化,但这更多是为了兼容性考虑,而不是鼓励使用。


为什么小李的代码"看起来"没问题?


回到小李的动态组件场景,@vue:mounted确实解决了问题:



  1. 集中管理 - 所有生命周期监听逻辑都在父组件一个地方

  2. 动态性强 - 不需要知道具体加载哪个组件

  3. 代码简洁 - 不需要修改每个子组件

  4. 即用即走 - 临时监听,用完就完


但问题在于,这是一个不稳定的API,随时可能被移除。


我给出的review意见


考虑到安全性和稳定性,还是以下方案靠谱


方案一:子组件主动汇报(推荐)


虽然需要修改子组件,但这是最可靠的方案:


<!-- ComponentA.vue -->
<template>
<div class="component-a">
<h3>我是组件A</h3>
<button @click="counter++">点击次数: {{ counter }}</button>
</div>
</template>

<script setup>
import { ref, onMounted, onUpdated, onBeforeUnmount } from 'vue'

const emit = defineEmits(['lifecycle'])
const counter = ref(0)

onMounted(() => {
emit('lifecycle', { type: 'mounted', componentName: 'ComponentA' })
})

onUpdated(() => {
emit('lifecycle', { type: 'updated', componentName: 'ComponentA' })
})

onBeforeUnmount(() => {
emit('lifecycle', { type: 'beforeUnmount', componentName: 'ComponentA' })
})
</script>

<!-- ComponentB.vue -->
<template>
<div class="component-b">
<h3>我是组件B</h3>
<input v-model="text" placeholder="输入文字">
<p>{{ text }}</p>
</div>
</template>

<script setup>
import { ref, onMounted, onUpdated, onBeforeUnmount } from 'vue'

const emit = defineEmits(['lifecycle'])
const text = ref('')

onMounted(() => {
emit('lifecycle', { type: 'mounted', componentName: 'ComponentB' })
})

onUpdated(() => {
emit('lifecycle', { type: 'updated', componentName: 'ComponentB' })
})

onBeforeUnmount(() => {
emit('lifecycle', { type: 'beforeUnmount', componentName: 'ComponentB' })
})
</script>

父组件使用:


<component 
:is="currentComponent"
v-if="currentComponent"
@lifecycle="handleLifecycle"
/>

<script setup>
const handleLifecycle = ({ type, componentName }) => {
const statusMap = {
mounted: '✅ 已挂载',
updated: '🔄 已更新',
beforeUnmount: '❌ 即将卸载'
}
componentStatus.value = `${componentName} ${statusMap[type]}`
console.log(`${componentName} ${type}`)
}
</script>

优点:稳定可靠,官方推荐


缺点:需要修改每个子组件,有一定的重复代码


方案二:通过ref访问(适合特定场景)


如果你确实需要访问组件实例:


<component 
:is="currentComponent"
v-if="currentComponent"
ref="dynamicComponentRef"
/>

<script setup>
import { ref, watch, nextTick } from 'vue'

const dynamicComponentRef = ref(null)

// 监听组件变化
watch(currentComponent, async (newComponent) => {
if (newComponent) {
await nextTick()
console.log('组件实例:', dynamicComponentRef.value)
componentStatus.value = '✅ 组件已挂载'
// 可以访问组件的方法和数据
if (dynamicComponentRef.value?.someMethod) {
dynamicComponentRef.value.someMethod()
}
}
}, { immediate: true })
</script>

优点:可以直接访问组件实例和方法


缺点:只能监听到挂载,无法监听更新和卸载


方案三:provide/inject(深层通信)


如果是复杂的嵌套场景,组件层级深的时候我们可以使用这个:


<!-- 父组件 -->
<script setup>
import { provide, ref } from 'vue'

const componentStatus = ref('无组件')

const lifecycleHandler = {
onMounted: (name) => {
componentStatus.value = `✅ ${name} 已挂载`
console.log(`${name} 已挂载`)
},
onUpdated: (name) => {
componentStatus.value = `🔄 ${name} 已更新`
console.log(`${name} 已更新`)
},
onBeforeUnmount: (name) => {
componentStatus.value = `❌ ${name} 即将卸载`
console.log(`${name} 即将卸载`)
}
}

provide('lifecycleHandler', lifecycleHandler)
</script>

<template>
<div>
<div class="status">{{ componentStatus }}</div>
<component :is="currentComponent" v-if="currentComponent" />
</div>
</template>

<!-- 子组件 -->
<script setup>
import { inject, onMounted, onUpdated, onBeforeUnmount } from 'vue'

const lifecycleHandler = inject('lifecycleHandler', {})
const componentName = 'ComponentA' // 每个组件设置自己的名称

onMounted(() => {
lifecycleHandler.onMounted?.(componentName)
})

onUpdated(() => {
lifecycleHandler.onUpdated?.(componentName)
})

onBeforeUnmount(() => {
lifecycleHandler.onBeforeUnmount?.(componentName)
})
</script>

优点:适合深层嵌套,可以跨多层传递


各种方案的对比


方案实现难度可靠性维护性集中管理适用场景
emit事件⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐🏆 大部分场景的首选
ref访问⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐需要调用组件方法时
provide/inject⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐深层嵌套组件通信
@vue:mounted⭐⭐⚠️ 自己项目可以玩玩,不推荐生产使用

总结


通过这次code review,我们学到了:



  1. 技术选型要考虑长远 - 不是所有能用的功能都应该用,稳定性比便利性更重要

  2. 特定场景的权衡 - 在动态组件场景下,@vue:[生命周期]确实有集中管理的优势,但要权衡风险

  3. 迁移策略很重要 - 不能一刀切,要有合理的过渡方案

  4. 代码review的价值 - 不仅仅是找bug,更是知识分享和技术决策的过程

  5. 文档化的重要性 - 未文档化的API往往意味着不稳定,使用时要谨慎


虽然@vue:[生命周期]在动态组件场景下确实好用,但从工程化角度考虑,还是建议逐步迁移到官方推荐的方案。毕竟,今天的便利可能是明天的技术债务。


当然,如果你正在维护老项目,且迁移成本较高,也可以考虑先保留现有代码,但一定要有明确的迁移计划和风险控制措施。


恩恩……懦夫的味道


作者:奈德丽
来源:juejin.cn/post/7514275553726644235
收起阅读 »

😧纳尼?前端也能做这么复杂的事情了?

web
前言 我偶然间发现一个宝藏网站,aicut.online 是一款基于本地AI实现的背景移除工具。 我研究了一下,发现他是使用了u2net模型 + onnxruntime-web实现的本地模型推理能力,下面简单介绍一下这些概念。 github:github.co...
继续阅读 »

前言


我偶然间发现一个宝藏网站,aicut.online 是一款基于本地AI实现的背景移除工具。
我研究了一下,发现他是使用了u2net模型 + onnxruntime-web实现的本地模型推理能力,下面简单介绍一下这些概念。


github:github.com/yuedud/aicu…

体验网址:aicut.online


image.png


概念


WebAssembly



  • 基本概念:  WebAssembly 是一种低级的二进制指令格式,设计目标是成为一种高效、可移植、安全的编译目标,使其能在现代 Web 浏览器中运行。你可以把它想象成一种为 Web 设计的“通用机器语言”。

  • 核心特点:



    • 高性能:  它不是解释执行的(像传统 JavaScript),而是被设计成可以以接近原生代码的速度运行。它提供线性内存模型和低级操作,便于编译器优化。

    • 可移植性:  Wasm 模块是平台无关的,可以在支持 Wasm 的任何浏览器(或运行时环境)中运行,无需修改。

    • 安全性:  它在内存安全的沙箱环境中执行,无法直接访问主机操作系统或 DOM。只能通过明确定义的 API 与宿主环境(如浏览器)交互。

    • 多语言支持:  开发者可以使用 C、C++、Rust、Go 等多种语言编写代码,然后编译成 Wasm 模块,在浏览器中运行。这使得重用现有的高性能库或编写对性能要求极高的新功能成为可能。



  • 目标:  解决 JavaScript 在处理计算密集型任务(如游戏物理引擎、视频编辑、3D渲染、科学计算、加密解密、机器学习模型推理等)时性能不足的问题,同时保持 Web 的安全性和可移植性。

  • 简单比喻:  就像为浏览器引入了一个新的、更接近硬件的“CPU 指令集”,让浏览器能直接运行编译好的高性能代码。


Onnxruntime-Web



  • 基本概念:  onnxruntime-web 是 ONNX Runtime 的一个专门构建的版本,目的是让开发者能够直接在 Web 浏览器中运行 ONNX 格式的机器学习模型

  • 核心特点:



    • ONNX 支持:  它理解并执行符合 ONNX 标准的模型文件。ONNX 是一个开放的模型格式,允许模型在各种框架之间转换和互操作。

    • 浏览器内推理:  最大的价值在于它允许 ML 模型的推理计算完全在用户的浏览器中发生,无需依赖远程服务器。这带来了低延迟、隐私保护(数据无需离开用户设备)和离线能力。

    • 多种后端执行引擎:  为了适应不同的浏览器环境、设备性能和模型需求,它提供了多种执行引擎后端:



      • WebAssembly (Wasm):  提供接近原生的性能,是主要的跨浏览器高性能后端。支持单线程和多线程(需浏览器支持)。

      • WebGL:  利用 GPU 进行加速,尤其适合某些计算模式与图形处理相似的模型(如卷积神经网络)。性能潜力高,但兼容性和精度可能不如 Wasm。

      • WebNN (预览/实验性):  旨在利用操作系统提供的原生 ML 硬件加速(如 NPU)。性能潜力最高,但目前浏览器支持有限。

      • JavaScript (CPU):  兼容性最好但速度最慢的后备方案。



    • 优化:  包含针对 Web 环境(特别是 Wasm 和 WebGL)的特定优化,以提升模型在浏览器中的运行效率。



  • 目标:  降低在 Web 应用中集成和部署机器学习模型的门槛,提供高性能、跨平台的浏览器内推理能力。

  • 简单比喻:  它是一个专门为浏览器定制的“机器学习模型运行引擎”,支持多种“驱动方式”(Wasm, WebGL, WebNN),让各种 ONNX 格式的模型能在网页里“活”起来并高效工作。


u2net



  • 基本概念:  u2net 是一种深度学习神经网络架构,特别设计用于显著目标检测任务。它的核心任务是从图像或视频中精确地分割出最吸引人注意的前景目标

  • 核心特点:



    • 嵌套 U 型结构:  这是其名称的由来(U^2-Net)。它包含一个主 U 型编码器-解码器网络,并且在每个阶段内部又嵌套了更小的 U 型块(ReSidual U-blocks, RSU)。这种设计能更有效地捕捉不同尺度的上下文信息,同时保持高分辨率的细节。

    • 多尺度特征融合:  通过嵌套的 RSU 块和跳跃连接,模型能融合来自不同深度和尺度的特征,这对精确描绘目标边界至关重要。

    • 高效性:  相比一些非常深的网络(如 ResNet),u2net 结构相对轻量,但性能优异。

    • 应用广泛:  主要用于高质量的图像/视频前景背景分割(抠图)。典型的应用包括:



      • 移除或替换图片/视频背景

      • 创建透明 PNG 图像

      • 人像分割

      • 视频会议虚拟背景

      • 图像编辑工具





  • 目标:  提供一种高效且准确的架构,解决图像中前景目标的精确分割问题。

  • 简单比喻:  u2net 是一个专门训练出来的“智能剪刀手”,它能自动识别图片里最重要的主体(比如人、动物、物体),并用极高的精度把它从背景中“剪”出来。


技术架构


架构图


+-------------------------------------------------------+
| **用户层 (Web Application)** |
+-------------------------------------------------------+
| - 用户界面 (HTML, CSS) |
| - 业务逻辑 (JavaScript/TypeScript) |
| * 捕获用户输入 (e.g., 上传图片/视频流) |
| * 调用 `onnxruntime-web` API 执行推理 |
| * 处理输出 (e.g., 显示抠图结果,合成新背景) |
+-------------------------------------------------------+
↓ (JavaScript API 调用)
+-------------------------------------------------------+
| **模型服务层 (ONNX Runtime Web)** |
+-------------------------------------------------------+
| - **onnxruntime-web** 库 (JavaScript) |
| * 加载并解析 **u2net.onnx** 模型文件 |
| * 管理输入/输出张量 (Tensor) 的内存 |
| * 调度计算任务到下层执行引擎 |
| * 提供统一的 JavaScript API 给上层应用 |
+-------------------------------------------------------+
↓ (选择最佳后端执行)
+-------------------------------------------------------+
| **执行引擎层 (Runtime Backends)** |
+-------------------------------------------------------+
| +---------------------+ +---------------------+ |
| | **WebAssembly (Wasm)** | **WebGL** | ... |
| +---------------------+ +---------------------+ |
| | * **核心加速引擎** | * 利用GPU加速 | |
| | * 接近原生CPU速度 | * 适合特定计算模式 | |
| | * 安全沙箱环境 | * 兼容性/精度限制 | |
| | * 多线程支持 (可选) | | |
| +---------------------+ +---------------------+ |
| **首选后端** **备选/补充后端** |
+-------------------------------------------------------+
↓ (执行编译后的低级代码)
+-------------------------------------------------------+
| **模型层 (U2Net 神经网络)** |
+-------------------------------------------------------+
| - **u2net.onnx** 模型文件 |
| * 包含训练好的 u2net 网络架构 (嵌套U型结构) |
| * 包含网络权重参数 |
| * 格式:开放神经网络交换格式 (ONNX) |
| * 任务:显著目标检测 / 图像抠图 |
+-------------------------------------------------------+
↓ (模型文件来源)
+-------------------------------------------------------+
| **资源层 (Browser Environment)** |
+-------------------------------------------------------+
| - 模型文件存储: HTTP Server / IndexedDB / Cache API |
| - 浏览器提供: WebAssembly 引擎, WebGL API, WebNN API |
| - 计算资源: CPU (Wasm), GPU (WebGL), NPU (WebNN) |
+-------------------------------------------------------+


详细解释



  1. 用户层 (Web Application):



    • 这是用户直接交互的网页界面。

    • 使用 JavaScript/TypeScript 编写应用逻辑。

    • 核心操作:获取用户输入(如图片或视频帧),调用 onnxruntime-web 提供的 API 来运行 u2net 模型进行抠图推理,接收模型输出的结果(通常是掩码图或透明度通道),最后将结果渲染给用户(如显示抠好的图或与背景合成)。



  2. 模型服务层 (ONNX Runtime Web):



    • 核心枢纽。这是集成到 Web 应用中的 JavaScript 库。

    • 负责加载存储在资源层中的 u2net.onnx 模型文件。

    • 管理模型运行所需的内存(准备输入 Tensor,接收输出 Tensor)。

    • 提供简洁的 JS API(如 InferenceSession.create()session.run())供上层应用调用。

    • 最关键的作用:根据浏览器支持情况和模型需求,智能选择并调度计算任务到下层的最佳执行引擎(首选通常是 WebAssembly)。



  3. 执行引擎层 (Runtime Backends):



    • onnxruntime-web 实际执行模型计算的地方

    • WebAssembly (Wasm) 后端是核心加速引擎



      • u2net 模型的计算密集型操作(卷积、矩阵乘等)被编译成高效的 Wasm 字节码。

      • Wasm 引擎在浏览器的安全沙箱中以接近原生代码的速度执行这些字节码。

      • 这是实现高性能浏览器内推理的关键,使得复杂的 u2net 模型能在用户设备上流畅运行。



    • WebGL 后端 (备选)



      • 利用 GPU 进行加速,特别适合 u2net 中大量使用的卷积操作。

      • 性能潜力高,但可能受浏览器兼容性、WebGL 精度限制和特定模型适配的影响。



    • (可选) WebNN 后端 (未来方向) :直接调用操作系统提供的底层 AI 硬件加速(如 NPU),潜力最大,但目前支持有限。



  4. 模型层 (U2Net 神经网络):



    • 包含训练好的 u2net 模型,以 ONNX 格式 (.onnx 文件)  存储。

    • ONNX 是一个开放的、框架无关的模型表示格式,使得 u2net 模型可以被 onnxruntime-web 加载和运行。

    • 这个文件包含了 u2net 独特的嵌套 U 型结构 (U^2-Net) 的定义以及训练得到的所有权重参数。

    • 它定义了具体的抠图任务如何执行。



  5. 资源层 (Browser Environment):



    • 提供模型文件 u2net.onnx 的来源(通过 HTTP 下载、存储在 IndexedDB 或利用 Cache API)。

    • 提供运行时环境:浏览器内置的 WebAssembly 引擎负责执行 Wasm 字节码,WebGL API 用于 GPU 加速,WebNN API (如果可用) 用于底层硬件加速。

    • 提供硬件计算资源:用户的 CPU (用于运行 Wasm)、GPU (用于 WebGL)、潜在的专用 AI 处理器 NPU/APU (用于 WebNN)。




源代码解析


Github:github.com/yuedud/aicu…


目录解析


image.png


public


public是存放静态资源的地方,存储了onnx模型和一些静态的资源图片


src


src是核心代码存放的地方,下面我们只来介绍一下关于抠图部分的代码,核心代码在src/components/ImageSegmentation.js


可以看到在进入网站之后,第一时间就开始加载模型,同时使用了indexedDB进行了模型缓存,二次使用的时候直接用indexedDB里获取模型,由于模型较大,所以加载时间会比较长。


  // 加载模型
useEffect(() => {
const loadModel = async () => {
try {
setError(null);
const db = await openDB();
let modelData = await getModelFromDB(db);
if (modelData) {
console.log('从IndexedDB加载模型.');
} else {
console.log('IndexedDB中未找到模型,从网络下载...');
const response = await fetch('./u2net.onnx');
if (!response.ok) {
throw new Error(`网络请求模型失败: ${response.status} ${response.statusText}`);
}
modelData = await response.arrayBuffer();
console.log('模型下载完成,存入IndexedDB...');
await storeModelInDB(db, modelData);
console.log('模型已存入IndexedDB.');
}

const newSession = await ort.InferenceSession.create(modelData, {
executionProviders: ['wasm'], // 'webgl' 或 'wasm'
graphOptimizationLevel: 'all',
});
setSession(newSession);
console.log('ONNX模型加载并初始化成功');
} catch (e) {
console.error('ONNX模型加载或初始化失败:', e);
setError(`模型处理失败: ${e.message}`);
}
};
loadModel();
}, []);

然后可以看到在上传完图片之后进行了图片的预处理,主要是将图片转换成了模型的入参Tensor


  const preprocess = async (imgElement) => {
const canvas = document.createElement('canvas');
const modelWidth = 320;
const modelHeight = 320;
canvas.width = modelWidth;
canvas.height = modelHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgElement, 0, 0, modelWidth, modelHeight);
const imageData = ctx.getImageData(0, 0, modelWidth, modelHeight);
const data = imageData.data;

const float32Data = new Float32Array(1 * 3 * modelHeight * modelWidth);
const mean = [0.485, 0.456, 0.406];
const std = [0.229, 0.224, 0.225];

for (let i = 0; i < modelHeight * modelWidth; i++) {
float32Data[i] = (data[i * 4] / 255 - mean[0]) / std[0]; // R
float32Data[i + modelHeight * modelWidth] = (data[i * 4 + 1] / 255 - mean[1]) / std[1]; // G
float32Data[i + 2 * modelHeight * modelWidth] = (data[i * 4 + 2] / 255 - mean[2]) / std[2]; // B
}
return new ort.Tensor('float32', float32Data, [1, 3, modelHeight, modelWidth]);
};

然后就是将模型的入参放到模型中去推理


  const runSegmentation = async () => {
if (!image || !session) {
setError('请先上传图片并等待模型加载完成。');
return;
}
setError(null);
setOutputImage(null);

try {
const imgElement = imageRef.current;
if (!imgElement) {
throw new Error('图片元素未找到。');
}

// 确保图片完全加载
if (!imgElement.complete) {
await new Promise(resolve => { imgElement.onload = resolve; });
}

const inputTensor = await preprocess(imgElement);
const feeds = { 'input.1': inputTensor }; // 确保输入名称与模型一致
const results = await session.run(feeds);
const outputTensor = results[session.outputNames[0]];
const outputDataURL = postprocess(outputTensor, imgElement);
setOutputImage(outputDataURL);
} catch (e) {
console.error('抠图失败:', e);
setError(`抠图处理失败: ${e.message}`);
}
};

当模型推理完之后,进行模型推理结果的后处理,主要是将alpha通道和原图片进行合成


  // 后处理:将模型输出转换为透明背景图像
const postprocess = (outputTensor, originalImgElement) => {
const outputData = outputTensor.data;
const [height, width] = outputTensor.dims.slice(-2); // 通常是 [1, 1, H, W]

const canvas = document.createElement('canvas');
canvas.width = originalImgElement.naturalWidth; // 使用原始图片尺寸
canvas.height = originalImgElement.naturalHeight;
const ctx = canvas.getContext('2d');

// 1. 绘制原始图片
ctx.drawImage(originalImgElement, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixelData = imageData.data;

// 2. 创建一个临时的canvas来处理和缩放mask
const maskCanvas = document.createElement('canvas');
maskCanvas.width = width; // U2Net输出mask的原始宽度
maskCanvas.height = height; // U2Net输出mask的原始高度
const maskCtx = maskCanvas.getContext('2d');
const maskImageData = maskCtx.createImageData(width, height);

// 归一化mask值 (通常U2Net输出在0-1之间,但最好检查一下)
let minVal = Infinity;
let maxVal = -Infinity;
for (let i = 0; i < outputData.length; i++) {
minVal = Math.min(minVal, outputData[i]);
maxVal = Math.max(maxVal, outputData[i]);
}

for (let i = 0; i < height * width; i++) {
let value = (outputData[i] - minVal) / (maxVal - minVal); // 归一化到 0-1
value = Math.max(0, Math.min(1, value)); // 确保在0-1范围内
const alpha = value * 255;
maskImageData.data[i * 4] = 0; // R
maskImageData.data[i * 4 + 1] = 0; // G
maskImageData.data[i * 4 + 2] = 0; // B
maskImageData.data[i * 4 + 3] = alpha; // Alpha
}
maskCtx.putImageData(maskImageData, 0, 0);

// 3. 将缩放后的mask应用到原始图像的alpha通道
// 创建一个新的canvas用于绘制最终结果,并将mask缩放到原始图像尺寸
const finalMaskCanvas = document.createElement('canvas');
finalMaskCanvas.width = originalImgElement.naturalWidth;
finalMaskCanvas.height = originalImgElement.naturalHeight;
const finalMaskCtx = finalMaskCanvas.getContext('2d');
finalMaskCtx.drawImage(maskCanvas, 0, 0, finalMaskCanvas.width, finalMaskCanvas.height);
const finalMaskData = finalMaskCtx.getImageData(0, 0, finalMaskCanvas.width, finalMaskCanvas.height);

for (let i = 0; i < pixelData.length / 4; i++) {
pixelData[i * 4 + 3] = finalMaskData.data[i * 4 + 3]; // 将mask的alpha通道应用到原始图片
}
ctx.putImageData(imageData, 0, 0);

return canvas.toDataURL();
};


至此将合成的图片渲染到屏幕上就可以了。


如何启动


首先我们要对仓库进行克隆


git clone https://github.com/yuedud/aicut.git


然后安装依赖


npm install


然后直接启动项目


npm start


启动之后你就可以在本地尝试背景移除工具。


作者:我是小七呦
来源:juejin.cn/post/7512058418623971343
收起阅读 »

UI设计不求人,对话 Figma MCP 直出UI设计稿!

web
引言 🤡 年初立过的Flag中包含一条:开发开源个人效率APP——惜命,这都半年过去了,搞得怎么样啦~ 🤣 em... 有在做的啦~ 就是进度有点慢,搞了这么久,还TM在 搞天气的模块,em... 光UI都改几次了,第二版UI: 第三版UI: 归根结底...
继续阅读 »

引言


🤡 年初立过的Flag中包含一条:开发开源个人效率APP——惜命,这都半年过去了,搞得怎么样啦~



🤣 em... 有在做的啦~



就是进度有点慢,搞了这么久,还TM在 搞天气的模块,em... 光UI都改几次了,第二版UI:



第三版UI:



归根结底还是一个字 "乱+完美主义",对自己想要的目标非常模糊,以往都是 产品经理捋清交互出原型设计师出设计稿,我照葫芦画瓢写界面就好了。🐶 而现在这两个都要我自己来做,产品功能还好,我自己梳理清楚逻辑就行,但 UI设计 这块,我是真的一窍不通,完全无从下手。🤡 上面两个界面都是写提示词让 Cursor 直接写的页面,主打一个 随缘,但也带来了问题:页面风格的不一致,上一个页面是 Material 风格,下一个页面秒变 iOS 风格,🙃 让人有一种撕裂感。


🤔 一种解法是写一堆长篇大论的 rule 来严格限制 Cursor 生成的画面风格,另一种就是自己整 UI设计稿 (原型),我选择了后者,学PS是不可能的🐶,周期太长了,搜了圈"简单UI设计工具",很多人安利用 Figma,直接B站搜 "Figma速成",选了这个快速看完:


《Figma新手教程2025|30分钟高效掌握Figma基础操作与UI设计流程》


😄 照着Up主的视频走了一遍案例,工具操作确实不复杂,然后觉得自己强得可怕💪,新建惜命项目,然后对着空白页面,我又陷入了呆滞,TM该怎么开始 ???根本不知道要弄成什么样的页面...



🤡 归根结底:工具是"术",设计理论是"道" ,关于道我一点 经验积累 都没有,这需要大量的看和模仿练习。自己画不出来,但是画得好不好看,我是能评判的,突然有一个想法:🤔 能不能让 AI线框图,我再自己调整和细化?😳 Figma 是有AI功能的,但现在只有 付费用户 能用,白嫖教育版 没法耍咯:



😏 没法用官方的AI功能,但有 MCP Server 啊!官方有一个 Dev Mode MCP,试了下不太好用🤷‍♀️:


《Introducing our Dev Mode MCP server: Bringing Figma int0 your workflow》


《Guide to the Dev Mode MCP Server》


🐶 也可能不太符合我们的场景,直接在它的 插件商店 搜了下,发现这个:Cursor Talk To Figma MCP Plugin



👍 这插件还是开源的:


sonnylazuardi/cursor-talk-to-figma-mcp


插件效果视频:


😋 体验了一下,确实是我们想要的 嘴遁出Figma设计稿的MCP,接着详细介绍下怎么用~


安装


① Clone 项目到本地


git clone https://github.com/sonnylazuardi/cursor-talk-to-figma-mcp.git

顺手让 Cursor 生成一份 详细的项目结构说明文档



😄 不难看出这个MCP主要由三部分组成:MCP服务WebSocket通信Figma插件,对具体实现感兴趣的童鞋,可以自行看下生成的文档:


《项目结构详细说明文档》


② 安装 Bun



安装完,重启下 PowerShell,键入 bun -v 查看版本号,确定是否安装成功:



③ 初始化项目


接着 cd 到项目的根目录,执行 bun setup 进行 初始化,🐶 理论上是这样,但 Windows 运行会直接报错,原因是系统 不支持直接运行.sh脚本文件



🤡 解法就是:手动执行 setup.sh 脚本里的命令:



😶 其实就是创建下 .cursor/mcp.json文件执行 bun install (😄 搞不定就问Cursor~),安装完后:



④ 启动Websocket


键入 bun socket 启动 Websocket



⑤ Cursor 配置 MCP


Chat SettingsMCP ToolsTalkToFigma (一般默认有的,没有自己就配下,很简单) → 启用



🤡 我这里启用完是红的 (正常是绿色的),说明有问题:



试了下文档中提到的 windows wsl 要去掉这行的注释:



然后 Ctrl+C 停掉 WebSocket 服务,然后再执行 bun socket,依旧爆红... 🐶 折腾了一会儿发现,是 Cursor 终端没有更新 (装了Bun要重启),重启下 Cursor 就好了:



⑥ 安装Figma桌面端 + 配置Figma 插件


点击用户头像,下拉找到 Get desktop app 进行下载安装:



打开桌面端,进入 要生成设计稿的Page,点击 Actions



底下会有弹窗,依次点击:Plugins & widgetsImport from manifest..



然后按照下图中的路径选中 manifest.json 文件:



接着点击这个插件:



会弹窗,显示正在连接上面启动的 Websocket 服务 (如果失败的话,重启试试,在 Cursor 的终端直接执行 bun socket!)



这个 Channel ID 等下 Cursor 也要用到,终端也会输出:



CursorAgent 模式,输入提示词进行链接,示例:



  • 使用channel: channel ID 连接服务和Figma进行对话

  • Talk to Figma, channel [您的Channel ID]


连接后会有输出信息:



接着让它开始整设计稿,弄个 简单的登录页 看看效果,Cursor 疯狂输出:



另一边 Figma桌面端 也是热火朝天的堆砌UI:



最终输出结果:



🐶 左上角这个 表单区域 有点迷,还有登录按钮上那个 紫色半透明圆形Shift + 鼠标 选中 这三组件



Cursor 的回答:



完全不懂这什么设计...



接着让它删掉这三,移动下组件,添加一个同意隐私协议的组件:



最终效果:



🐂🍺,Cursor 通过这个 MCP,不止能读,还能操作设计稿 👍。另外,除了用 Cursor 外,其它支持 MCP 调用的工具也是可以用的,自己做下配置就好,如:Trae、Cursor,甚至是 Cherry Studio






修改后的设计稿:



以上就是这个MCP的基本用法,🤔 感觉很适合初期,没什么灵感时,让它来搭建基本的主体框架,然后自己再此基础上做精细化的调整。一些常规命令示例:



  • create_rectangle:创建一个新的矩形。

  • create_ellipse:创建一个新的椭圆或圆形。

  • create_text:创建一个新的文本元素。

  • create_frame:创建一个新的框架。

  • set_fill_color:设置节点的填充颜色。

  • set_stroke_color:设置节点的描边颜色。

  • move_node:移动节点到新位置。

  • resize_node:调整节点大小。



  • set_font_name:设置文本节点的字体名称和样式。

  • set_font_size:设置文本节点的字体大小。

  • set_font_weight:设置文本节点的字体粗细。

  • set_letter_spacing:设置文本节点的字母间距。

  • set_line_height:设置文本节点的行高。

  • set_paragraph_spacing:设置文本节点的段落间距。


别人分享的提示词


💁‍♂️ 有 生成HTML页面 需求的童鞋,可以在提示词里让 Cursor 直接生成对应代码,这是别处的看到的提示词:


获取Profile的所有信息,并根据设计稿信息进行开发
- 使用HTML,Tailwindcss
- 苹果、google等大厂设计配色风格
- 生成的文件保存到`figma-demo`目录下
- 无法下载的图片可以使用`export_node_as_image`生成或者使用unsplash

😶 没这个需求,就不尝试了,生成代码也是耗费点数的,Cursor Pro 一个月才500点,根本不够花,能省一点是一点🤷‍♀️。还看到一个更全提示词,也CV下,真正需要用到的时候参考着改就好了:


你是一名大厂资深UI/UX设计专家,拥有丰富的移动端应用设计经验,精通苹果人机界面设计指南。请帮我完成一款名为`百草集`iOS App的原型设计。请按照以下要求输出一套完整的高质量Figma APP原型图:
1. 设计目标
- 创建符合苹果人机界面指南(Human Interface Guidelines)的iOS原生风格设计
- 面向中草药爱好者和自然探索者,设计简洁直观的界面
- 确保原型图能直观展示APP的功能流程和用户体验
2. 用户需求分析
- 目标用户群体:对中草药、植物学、自然疗法感兴趣的用户,包括初学者和爱好者
- 用户痛点:缺乏系统化的中草药知识、难以识别野外植物及其药用价值、无法记录和整理自己的植物观察
- 用户期望:直观的植物识别功能、个性化学习路径和推荐、社区互动和知识分享
3. 功能规划
- 主页:提供快速访问草本图鉴、观察记录和社区的入口
- 草本图鉴:分类别展示中草药,配有详细图文介绍和音频讲解
- 观察记录:记录用户在野外的植物观察,支持拍照识别和地理位置标记
- 配方推荐:基于用户兴趣推荐草本配方和使用方法
- 社区互动:分享观察、交流经验、获取专业指导
- 设置:个人信息管理、通知设置等
4. 设计规范
- 使用最新的iOS设计元素和交互模式
- 遵循iPhone 6尺寸规格(宽度750px, 高度1624px)
- 采用自然、清新的配色方案,符合草本主题氛围
- 重视无障碍设计,确保文字对比度和交互区域大小合适
- 使用简洁清晰的图标和插图风格,融入自然元素
5. 原型图呈现要求
- 使用Figma创建所有设计稿
- 为每个功能设计一个到两个屏幕,如:登录/注册、主页、草本图鉴、观察记录、配方推荐、社区互动、设置
- 每行最多排列三个屏幕,之后换行继续展示
- 为每个屏幕添加设备边框和阴影,不要遮住屏幕内的内容
- 为每个屏幕添加简短说明,解释其功能和设计考虑
6. 关键用户旅程原型屏幕
- 6.1 登录/注册屏幕
- 功能:用户可以通过邮箱、手机号或社交媒体账号登录/注册
- 设计考虑:使用简洁的表单设计,提供快速登录选项,符合iOS设计规范
- 6.2 主页屏幕
- 功能:展示主要功能入口,包括草本图鉴、观察记录、配方推荐和社区动态
- 设计考虑:采用卡片式布局,突出视觉重点,使用自然色调
- 6.3 草本图鉴屏幕
- 功能:分类展示中草药,支持搜索和筛选
- 设计考虑:使用网格布局,提供清晰的视觉层次,支持图片预览
- 6.4 植物详情屏幕
- 功能:展示植物的详细信息,包括图片、文字介绍、音频讲解
- 设计考虑:采用上下滑动的单页布局,提供丰富的多媒体内容
- 6.5 观察记录屏幕
- 功能:记录用户的植物观察,支持拍照识别和地理位置标记
- 设计考虑:使用时间线布局,提供直观的记录展示方式
- 6.6 配方推荐屏幕
- 功能:基于用户兴趣推荐草本配方,支持收藏和分享
- 设计考虑:采用卡片式布局,突出配方的视觉吸引力
- 6.7 社区互动屏幕
- 功能:用户可以发布动态、浏览社区内容、与其他用户互动
- 设计考虑:使用流式布局,支持点赞、评论等社交互动
- 6.8 设计规范概述
- 配色方案:主色调为自然绿色(#4CAF50),辅助色为棕色(#795548)和黄色(#FFC107)
- 图标:采用简洁的线性图标风格,融入自然元素
- 无障碍设计:确保文字对比度符合WCAG 2.1标准,交互区域大小合适
- 动效:使用微妙的过渡动画,提升用户体验但不干扰主要功能

😄 设计效果看起还是挺不错的:



😏 Figma 免费版:适合个人或小型团队,支持无限文件存储,但只能创建3个项目,最多2人协作,版本历史仅保留30天,不能共享设计文件进行多人实时编辑,离线时无法使用。专业版:适合2人以上设计团队,取消项目和编辑者数量限制,版本历史无限,支持团队组件库、Slack集成、私人项目等高级协作功能,价格约12-16美元/月/人(年付较便宜),可按月或按年订阅。😄 限于篇幅,怎么 白嫖专业版 可以参见另外一篇文章~


作者:coder_pig
来源:juejin.cn/post/7515231445276852239
收起阅读 »

antd 对 ai 下手了!Vue 开发者表示羡慕!

web
前端开发者应该对 Ant Design 不陌生,特别是 React 开发者,antd 应该是组件库的标配了。 近年来随着 AI 的爆火,凡是想要接入 AI 的都想搞一套自己的 AI 交互界面。专注于 AI 场景组件库的开源项目倒不是很多见,近日 antd 宣布...
继续阅读 »


前端开发者应该对 Ant Design 不陌生,特别是 React 开发者,antd 应该是组件库的标配了。


近年来随着 AI 的爆火,凡是想要接入 AI 的都想搞一套自己的 AI 交互界面。专注于 AI 场景组件库的开源项目倒不是很多见,近日 antd 宣布推出 Ant Design X 1.0 🚀 ,这是一个基于 Ant Design 的全新 AGI 组件库,使用 React 构建 AI 驱动的用户交互变得更简单了,它可以无缝集成 AI 聊天组件和 API 服务,简化 AI 界面的开发流程。


该项目已在 Github 开源,拥有 1.6K Star!



看了网友的评论,看来大家还是需要的!当前的 Ant Design X 只支持 React 项目,看来 Vue 开发者要羡慕了...



ant-design-x 特性



  • 🌈 源自企业级 AI 产品的最佳实践:基于 RICH 交互范式,提供卓越的 AI 交互体验

  • 🧩 灵活多样的原子组件:覆盖绝大部分 AI 对话场景,助力快速构建个性化 AI 交互页面

  • ⚡ 开箱即用的模型对接能力:轻松对接符合 OpenAI 标准的模型推理服务

  • 🔄 高效管理对话数据流:提供好用的数据流管理功能,让开发更高效

  • 📦 丰富的样板间支持:提供多种模板,快速启动 LUI 应用开发

  • 🛡 TypeScript 全覆盖:采用 TypeScript 开发,提供完整类型支持,提升开发体验与可靠性

  • 🎨 深度主题定制能力:支持细粒度的样式调整,满足各种场景的个性化需求


支持组件


以下圈中的部分为 ant-design-x 支持的组件。可以看到主要都是基于 AI Chat 场景的组件设计。现在你可以基于这些组件自由组装搭建一个自己的 AI 界面。



ant-design-x 也提供了一个完整 AI Chat 的 Demo 演示,可以查看 Demo 的代码并直接使用。



更多组件详细内容可参考 组件文档


使用


以下命令安装 @ant-design/x 依赖。


注意,ant-design-x 是基于 Ant Design,因此还需要安装依赖 antd


yarn add antd @ant-design/x

import React from 'react';
import {
// 消息气泡
Bubble,
// 发送框
Sender,
} from '@ant-design/x';

const messages = [
{
content: 'Hello, Ant Design X!',
role: 'user',
},
];
const App = () => (
<div>
<Bubble.List items={messages} />
<Sender />
</div>

);

export default App;

Ant Design X 前生 ProChat


不知道有没有小伙伴们使用过 ProChat,这个库后面的维护可能会有些不确定性,其维护者表示 “24 年下半年后就没有更多精力来维护这个项目了,Github 上的 Issue 存留了很多,这边只能尽量把一些恶性 Bug 修复



如上所示,也回答了其和 Ant Design X 的关系:ProChat 是 x 的前生,新用户请直接使用 x,老用户也请尽快迁移到 x


感兴趣的朋友们可以去试试哦!


作者:智见君
来源:juejin.cn/post/7444878635717443595
收起阅读 »

基于Flutter实现的小说阅读器——BITReader ,相信我你也可以变成光!

web
前言 最近感觉自己有点颓废,左思右想后觉得不能这样浪费时间,天天来摆烂。受到了群友的激励以及最近自己喜欢看小说。就想我能不能自己也做一款小说阅读器出来呢。在最开始的时候花了一段时间写了一个版本。当时用的是一个开源的接口,当我写好后使用了两天接口挂了我就只有大眼...
继续阅读 »
6d95f5df68248bb55b5b97b4502332711ff7d073.png@2560w_400h_100q_1o.webp

前言


最近感觉自己有点颓废,左思右想后觉得不能这样浪费时间,天天来摆烂。受到了群友的激励以及最近自己喜欢看小说。就想我能不能自己也做一款小说阅读器出来呢。在最开始的时候花了一段时间写了一个版本。当时用的是一个开源的接口,当我写好后使用了两天接口挂了我就只有大眼瞪小眼了。之后在 FlutterCandies里面咨询了群友,发现了一种使用外部提供书籍数据源的方法可以避免数据来源挂掉,说干就干vscode启动!




项目地址


github.com/fluttercand…


项目介绍


BITReader是一款基于Flutter实现的小说阅读器


当前功能包含:



  • 源搜索:使用内置数据来源进行搜索数据(后续更新:用户可以自行导入来源进行源搜索

  • 收藏书架

  • 阅读历史记录

  • 阅读设置:字号设置,字体颜色更改,自定义阅读背景(支持调色板自定义选择,支持image设置为背景

  • 主题设置:支持九种颜色的主题样式

  • 书籍详情:展示书籍信息以及章节目录等书籍信息




支持平台


平台是否支持
Android
IOS
Windows
MacOS
Web
Linux

项目截图


729_1x_shots_so.png
360_1x_shots_so.png
57_1x_shots_so.png
300_1x_shots_so.png
402_1x_shots_so.png

mac运行截图


CE7D99422AA2804700F33FC94D273EC7.png

windows运行截图


d7a40aa1-1572-4969-9d78-55d2abcd791b.png

项目结构


lib
├── main.dart -- 入口
├── assets -- 本地资源生成
├── base -- 请求状态、页面状态
├── db -- 数据缓存
├── icons -- 图标
├── net -- 网络请求、网络状态
├── n_pages
├── detail -- 详情页
├── home -- 首页
├── search -- 全网搜索搜索页
├── history -- 历史记录
├── read -- 小说阅读
└── like -- 收藏书架
├── pages 已废弃⚠
├── home -- 首页
├── novel -- 小说阅读
├── search -- 全网搜索
├── category -- 小说分类
├── detail_novel -- 小说详情
├── book_novel -- 书架、站源
└── collect_novel -- 小说收藏
├── route -- 路由
└── theme -- 主题管理
└── themes -- 主题颜色-9种颜色
├── tools -- 工具类 、解析工具、日志、防抖。。。
└── widget -- 自定义组件、工具 、加载、状态、图片 等。。。。。。

阅读器主要包含的模块



  • 阅读显示:文本解析,对文本进行展示处理

  • 数据解析: 数据源的解析,以及数据来源的解析(目前只支持简单数据源格式解析、后续可能会更新更多格式解析

  • 功能:阅读翻页样式、字号、背景、背景图、切换章节、收藏、历史记录、本地缓存等


阅读显示


阅读文本展示我用的是extended_text因为支持自定义效果很好。


实现的效果把文本中 “ ” 引用起来的文本自定义成我自己想要的效果样式。


class MateText extends SpecialText {
MateText(
TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap, {
this.showAtBackground = false,
required this.start,
required this.color,
}) : super(flag, '”', textStyle, onTap: onTap);
static const String flag = '“';
final int start;
final Color color;

/// whether show background for @somebody
final bool showAtBackground;

@override
InlineSpan finishText() {
final TextStyle textStyle =
this.textStyle?.copyWith(color: color) ?? const TextStyle();

final String atText = toString();

return showAtBackground
? BackgroundTextSpan(
background: Paint()..color = Colors.blue.withOpacity(0.15),
text: atText,
actualText: atText,
start: start,

///caret can move int0 special text
deleteAll: true,
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
if (onTap != null) {
onTap!(atText);
}
}))
: SpecialTextSpan(
text: atText,
actualText: atText,
start: start,
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
if (onTap != null) {
onTap!(atText);
}
}));
}
}


class NovelSpecialTextSpanBuilder extends SpecialTextSpanBuilder {
NovelSpecialTextSpanBuilder({required this.color});
Color color;
set setColor(Color c) => color = c;
@override
SpecialText? createSpecialText(String flag,
{TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap,
int? index}) {
if (flag == '') {
return null;
} else if (isStart(flag, AtText.flag)) {
return AtText(
textStyle,
onTap,
start: index! - (AtText.flag.length - 1),
color: color,
);
} else if (isStart(flag, MateText.flag)) {
return MateText(
textStyle,
onTap,
start: index! - (MateText.flag.length - 1),
color: color,
);
}
// index is end index of start flag, so text start index should be index-(flag.length-1)
return null;
}
}

数据解析编码格式转换


首先数据是有不同的编码格式,否则我们直接展示可能会导致乱码问题。
先把数据给根据查找到的编码类型来做单独的处理转换。


/// 解析html数据 解码 不同编码
static String parseHtmlDecode(dynamic htmlData) {
String resultData = gbk.decode(htmlData);
final charset = ParseSourceRule.parseCharset(htmlData: resultData) ?? "gbk";
if (charset.toLowerCase() == "utf-8" || charset.toLowerCase() == "utf8") {
resultData = utf8.decode(htmlData);
}
return resultData;
}

 static String? parseCharset({
required String htmlData,
}) {
Document document = parse(htmlData);

List<Element> metaTags = document.getElementsByTagName('meta').toList();
for (Element meta in metaTags) {
String? charset = meta.attributes['charset'];
String content = meta.attributes['content'] ??
""; //<meta http-equiv="Content-Type" content="text/html; charset=utf-8">

if (charset != null) {
return charset;
}
List<String> parts = content.split(';');
for (String part in parts) {
part = part.trim();
if (part.startsWith('charset=')) {
return part.split('=').last.trim();
}
}
}

return null;
}

数据结构解析-代码太多只展示部分


Document document = parse(htmlData);

//
List<Element> rootNodes = [];
if (rootSelector != null && rootSelector.isNotEmpty) {
//
List<String> rootParts = rootSelector.split(RegExp(r'[@>]'));
String initialPart = rootParts[0].trim();

//
if (initialPart.startsWith('class.')) {
String className = initialPart.split('.')[1];
rootNodes = document.getElementsByClassName(className).toList();
} else if (initialPart.startsWith('.')) {
String className = initialPart.substring(1);
rootNodes = document.getElementsByClassName(className).toList();
} else if (initialPart.startsWith('#')) {
String idSelector = initialPart.substring(1);
rootNodes = document.querySelectorAll('#$idSelector').toList();
} else if (initialPart.startsWith('id.')) {
String idSelector = initialPart.split('.')[1];
var element = document.querySelector('#$idSelector');
if (element != null) {
rootNodes.add(element);
}
} else if (initialPart.contains(' ')) {
String idSelector = initialPart.replaceAll(' ', ">");
var element = document.querySelector(idSelector);
if (element != null) {
rootNodes.add(element);
}
} else {
rootNodes = document.getElementsByTagName(initialPart).toList();
}

存储工具类 - 部分代码


/// shared_preferences
class PreferencesDB {
PreferencesDB._();
static final PreferencesDB instance = PreferencesDB._();
SharedPreferencesAsync? _instance;
SharedPreferencesAsync get sps => _instance ??= SharedPreferencesAsync();

/*** APP相关 ***/

/// 主题外观模式
///
/// system(默认):跟随系统 light:普通 dark:深色
static const appThemeDarkMode = 'appThemeDarkMode';

/// 多主题模式
///
/// default(默认)
static const appMultipleThemesMode = 'appMultipleThemesMode';

/// 字体大小
///
///
static const fontSize = 'fontSize';

/// 字体粗细
static const fontWeight = 'fontWeight';

/// 设置-主题外观模式
Future<void> setAppThemeDarkMode(ThemeMode themeMode) async {
await sps.setString(appThemeDarkMode, themeMode.name);
}

/// 获取-主题外观模式
Future<ThemeMode> getAppThemeDarkMode() async {
final String themeDarkMode =
await sps.getString(appThemeDarkMode) ?? 'system';
return darkThemeMode(themeDarkMode);
}

/// 设置-多主题模式
Future<void> setMultipleThemesMode(String value) async {
await sps.setString(appMultipleThemesMode, value);
}

/// 获取-多主题模式
Future<String> getMultipleThemesMode() async {
return await sps.getString(appMultipleThemesMode) ?? 'default';
}

/// 获取-fontsize 大小 默认18
Future<double> getNovelFontSize() async {
return await sps.getDouble(fontSize) ?? 18;
}

/// 设置 -fontsize 大小
Future<void> setNovelFontSize(double size) async {
await sps.setDouble(fontSize, size);
}

/// 设置-多主题模式
Future<void> setNovelFontWeight(NovelReadFontWeightEnum value) async {
await sps.setString(fontWeight, value.id);
}

/// 获取-多主题模式
Future<String> getNovelFontWeight() async {
return await sps.getString(fontWeight) ?? 'w300';
}
}

最后


特别鸣谢FlutterCandies糖果社区,也欢迎加入我们的大家庭。让我们一起学习共同进步


免责声明:本项目提供的源代码仅用学习,请勿用于商业盈利。


作者:7_bit
来源:juejin.cn/post/7433306628994940979
收起阅读 »

Cursor生成UI,加一步封神

web
用 Cursor 做 UI,有两种最简单又有效的方法,一个免费一个付费,不管你要做网页 UI 还是应用程序 UI,都能用。 我这里不推荐直接用 Cursor 自带模型生成 UI,模型生成出来的效果比较差,就算是最强的 Claude 也不太行。 本文我分享的方法...
继续阅读 »

用 Cursor 做 UI,有两种最简单又有效的方法,一个免费一个付费,不管你要做网页 UI 还是应用程序 UI,都能用。


我这里不推荐直接用 Cursor 自带模型生成 UI,模型生成出来的效果比较差,就算是最强的 Claude 也不太行。


本文我分享的方法是我最近学到的,先说免费的。当我们手头有一张 UI 图片时,不要直接丢给 Cursor,而是先用 Google 的 Gemini 模型、Claude 或者 ChatGPT,这里我用的是 Gemini 并打开 Canvas 功能。



我把 UI 图片放到 Gemini 中,然后让它根据 UI 截图生成一份 JSON 格式的设计规范文件。


提示词参考:


Create a JSON-formatted design system profile. This profile should extract relevant visualdesign information from the provided screenshots. The JSON output must specifically include:
The overarching design style (e.g., color palette, typography, spacing, visual hierarchy).The structural elements and layout principles.Any other attributes crucial for an Al to consistently replicate these design systems.Crucially, do not include the specific content or data present within the images, focusing solely


生成出来的 JSON 包含整体设计风格、结构元素、布局原则,以及一些关键属性。


接着把这份 JSON 文件复制到 Cursor 中,让 Cursor 根据这份 JSON 来生成代码。


提示词参考:


参考 @design.json 设计规范,根据图片中的样式,生成一个网页。


生成效果如下:



对比一下如果直接用 Cursor 根据截图生成代码,不用 JSON 文件。


提示词:


按照图片中的UI样式,创建一个新的页面。注意:尽可能按照图片中的样子创建!!!


效果如下:



可以看到,效果差了很多,我原型 UI 的截图如下:



这是我随便找的一张图片作为例子,可以明显看出,先提取一份 JSON 文件,然后再让 Cursor 生成代码,效果要好很多。


为什么这种先提取 JSON 文件再生成代码的方法很有效?因为当任务涉及精确、结构化、无歧义的数据时,JSON 让模型理解更清晰,处理更高效,生成的结果也更稳定。


以上就是免费的方法。


接下来是付费的方法。


如果你对 UI 要求比较高,比如需要反复修改,那我推荐直接用 v0 API。v0 模型是 Vercel 推出的,专门针对 UI 和前端开发优化,所以在处理这类任务时,v0 比 Claude、Gemini、ChatGPT 都更强。


我一般会在需要大量生成 UI 时订阅 v0,一个月 20 美金,这个月把需要的 UI 全部生成完,然后就可以退订。



订阅后去后台生成 API Key,然后在 Cursor 中调用 v0 模型即可。


在 Cursor 模型设置中,把 v0 的 API Key 填进去,v0 模型是符合 OpenAI API 规范的,所以直接选择 OpenAI 模型即可。


实际使用时,你在对话中用的是 OpenAI 模型,但后台用的其实是 v0 模型。



好了,这就是免费和付费的两种方法。


最后再推荐两个动画工具:Framer MotionReact Bits,也都是很棒的选择。


你可以把 React Bits 中动画代码直接粘贴到 Cursor 中,让模型帮你集成即可。



  • React:相当于项目经理和架构师

  • Radix UI:相当于功能工程师

  • Tailwind CSS:相当于视觉设计师

  • Framer Motion:相当于动效设计师


以上就是一套现代强大 UI 开发工具箱,大家可以根据需要组合使用!


作者:程序员NEO
来源:juejin.cn/post/7519407199765987343
收起阅读 »

Vue3.5正式上线,父传子props用法更丝滑简洁

web
前言 Vue3.5在2024-09-03正式上线,目前在Vue官网显最新版本已经是Vue3.5,其中主要包含了几个小改动,我留意到日常最常用的改动就是props了,肯定是用Vue3的人必用的,所以针对性说一下props的两个小改动使我们日常使用更加灵活。 一...
继续阅读 »

前言


Vue3.52024-09-03正式上线,目前在Vue官网显最新版本已经是Vue3.5,其中主要包含了几个小改动,我留意到日常最常用的改动就是props了,肯定是用Vue3的人必用的,所以针对性说一下props两个小改动使我们日常使用更加灵活。


image.png


一、带响应式Props解构赋值


简述: 以前我们对Props直接进行解构赋值是会失去响应式的,需要配合使用toRefs或者toRef解构才会有响应式,那么就多了toRefs或者toRef这工序,而最新Vue3.5版本已经不需要了。



这样直接解构,testCount能直接渲染显示,但会失去响应式,当我们修改testCount时页面不更新。



<template>
<div>
{{ testCount }}
</div>
</template>

<script setup>
import { defineProps } from 'vue';
const props = defineProps({
testCount: {
type: Number,
default: 0,
},
});

const { testCount } = props;
</script>


保留响应式的老写法,使用toRefs或者toRef解构



<template>
<div>
{{ testCount }}
</div>
</template>

<script setup>
import { defineProps, toRef, toRefs } from 'vue';
const props = defineProps({
testCount: {
type: Number,
default: 0,
},
});

const { testCount } = toRefs(props);
// 或者
const testCount = toRef(props, 'testCount');
</script>


最新Vue3.5写法,不借助”外力“直接解构,依然保持响应式



<template>
<div>
{{ testCount }}
</div>
</template>

<script setup>
import { defineProps } from 'vue';
const { testCount } = defineProps({
testCount: {
type: Number,
},
});

</script>

相比以前简洁了真的太多,直接解构使用省去了toRefs或者toRef


二、Props默认值新写法


简述: 以前默认值都是用default: ***去设置,现在不用了,现在只需要解构的时候直接设置默认值,不需要额外处理。



先看看旧的default: ***默认值写法



如下第12就是旧写法,其它以前Vue2也是这样设置默认值


<template>
<div>
{{ props.testCount }}
</div>
</template>

<script setup>
import { defineProps } from 'vue';
const props = defineProps({
testCount: {
type: Number,
default: 1
},
});
</script>

最新优化的写法
如下第9行,解构的时候直接一步到位设置默认值,更接近js语法的写法。


<template>
<div>
{{ testCount }}
</div>
</template>

<script setup>
import { defineProps } from 'vue';
const { testCount=18 } = defineProps({
testCount: {
type: Number,
},
});
</script>

小结


这次更新其实props的本质功能并没有改变,但写法确实变的更加丝滑好用了,props使用非常高频感觉还是有必要跟进这种更简洁的写法。如果那里写的不对或者有更好建议欢迎大佬指点啊。


作者:天天鸭
来源:juejin.cn/post/7410333135118090279
收起阅读 »

油猴+手势识别:我实现了任意网页隔空控制!

web
引言 最近我的小册《油猴脚本实战指南》上线了,很多同学都很感兴趣。 有些人学习后就私下问我,油猴既然能将任意前端js注入到当前网页中,是否能结合手势识别实现任意网页隔空控制,实现类似手机上的隔空翻页功能呢? 这是个非常好的想法,于是,我经过研究,将它实现出来...
继续阅读 »

引言


最近我的小册《油猴脚本实战指南》上线了,很多同学都很感兴趣。



有些人学习后就私下问我,油猴既然能将任意前端js注入到当前网页中,是否能结合手势识别实现任意网页隔空控制,实现类似手机上的隔空翻页功能呢?


这是个非常好的想法,于是,我经过研究,将它实现出来了!先看看脚本效果:




1️⃣ 上下翻页功能



  • 左手张开,右手可以控制网页向下翻页

  • 左手握拳,右手可以控制网页向上翻页



2️⃣ 右手可以控制一个模拟光标移动



3️⃣ 右手握拳,实现点击效果



当然,还预设了很多手势,比如双手比✌🏻关闭当前网页,左手竖起大拇指,右手实现缩放网页等效果。


实现原理


其实实现原理非常简单,就是油猴+手势识别


油猴Tampermonkey


油猴(Tampermonkey)是一款浏览器插件,允许用户在网页加载时注入自定义的 JavaScript 脚本,来增强、修改或自动化网页行为


通俗地说,借助油猴,你可以将自己的 JavaScript 代码“植入”任意网页,实现自动登录、抢单、签到、数据爬取、广告屏蔽等各种“开挂级”功能,彻底掌控页面行为。


如果你想深入了解,可以参考文章:juejin.cn/book/751468…


手势识别MediaPipe


手势识别其实已经不是一个新鲜词了,随着大模型的普及,AI识别手势非常简单方便。本示例中使用的AI模型识别,主要依赖了谷歌的MediaPipe。


MediaPipe 解决方案提供了一套库和工具,可帮助您快速在应用中应用人工智能 (AI) 和机器学习 (ML) 技术。



本示例中的demo就是借助它的手势识别能力实现的。在web中,我们可以借助MediaPipe @mediapipe/tasks-vision NPM 软件包获取手势识别器代码。


MediaPipe @mediapipe/tasks-vision

它的使用也非常简单


// Create task for image file processing:
const vision = await FilesetResolver.forVisionTasks(
// path/to/wasm/root
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm "
);
const gestureRecognizer = await GestureRecognizer.createFromOptions(vision, {
baseOptions: {
modelAssetPath: "https://storage.googleapis.com/mediapipe-tasks/gesture_recognizer/gesture_recognizer.task"
},
numHands: 2
});

如何将两者结合


借助油猴的脚本注入能力,我们能让我们的手势识别代码运行在任意网页,从而轻松实现隔空手势控制效果。


当然,脚本运行时必须开启摄像机权限,页面其实会有一个画面,但是很尴尬,于是实际脚本中,我将画面隐藏了。



手势识别的原理其实也不复杂,通过tasks-vision,我们可以拿到上图中各个关键的点的位置信息,通过判断不同点位之间的距离,实现不同的手势判断。


// 判定手势
// 手掌张开手势
function isHandOpen(hand) {
const fingers = [[8, 5], [12, 9], [16, 13], [20, 17]];
return fingers.filter(([tip, base]) => dist(hand[tip], hand[base]) > 0.1).length >= 4;
}
// 握拳手势
function isFist(hand) {
const fingers = [[8, 5], [12, 9], [16, 13], [20, 17]];
return fingers.filter(([tip, base]) => dist(hand[tip], hand[base]) < 0.06).length >= 3;
}
// 胜利手势
function isVictory(hand) {
const extended = [8, 12];
const folded = [16, 20];
return (
extended.every((i) => dist(hand[i], hand[i - 3]) > 0.1) &&
folded.every((i) => dist(hand[i], hand[i - 3]) < 0.05)
);
}

上述代码中的hand就是mediapipe/tasks-vision返回的手势信息。结合这些自定义的手势信息,我们就能实现各种花里胡哨的功能!


进一步学习


对于手势识别的学习,我们可以去学习官方的demo,在npmjs上,我们可以找到使用说明



这个包人脸识别、手势识别等非常多的功能,非常强大!


如果你对油猴脚本感兴趣,可以看看教程 油猴脚本实战指南 》, 本示例中的demo也会在这个教程中详细讲解。



当然,你也可以加我shc1139874527,我会拉你进学习交流群,一起体验油猴脚本开发的魅力!



作者:石小石Orz
来源:juejin.cn/post/7521250468267360307
收起阅读 »

三个请求,怎么实现a、b先发送,c最后发送

web
方案一:使用 Promise.all 控制并发 最直接的方法是使用Promise.all并行处理 A 和 B,待两者都完成后再发送 C。 async function fetchData() { try { // 同时发送请求A和请求B c...
继续阅读 »

方案一:使用 Promise.all 控制并发


最直接的方法是使用Promise.all并行处理 A 和 B,待两者都完成后再发送 C。


async function fetchData() {
try {
// 同时发送请求A和请求B
const [resultA, resultB] = await Promise.all([
fetchRequestA(), // 假设这是你的请求A函数
fetchRequestB() // 假设这是你的请求B函数
]);

// 请求A和B都完成后,发送请求C
const resultC = await fetchRequestC(resultA, resultB); // 请求C可能依赖A和B的结果

return resultC;
} catch (error) {
console.error('请求失败:', error);
throw error;
}
}

优点:实现简单,代码清晰

缺点:如果请求 C 不依赖 A 和 B 的结果,这种方式会增加不必要的等待时间


方案二:手动管理 Promise 并发


如果请求 C 不依赖 A 和 B 的结果,可以让 C 在 A 和 B 开始后立即发送,但在 A 和 B 都完成后再处理 C 的结果。


async function fetchData() {
try {
// 立即发送请求A、B、C
const promiseA = fetchRequestA();
const promiseB = fetchRequestB();
const promiseC = fetchRequestC();

// 等待A和B完成(不等待C)
const [resultA, resultB] = await Promise.all([promiseA, promiseB]);

// 此时A和B已完成,获取C的结果(无论C是否已完成)
const resultC = await promiseC;

return { resultA, resultB, resultC };
} catch (error) {
console.error('请求失败:', error);
throw error;
}
}

优点:C 的执行不会被 A 和 B 阻塞,适合 C 不依赖 A、B 结果的场景

缺点:代码复杂度稍高,需要确保 C 的处理逻辑确实不需要 A 和 B 的结果


方案三:使用自定义并发控制器


对于更复杂的并发控制需求,可以封装一个通用的并发控制器。


class RequestController {
constructor() {
this.runningCount = 0;
this.maxConcurrency = 2; // 最大并发数
this.queue = [];
}

async addRequest(requestFn) {
// 如果达到最大并发数,将请求放入队列等待
if (this.runningCount >= this.maxConcurrency) {
await new Promise(resolve => this.queue.push(resolve));
}

this.runningCount++;

try {
// 执行请求
const result = await requestFn();
return result;
} finally {
// 请求完成,减少并发计数
this.runningCount--;

// 如果队列中有等待的请求,取出一个继续执行
if (this.queue.length > 0) {
const next = this.queue.shift();
next();
}
}
}
}

// 使用示例
async function fetchData() {
const controller = new RequestController();

// 同时发送A和B(受并发数限制)
const promiseA = controller.addRequest(fetchRequestA);
const promiseB = controller.addRequest(fetchRequestB);

// 等待A和B完成
await Promise.all([promiseA, promiseB]);

// 发送请求C
const resultC = await fetchRequestC();

return resultC;
}

优点:灵活控制并发数,适用于更复杂的场景

缺点:需要额外的代码实现,适合作为工具类复用


选择建议



  • 如果 C 依赖 A 和 B 的结果,推荐方案一

  • 如果 C 不依赖 A 和 B 的结果,但希望 A 和 B 先完成,推荐方案二

  • 如果需要更复杂的并发控制,推荐方案三


作者:Sparkxuan
来源:juejin.cn/post/7513069939974225957
收起阅读 »

🤡什么鬼?两行代码就能适应任何屏幕?

web
你可能想不到,只用两行 CSS,就能让你的卡片、图片、内容块自动适应各种屏幕宽度,彻底摆脱复杂的媒体查询! 秘诀就是 CSS Grid 的 auto-fill 和 auto-fit。 马上教你用!✨ 🧩 基础概念 假设你有这样一个需求: 一排展示很多卡片...
继续阅读 »

你可能想不到,只用两行 CSS,就能让你的卡片、图片、内容块自动适应各种屏幕宽度,彻底摆脱复杂的媒体查询!
秘诀就是 CSS Grid 的 auto-fillauto-fit


20250428112254_rec_.gif


马上教你用!✨




🧩 基础概念


假设你有这样一个需求:



  • 一排展示很多卡片

  • 每个卡片最小宽度 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 个卡片拉伸填满整行。


20250428151427_rec_.gif


👇 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;
}

兼容性


caniuse.com/?search=aut…


image.png




🎯 什么时候用 auto-fill,什么时候用 auto-fit?



  • 希望每行“有多少内容就撑多宽”,用 auto-fit

    适合卡片式布局、相册、响应式按钮等。

  • 希望“固定列数/有占位”,用 auto-fill

    比如表格、日历,或者你希望网格始终对齐,即使内容不满。




📝 总结


属性空轨道内容拉伸适用场景
auto-fill保留固定列数、占位网格
auto-fit折叠流式布局、拉伸填充



🌟 小结



  • auto-fill 更像“占位”,auto-fit 更像“自适应”

  • 推荐大部分响应式卡片用 auto-fit

  • 善用 minmax 配合,让列宽自适应得更自然




只需两行代码,你的页面就能优雅适配各种屏幕!

觉得有用就点赞收藏吧,更多前端干货持续更新中!🚀✨


作者:前端九哥
来源:juejin.cn/post/7497895954101403688
收起阅读 »

🚀惊了,这个国产软件居然这么牛,比 uniapp 还全能❤️

web
最近跟同事闲聊,大家都在吐槽一个问题: ! App 是越做越像平台了,但开发却越做越痛苦了。 你想加个活动页,产品说今晚上线; 你想做个业务扩展,运营说要不你再写个低代码工具; 你想适配鸿蒙,领导说最好做个 React Native 得了; 同事活成了“加班工...
继续阅读 »

最近跟同事闲聊,大家都在吐槽一个问题:

App 是越做越像平台了,但开发却越做越痛苦了。


你想加个活动页,产品说今晚上线;

你想做个业务扩展,运营说要不你再写个低代码工具;

你想适配鸿蒙,领导说最好做个 React Native 得了;


同事活成了“加班工具人”,App 也做成了臃肿的 “功能集成器”。


难道开发一个通用的 App ,就非得这么累吗?


于是,我们试着去找更轻、更灵活的解决方案。


我们只是想做一个“活动页托管方案”,不想每次上线都发版,更不想因为临时需求牵扯整个开发团队。


但随着调研的深入,我们发现这种痛点其实根本不是“活动页”本身,而是:App 缺乏一个**“包容性很强的容器”**。


比如:



  • 新功能不用频繁发版;

  • 能复用现有页面或者组件;

  • 可以独立上线,不干扰主应用。


我们对比了几个方向:



  • WebView + H5:快是快,但弱得可怕,尤其是 JSBridge 管理地狱,体验不佳;

  • 低代码平台:适合特定场景,但定制性不足,复杂页面性能堪忧;

  • RN/Flutter 微模块化:维护成本太高,涉及太多客户端改动。


直到我在调研中遇到了 FinClip,才意识到这事完全可以换个方式。


让人眼前一亮的 FinClip


FinClip 是什么?


一句话说完:把小程序能力,通用化、标准化,塞进任何 App。


从技术架构来说,FinClip 提供的是一个极其轻量的小程序容器 SDK(3MB都不到),可以直接嵌进你的 iOSAndroidHarmonyOS App,甚至 React NativeFluttermacOS、车机都能跑。


强大的能力


开发者只要写一套小程序代码,放进去就能运行。不用重新适配底层系统,也不用改框架结构。


而且它兼容微信/支付宝/抖音小程序语法,意味着你过去写的项目,可能几乎零改动就能跑起来。


于是,我们立刻拉了群,软磨硬泡,搞来了二组同事开发的活动页项目,


这个是开源的活动页


需要的同学请戳这里:github.com/FernAbby/H5…


导入之后


然后通过 FinClip Studio 打包上传,再嵌入 App


FinClip Studio,真的有点香


讲真,刚开始用 FinClip Studio,我也做好了“将就一下”的心理准备。


结果没想到是真香警告。


首先,新建项目一键生成模板,跟微信小程序开发工具 99% 像;


创建工程


你也可以和我一样选择导入已有的项目,


导入之后


其次,模拟器支持多终端调试,拖拉缩放,全程无需真机;


另外,发布打包一条龙服务,你只需要上传至云端后台:


上传


输入相关上传信息:


上传信息


等待上传成功即可!


上传成功


后台是平台运营的“指挥中心”


接下来的重头戏,需要我们登陆后台系统,


一个超级应用不是靠开发者单打独斗,而是靠多个角色协同。FinClip 的后台做得非常细腻,功能齐全,不管是开发还是运维同学,都可以轻松驾驭!


首页后台


小程序管理模块,不仅可以新建、管理前面上传的小程序,还可以体验预览版、发布审核;


首先,在隐私设置栏目里设置隐私保护指引:


配置隐私设置


然后我们就可以配置审核版本或者体验版本了!


审核流程


体验发布


接着我们就可以直接跳转到待办中心通过审核!


通过审核


除此之外,常用的灰度发布、权限范围、自动上架全都支持;


小程序详情


数据分析清晰易读,不需要 BI 工具也能看懂;


数据看板


页面数据


让你不再为如何做好运维而发愁!


用了一周的真实感受


流程


使用一周多了,整体的流程是这样的:



  1. 本地写代码,IDE 模拟器预览;

  2. 上传代码,后台提交审核;

  3. 设置灰度策略,用户扫码体验;

  4. 最终发布上线。


优点


我们没改动原生代码,甚至没有重新接入任何 SDK,只是增加一个容器模块 + 几行配置。


团队有个原来的 RN 老项目,直接用 FinClip 的容器跑起来,居然都不用重写,兼容度真的惊人。


缺点


但是缺点也有:


比如,导入已有项目会进行检测,并且明确的告知用户,其实可以后台默认执行,用户体验会更好!


导入


检测过程


另外最主要的是,后台和编辑器的登陆状态是临时的,不会长期保持!每次登陆挺麻烦的


彩蛋


首先,FinClip 贴心的内置了 AI 助手,你使用过程遇到的任何问题都可以在这里找到答案!


内置的 AI 助手


最重要的是,FinClip 提供了基于接口的 AI 能力,可以通过 RAG 技术为小程序注入了智能化能力,涵盖内容生成、上下文交互、数据分析等多方面功能。


这不仅提升了用户体验,也为开发者提供了便捷的 AI 集成工具,进一步增强了 FinClip 生态的竞争力!


基于 RAG 的 AI 能力


总结


如果再给我造一次 App 的机会,我一定毫不犹豫地选择 FinClip


当我们从“做功能”切换到“建生态”,思路就会完全不一样:



  • App 不再是“巨石应用”,而是一个个业务模块的拼图

  • 小程序就像“微服务 UI 化”,能独立更新、上线、下架

  • 技术架构也从“一体化耦合”变成“解耦 + 动态加载”


FinClip 帮助开发者从“重复搬砖” 变成 “生态平台管理员”!


如果你也有和我一样的困惑,你也可以试试:



  • 把一个已有的活动页,用 FinClip 打包成小程序;

  • 嵌进你现有 App 中,再用 FinClip Studio 发布版本;

  • 后台配置白名单,手机扫码预览。


1 天内,你就能体验一把“做平台”的感觉。


时代正在变化。我们不该再为“发布一个功能”耗尽精力,而应该把更多时间留给真正重要的东西 —— 创新、体验、增长。


FinClip 不只是工具,更是重构开发者角色的机会。


你准备好了吗?


作者:萌萌哒草头将军
来源:juejin.cn/post/7493798605658816553
收起阅读 »

老板让我弄3d,从0学建模😂

web
blender导出的轿车🚗.glb: 最近因为有需求,不得不搞起3d这一块,说到3d,以为是学一个threejs就够了,结果是blender也要学。 blender可能有的前端开发或者后端开发没了解过,简单得说就是捏3d模型的这么一个东西。 经常听人家说...
继续阅读 »

blender导出的轿车🚗.glb


1.gif



最近因为有需求,不得不搞起3d这一块,说到3d,以为是学一个threejs就够了,结果是blender也要学。



blender可能有的前端开发或者后端开发没了解过,简单得说就是捏3d模型的这么一个东西。


经常听人家说建模建模,就是这个东西来着。


下载下来就是这么一个软件👇🏻:


image.png


通过对blender的学习可以做很多东西,那blender究竟可以做什么。要想知道能做什么,就要先知道blender是个啥。


blender是一个永久开源且免费的三维创作软件,支持建模雕刻骨骼装配、动画模拟实时渲染合成和运动跟踪等等三维创作


推荐一下大家一些现成的模型网站或插件或者材质贴图等:



🔴 入门


⭕︎ 课程内容与目标



  • 学习基本设置、模型变换、建模、UV编辑、材质与贴图、渲染等核心流程

  • 掌握独立制作初级3D模型的能力




核心学习点


观察 -> 辅助建模,提供更好的视觉策略

变换 -> 基本变化,实现移动、旋转、复制图像等移动策略

建模 -> 重塑多边形,杜绝线建模障碍

修改器 -> 提供更便捷的迭代可能性

uv -> 纹理图层的映射到表面的方法

材质 -> 基本材质的属性设置,只需参照别人设置的方式即可

渲染 -> 了解基本的渲染设置,灯光




不要被界面中无关的设置项影响。每个三维软件都是复杂的,但是目的只是为了满足不同人的不同需求。使用时,只需要按照方法简单设置一些需要的参数即可。别的参数默认即可。


如果你看文字太多,觉得烦躁,那就记得:没有太多欲望的话,我们目的就是实现建模,表现它的材质,把它渲染出来就可以了。三点:建模->材质->渲染


⭕︎ Blender核心优势



  1. 轻量化设计:相比传统3D软件更轻便快捷

  2. 开源免费:完全免费且持续更新

  3. 社区生态

    • 开放社区支持

    • 原生支持GLB等现代格式

    • 丰富插件生态



  4. 发展前景:在开源3D工具中处于领先地位


⭕︎ 基础设置指南



  1. 软件下载

    官方下载链接:http://www.blender.org/download/

  2. 中文设置

    路径:偏好设置 > 界面 > 翻译 > 勾选"中文(简体)"

  3. 默认间隔多久保存 : 可设置。不怕断电、崩溃、找不到正在做而没有保存到文件。


image.png


⭕︎ 视口操作


快捷键ESC下面的波浪键,英文模式下:
image.png


flowchart TD
B[基本视图控制]

B --> B1[旋转视图]
B1 -->|操作方式| 鼠标中键
B1 -->|效果| 围绕视点中心旋转

B --> B2[平移视图]
B2 -->|操作方式| Shift+鼠标中键
B2 -->|效果| 平移观察视角

B --> B3[缩放视图]
B3 -->|操作方式1| 滚动鼠标中键
B3 -->|操作方式2| Shift+B框选缩放

B --> B4[快捷键ESC下面的波浪键,英文模式下]

style B fill:#4b8bf5

🔴 基础操作


⭕︎ 语言的设置


image.png


image.png


⭕︎ 场景设置单位


image.png


⭕︎ 文件栏


文件 - 编辑 - 渲染 - 窗口 - 帮助


image.png


⭕︎ 工作台


image.png


比如说uv编辑器:


image.png


比如说贴图


image.png


比如说着色器


image.png


比如编辑多边形的工具台:


image.png


image.png


⭕︎ 快捷键操作


按住鼠标中键 -> 旋转


按住鼠标中键 + shift -> 平移


鼠标中键滚动 -> 放大缩小


⭕︎ 不同视口查看


切换四格图:


image.png


image.png


shift+a创建一个网格:


image.png


ctrl+alt+q切换成四格图,同样再按一遍就是退出四格图。


image.png


如果需要查看更多的视图,也可以按一下Tab上面的波浪键,像这样:


image.png


(按住左键长按选中某个物体,可以单独查看选中物体的视图。)


接下来看一下这些视图的小图标,具体代表什么,如果有不太会的(大家可以鼠标悬浮在图标上面,它会给出具体的提示,然后大家可以每个小图标点一下试一试,不用害怕软件会崩盘,怎么弄软件都不会出事,自己可以多研究研究,即使崩了也可以重下载,放心大胆去试):
image.png


一个是叠加层:可以添加线框,统计信息等辅助观察。


一个是视图着色方式。


🔴 基本体


点击文件->新建->常规,之前的文件看需求看看要不要保存。


默认会出现一个立方体,我们按x键,它会提示我们要删除这个物体吗?我们先删掉这个立方体。


image.png


上面我们说过,shift+a可以弹出一个面板:


image.png


这样子,我们先创建一个立方体网格+立方体


游标(在游标模式下,可以任意拖动游标):


image.png


或者在选择模式下,按住shift+右键也是可以拖动游标的。


拖动游标,然后去新建一个立方体,我们会发现物体会创建在以游标为中心的位置。所以我们去创建一个物体,首先先要把游标的位置给设好,创新物体就会直接在游标那个位置了。


物体的设置面板


image.png


选择某些或者某个物体,按住左键进行框选即可。


有时候选择的时候会发现框选住的,有一个是红的,一个是黄的。黄的是后加选上的,可以作为移动物体这样子。
image.png


a键就是全选。ctrl + i就是反选。shift是加选。


🔴 基本变换



  1. 基础操作

    鼠标中键旋转视图

    shiftA:新建立方体

    shift+中键上下,左右移动视图

    鼠标滚轮放大缩小视图

    G:移动物体 GX/GY/GZ=(沿着x、y、z轴移动)

    R:旋转物体 RX/RY/RZ=(沿着x、y、z轴旋转)

    S:缩放物体 SX/SY/SZ=(沿着x、y、z轴缩放)

  2. 设置界面布局,保存窗口布局.

  3. 小键盘“0”摄像机视角

  4. “N”收放右边菜单栏


纸上得来终觉浅,我们还是得多动手去尝试尝试,就算是做一个小物件小物体,前期也会觉得会有满满的成就感,用某个操作键的知识特定得做一个小小练习。


⭕︎ 对齐、捕捉、复制


选中圆锥体,然后按shift选中平面。


那么圆锥体就是选中项,然后平面就是活动项


image.png


image.png


圆锥体相对于平面这个活动体Z轴对齐:
image.png


吸附相关:
image.png


shift+D复制选中物体。ctrl+c + ctrl+v也可以复制粘贴物体。


作为一款程序员或者建筑设计行业的一款建模软件来讲,跟我们在学校里学的photoshop一样,需要投入主动学习成本,还有一些习惯上的成本比如一些快捷键取代图形化界面是非常有必要的。


到最后再结合去做three.js或者cesium模型加载展示、材质处理和动画。


image.png


🔴 总结


当然,除了blender,还有很多优秀的流行的3d渲染软件:



  • blender

  • 3dx Max

  • Maya

  • Cinema4D

  • KeyShot


一些室内设计师用的: cad酷家乐(要钱)


我们这篇讲的是blenderthreejs的结合。就是说blender负责建模和导出threejs负责加载和交互,去做出交互式3d网页应用


⭕︎ 流程


⭕︎ 1、blender导出:


blender中创建模型,然后导出格式为.glb(二进制格式,包含材质、动画等)或.gltf


image.png


image.png


⭕︎ 2、加载模型并交互:


// 导出默认函数,用于创建城市场景
export default function createCity() {
// 创建GLTF加载器实例,用于加载.glb/.gltf格式的3D模型
const gltfLoader = new GLTFLoader();

// 加载城市模型文件
gltfLoader.load("./model/city.glb", (gltf) => {
// 遍历模型中的所有子对象
gltf.scene.traverse((item) => {
// 只处理网格类型(Mesh)的对象
if (item.type == "Mesh") {
console.log(item); // 调试用,打印网格信息

// 创建新的基础材质并设置为深蓝色
const cityMaterial = new THREE.MeshBasicMaterial({
color: new THREE.Color(0x0c0e33), // 十六进制颜色值
});
item.material = cityMaterial; // 应用新材质到当前网格

// 调用自定义函数修改城市材质(函数实现未展示)
modifyCityMaterial(item);

// 特殊处理名为"Layerbuildings"的网格
if (item.name == "Layerbuildings") {
// 使用MeshLine库创建线框效果(需额外引入MeshLine库)
const meshLine = new MeshLine(item.geometry);
const size = item.scale.x; // 获取原始缩放值
meshLine.mesh.scale.set(size, size, size); // 保持原始比例
scene.add(meshLine.mesh); // 将线框添加到场景
}
}
});

// 将整个模型添加到场景中
scene.add(gltf.scene);

// 以下是被注释掉的可选效果,可根据需要取消注释:

// 添加普通飞线效果
const flyLine = new FlyLine();
scene.add(flyLine.mesh);

// 添加着色器实现的飞线(性能更好)
const flyLineShader = new FlyLineShader();
scene.add(flyLineShader.mesh);

// 添加雷达扫描效果
const lightRadar = new LightRadar();
scene.add(lightRadar.mesh);

// 添加光墙效果
const lightWall = new LightWall();
scene.add(lightWall.mesh);

// 添加可交互的警告标识
const alarmSprite = new AlarmSprite();
scene.add(alarmSprite.mesh);
// 绑定点击事件
alarmSprite.onClick(function (e) {
console.log("警告", e); // 点击时触发
});
});
}

1.gif


作者:curdcv_po
来源:juejin.cn/post/7518932901699223592
收起阅读 »

前端佬们!塌房了!用过Element-Plus的进来~

web
原有的结论有问题,这是最新的弥补篇。如果您还没有看过,可以先看新篇提出的问题再看这个。如果您已看过,可以看下最新篇。希望不会给您带来困扰,随时接受大佬们的批评。 新篇戳这里。更崩溃!!感觉被偷家了!Element-plus组件测试的后续~ ----------...
继续阅读 »

原有的结论有问题,这是最新的弥补篇。如果您还没有看过,可以先看新篇提出的问题再看这个。如果您已看过,可以看下最新篇。希望不会给您带来困扰,随时接受大佬们的批评。


新篇戳这里。更崩溃!!感觉被偷家了!Element-plus组件测试的后续~


---------------------以下为原文---------------------------


进来着急的前端佬,我直接抛出结论吧!


Element-plus的组件,经过测验,如下组件存在内存泄漏。如下:



  • el-carousel

  • el-select + el-options

  • el-descriptions

  • el-tag

  • el-dialog

  • el-notification

  • el-loading

  • el-result

  • el-message

  • el-button

  • el-tabs

  • el-menu

  • el-popper


验证环境为:



Vue Version: 3.5.13

Element Plus Version: 2.9.7

Browser / OS: window 10 / Edge 134.0.3124.85 (正式版本) (64 位)

Build Tool: Webpack



不排查ElementUI也存在这个问题。


好了。接下来细细聊。


pcJpq404.gif


前因


为什么检测到这种问题?主要因为一个项目引用了Element-plus。然后,你懂的,买的人永远都会想要最好的,然后买的人就这么一顿狂点Web页面,看见内存占用飙到老高。


于是...前端佬都懂的,来活了。


7166eec470f04755a2f52fe819a62493.gif


排查


一开始我是不敢怀疑这种高star开源组件的。总以为自己是写的代码有问题。


详细代码就不贴了,主要用ElDialog组件,封装成一个命令式的Dialog组件,避免频繁的使用v-modal参数。


然后,就直接怀疑上这个组件了。


经过测试,果不其然,从关闭到销毁,会导致内存猛增,因为Dialog中有各种表单组件,一打开就创建了一大堆的Element元素。


image.png


精确定位,使用了FinalizationRegistry类追踪创建的Dialog实体,代码如下:


const finalizerRegistry = new FinalizationRegistry((heldValue) => {
console.log('Finalizing instance: ',heldValue);
});


// 在创建处监听
const heldValue = Symbol(`DialogCommandComponent_${Date.now()}`);
finalizerRegistry.register(this, heldValue);
console.log(`Constructed instance:`,heldValue);

发现一直没有Constructed instance销毁的信息输出。


随后,使用了Edge浏览器中的分离元素来打快照,步骤如下图。


image.png


经过反复的操作,然后点击主动垃圾回收,然后发现el-dialog的元素都会增加,基本确认无疑了。


但还是怀疑,会不会是Dialog中,引用的问题,导致元素一直没能销毁?所以,使用了纯纯的el-dialog来校验,同样的操作,既然如故。


a4.jpg


然后的然后,我使用了如下的代码,去校验其它组件是否存在同样的问题。代码如下:


<template>
<div>
<el-button @click="fn2">Reset</el-button>
</div>
<el-dialog v-model="model" destroy-on-close @closed="fn1" append-to-body v-if="destroyDialogModelValue"></el-dialog>
<el-button @click="fn0" v-if="!button" primse>Click</el-button>
<div class="weak" v-if="!button">xxx</div>
<el-input v-if="!button" />
<el-border v-if="!button" />
<el-select v-if="!button">
<el-option>1111</el-option>
</el-select>
<el-switch v-if="!button" />
<el-radio v-if="!button" />
<el-rate v-if="!button" />
<el-slider v-if="!button" />
<el-time-picker v-if="!button" />
<el-time-select v-if="!button" />
<el-transfer v-if="!button" />
<el-tree-select v-if="!button" />
<el-calendar v-if="!button" />
<el-card v-if="!button" />
<el-carousel height="150px" v-if="!button">
<el-carousel-item v-for="item in 4" :key="item">
<h3 class="small justify-center" text="2xl">{{ item }}</h3>
</el-carousel-item>
</el-carousel>
<el-descriptions title="User Info" v-if="!button">
<el-descriptions-item label="Username">kooriookami</el-descriptions-item>
</el-descriptions>
<el-table style="width: 100%" v-if="!button">
<el-table-column prop="date" label="Date" width="180" />
<el-table-column prop="name" label="Name" width="180" />
<el-table-column prop="address" label="Address" />
</el-table>
<el-avatar v-if="!button" />
<el-pagination layout="prev, pager, next" :total="50" v-if="!button" />
<el-progress :percentage="50" v-if="!button" />
<el-result icon="success" title="Success Tip" sub-title="Please follow the instructions" v-if="!button">
<template #extra>
<el-button type="primary">Back</el-button>
</template>
</el-result>
<el-skeleton v-if="!button" />
<el-tag v-if="!button" />
<el-timeline v-if="!button" />
<el-tree v-if="!button" />
<el-avatar v-if="!button" />
<el-segmented size="large" v-if="!button" />
<el-dropdown v-if="!button">
<span class="el-dropdown-link">
Dropdown List
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>Action 1</el-dropdown-item>
<el-dropdown-item>Action 2</el-dropdown-item>
<el-dropdown-item>Action 3</el-dropdown-item>
<el-dropdown-item disabled>Action 4</el-dropdown-item>
<el-dropdown-item divided>Action 5</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-menu class="el-menu-demo" mode="horizontal" v-if="!button">
<el-menu-item index="1">Processing Center</el-menu-item>
<el-sub-menu index="2">
<template #title>Workspace</template>
<el-menu-item index="2-1">item one</el-menu-item>
<el-menu-item index="2-2">item two</el-menu-item>
<el-menu-item index="2-3">item three</el-menu-item>
<el-sub-menu index="2-4">
<template #title>item four</template>
<el-menu-item index="2-4-1">item one</el-menu-item>
<el-menu-item index="2-4-2">item two</el-menu-item>
<el-menu-item index="2-4-3">item three</el-menu-item>
</el-sub-menu>
</el-sub-menu>
<el-menu-item index="3" disabled>Info</el-menu-item>
<el-menu-item index="4">Orders</el-menu-item>
</el-menu>

<el-steps style="max-width: 600px" active="0" finish-status="success" v-if="!button">
<el-step title="Step 1" />
<el-step title="Step 2" />
<el-step title="Step 3" />
</el-steps>

<el-tabs class="demo-tabs" v-if="!button">
<el-tab-pane label="User" name="first">User</el-tab-pane>
<el-tab-pane label="Config" name="second">Config</el-tab-pane>
<el-tab-pane label="Role" name="third">Role</el-tab-pane>
<el-tab-pane label="Task" name="fourth">Task</el-tab-pane>
</el-tabs>

<el-alert title="Success alert" type="success" v-if="!button" />
<el-drawer title="I am the title" v-if="!button">
<span>Hi, there!</span>
</el-drawer>

<div v-loading="model" v-if="!button"></div>

<el-popconfirm confirm-button-text="Yes" cancel-button-text="No" icon-color="#626AEF"
title="Are you sure to delete this?" v-if="!button">
<template #reference>
<el-button>Delete</el-button>
</template>
</el-popconfirm>

<el-popover class="box-item" title="Title" content="Top Center prompts info" placement="top" v-if="!button">
<template #reference>
<div>top</div>
</template>
</el-popover>

<el-tooltip class="box-item" effect="dark" content="Top Left prompts info" placement="top-start" v-if="!button">
<div>top-start</div>
</el-tooltip>
</template>

<script setup>
import { ref } from "vue";
import { ElMessage, ElMessageBox, ElNotification } from "element-plus";

const model = ref(false);
const destroyDialogModelValue = ref(false);
const button = ref(false);

function fn0() {
model.value = true;
destroyDialogModelValue.value = true;
ElMessage("This is a message.");
ElMessageBox.alert("This is a message", "Title");
ElNotification({
title: "Title",
message: "This is a reminder",
});
}
function fn1() {
console.log("closed");
destroyDialogModelValue.value = false;
button.value = true;
}
function reset() {
model.value = false
}
</script>

<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>


如上代码,进入页面后,点击click,然后关闭所有的弹窗。然后再次点击reset按钮,然后再次点击click,关闭所有弹窗。如此可以多操作几次。


就发现了开头的组件,都存在内存泄漏问题。


未能解决


有问题,当然首先看看别人有没有出现过。各种搜索就不说了,大掘金也搜过,在Element-plus的github仓里的Issues中找过,发现的办法基本无用。


以下是自己思考的几条路子:



  1. 有泄漏的,都手搓一个?

  2. Eldialog全局只用一到两个?

  3. 将所有路由,都打成一个单页面(html)。

  4. 改源码....


结尾


还是在这里,求助大佬,看以上思路是否有错,然后跪求orz解决办法。


自己后续如果解决对应一些问题,会即时和大家分享。


作者:大怪v
来源:juejin.cn/post/7485966905418760227
收起阅读 »

Vue实现一个“液态玻璃”效果登录卡片

web
Vue实现一个“液态玻璃”效果登录卡片 效果介绍 液态玻璃(Liquid Glass)是一种极具现代感的UI视觉风格,常见于高端网站和操作系统界面。它通过多层叠加、模糊、光泽、滤镜等技术,模拟出玻璃的通透、折射和高光质感。苹果的这次系统设计更新,带火了这一设计...
继续阅读 »

Vue实现一个“液态玻璃”效果登录卡片


效果介绍


液态玻璃(Liquid Glass)是一种极具现代感的UI视觉风格,常见于高端网站和操作系统界面。它通过多层叠加、模糊、光泽、滤镜等技术,模拟出玻璃的通透、折射和高光质感。苹果的这次系统设计更新,带火了这一设计效果,本教程将带你一步步实现一个带有3D灵动倾斜交互的液态玻璃登录卡片。


实际效果:


PixPin_2025-06-14_23-07-12.gif




技术原理解析


1. 多层叠加


液态玻璃效果的核心是多层视觉叠加:



  • 模糊层(blur):让背景内容变得虚化,产生玻璃的通透感。

  • 色调层(tint):为玻璃加上一层淡淡的色彩,提升质感。

  • 高光层(shine):模拟玻璃边缘的高光和内阴影,增强立体感。

  • SVG滤镜:通过 SVG 的 feTurbulencefeDisplacementMap,让玻璃表面产生微妙的扭曲和流动感。


2. 3D灵动倾斜


通过监听鼠标在卡片上的移动,动态计算并设置 transform: perspective(...) rotateX(...) rotateY(...),让卡片随鼠标灵动倾斜,增强交互体验。


3. 背景与环境


背景可以是渐变色,也可以是图片。玻璃卡片通过 backdrop-filter 与背景内容产生交互,形成真实的玻璃质感。




实现步骤详解


1. 结构搭建


<template>
<div class="login-container animated-background">
<!-- SVG滤镜库 -->
<svg style="display: none">...</svg>
<!-- 登录卡片 -->
<div
class="glass-component login-card"
ref="tiltCard"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
>
<div class="glass-effect"></div>
<div class="glass-tint"></div>
<div class="glass-shine"></div>
<div class="glass-content">
<!-- 登录表单内容 -->
</div>
</div>
</div>
</template>

2. SVG滤镜实现液态扭曲


<svg style="display: none">
<filter id="glass-distortion" x="0%" y="0%" width="100%" height="100%" filterUnits="objectBoundingBox">
<feTurbulence type="fractalNoise" baseFrequency="0.001 0.005" numOctaves="1" seed="17" result="turbulence" />
<feComponentTransfer in="turbulence" result="mapped">
<feFuncR type="gamma" amplitude="1" exponent="10" offset="0.5" />
<feFuncG type="gamma" amplitude="0" exponent="1" offset="0" />
<feFuncB type="gamma" amplitude="0" exponent="1" offset="0.5" />
</feComponentTransfer>
<feGaussianBlur in="turbulence" stdDeviation="3" result="softMap" />
<feSpecularLighting in="softMap" surfaceScale="5" specularConstant="1" specularExponent="100" lighting-color="white" result="specLight">
<fePointLight x="-200" y="-200" z="300" />
</feSpecularLighting>
<feComposite in="specLight" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="litImage" />
<feDisplacementMap in="SourceGraphic" in2="softMap" scale="200" xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>


  • 这段 SVG 代码必须放在页面结构内,供 CSS filter 调用。


3. 背景设置


.animated-background {
width: 100vw;
height: 100vh;
background-image: url('你的背景图片路径');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
position: fixed;
top: 0;
left: 0;
z-index: -1;
}


  • 建议用高质量渐变或壁纸,能更好衬托玻璃质感。


4. 卡片多层玻璃结构


.login-card {
width: 400px;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.10), 0 1.5px 6px 0 rgba(0,0,0,0.08);
background: transparent;
position: relative;
}
.glass-effect {
position: absolute;
inset: 0;
z-index: 0;
backdrop-filter: blur(5px);
filter: url(#glass-distortion);
isolation: isolate;
border-radius: 24px;
}
.glass-tint {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(0, 0, 0, 0.15);
border-radius: 24px;
}
.glass-shine {
position: absolute;
inset: 0;
z-index: 2;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 24px;
box-shadow:
inset 1px 1px 8px 0 rgba(255, 255, 255, 0.18),
inset -1px -1px 8px 0 rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.glass-content {
position: relative;
z-index: 3;
padding: 2rem;
color: white;
}


  • 每一层都要有一致的 border-radius,才能保证圆角处无割裂。


5. 3D灵动倾斜交互


methods: {
handleMouseMove (e) {
const card = this.$refs.tiltCard
const rect = card.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const centerX = rect.width / 2
const centerY = rect.height / 2
const maxTilt = 18
const rotateY = ((x - centerX) / centerX) * maxTilt
const rotateX = -((y - centerY) / centerY) * maxTilt
card.style.transform = `perspective(600px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.03)`
},
handleMouseLeave () {
const card = this.$refs.tiltCard
card.style.transform = 'perspective(600px) rotateX(0deg) rotateY(0deg) scale(1)'
}
}


  • 鼠标移动时,卡片会根据指针位置灵动倾斜。

  • 鼠标移出时,卡片平滑恢复。


6. 细节优化



  • 阴影柔和:避免黑色边缘过重,提升高级感。

  • 高光线条:用低透明度白色边框和内阴影,模拟玻璃高光。

  • 所有层的圆角一致:防止割裂。

  • 表单输入框:用半透明背景和模糊,保持整体风格统一。


7.完整代码


<template>
<div class="login-container animated-background">
<!-- SVG滤镜库 -->
<svg style="display: none">
<filter id="glass-distortion" x="0%" y="0%" width="100%" height="100%" filterUnits="objectBoundingBox">
<feTurbulence type="fractalNoise" baseFrequency="0.001 0.005" numOctaves="1" seed="17" result="turbulence" />
<feComponentTransfer in="turbulence" result="mapped">
<feFuncR type="gamma" amplitude="1" exponent="10" offset="0.5" />
<feFuncG type="gamma" amplitude="0" exponent="1" offset="0" />
<feFuncB type="gamma" amplitude="0" exponent="1" offset="0.5" />
</feComponentTransfer>
<feGaussianBlur in="turbulence" stdDeviation="3" result="softMap" />
<feSpecularLighting in="softMap" surfaceScale="5" specularConstant="1" specularExponent="100" lighting-color="white" result="specLight">
<fePointLight x="-200" y="-200" z="300" />
</feSpecularLighting>
<feComposite in="specLight" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="litImage" />
<feDisplacementMap in="SourceGraphic" in2="softMap" scale="200" xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>

<!-- 登录卡片 -->
<div
class="glass-component login-card"
ref="tiltCard"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
>
<div class="glass-effect"></div>
<div class="glass-tint"></div>
<div class="glass-shine"></div>
<div class="glass-content">
<h2 class="login-title">欢迎登录</h2>
<form class="login-form">
<div class="form-group">
<input type="text" placeholder="用户名" class="glass-input">
</div>
<div class="form-group">
<input type="password" placeholder="密码" class="glass-input">
</div>
<button type="submit" class="glass-button">登录</button>
</form>
</div>
</div>
</div>
</template>

<script>
export default {
name: 'LiquidGlass',
data () {
return {
// 可以添加需要的数据
}
},
methods: {
handleMouseMove (e) {
const card = this.$refs.tiltCard
const rect = card.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const centerX = rect.width / 2
const centerY = rect.height / 2
// 最大旋转角度
const maxTilt = 18
const rotateY = ((x - centerX) / centerX) * maxTilt
const rotateX = -((y - centerY) / centerY) * maxTilt
card.style.transform = `perspective(600px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.03)`
},
handleMouseLeave () {
const card = this.$refs.tiltCard
card.style.transform = 'perspective(600px) rotateX(0deg) rotateY(0deg) scale(1)'
}
}
}
</script>

<style lang="scss" scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}

.animated-background {
width: 100%;
height: 100%;
background-image: url('../../assets/macwallpaper.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}

.login-card {
width: 400px;
position: relative;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.10), 0 1.5px 6px 0 rgba(0,0,0,0.08);
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.6);
cursor: pointer;
background: transparent;
}

.glass-effect {
position: absolute;
inset: 0;
z-index: 0;
backdrop-filter: blur(5px);
filter: url(#glass-distortion);
isolation: isolate;
border-radius: 24px;
}

.glass-tint {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(0, 0, 0, 0.15);
border-radius: 24px;
}

.glass-shine {
position: absolute;
inset: 0;
z-index: 2;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 24px;
box-shadow:
inset 1px 1px 8px 0 rgba(255, 255, 255, 0.18),
inset -1px -1px 8px 0 rgba(255, 255, 255, 0.08);
pointer-events: none;
}

.glass-content {
position: relative;
z-index: 3;
padding: 2rem;
color: white;
}

.login-title {
text-align: center;
color: #fff;
margin-bottom: 2rem;
font-size: 2rem;
font-weight: 600;
text-shadow: 0 1px 3px rgba(0,0,0,0.2);
}

.form-group {
margin-bottom: 1.5rem;
}

.glass-input {
width: 90%;
padding: 12px 20px;
border: none;
border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 1rem;
backdrop-filter: blur(5px);
transition: all 0.3s ease;

&::placeholder {
color: rgba(255, 255, 255, 0.7);
}

&:focus {
outline: none;
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 0 15px rgba(255, 255, 255, 0.1);
}
}

.glass-button {
width: 100%;
padding: 12px;
border: none;
border-radius: 10px;
background: rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(5px);
position: relative;
overflow: hidden;

&:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}

&:active {
transform: translateY(0);
}
}

// 添加点击波纹效果
.click-gradient {
position: absolute;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.4) 0%, rgba(180,180,255,0.2) 40%, rgba(100,100,255,0.1) 70%, rgba(50,50,255,0) 100%);
transform: translate(-50%, -50%) scale(0);
opacity: 0;
pointer-events: none;
z-index: 4;
}

.glass-component.clicked .click-gradient {
animation: gradient-ripple 0.6s ease-out;
}

@keyframes gradient-ripple {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(3);
opacity: 0;
}
}

.glass-component {
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
</style>





常见问题与优化建议



  1. 阴影过重/黑边:减小 box-shadow 的透明度和模糊半径。

  2. 圆角割裂:所有玻璃层都要加 border-radius。

  3. 背景不通透:确保 glass-effect 层有 blur 和 SVG filter。

  4. 性能问题:backdrop-filter 在低端设备上可能有性能损耗,建议只在必要区域使用。

  5. 浏览器兼容性:backdrop-filter 需现代浏览器支持,IE/部分安卓浏览器不兼容。




技术要点总结



  • SVG滤镜:让玻璃表面有微妙的流动和扭曲感。

  • backdrop-filter: blur:实现背景虚化。

  • 多层叠加:色调、高光、阴影共同营造真实玻璃质感。

  • 3D transform:提升交互体验。

  • 细节打磨:阴影、边框、圆角、色彩都要精细调整。




结语


液态玻璃效果是现代前端视觉的代表之一。只要理解其原理,分层实现、细致调优,任何人都能做出媲美 macOS、Win11 的高端玻璃UI。希望本教程能帮助你掌握这项技术,做出属于自己的酷炫界面!


作者:前端不端钱
来源:juejin.cn/post/7516306850715910182
收起阅读 »

尤雨溪搞响应式为什么要从 Object.defineProperty 换成 Proxy❓

web
前言 你说,为什么❓尤雨溪搞响应式,他为什么要换掉Object.defineProperty呢❓ proxy什么来头❓ 有一次👀看他直播,说去面试人家问他原型链,他不会,GG了面试黄了,你说他是不是无中生有暗度陈仓凭空想象凭空捏造new Proxy来...
继续阅读 »

前言



你说,为什么❓尤雨溪搞响应式,他为什么要换掉Object.defineProperty呢❓



proxy什么来头❓





有一次👀看他直播,说去面试人家问他原型链,他不会,GG了面试黄了,你说他是不是无中生有暗度陈仓凭空想象凭空捏造new Proxy来换掉Object.defineProperty的呢?



还真不是,尤雨溪的响应式,我们暂且叫成插一脚吧👇,请听我细细道来👂


在前端开发中,响应式系统是现代框架的核心特性。无论是 Vue 还是 React,它们都需要实现一个基本功能:当数据变化时,自动更新相关的视图。用通俗的话说,就是要在数据被读取或修改时"插一脚",去执行一些额外的操作(比如界面刷新、计算属性重新计算等)。


// 读取属性时
obj.a; // 需要知道这个属性被读取了

// 修改属性时
obj.a = 3; // 需要知道这个属性被修改了

但原生 JavaScript 对象不会告诉我们这些操作的发生。那么,尤雨溪是如何实现这种"插一脚"的能力的呢?


正文


Vue 2 的"插一脚"方案 - Object.defineProperty


基本实现原理


Vue 2 使用的是 ES5 的 Object.defineProperty API。这个 API 允许我们定义或修改对象的属性,并为其添加 getter 和 setter。


const obj = { a: 1 };

let v = obj.a;
Object.defineProperty(obj, 'a', {
get() {
console.log('读取 a'); // 插一脚:知道属性被读取了
return v;
},
set(val) {
console.log('更新 a'); // 插一脚:知道属性被修改了
v = val;
}
});

obj.a; // 输出"读取 a"
obj.a = 3; // 输出"更新 a"

完整对象监听


为了让整个对象可响应,Vue 2 需要遍历对象的所有属性:


function observe(obj) {
for (const k in obj) {
let v = obj[k];
Object.defineProperty(obj, k, {
get() {
console.log('读取', k);
return v;
},
set(val) {
console.log('更新', k);
v = val;
}
});
}
}

处理嵌套对象


对于嵌套对象,还需要递归地进行观察:


function _isObject(v) {
return typeof v === 'object' && v !== null;
}

function observe(obj) {
for (const k in obj) {
let v = obj[k];
if (_isObject(v)) {
observe(v); // 递归处理嵌套对象
}
Object.defineProperty(obj, k, {
get() {
console.log('读取', k);
return v;
},
set(val) {
console.log('更新', k);
v = val;
}
});
}
}

Vue 2 方案的两大缺陷


缺陷一:效率问题


在这种模式下,他就必须要去遍历这个对象里边的每一个属性...这是第一个缺陷:必须遍历对象的所有属性,对于大型对象或深层嵌套对象,这会带来性能开销。


缺陷二:新增属性问题


无法检测到对象属性的添加或删除:


obj.d = 2; // 这个操作不会被监听到

因为一开始遍历的时候没有这个属性,后续添加的属性不会被自动观察。


Vue 3 的"插一脚"方案 - Proxy


基本实现原理


Vue 3 使用 ES6 的 Proxy 来重构响应式系统。Proxy 可以拦截整个对象的操作,而不是单个属性。


const obj = { a: 1 };

const proxy = new Proxy(obj, {
get(target, k) {
console.log('读取', k); // 插一脚
return target[k];
},
set(target, k, val) {
if (target[k] === val) return true;
console.log('更新', k); // 插一脚
target[k] = val;
return true;
}
});

proxy.a; // 输出"读取 a"
proxy.a = 3; // 输出"更新 a"
proxy.d; // 输出"读取 d" - 连不存在的属性也能监听到!

完整实现


function _isObject(v) {
return typeof v === 'object' && v !== null;
}

function reactive(obj) {
const proxy = new Proxy(obj, {
get(target, k) {
console.log('读取', k);
const v = target[k];
if (_isObject(v)) {
return reactive(v); // 惰性递归
}
return v;
},
set(target, k, val) {
if (target[k] === val) return true;
console.log('更新', k);
target[k] = val;
return true;
}
});
return proxy;
}

Proxy 的优势



  1. 无需初始化遍历:直接代理整个对象,不需要初始化时遍历所有属性

  2. 全面拦截:可以检测到所有属性的访问和修改,包括新增属性

  3. 性能更好:采用惰性处理,只在属性被访问时才进行响应式处理

  4. 更自然的开发体验:不需要特殊 API 处理数组和新增属性



"proxy 它解决了什么问题?两个问题。



第一个问题不需要深度遍历了,因为它不再监听属性了,而是监听的什么?整个对象。


同时也由于它监听了整个对象,就解决了第二个问题:能监听这个对象的所有操作,包括你去读写一些不存在的属性,都能监听到。"




原理对比与源码解析


原理对比


特性Object.definePropertyProxy
拦截方式属性级别对象级别
新增属性检测不支持支持
性能初始化时需要遍历按需处理
深层嵌套处理初始化时递归处理访问时递归处理

源码实现差异


Vue 2 实现



  • src/core/observer 目录下

  • 初始化时递归遍历整个对象

  • 需要特殊处理数组方法


Vue 3 实现



  • 独立的 @vue/reactivity

  • 使用 Proxy 实现基础响应式

  • 惰性处理嵌套对象

  • 更简洁的 API 设计


为什么 Proxy 是更好的选择?



  1. 更全面的拦截能力:可以拦截对象的所有操作,包括属性访问、赋值、删除等

  2. 更好的性能:不需要初始化时递归遍历整个对象

  3. 更简洁的 API:不再需要 Vue.set/Vue.delete 等特殊 API

  4. 更自然的开发体验:开发者可以使用普通的 JavaScript 语法操作对象


总结


需显式操作(defineProperty)-> 声明式编程(Proxy)


局部监听(属性级别)-> 全局拦截(对象级别)


从 Object.defineProperty 到 Proxy 的转变,不仅是 API 的升级,更是前端框架设计理念的进步。Vue 3 的响应式系统通过 Proxy 实现了更高效、更全面的数据监听。


作者:盏灯
来源:juejin.cn/post/7493539513106677769
收起阅读 »

状态机设计:比if-else优雅100倍的设计

web
作为一名后端开发工程师,当你面对复杂的业务流程时,是否常感到逻辑混乱、边界不清?学会状态机设计,让你的代码优雅如诗! 引言:为什么需要状态机? 在后台系统开发中,我们经常需要处理对象的状态流转问题:订单从"待支付"到"已支付"再到"已发货",工单系统从"打开...
继续阅读 »

作为一名后端开发工程师,当你面对复杂的业务流程时,是否常感到逻辑混乱、边界不清?学会状态机设计,让你的代码优雅如诗!



引言:为什么需要状态机?


在后台系统开发中,我们经常需要处理对象的状态流转问题:订单从"待支付"到"已支付"再到"已发货",工单系统从"打开"到"处理中"再到"解决",这些场景都涉及状态管理


如果不使用状态机设计,我们可能会写出这样的面条式代码:


func HandleOrderEvent(order *Order, event Event) error {
if order.Status == "待支付" {
if event.Type == "支付成功" {
order.Status = "已支付"
// 执行支付成功逻辑...
} else if event.Type == "取消订单" {
order.Status = "已取消"
// 执行取消逻辑...
} else {
return errors.New("非法事件")
}
} else if order.Status == "已支付" {
if event.Type == "发货" {
order.Status = "已发货"
// 执行发货逻辑...
}
// 更多else if...
}
// 更多else if...
}

这种代码存在几个致命问题:



  1. 逻辑分支嵌套严重(俗称箭头代码)

  2. 状态流转规则难以维护

  3. 容易遗漏边界条件

  4. 可扩展性差(新增状态需要改动核心逻辑)


状态机正是解决这类问题的银弹!


状态机设计核心概念


状态机三要素


概念描述订单系统示例
状态(State)系统所处的稳定状态待支付、已支付、已发货
事件(Event)触发状态变化的动作支付成功、取消订单
转移(Transition)状态变化的规则待支付 → 已支付

状态机的类型



  1. 有限状态机(FSM):最简单的状态机形式

  2. 分层状态机(HSM):支持状态继承,减少冗余

  3. 状态图(Statecharts):支持并发、历史状态等高级特性


graph LR
A[待支付] -->|支付成功| B[已支付]
B -->|发货| C[已发货]
B -->|申请退款| D[退款中]
A -->|取消订单| E[已取消]
D -->|退款成功| E
D -->|退款失败| B

Go实现状态机实战


基本结构定义


package main

import "fmt"

// 定义状态类型
type State string

// 定义事件类型
type Event string

// 状态转移函数类型
type TransitionHandler func() error

// 状态转移定义
type Transition struct {
From State
Event Event
To State
Handle TransitionHandler
}

// 状态机定义
type StateMachine struct {
Current State
transitions []Transition
}

// 注册状态转移规则
func (sm *StateMachine) AddTransition(from State, event Event, to State, handler TransitionHandler) {
sm.transitions = append(sm.transitions, Transition{
From: from,
Event: event,
To: to,
Handle: handler,
})
}

// 处理事件
func (sm *StateMachine) Trigger(event Event) error {
for _, trans := range sm.transitions {
if trans.From == sm.Current && trans.Event == event {
// 执行处理函数
if err := trans.Handle(); err != nil {
return err
}
// 更新状态
sm.Current = trans.To
return nil
}
}
return fmt.Errorf("非法事件[%s]或当前状态[%s]不支持", event, sm.Current)
}

订单状态机示例


// 订单状态定义
const (
StatePending State = "待支付"
StatePaid State = "已支付"
StateShipped State = "已发货"
StateCanceled State = "已取消"
)

// 事件定义
const (
EventPaySuccess Event = "支付成功"
EventCancel Event = "取消订单"
EventShip Event = "发货"
)

func main() {
// 创建状态机
sm := &StateMachine{Current: StatePending}

// 注册状态转移
sm.AddTransition(StatePending, EventPaySuccess, StatePaid, func() error {
fmt.Println("执行支付成功处理逻辑...")
return nil // 实际业务中可能有错误处理
})

sm.AddTransition(StatePending, EventCancel, StateCanceled, func() error {
fmt.Println("执行订单取消逻辑...")
return nil
})

sm.AddTransition(StatePaid, EventShip, StateShipped, func() error {
fmt.Println("执行发货逻辑...")
return nil
})

sm.AddTransition(StatePaid, EventCancel, StateCanceled, func() error {
fmt.Println("执行已支付状态的取消逻辑...")
return nil
})

// 执行事件测试
fmt.Println("当前状态:", sm.Current)
_ = sm.Trigger(EventPaySuccess) // 支付成功
fmt.Println("当前状态:", sm.Current)
_ = sm.Trigger(EventShip) // 发货
fmt.Println("当前状态:", sm.Current)

// 测试非法转移
err := sm.Trigger(EventCancel)
fmt.Println("尝试取消:", err) // 非法操作
}

输出结果:


当前状态: 待支付
执行支付成功处理逻辑...
当前状态: 已支付
执行发货逻辑...
当前状态: 已发货
尝试取消: 非法事件[取消订单]或当前状态[已发货]不支持

扩展:表驱动状态机


上面的实现足够清晰,但存在性能问题——每次触发事件都需要遍历转移表。我们优化为更高效的版本:


type StateMachineV2 struct {
Current State
transitionMap map[State]map[Event]*Transition
}

func (sm *StateMachineV2) AddTransition(from State, event Event, to State, handler TransitionHandler) {
if sm.transitionMap == nil {
sm.transitionMap = make(map[State]map[Event]*Transition)
}
if _, exists := sm.transitionMap[from]; !exists {
sm.transitionMap[from] = make(map[Event]*Transition)
}
sm.transitionMap[from][event] = &Transition{
From: from,
Event: event,
To: to,
Handle: handler,
}
}

func (sm *StateMachineV2) Trigger(event Event) error {
if events, exists := sm.transitionMap[sm.Current]; exists {
if trans, exists := events[event]; exists {
if err := trans.Handle(); err != nil {
return err
}
sm.Current = trans.To
return nil
}
}
return fmt.Errorf("非法事件[%s]或当前状态[%s]不支持", event, sm.Current)
}

进阶技巧:状态机实践指南


状态转移图可视化


绘制状态转移图,与代码实现保持同步:



状态模式的优雅实现


使用Go的接口特性实现面向对象的状态模式:


type OrderState interface {
Pay() error
Cancel() error
Ship() error
// 其他操作方法...
}

type pendingState struct{}

func (s *pendingState) Pay() error {
fmt.Println("执行支付成功处理逻辑...")
return nil
}

func (s *pendingState) Cancel() error {
fmt.Println("执行待支付状态取消逻辑...")
return nil
}

func (s *pendingState) Ship() error {
return errors.New("当前状态不能发货")
}

// 其他状态实现...

type Order struct {
state OrderState
}

func (o *Order) ChangeState(state OrderState) {
o.state = state
}

func (o *Order) Pay() error {
return o.state.Pay()
}

// 其他方法...

状态机的持久化


如何在数据库中存储状态机?永远只存储状态,而不是存储状态机逻辑!


数据库表设计示例:


字段名类型描述
idint主键ID
statusvarchar(20)当前状态
event_historyjson事件历史记录

状态恢复代码实现:


type Order struct {
ID int
Status State
}

func RecoverOrderStateMachine(order Order) *StateMachine {
sm := CreateStateMachine() // 创建初始状态机
sm.Current = order.Status // 恢复状态
return sm
}

真实案例:电商订单系统


复杂状态机设计



处理并发操作


var mutex sync.Mutex

func (sm *StateMachine) SafeTrigger(event Event) error {
mutex.Lock()
defer mutex.Unlock()
return sm.Trigger(event)
}

// 使用channel同步
func (sm *StateMachine) AsyncTrigger(event Event) error {
eventChan := make(chan error)
go func() {
mutex.Lock()
defer mutex.Unlock()
eventChan <- sm.Trigger(event)
}()
return <-eventChan
}

避免状态机设计的反模式



  1. 过度复杂的状态机:如果状态超过15个,考虑拆分

  2. 上帝状态机:避免一个状态机控制整个系统

  3. 忽略状态回退:重要系统必须设计回退机制

  4. 缺乏监控:记录状态转移日志


监控状态转移示例:


func (sm *StateMachine) Trigger(event Event) error {
startTime := time.Now()
defer func() {
log.Printf("状态转移监控: %s->%s (%s) 耗时: %v",
oldState, sm.Current, event, time.Since(startTime))
}()
// 正常处理逻辑...
}

结语:状态机的无限可能


状态机不只是解决业务逻辑的工具,它更是一种思维方式。通过今天的学习,你应该掌握了:



  1. 状态机的基本概念与类型 ✅

  2. Go语言实现状态机的多种方式 ✅

  3. 复杂状态机的设计技巧 ✅

  4. 真实项目的状态机应用模式 ✅


当你在设计下一个后端系统时,先问自己三个问题:



  1. 我的对象有哪些明确的状态?

  2. 触发状态变化的事件是什么?

  3. 状态转移需要哪些特殊处理?


思考清楚这些问题,你的代码设计将变得更加清晰优雅!


作者:草捏子
来源:juejin.cn/post/7513752860162129960
收起阅读 »

用了三年 Vue,我终于理解为什么“组件设计”才是重灾区

web
一开始写 Vue 的时候,谁不是觉得:“哇,组件好优雅!”三年后再回头一看,组件目录像垃圾堆,维护一处改三处,props 乱飞、事件满天飞,复用全靠 copy paste。于是我终于明白 —— 组件设计,才是 Vue 项目的重灾区。 1. 抽组件 ≠ 拆文...
继续阅读 »

一开始写 Vue 的时候,谁不是觉得:“哇,组件好优雅!”三年后再回头一看,组件目录像垃圾堆,维护一处改三处,props 乱飞、事件满天飞,复用全靠 copy paste。于是我终于明白 —— 组件设计,才是 Vue 项目的重灾区





1. 抽组件 ≠ 拆文件夹


很多初学 Vue 的人对“组件化”的理解就是:“页面上出现重复的 UI?好,抽个组件。”


于是你会看到这样的组件:


<!-- TextInput.vue -->
<template>
<input :value="value" @input="$emit('update:value', $event.target.value)" />
</template>

接着你又遇到需要加图标的输入框,于是复制一份:


<!-- IconTextInput.vue -->
<template>
<div class="icon-text-input">
<i class="icon" :class="icon" />
<input :value="value" @input="$emit('update:value', $event.target.value)" />
</div>
</template>

再后来你需要加验证、loading、tooltip……结果就变成了:



  • TextInput.vue

  • IconTextInput.vue

  • ValidatableInput.vue

  • LoadingInput.vue

  • FormInput.vue


组件爆炸式增长,但每一个都只是“刚好凑合”,共用不了。




2. 抽象失控:为了复用而复用,结果没人敢用


比如下面这个场景:


你封装了一个超级复杂的表格组件:


<CustomTable
:columns="columns"
:data="tableData"
:show-expand="true"
:enable-pagination="true"
:custom-actions="['edit', 'delete']"
/>


你美其名曰“通用组件”,但别人拿去一用就发现:



  • 某个页面只要展示,不要操作按钮,配置了也没法删;

  • 有个页面需要自定义排序逻辑,你这边死写死;

  • 另一个页面用 element-plus 的样式,这边你自绘一套 UI;

  • 报错时控制台输出一大堆 warning,根本不知道哪来的。


最后大家的做法就是 —— 不用你这套“通用组件”,自己抄一份改改




3. 数据向下流、事件向上传:你真的理解 props 和 emit 吗?


Vue 的单向数据流原则说得很清楚:



父组件通过 props 向下传数据,子组件通过 emit 通知父组件。



但现实是:



  • props 传了 7 层,页面逻辑根本看不懂数据哪来的;

  • 子组件 emit 了两个 event,父组件又传回了回调函数;

  • 有时候干脆直接用 inject/providerefeventBus 偷偷打通通信。


举个例子:


<!-- 祖父组件 -->
<template>
<PageWrapper>
<ChildComponent :formData="form" @submit="handleSubmit" />
</PageWrapper>
</template>

<!-- 子组件 -->
<template>
<Form :model="formData" />
<button @click="$emit('submit', formData)">提交</button>
</template>

看上去还好?但当 ChildComponent 再包一层 FormWrapper、再嵌套 InputList,你就发现:



  • formData 根本不知道是哪个组件控制的

  • submit 被多层包装、debounce、防抖、节流、劫持

  • 你改一个按钮逻辑,要翻 4 个文件




4. 技术债爆炸的罪魁祸首:不敢删、不敢动


组件目录看似整齐,但大部分组件都有如下特征:



  • 有 10 个 props,3 个事件,但没人知道谁在用;

  • 注释写着“用于 A 页面”,实际上 B、C、D 页面也在引用;

  • 一个小改动能引发“蝴蝶效应”,整个系统发疯。


于是你只能选择 —— 拷贝再新建一个组件,给它加个 V2 后缀,然后老的你也不敢删。


项目后期的结构大概就是:


components/
├── Input.vue
├── InputV2.vue
├── InputWithTooltip.vue
├── InputWithValidation.vue
├── InputWithValidationV2.vue
└── ...

“为了让别人能维护我的代码,我决定不动它。”




5. 组件设计的核心,其实是抽象能力


我用三年才悟到一个道理:



Vue 组件设计的难点,不是语法、也不是封装,而是你有没有抽象问题的能力



举个例子:


你需要设计一个“搜索区域”组件,包含输入框 + 日期范围 + 搜索按钮。


新手写法:


<SearchHeader
:keyword="keyword"
:startDate="start"
:endDate="end"
@search="handleSearch"
/>


页面需求一改,换成了下拉框 + 单选框怎么办?又封一个组件?


更好的设计是 —— 提供slots 插槽 + 作用域插槽


<!-- SearchHeader.vue -->
<template>
<div class="search-header">
<slot name="form" />
<button @click="$emit('search')">搜索</button>
</div>
</template>

<!-- 使用 -->
<SearchHeader @search="search">
<template #form>
<el-input v-model="keyword" placeholder="请输入关键词" />
<el-date-picker v-model="range" type="daterange" />
</template>
</SearchHeader>

把结构交给组件,把行为交给页面。组件不掌控一切,而是协作。




6. 那么组件怎么设计才对?


我总结出 3 条简单但有效的建议:


✅ 1. 明确组件职责:UI?交互?逻辑?



  • UI 组件只关心展示,比如按钮、标签、卡片;

  • 交互组件只封装用户操作,比如输入框、选择器;

  • 逻辑组件封装业务规则,比如筛选区、分页器。


别让一个组件又画 UI 又写逻辑还请求接口。




✅ 2. 精简 props 和 emit,只暴露“必需”的接口



  • 一个组件 props 超过 6 个,要小心;

  • 如果事件名不具备业务语义(比如 click),考虑抽象;

  • 不要用 ref 操作子组件的内部逻辑,那是反模式。




✅ 3. 使用 slots 替代“高度定制的 props 方案”


如果你发现你组件 props 变成这样:


<SuperButton
:label="'提交'"
:icon="'plus'"
:iconPosition="'left'"
:styleType="'primary'"
:loading="true"
/>


那它该用 slot 了:


<SuperButton>
<template #icon><PlusIcon /></template>
提交
</SuperButton>



🙂


三年前我以为组件化是 Vue 最简单的部分,三年后我才意识到,它是最深、最难、最容易出坑的部分。


如果你也踩过以下这些坑:



  • 组件复用越写越复杂,别人都不敢用;

  • props 和事件像迷宫一样,维护成本极高;

  • UI 和逻辑耦合,改一点动全身;

  • 项目后期组件膨胀、技术债堆积如山;


别再让组件成为项目的“技术债”。你们也有遇到吗?


📌 你可以继续看我的系列文章



作者:ErpanOmer
来源:juejin.cn/post/7514947261396205587
收起阅读 »

Vite 底层彻底换血,尤雨溪想要控制整个前端生态?

web
Hello,大家好,我是 Sunday。 最近,尤雨溪发了一篇非常关键的文章,宣布 Vite 正式引入 Rust 写的打包器 Rolldown,并将逐步替代现有的 Rollup 成为 默认打包器。 该文章发布在 尤雨溪 新公司 void(0),文章链接:h...
继续阅读 »

Hello,大家好,我是 Sunday。


最近,尤雨溪发了一篇非常关键的文章,宣布 Vite 正式引入 Rust 写的打包器 Rolldown,并将逐步替代现有的 Rollup 成为 默认打包器




该文章发布在 尤雨溪 新公司 void(0),文章链接:https://voidzero.dev/posts/announcing-rolldown-vite



虽然这篇文章的内容并不长,但是内部做出的改成确实非常大的,可以毫不夸张的说:尤雨溪把整个 vite 的心脏都换掉了!


所以,咱们今天这篇文章,我不打算重复发布会上的内容,而是一起来看看这波 “换心脏” 的背后逻辑:为什么 Rust 能上位?真实速度到底快了多少?尤雨溪到底在下一盘什么棋?


01:Vite 正在全面 Rust 化


很多人看到这次更新,可能会说:“Rolldown 不就是个性能更好的打包器吗?用不用都行吧?”


说实话,这种理解可能有些过于表面了。


这次更新的不仅仅是一个工具,而是把整个的 vite 底层都重写了一遍:



  • Vite 的打包器,从 JS 写的 Rollup,换成了 Rust 写的 Rolldown

  • 配套的 Babel、Terser、ESLint,也被 Rust 实现的 Oxc 接管

  • 整个构建链路,从解析到压缩,从转换到分析,全都 Rust


为什么要这么干呢?


很简单,因为:JS 写的构建工具已经摸到天花板了。


不管你怎么做缓存、怎么压缩 AST、怎么优化 Plugin 顺序,JS 就是做不到 Rust 那种级别的执行效率。


而现代前端的项目体积正在变得越来越大,早就不是之前只写几个静态页面的事情了!


目前 微前端、组件库、国际化、权限系统……每加一个功能,构建时间就会变得越来越长,特别是很多公司在配合 CI/CD 的逻辑,每构建跑一次可能就得跑 2 分钟,而如果换成 Rolldown 那么就只要 15 秒上下了,你算算整个团队每天省下多少时间?


因此,这样的替换 “势在必行”,同时这也标记着:Vite 已经不再是一个 JS 写的现代前端工具了,而是一个由 Rust 驱动的、高性能构建内核。


02:真实表现到底快了多少?


咱们先来看官方数据:



这些官方给出的数据看上去是不是非常炸裂!


但是,根据前端圈的历史特性:所有官方性能对比,都只能作为参考,而不能作为实际的决策依据。


为啥呢?


因为,实际开发中,环境不同、项目结构不同、依赖链不同、构建目标不同,变量太多了。


很多的 demo 是干净环境下跑的,而你实际项目里,插件、polyfill、非预构建依赖一大堆,所以 官方数据,仅供参考!


但我要说的是:哪怕实际操作中,只能做到官方数据的一半,这件事也值得我们去尝试下。


就拿我自己接触的几个中大型项目来说,生产环境下的 Vite 构建时间基本都在 30 秒到 2 分钟之间浮动,特别是:



  • 多语言、主题、子应用拆包场景下,Rollup 明显吃力

  • babel + terser 的组合在压缩阶段特别耗 CPU

  • 内存比较小的,如果在启动其他的任务(你电脑总得开其他的软件吧),那速度就更慢了


换句话说,如果 Rolldown 真能在这些环节上带来 哪怕 30% 的性能提升,对于团队的持续集成、构建稳定性、开发反馈体验,都是实打实的收益。


03:尤雨溪在下一盘大棋


很多同学可能会说:Vite 已经“遥遥领先”了,为啥还非要换底层呢?多麻烦呀!


如果你有这种疑惑的话,那么可能是因为你对 vite 使用到的这些新工具还不太了解,如果你了解一下背后的发布方,就知道这件事没那么简单。


Rolldown 是谁发布的?不是 Vue,也不是 Vite 核心团队,而是尤雨溪创办的新公司 —— VoidZero (也叫做 void(0) ) 。想要详细了解的,可以看下我之前发的这篇文章 尤雨溪新公司 Void(0) 首款产品发布,竟然是它...


这是一家由 尤雨溪 创建的专门做 JavaScript 工具链的开源公司。关于这一块的详细介绍,可以看这篇博客 尤雨溪创建 VoidZero ,并得到 460 万美金融资


这家公司刚一出手就连放两个大招:



  • 第一个是 Oxc :这是一个全新的 Rust 实现 JS 工具链(parser、transform、minifier、linter、formatter,全都自己造)

  • 第二个就是 Rolldown:Vite 打包器的 Rust 替代方案,目标直接瞄准 Rollup、Babel、Terser 这整条传统链路


而这次 Vite 接入 Rolldown,正是 void(0) 把自家工具「回注入」开源生态的第一步。



所以这不是在“优化 Vite”,而是想要 “替换整条构建基础设施”



你可以这么理解 void(0) 的策略路径:



  1. Vue 站稳前端框架圈核心位置

  2. Vite 用 Rollup 起家,成为构建工具主流选择

  3. void(0) 作为新公司登场,切入工具链底层,用 Rust 重写一整套生态

  4. 再反哺 Vite,用 Rolldown 替代原来的 JS 构建方案

  5. 最终形成:Vue + Vite + void(0) 工具链 的闭环


这其实是一个很聪明、很清晰的长期路线图:不再被 Babel、Terser、ESLint 等“生态外依赖”所绑定,而是自己控制工具底层、性能节奏、开发体验。


尤雨溪本人也在社区里反复提过:Vite 的未来,不只是“构建工具”,而是下一代工程化的“前端开发基建平台”。


而这张底牌,就是 Rolldown + Oxc。


你可以想想看,如果:



  • Vue 生态已经在试水 Rolldown

  • Vite 即将全面接入 Rolldown

  • Vite 插件作者必须适配 Rolldown(否则未来会不兼容)


那就意味着:



无论你是 Vue、React、Svelte,还是用 Vite 的任何框架,都必须配合这次 “Rust 工具链” 的迁移。 否则将有可能会被踢出前端生态。



而想要参与,就必须要使用 Void(0) 的产品。


这样,尤雨溪就可以很成功的让 Void(0) 变成整个前端生态的标准了!


作者:程序员Sunday
来源:juejin.cn/post/7511583779578642483
收起阅读 »

为什么说 AI 时代,前端开发者对前端工程化的要求更高了❓❓❓

web
前端工程化在前端领域地位极高,因为它系统性地解决了前端开发中效率、协作、质量、维护性等一系列核心问题,可以说是现代前端技术体系的基石。前端工程化带来的价值可以从这四个方面看:提升开发效率:模块化开发:通过组件、模块拆分使开发更加清晰,复用性更强。自动化构建:W...
继续阅读 »

前端工程化在前端领域地位极高,因为它系统性地解决了前端开发中效率、协作、质量、维护性等一系列核心问题,可以说是现代前端技术体系的基石。

前端工程化带来的价值可以从这四个方面看:

  1. 提升开发效率:

    • 模块化开发:通过组件、模块拆分使开发更加清晰,复用性更强。
    • 自动化构建:Webpack、Vite 等工具自动处理打包、压缩、转译等。
    • 代码热更新 / HMR:开发过程中能实时看到改动,节省调试时间。
  2. 规范团队协作

    • 代码规范检查:如 ESLint、Stylelint 统一代码风格,避免“风格大战”。
    • Git 提交规范:如使用 commitlint + husky 保证提交信息标准化。
    • 持续集成(CI):如 GitHub Actions、Jenkins 保证每次提交自动测试、构建。
  3. 提升代码质量和可维护性

    • 单元测试 / 集成测试:如 Jest、Cypress 确保代码稳定可靠。
    • 类型系统支持:TypeScript 保证更严格的类型检查,降低 Bug 率。
    • 文档生成工具:如 Storybook、jsdoc 方便维护和阅读。
  4. 自动化部署与运维

    • 自动化构建发布流程(CI/CD)使得上线更安全、更快速。
    • 多环境配置管理(开发/测试/生产)更加方便和稳定。

总的来说,前端工程化让开发者从单纯的 “切图仔” 成长为能够参与大型系统开发的工程师。通过引入规范与工具,不仅显著提升了团队协作效率,还有效减少了开发过程中的冲突与返工,成为现代前端团队协作的 “润滑剂”

什么是前端工程化

前端工程化 大约在 2018 年前后在国内被广泛提出,其核心是将后端成熟的软件工程理念、工具与流程系统性地引入前端开发。

它旨在通过规范、工具链与协作流程,提升开发效率、保障交付质量、降低维护成本。前端工程化不只是技术选型,更是一种体系化、流程化的开发方式。

其核心包括代码规范、自动化构建、模块化设计、测试体系和持续集成等关键环节。通过工程化,前端从“写页面”转向“做工程”,实现了从个体开发到团队协作的转变。

它不仅优化了前端的生产方式,也推动了大型系统开发中前端角色的重要性。如今,前端工程化已成为现代前端开发不可或缺的基础能力。

为什么 AI 时代,前端工程化更重要

在 AI 时代,前端工程化不仅没有“过时”,反而变得更重要,甚至成为人机协作高效落地的关键基石。原因可以从以下几个方面理解。

虽然 AI 可以辅助生成代码、文档甚至 UI,但它并不能替代工程化体系,原因有:

  1. AI 的代码质量不稳定:没有工程化流程约束,容易引入 Bug 或不一致的风格。
  2. AI 更依赖工程规范作为提示上下文:没有良好的工程结构,AI 输出也会混乱低效。
  3. AI 更像“助理”,而非“工程师”:它执行快,但依然需要工程体系保障产出质量和集成稳定性。

最差的情况下有可能会删除或者修改你之前已经写好的代码,如果缺少这些工程化的手段,你甚至不知道它已经修改你的代码了,最终等到上线的时候无数的 bug 产生。

通过标准化输出让 AI 更智能,清晰的项目结构、代码规范、模块划分能让 AI 更准确地补全、修改或重构代码。例如 ESLint、TypeScript 的规则为 AI 提供了明确的限制条件,有助于生成更高质量的代码。

在生成的代码需要规范,生成完成之后更需要检验,大概的流程也有如下几个方面:

  1. 格式化检查(Prettier、ESLint)
  2. 单元测试(Jest)
  3. 构建打包(Vite/Webpack)
  4. 自动部署(CI/CD)

没有工程化,AI 产出的代码难以被真正“上线使用”。

AI 时代,对一些 CRUD 的简单要求减少了,但是对工程化提出了更高要求。

方面普通时代要求AI 时代新挑战
模块结构清晰划分需辅助 AI 理解上下文
代码规范避免团队矛盾指导 AI 输出符合规范
自动化测试保证功能正确验证 AI 代码不会引发异常
CI/CD 流程提升上线效率确保 AI 代码自动验证上线

前端工程化

接下来我们将分为多个小节来讲解一下前端工程化的不同技术充当着什么角色。

技术选型

在前端工程化中,技术选型看似是一道“选择题”,本质上却关系到项目的开发效率、团队协作和未来的可维护性。对于框架选择而言,建议优先考虑两个关键因素:

  1. 团队熟悉程度:选择你或团队最熟悉的框架,能确保在遇到复杂或疑难问题时,有人能迅速定位问题、解决“坑点”,避免因为不熟悉而拖慢项目进度。
  2. 市场占有率与人才生态:选择主流、活跃度高的框架(如 Vue、React),不仅能更容易找到合适的开发者,还意味着有更丰富的社区资源、第三方生态和维护支持,降低长期人力与技术风险。

统一规范

统一规范又分为代码规范、git 规范、项目规范和 UI 规范。

代码规范

统一代码规范带来的好处是显而易见的,尤其在团队开发中更显重要:

  1. 提升团队协作效率:统一的代码风格能让团队成员在阅读和理解他人代码时无障碍,提高沟通效率,减少因风格差异带来的理解成本。
  2. 降低项目维护成本:规范的代码结构更易读、易查、易改,有助于快速定位问题和后期维护。
  3. 促进高效 Code Review:一致的代码格式可以让审查者专注于业务逻辑本身,而非纠结于命名、缩进等细节。
  4. 帮助程序员自身成长:遵循良好的代码规范,有助于开发者养成系统化的编程思维,提升工程意识和代码质量。

当团队成员都严格遵循统一的代码规范时,整个项目的代码风格将保持高度一致,看别人的代码就像在看自己的代码一样自然顺畅。

为了实现这一目标,我们可以借助工具化手段来强制和规范编码行为,例如使用 ESLint 检查 JavaScript/TypeScript 的语法和代码质量,Stylelint 统一 CSS/SCSS 的书写规范,而 Prettier 则负责自动格式化各类代码,使其保持整洁一致。

这些工具不仅能在编码阶段就发现潜在问题,还能集成到 Git Hook 或 CI 流程中,确保所有提交的代码都符合团队标准。统一规范减少了 code review 中对格式问题的争论,让团队更专注于业务逻辑的优化。

更重要的是,长期在规范的约束下编程,有助于开发者养成良好的工程素养和职业习惯,提升整体开发质量和协作效率。工具是手段,习惯是目标,工程化规范最终是为了让每一位开发者都能写出“团队级”的代码。

除了上面提到的,还有很多相同功能的工具这里就不细说了。

Git 规范

Git 规范主要指团队在使用 Git 进行代码版本管理时,对分支策略、提交信息、代码合并方式等的统一约定,其目标是提升协作效率、降低沟通成本、保障版本可控。

分支管理规范可以遵循如下 Git Flow 模型:

main        # 生产环境分支
develop # 开发集成分支
feature/* # 功能分支
release/* # 发布准备分支
hotfix/* # 线上紧急修复分支

分支重要,提交信息规范也更重要,一份清晰规范的提交信息对后期维护、回滚、自动发布都非常重要,好的提交信息让其他协作人员知道你这个分支具体做了什么。

推荐使用 Conventional Commits 规范,格式如下:

<type>(): 

常见的  类型:

类型说明
feat新增功能
fix修复 bug
docs修改文档
style格式修改(不影响代码运行)
refactor重构(无新功能或修复)
test添加测试
chore构建过程或辅助工具变动

如下示例所示:

feat(login): 添加用户登录功能
fix(api): 修复接口返回字段错误
docs(readme): 完善项目使用说明

配套的工具推荐如下表所示:

工具作用
Commitlint校验提交信息是否符合格式规范
HuskyGit 钩子管理工具(如提交前检查)
lint-staged提交前只格式化/检查改动的文件
Standard Version自动生成 changelog、自动打 tag 和版本号

Git 规范,是让代码“有条不紊”地流动在团队之间的交通规则,是高效协作和持续交付的基础设施。

项目规范

项目规范是对整个项目工程的结构、组织方式、开发约定的一套统一标准,它能帮助团队协作、代码维护、快速上手和高质量交付。

项目目录结构规范可以让项目保持统一、清晰的项目目录结构,有助于快速定位文件、分工协作,如下是一个简单的目录规范:

src/
├── assets/ # 静态资源(图片、字体等)
├── components/ # 可复用的基础组件
├── pages/ # 页面级组件
├── services/ # API 请求模块
├── utils/ # 工具函数
├── hooks/ # 自定义 hooks(React 项目)
├── styles/ # 全局样式
├── config/ # 配置文件(如常量、环境变量)
├── router/ # 路由配置
├── store/ # 状态管理(如 Vuex / Redux)
└── main.ts # 应用入口

这只是一个很简答也很通用的目录结构,还有很多进阶的目录结构方案。

命名方式,这个可以根据不同的团队不同的风格来指定。

部署

借助自动化流程实现一键部署或者自动部署,常用的工具主要有以下:

  1. GitHub Actions
  2. GitLab CI
  3. Jenkins

流程通常如下:

Push → 检查代码规范 → 构建 → 运行测试 → 上传产物 → 通知部署 → 上线

可以参考一下 Action 配置:

name: Deploy Next.js to Alibaba Cloud ECS

on:
push:
branches:
- main

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4.2.0

- name: Set up Node.js
uses: actions/setup-node@v4.2.0
with:
node-version: "22.11.0"

- name: Install pnpm
run: npm install -g pnpm@9.4.0

- name: Deploy to server via SSH
uses: appleboy/ssh-action@v1.2.1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
port: ${{ secrets.SERVER_PORT }}
password: ${{ secrets.SERVER_PASSWORD }}
script: |
# 显示当前环境信息
echo "Shell: $SHELL"
echo "PATH before: $PATH"

# 加载环境配置文件
source ~/.bashrc
source ~/.profile

# 如果使用 NVM,加载 NVM 环境
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

# 添加常见的 Node.js 安装路径到 PATH
export PATH="$HOME/.nvm/versions/node/*/bin:/usr/local/bin:/usr/bin:/bin:$HOME/.npm-global/bin:$PATH"
echo "PATH after: $PATH"

# 查找 npm 的位置
which npm || echo "npm still not found in PATH"

# 使用绝对路径查找 npm
NPM_PATH=$(find /usr -name npm -type f 2>/dev/null | head -1)
if [ -n "$NPM_PATH" ]; then
echo "Found npm at: $NPM_PATH"
export PATH="$(dirname $NPM_PATH):$PATH"
fi

# 确保目标目录存在
mkdir -p /home/interview-guide
cd /home/interview-guide

# 如果本地仓库不存在,进行克隆
if [ ! -d "/home/interview-guide/.git" ]; then
echo "Cloning the repository..."
# 删除可能存在的空目录内容
rm -rf /home/interview-guide/*
# 使用 SSH 方式克隆
git clone git@github.com:xun082/interview-guide.git .
else
# 确保远程 URL 使用 SSH
git remote set-url origin git@github.com:xun082/interview-guide.git
# 获取最新代码
git fetch origin main
git reset --hard origin/main
fi

# 使用找到的 npm 路径或尝试直接运行
if [ -n "$NPM_PATH" ]; then
$NPM_PATH install -g pnpm@9.4.0
$NPM_PATH install -g pm2
else
npm install -g pnpm@9.4.0
npm install -g pm2
fi

# 安装依赖
pnpm install || npm install

# 构建项目
pnpm run build || npm run build

# 重启应用
pm2 restart interview-guide || pm2 start "pnpm start" --name interview-guide || pm2 start "npm start" --name interview-guide

这段 GitHub Actions 配置实现了将 Next.js 项目自动部署到阿里云 ECS 服务器的流程。它在检测到 main 分支有新的代码提交后,自动拉取代码、安装依赖并构建项目。随后通过 SSH 远程连接服务器,拉取或更新项目代码,并使用 PM2 启动或重启应用。整个流程自动化,无需人工干预,保障部署高效、可重复。

除了 PM2 之外,我们还可以使用 Docker 镜像部署。

🛡️ 监控

前端监控是指:对 Web 应用在用户真实环境中的运行状态进行实时采集与分析,以发现性能瓶颈、错误异常和用户行为,最终帮助开发团队提升系统稳定性和用户体验。

🚦 性能监控

性能监控的目标是衡量页面加载速度、交互流畅度等关键性性能指标。

常见指标:

  1. 首屏加载时间(FP/FCP)
  2. 页面完全加载时间(Load)
  3. 首次输入延迟(FID)
  4. 长任务(Long Task)
  5. 慢资源加载(如图片、脚本)

它有助于定位性能瓶颈(如资源过大、阻塞脚本)、优化用户体验(如加载缓慢或白屏问题),并支持性能回归分析,及时发现上线后的性能退化。

❌ 错误监控

错误监控的目标是捕捉并上报运行时异常,辅助开发快速修复 Bug。

常见的错误类型主要有以下几个方面:

错误类型示例说明
JS 运行错误ReferenceErrorTypeError 等
Promise 异常unhandledrejection
资源加载失败图片、脚本、字体 404、403
网络请求异常接口失败、超时、断网等
跨域/白屏CORS 错误、DOM 元素为空
控制台报错console.error() 日志监控
用户行为异常点击无响应、重复操作、高频异常等

假设我们使用了 fetch 进行封装,那么我们就可以对错误进行统一处理,后续我们可以再具体调用的时候根据不同的场景来传入不同的错误提示告知用户:

dc087a4417765c239c2d104ee5d03548

错误上报

数据上报是指前端在运行过程中将采集到的监控信息(性能、错误、行为等)发送到服务端的过程。它是前端监控从“收集”到“分析”的桥梁。

上报的数据类型主要有以下几个方面:

类型说明
性能数据页面加载时间、资源加载时间、Web Vitals 等
错误信息JS 异常、Promise 异常、请求失败、白屏等
用户行为点击、跳转、页面停留时间、操作路径等
自定义事件特定业务事件,如支付、注册等
环境信息浏览器版本、设备类型、操作系统、用户 IP 等

数据上报需要重点考虑的几个关键因素:

  1. 怎么上报(上报方式)

    • 使用 sendBeacon、fetch、img 打点还是 WebSocket?
    • 是否异步?是否阻塞主线程?
    • 是否需要加密、压缩或编码?

建议:选择 异步非阻塞 且浏览器支持好的方式(优先 sendBeacon),并对数据做统一封装处理。

  1. 何时上报(上报时机)

    • 立即上报:错误发生后马上发送(如 JS 报错)
    • 延迟上报:页面稳定后延迟几秒,防止干扰首屏加载
    • 页面卸载前上报:用 sendBeacon 上报用户停留数据等
    • 批量上报:积累一批数据后统一发送,减少请求频率
    • 定时上报:用户停留一段时间后定期上报(行为数据)

建议:根据数据类型区分时机,错误即时上报、性能延迟上报、行为数据可批量处理。

  1. 上报频率控制(防抖 / 节流 / 采样)

    • 错误或点击频繁时可能产生大量上报请求
    • 需要加防抖、节流机制,或采样上报(如只上报 10% 用户)

🔍 建议:对于高频行为(如滚动、点击),加防抖或只上报部分用户行为,避免拖垮前端或服务端。

  1. 异常处理与重试机制:遇到网络断开、后端失败等应支持自动重试或本地缓存,可将数据暂存至 localStorage,等网络恢复后重发
  2. 数据结构设计:统一字段格式、数据类型,方便服务端解析,包含上下文信息:页面 URL、用户 ID、浏览器信息、时间戳等,如下所示:
{
"type": "error",
"event": "ReferenceError",
"message": "xxx is not defined",
"timestamp": 1716280000000,
"userId": "abc123",
"url": "https://example.com/home"
}

总的来说,数据上报是前端监控的核心环节,但只有在合适的时机,用合适的方式,上报合适的数据,才能真正发挥价值。


作者:Moment
来源:juejin.cn/post/7506414257401004071
收起阅读 »

我本是写react的,公司让我换赛道搞web3D

web
当你在会议室里争论需求时, 智慧工厂的数字孪生正同步着每一条产线的脉搏; 当你对着平面图想象空间时, 智慧小区的三维模型已在虚拟世界精准复刻每一扇窗的采光。 当你在CAD里调整参数时, 数字孪生城市的交通流正实时映射每辆车的轨迹; 当你等待客户确认方案...
继续阅读 »

当你在会议室里争论需求时,

智慧工厂的数字孪生正同步着每一条产线的脉搏;




当你对着平面图想象空间时,

智慧小区的三维模型已在虚拟世界精准复刻每一扇窗的采光。




当你在CAD里调整参数时,

数字孪生城市的交通流正实时映射每辆车的轨迹;

当你等待客户确认方案时,

机械臂的3D仿真已预演了十万次零误差的运动路径;




当你用二维图纸解释传动原理时,

可交互的3D引擎正让客户‘拆解’每一个齿轮;

当你担心售后维修难描述时,

AR里的动态指引已覆盖所有故障点;




当你用PS拼贴效果图时,

VR漫游的业主正‘推开’你设计的每一扇门;

当你纠结墙面材质时,

光影引擎已算出了午后3点最温柔的折射角度;



从前端到Web3D,

不是换条赛道,

而是打开新维度。



韩老师说过:再牛的程序员都是从小白开始,既然开始了,就全心投入学好技术。



🔴 工具


所有的api都可以通过threejs官网的document,切成中文,去搜:


image.png


🔴 平面


⭕️ Scene 场景


场景能够让你在什么地方什么东西来交给three.js来渲染,这是你放置物体灯光摄像机地方


image.png


import * as THREE from "three";

// console.log(THREE);

// 目标:了解three.js最基本的内容

// 1、创建场景
const scene = new THREE.Scene();

⭕️ camera 相机


示例:threejs.org/examples/?q…


image.png


import * as THREE from "three";

// console.log(THREE);

// 目标:了解three.js最基本的内容

// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
75, // 相机的角度
window.innerWidth / window.innerHeight, // 相机的宽高比
0.1, // 相机的近截面
1000 // 相机的远截面
);

// 设置相机位置
camera.position.set(0, 0, 10); // 相机位置 (X轴坐标, Y轴坐标, Z轴坐标)
scene.add(camera); // 相机添加到场景中

⭕️ 物体 cube


import * as THREE from "three";

// console.log(THREE);

// 目标:了解three.js最基本的内容

// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
75, // 相机的角度
window.innerWidth / window.innerHeight, // 相机的宽高比
0.1, // 相机的近截面
1000 // 相机的远截面
);

// 设置相机位置
camera.position.set(0, 0, 10); // 相机位置 (X轴坐标, Y轴坐标, Z轴坐标)
scene.add(camera); // 相机添加到场景中

// 添加物体
// 创建几何体
const cubeGeometry = new THREE.BoxGeometry(1, 1, 1); // 创建立方体的几何体 (长, 宽, 高)
const cubeMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 }); // MeshBasicMaterial 基础网格材质 ({ color: 0xffff00 }) 颜色
// 根据几何体和材质创建物体
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial); // 创建立方体的物体 (几何体, 材质)
// 将几何体添加到场景中
scene.add(cube); // 物体添加到场景中

⭕️ 渲染 render


// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight); // 设置渲染的尺寸大小 (窗口宽度, 窗口高度)
// console.log(renderer);
// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement); // 将webgl渲染的canvas内容添加到body

// 使用渲染器,通过相机将场景渲染进来
renderer.render(scene, camera); // 使用渲染器,通过相机将场景渲染进来 (场景, 相机)

⭕️ 效果


效果是平面的:


image.png


到这里,还不是3d的,如果要加3d,要加一下控制器


🔴 3d


⭕️ 控制器


添加轨道。像卫星☄围绕地球🌏,环绕查看的视角:


// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

// 目标:使用控制器查看3d物体

// // 使用渲染器,通过相机将场景渲染进来
// renderer.render(scene, camera);

// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement); // 创建轨道控制器 (相机, 渲染器dom元素)
controls.enableDamping = true; // 设置控制器阻尼,让控制器更有真实效果。

function render() {
renderer.render(scene, camera); // 浏览器每渲染一帧,就重新渲染一次
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render); // 浏览器渲染下一帧的时候就会执行render函数,执行完会再次调用render函数,形成循环,每秒60次
}

render();

⭕️ 加坐标轴辅助器


// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5); // 坐标轴(size轴的大小)
scene.add(axesHelper);

1.gif


⭕️ 设置物体移动


// 设置相机位置
camera.position.set(0, 0, 10);
scene.add(camera);

1.gif


cube.position.x = 3;

// 往返移动
function render() {
cube.position.x += 0.01;
if (cube.position.x > 5) {
cube.position.x = 0;
}
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

render();

⭕️ 缩放


cube.scale.set(3, 2, 1); // xyz, x3倍, y2倍

单独设置


cube.position.x = 3;

⭕️ 旋转


cube.rotation.set(Math.PI / 4, 0, 0, "XZY"); // x轴旋转45度

单独设置


cube.rotation.x = Math.PI / 4;

⭕️ requestAnimationFrame


function render(time) {
// console.log(time);
// cube.position.x += 0.01;
// cube.rotation.x += 0.01;

// time 是一个不断递增的数字,代表当前的时间
let t = (time / 1000) % 5; // 为什么求余数,物体移动的距离就是t,物体移动的距离是0-5,所以求余数
cube.position.x = t * 1; // 0-5秒,物体移动0-5距离

// if (cube.position.x > 5) {
// cube.position.x = 0;
// }
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

render();

⭕️ Clock 跟踪事件处理动画


// 设置时钟
const clock = new THREE.Clock();
function render() {
// 获取时钟运行的总时长
let time = clock.getElapsedTime();
console.log("时钟运行总时长:", time);
// let deltaTime = clock.getDelta();
// console.log("两次获取时间的间隔时间:", deltaTime);
let t = time % 5;
cube.position.x = t * 1;

renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

render();

大概是8毫秒一次渲染时间.


⭕️ 不用算 用 Gsap动画库


gsap.com/


// 导入动画库
import gsap from "gsap";

// 设置动画
var animate1 = gsap.to(cube.position, {
x: 5,
duration: 5,
ease: "power1.inOut", // 动画属性
// 设置重复的次数,无限次循环-1
repeat: -1,
// 往返运动
yoyo: true,
// delay,延迟2秒运动
delay: 2,
onComplete: () => {
console.log("动画完成");
},
onStart: () => {
console.log("动画开始");
},
});
gsap.to(cube.rotation, { x: 2 * Math.PI, duration: 5, ease: "power1.inOut" });

// 双击停止和恢复运动
window.addEventListener("dblclick", () => {
// console.log(animate1);
if (animate1.isActive()) {
// 暂停
animate1.pause();
} else {
// 恢复
animate1.resume();
}
});

function render() {
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

render();

⭕️ 根据尺寸变化 实现自适应


// 监听画面变化,更新渲染画面
window.addEventListener("resize", () => {
// console.log("画面变化了");
// 更新摄像头
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();

// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置渲染器的像素比
renderer.setPixelRatio(window.devicePixelRatio);
});


⭕️ 用js控制画布 全屏 和 退出全屏


window.addEventListener("dblclick", () => {
const fullScreenElement = document.fullscreenElement;
if (!fullScreenElement) {
// 双击控制屏幕进入全屏,退出全屏
// 让画布对象全屏
renderer.domElement.requestFullscreen();
} else {
// 退出全屏,使用document对象
document.exitFullscreen();
}
// console.log(fullScreenElement);
});

⭕️ 应用 图形 用户界面 更改变量


// 导入dat.gui
import * as dat from "dat.gui";

const gui = new dat.GUI();
gui
.add(cube.position, "x")
.min(0)
.max(5)
.step(0.01)
.name("移动x轴")
.onChange((value) => {
console.log("值被修改:", value);
})
.onFinishChange((value) => {
console.log("完全停下来:", value);
});

//   修改物体的颜色
const params = {
color: "#ffff00",
fn: () => {
// 让立方体运动起来
gsap.to(cube.position, { x: 5, duration: 2, yoyo: true, repeat: -1 });
},
};
gui.addColor(params, "color").onChange((value) => {
console.log("值被修改:", value);
cube.material.color.set(value);
});
// 设置选项框
gui.add(cube, "visible").name("是否显示");

var folder = gui.addFolder("设置立方体");
folder.add(cube.material, "wireframe");
// 设置按钮点击触发某个事件
folder.add(params, "fn").name("立方体运动");

image.png




🔴 结语



前端的世界,

不该只有VueReact——

还有WebGPU里等待你征服的星辰大海。"



“当WebGL成为下一代前端的基础设施,愿你是最早站在三维坐标系里的那个人。”


作者:curdcv_po
来源:juejin.cn/post/7517209356855164978
收起阅读 »

从前端的角度出发,目前最具性价比的全栈路线是啥❓❓❓

web
我正在筹备一套前端工程化体系的实战课程。如果你在学习前端的过程中感到方向模糊、技术杂乱无章,那么前端工程化将是你实现系统进阶的最佳路径。它不仅能帮你建立起对现代前端开发的整体认知,还能提升你在项目开发、协作规范、性能优化等方面的工程能力。 ✅ 本课程覆盖构建工...
继续阅读 »

我正在筹备一套前端工程化体系的实战课程。如果你在学习前端的过程中感到方向模糊、技术杂乱无章,那么前端工程化将是你实现系统进阶的最佳路径。它不仅能帮你建立起对现代前端开发的整体认知,还能提升你在项目开发、协作规范、性能优化等方面的工程能力。


✅ 本课程覆盖构建工具测试体系脚手架CI/CDDockerNginx 等核心模块,内容体系完整,贯穿从开发到上线的全流程。每一章节都配有贴近真实场景的企业级实战案例,帮助你边学边用,真正掌握现代团队所需的工程化能力,实现从 CRUD 开发者到工程型前端的跃迁。


详情请看前端工程化实战课程


学完本课程,对你的简历和具体的工作能力都会有非常大的提升。如果你对此项目感兴趣,或者课程感兴趣,可以私聊我微信 yunmz777


今年大部分时间都是在编码上和写文章上,但是也不知道自己都学到了啥,那就写篇文章来盘点一下目前的技术栈吧,也作为下一年的参考目标,方便知道每一年都学了些啥。


20241223154451


我的技术栈


首先我先来对整体的技术做一个简单的介绍吧,然后后面再对当前的一些技术进行细分吧。


React、Typescript、React Native、mysql、prisma、NestJs、Redis、前端工程化。


React


React 这个框架我花的时间应该是比较多的了,在校期间已经读了一遍源码了,对这些原理已经基本了解了。在随着技术的继续深入,今年毕业后又重新开始阅读了一遍源码,对之前的认知有了更深一步的了解。


也写了比较多跟 React 相关的文章,包括设计模式,原理,配套生态的使用等等都有一些涉及。


在状态管理方面,redux,zustand 我都用过,尤其在 Zustand 的使用上,我特别喜欢 Zustand,它使得我能够快速实现全局状态管理,同时避免了传统 Redux 中繁琐的样板代码,且性能更优。也对 Zustand 有比较深入的了解,也对其源码有过研究。


NextJs


Next.js 是一个基于 React 的现代 Web 开发框架,它为开发者提供了一系列强大的功能和工具,旨在优化应用的性能、提高开发效率,并简化部署流程。Next.js 支持多种渲染模式,包括服务器端渲染(SSR)、静态生成(SSG)和增量静态生成(ISR),使得开发者可以根据不同的需求选择合适的渲染方式,从而在提升页面加载速度的同时优化 SEO。


在路由管理方面,Next.js 采用了基于文件系统的路由机制,这意味着开发者只需通过创建文件和文件夹来自动生成页面路由,无需手动配置。这种约定优于配置的方式让路由管理变得直观且高效。此外,Next.js 提供了动态路由支持,使得开发者可以轻松实现复杂的 URL 结构和参数化路径。


Next.js 还内置了 API 路由,允许开发者在同一个项目中编写后端 API,而无需独立配置服务器。通过这种方式,前后端开发可以在同一个代码库中协作,大大简化了全栈开发流程。同时,Next.js 对 TypeScript 提供了原生支持,帮助开发者提高代码的可维护性和可靠性。


Typescript


今年所有的项目都是在用 ts 写了,真的要频繁修改的项目就知道用 ts 好处了,有时候用 js 写的函数修改了都不知道怎么回事,而用了 ts 之后,哪里引用到的都报红了,修改真的非常方便。


今年花了一点时间深入学习了一下 Ts 类型,对一些高级类型以及其实现原理也基本知道了,明年还是多花点时间在类型体操上,除了算法之外,感觉类型体操也可以算得上是前端程序员的内功心法了。


React Native



不得不说,React Native 不愧是接活神器啊,刚学完之后就来了个安卓和 ios 的私活,虽然没有谈成。



React Native 和 Expo 是构建跨平台移动应用的两大热门工具,它们都基于 React,但在功能、开发体验和配置方式上存在一些差异。React Native 是一个开放源代码的框架,允许开发者使用 JavaScript 和 React 来构建 iOS 和 Android 原生应用。Expo 则是一个构建在 React Native 之上的开发平台,它提供了一套工具和服务,旨在简化 React Native 开发过程。


React Native 的核心优势在于其高效的跨平台开发能力。通过使用 React 语法和组件,开发者能够一次编写应用的 UI 和逻辑,然后部署到 iOS 和 Android 平台。React Native 提供了对原生模块的访问,使开发者能够使用原生 API 来扩展应用的功能,确保性能和用户体验能够接近原生应用。


Expo 在此基础上进一步简化了开发流程。作为一个开发工具,Expo 提供了许多内置的 API 和组件,使得开发者无需在项目中进行繁琐的原生模块配置,就能够快速实现设备的硬件访问功能(如摄像头、位置、推送通知等)。Expo 还内置了一个开发客户端,使得开发者可以实时预览应用,无需每次都进行完整的构建和部署。


另外,Expo 提供了一个完全托管的构建服务,开发者只需将应用推送到 Expo 服务器,Expo 就会自动处理 iOS 和 Android 应用的构建和发布。这大大简化了应用的构建和发布流程,尤其适合不想处理复杂原生配置的开发者。


然而,React Native 和 Expo 也有各自的局限性。React Native 提供更大的灵活性和自由度,开发者可以更自由地集成原生代码或使用第三方原生库,但这也意味着需要更多的配置和维护。Expo 则封装了很多功能,简化了开发,但在需要使用某些特定原生功能时,开发者可能需要“弹出”Expo 的托管环境,进行额外的原生开发。


样式方案的话我使用的是 twrnc,大部分组件都是手撸,因为有 cursor 和 chatgpt 的加持,开发效果还是杠杠的。


rn 原理也争取明年能多花点时间去研究研究,不然对着盲盒开发还是不好玩。


Nestjs


NestJs 的话没啥好说的,之前也都写过很多篇文章了,感兴趣的可以直接观看:



对 Nodejs 的底层也有了比较深的理解了:



Prisma & mysql


Prisma 是一个现代化的 ORM(对象关系映射)工具,旨在简化数据库操作并提高开发效率。它支持 MySQL 等关系型数据库,并为 Node.js 提供了类型安全的数据库客户端。在 NestJS 中使用 Prisma,可以让开发者轻松定义数据库模型,并通过自动生成的 Prisma Client 执行类型安全的查询操作。与 MySQL 配合时,Prisma 提供了一种简单、直观的方式来操作数据库,而无需手动编写复杂的 SQL 查询。


Prisma 的核心优势在于其强大的类型安全功能,所有的数据库操作都能通过 Prisma Client 提供的自动生成的类型来进行,这大大减少了代码中的错误,提升了开发的效率。它还包含数据库迁移工具 Prisma Migrate,能够帮助开发者方便地管理数据库结构的变化。此外,Prisma Client 的查询 API 具有很好的性能,能够高效地执行复杂的数据库查询,支持包括关系查询、聚合查询等高级功能。


与传统的 ORM 相比,Prisma 使得数据库交互更加简洁且高效,减少了配置和手动操作的复杂性,特别适合在 NestJS 项目中使用,能够与 NestJS 提供的依赖注入和模块化架构很好地结合,提升整体开发体验。


Redis


Redis 和 mysql 都仅仅是会用的阶段,目前都是直接在 NestJs 项目中使用,都是已经封装好了的,直接传参调用就好了:


import { Injectable, Inject, OnModuleDestroy, Logger } from "@nestjs/common";
import Redis, { ClientContext, Result } from "ioredis";

import { ObjectType } from "../types";

import { isObject } from "@/utils";

@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);

constructor(@Inject("REDIS_CLIENT") private readonly redisClient: Redis) {}

onModuleDestroy(): void {
this.redisClient.disconnect();
}

/**
* @Description: 设置值到redis中
* @param {string} key
* @param {any} value
* @return {*}
*/

public async set(
key: string,
value: unknown,
second?: number
): Promise<Result<"OK", ClientContext> | null> {
try {
const formattedValue = isObject(value)
? JSON.stringify(value)
: String(value);

if (!second) {
return await this.redisClient.set(key, formattedValue);
} else {
return await this.redisClient.set(key, formattedValue, "EX", second);
}
} catch (error) {
this.logger.error(`Error setting key ${key} in Redis`, error);

return null;
}
}

/**
* @Description: 获取redis缓存中的值
* @param key {String}
*/

public async get(key: string): Promise<string | null> {
try {
const data = await this.redisClient.get(key);

return data ? data : null;
} catch (error) {
this.logger.error(`Error getting key ${key} from Redis`, error);

return null;
}
}

/**
* @Description: 设置自动 +1
* @param {string} key
* @return {*}
*/

public async incr(
key: string
): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.incr(key);
} catch (error) {
this.logger.error(`Error incrementing key ${key} in Redis`, error);

return null;
}
}

/**
* @Description: 删除redis缓存数据
* @param {string} key
* @return {*}
*/

public async del(key: string): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.del(key);
} catch (error) {
this.logger.error(`Error deleting key ${key} from Redis`, error);

return null;
}
}

/**
* @Description: 设置hash结构
* @param {string} key
* @param {ObjectType} field
* @return {*}
*/

public async hset(
key: string,
field: ObjectType
): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.hset(key, field);
} catch (error) {
this.logger.error(`Error setting hash for key ${key} in Redis`, error);

return null;
}
}

/**
* @Description: 获取单个hash值
* @param {string} key
* @param {string} field
* @return {*}
*/

public async hget(key: string, field: string): Promise<string | null> {
try {
return await this.redisClient.hget(key, field);
} catch (error) {
this.logger.error(
`Error getting hash field ${field} from key ${key} in Redis`,
error
);

return null;
}
}

/**
* @Description: 获取所有hash值
* @param {string} key
* @return {*}
*/

public async hgetall(key: string): Promise<Record<string, string> | null> {
try {
return await this.redisClient.hgetall(key);
} catch (error) {
this.logger.error(
`Error getting all hash fields from key ${key} in Redis`,
error
);

return null;
}
}

/**
* @Description: 清空redis缓存
* @return {*}
*/

public async flushall(): Promise<Result<"OK", ClientContext> | null> {
try {
return await this.redisClient.flushall();
} catch (error) {
this.logger.error("Error flushing all Redis data", error);

return null;
}
}

/**
* @Description: 保存离线通知
* @param {string} userId
* @param {any} notification
*/

public async saveOfflineNotification(
userId: string,
notification: any
): Promise<void> {
try {
await this.redisClient.lpush(
`offline_notifications:${userId}`,
JSON.stringify(notification)
);
} catch (error) {
this.logger.error(
`Error saving offline notification for user ${userId}`,
error
);
}
}

/**
* @Description: 获取离线通知
* @param {string} userId
* @return {*}
*/

public async getOfflineNotifications(userId: string): Promise<any[]> {
try {
const notifications = await this.redisClient.lrange(
`offline_notifications:${userId}`,
0,
-1
);
await this.redisClient.del(`offline_notifications:${userId}`);

return notifications.map((notification) => JSON.parse(notification));
} catch (error) {
this.logger.error(
`Error getting offline notifications for user ${userId}`,
error
);

return [];
}
}

/**
* 获取指定 key 的剩余生存时间
* @param key Redis key
* @returns 剩余生存时间(秒)
*/

public async getTTL(key: string): Promise<number> {
return await this.redisClient.ttl(key);
}
}

前端工程化


前端工程化这块花了很多信息在 eslint、prettier、husky、commitlint、github action 上,现在很多项目都是直接复制之前写好的过来就直接用。


后续应该是投入更多的时间在性能优化、埋点、自动化部署上了,如果有机会的也去研究一下 k8s 了。


全栈性价比最高的一套技术


最近刷到一个帖子,讲到了


20241223165138


我目前也算是一个小全栈了吧,我也来分享一下我的技术吧:



  1. NextJs

  2. React Native

  3. prisma

  4. NestJs

  5. taro (目前还不会,如果有需求就会去学)


剩下的描述也是和他下面那句话一样了(毕业后对技术态度的转变就是什么能让我投入最小,让我最快赚到钱的就是好技术)


总结


学无止境,任重道远。


最后再来提一下这两个开源项目,它们都是我们目前正在维护的开源项目:



如果你想参与进来开发或者想进群学习,可以添加我微信 yunmz777,后面还会有很多需求,等这个项目完成之后还会有很多新的并且很有趣的开源项目等着你。


作者:Moment
来源:juejin.cn/post/7451483063568154639
收起阅读 »