注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

前端实现画中画超简单,让网页飞出浏览器

web
Document Picture-in-Picture 介绍     今天,我来介绍一个非常酷的前端功能:文档画中画 (Document Picture-in-Picture, 本文简称 PiP)。你有没有想过,网页上的任何内容能悬浮在桌面上?😏 🎬 视频流媒...
继续阅读 »

Document Picture-in-Picture 介绍


    今天,我来介绍一个非常酷的前端功能:文档画中画 (Document Picture-in-Picture, 本文简称 PiP)。你有没有想过,网页上的任何内容能悬浮在桌面上?😏


🎬 视频流媒体的画中画功能


        你可能已经在视频平台(如腾讯视频哔哩哔哩等网页)见过这种效果:视频播放时,可以点击画中画后。无论你切换页面,它都始终显示在屏幕的最上层,非常适合上班偷偷看电视💻


pip.gif


        在今天的教程中,不仅仅是视频,我将教你如何将任何 HTML 内容放入画中画模式,无论是动态内容、文本、图片,还是纯炫酷的 div,统统都能“飞”起来。✨


        一个如此有趣的功能,在网上却很少有详细的教程来介绍这个功能的使用。于是我决定写一篇详细的教程来教大家如何实现画中画 (建议收藏)😁


体验网址:Treasure-Navigationpip08.gif


github地址




📖 Document Picture-in-Picture 详细教程


🛠 HTML 基本代码结构


    首先,我们随便写一个简单的 HTML 页面,后续的 JS 和样式都会基于它实现。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Picture-in-Picture API 示例</title>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
</head>

<body>
<div id="container">
<div id="pipContent">这是一个将要放入画中画的 div 元素!</div>
<button id="clickBtn">切换画中画</button>
</div>
<script>
// 在这里写你的 JavaScript 代码
</script>
</body>
</html>



1️. 请求 PiP 窗口


    PiP 的核心方法是 window.documentPictureInPicture.requestWindow。它是一个 异步方法,返回一个新创建的 window 对象。

    PIP 窗口可以将其看作一个新的网页,但它始终悬浮在屏幕上方。


document.getElementById("clickBtn").addEventListener("click", async function () {
// 获取将要放入 PiP 窗口的 DOM 元素
const pipContent = document.getElementById("pipContent");
// 请求创建一个 PiP 窗口
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200, // 设置窗口的宽度
height: 300 // 设置窗口的高度
});

// 将原始元素添加到 PiP 窗口中
pipWindow.document.body.appendChild(pipContent);
});

演示:


pip01.gif


👏 现在,我们已经成功创建了一个画中画窗口!
这段代码展示了如何将网页中的元素放入一个新的画中画窗口,并让它悬浮在最上面。非常简单吧


关闭PIP窗口


可以直接点右上角关闭PIP窗口,如果我们想在代码中实现关闭,直接调用window上的api就可以了


window.documentPictureInPicture.window.close();



2️. 检查是否支持 PiP 功能


    一切不能兼容浏览器的功能介绍都是耍流氓,我们需要检查浏览器是否支持PIIP功能
实际就是检查documentPictureInPicture属性是否存在于window上 🔧


if ('documentPictureInPicture' in window) {
console.log("🚀 浏览器支持 PiP 功能!");
} else {
console.warn("⚠️ 当前浏览器不支持 PiP 功能,更新浏览器或者换台电脑吧!");
}

    如果是只需要将视频实现画中画功能,视频画中画 (Picture-in-Picture) 的兼容性会好一点,但是它只能将<video>元素放入画中画窗口。它与本文介绍的 文档画中画(Document Picture-in-Picture) 使用方法也是十分相似的。


image.png


image.png




3️. 设置 PiP 样式


    我们会发现刚刚创建的画中画没有样式,一点都不美观。那是因为我们只放入了dom元素,没有添加css样式。


3.1. 全局样式同步


假设网页中的所有样式如下:


<head>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
<link rel="stylesheet" type="text/css" href="https://abc.css">
</head>

为了方便,我们可以直接把之前的网页的css样式全部赋值给画中画


// 1. document.styleSheets获取所有的css样式信息
[...document.styleSheets].forEach((styleSheet) => {
try {
// 转成字符串方便赋值
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
// 创建style标签
const style = document.createElement('style');
// 设置为之前页面中的css信息
style.textContent = cssRules;
console.log('style', style);
// 把style标签放到画中画的<head><head/>标签中
pipWindow.document.head.appendChild(style);
} catch (e) {
// 通过 link 引入样式,如果有跨域,访问styleSheet.cssRules时会报错。没有跨域则不会报错
const link = document.createElement('link');
/**
* rel = stylesheet 导入样式表
* type: 对应的格式
* media: 媒体查询(如 screen and (max-width: 600px))
* href: 外部样式表的 URL
*/

link.rel = 'stylesheet';
link.type = styleSheet.type;
link.media = styleSheet.media;
link.href = styleSheet.href ?? '';
console.log('error: link', link);
pipWindow.document.head.appendChild(link);
}
});

演示:

image.png




3.2. 使用 link 引入外部 CSS 文件


向其他普通html文件一样,可以通过link标签引入特定css文件:


创建 pip.css 文件:


#pipContent {
width: 600px;
height: 300px;
background: skyblue;
}

js引用:


// 其他不变
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = './pip.css'; // 引入外部 CSS 文件
pipWindow.document.head.appendChild(link);
pipWindow.document.body.appendChild(pipContent);

演示:

pip02.gif


3.3. 媒体查询的支持


可以设置媒体查询 @media (display-mode: picture-in-picture)。在普通页面中会自动忽略样式,在画中画模式会自动渲染样式


<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}

<!-- 普通网页中会忽略 -->
@media (display-mode: picture-in-picture) {
#pipContent {
background: lightgreen;
}
}
</style>

在普通页面中显示为粉色,在画中画自动变为浅绿色


演示:

pip03.gif




4️. 监听进入和退出 PiP 模式的事件


我们还可以为 PiP 窗口 添加事件监听,监控画中画模式的 进入退出。这样,你就可以在用户操作时,做出相应的反馈,比如显示提示或执行其他操作。


// 进入 PIP 事件
documentPictureInPicture.addEventListener("enter", (event) => {
console.log("已进入 PIP 窗口");
});

const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});
// 退出 PIP 事件
pipWindow.addEventListener("pagehide", (event) => {
console.log("已退出 PIP 窗口");
});

演示


pip04.gif




5️. 监听 PiP 焦点和失焦事件


const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});

pipWindow.addEventListener('focus', () => {
console.log("PiP 窗口进入了焦点状态");
});

pipWindow.addEventListener('blur', () => {
console.log("PiP 窗口失去了焦点");
});

演示


pip05.gif




6. 克隆节点画中画


我们会发现我们把原始元素传入到PIP窗口后,原来窗口中的元素就不见了。

我们可以把原始元素克隆后再传入给PIP窗口,这样原始窗口中的元素就不会消失了


const pipContent = document.getElementById("pipContent");
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});
// 核心代码:pipContent.cloneNode(true)
pipWindow.document.body.appendChild(pipContent.cloneNode(true));

演示


pip07.gif


PIP 完整示例代码


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Picture-in-Picture API 示例</title>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
</head>
<body>
<div id="container">
<div id="pipContent">这是一个将要放入画中画的 div 元素!</div>
<button id="clickBtn">切换画中画</button>
</div>

<script>
// 检查是否支持 PiP 功能
if ('documentPictureInPicture' in window) {
console.log("🚀 浏览器支持 PiP 功能!");
} else {
console.warn("⚠️ 当前浏览器不支持 PiP 功能,更新浏览器或者换台电脑吧!");
}

// 请求 PiP 窗口
document.getElementById("clickBtn").addEventListener("click", async function () {
const pipContent = document.getElementById("pipContent");

// 请求创建一个 PiP 窗口
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200, // 设置窗口的宽度
height: 300 // 设置窗口的高度
});

// 将原始元素克隆并添加到 PiP 窗口中
pipWindow.document.body.appendChild(pipContent.cloneNode(true));

// 设置 PiP 样式同步
[...document.styleSheets].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
const style = document.createElement('style');
style.textContent = cssRules;
pipWindow.document.head.appendChild(style);
} catch (e) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = styleSheet.type;
link.media = styleSheet.media;
link.href = styleSheet.href ?? '';
pipWindow.document.head.appendChild(link);
}
});

// 监听进入和退出 PiP 模式的事件
pipWindow.addEventListener("pagehide", (event) => {
console.log("已退出 PIP 窗口");
});

pipWindow.addEventListener('focus', () => {
console.log("PiP 窗口进入了焦点状态");
});

pipWindow.addEventListener('blur', () => {
console.log("PiP 窗口失去了焦点");
});
});

// 关闭 PiP 窗口
// pipWindow.close(); // 可以手动调用关闭窗口
</script>
</body>
</html>




总结


🎉 你现在已经掌握了如何使用 Document Picture-in-Picture API 来悬浮任意 HTML 内容!
希望能带来更灵活的交互体验。✨


如果你有什么问题,或者对 PiP 功能有更多的想法,欢迎在评论区与我讨论!👇📬


作者:前端金熊
来源:juejin.cn/post/7441954981342036006
收起阅读 »

还在每次都写判断?试试惰性函数,让你的代码更聪明!

web
什么是惰性函数? 先来看个例子 function addEvent(el, type, handler) { if (window.addEventListener) { el.addEventListener(type, handler, fal...
继续阅读 »

什么是惰性函数?


先来看个例子


function addEvent(el, type, handler) {
if (window.addEventListener) {
el.addEventListener(type, handler, false);
} else {
el.attachEvent('on' + type, handler);
}
}

上面这段代码中,每次调用 addEvent 都会进行一遍判断。实际上,我们并不需要每次都进行判断,只需要执行一次就够了,当然,我们也可以存个全局的flag来记录,但是,有更好的办法了


function addEvent(el, type, handler) {
if (window.addEventListener) {
addEvent = function(el, type, handler) {
el.addEventListener(type, handler, false);
}
} else {
addEvent = function(el, type, handler) {
el.attachEvent('on' + type, handler);
}
}
return addEvent(el, type, handler); // 调用新的函数
}

这就是惰性函数:第一次执行时会根据条件覆盖自身,后续调用直接执行更新后的逻辑


惰性函数的实现方式


惰性函数一般情况下有两种实现方式


在函数内部重写自身


这种实现方式就是上面我们介绍的那样


function foo() {
console.log('初始化...');
foo = function() {
console.log('后续逻辑');
}
}

大多数情况下,这种实现方式都可以覆盖


用函数表达式赋值


const foo = (function() {
if (someCondition) {
return function() { console.log('A'); }
} else {
return function() { console.log('B'); }
}
})();

这种情况适用于模块或者立即执行的情况,其实就是用闭包做了个封装


惰性函数的应用场景


兼容性判断


我们在做适配的时候,很多时候需要进行浏览器特性的判断,比如之前提到的事件绑定


性能优化


其实惰性函数说起来很像单例,他的原理就是只执行一次,那么如果想要避免一些重复操作,尤其是重复初始化,就可以想一下是不是可以用惰性函数来处理,比如Canvas渲染引擎,加载某些外部依赖、判断登录状态等等


注意事项



  1. 写好注释,一定要写好注释,因为函数在执行后会变化,不写注释如果除了一些问题,可能后面维护的人会骂街,会大大增加你的不可替代性,咳咳,千万不要这么操作,一定要写好注释

  2. 不适合频繁修改逻辑和复杂上下文的场景,会增加复杂度


一句话总结:能判断一次就不要判断两次,惰性函数让你的代码更聪明


作者:那小孩儿
来源:juejin.cn/post/7490850417976508428
收起阅读 »

Electron 应用太重?试试 PakePlus 轻装上阵

web
Electron 作为将 Web 技术带入桌面应用领域的先驱框架,让无数开发者能够使用熟悉的 HTML、CSS 和 JavaScript 构建跨平台应用。然而,随着应用规模的扩大,Electron 应用的性能问题逐渐显现——内存占用高、启动速度慢、安装包体积庞...
继续阅读 »


Electron 作为将 Web 技术带入桌面应用领域的先驱框架,让无数开发者能够使用熟悉的 HTML、CSS 和 JavaScript 构建跨平台应用。然而,随着应用规模的扩大,Electron 应用的性能问题逐渐显现——内存占用高、启动速度慢、安装包体积庞大,这些都成为了用户体验的绊脚石。不过,现在有了 PakePlus,这些烦恼都将迎刃而解。


PakePlus官网文档:PakePlus


PakePlus开源地址:github.com/Sjj1024/Pak…



首先要轻


以一款基于 Electron 的文档编辑应用为例,在使用 PakePlus 优化前,安装包大小达 200MB,启动时间超过 10 秒。但是使用PakePlus重新打包之后,安装包大小控制在5M左右,缩小了将近40倍!启动时间也做到了2秒以内!这就是PakePLus的魅力所在。


开发者反馈:"迁移过程出乎意料的顺利,大部分代码无需修改,性能提升却立竿见影。"




其次都是其次



  • 🚀 基于 Rust Tauri,PakePlus 比基于 JS 的框架更轻量、更快。

  • 📦 内置丰富功能包——支持快捷方式、沉浸式窗口、极简自定义。

  • 👻 PakePlus 只是一个极简的软件,用 Tauri 替代旧的打包方式,支持跨平台桌面,将很快支持手机端。

  • 🤗 PakePlus 易于操作使用,只需一个 GitHub Token,即可获得桌面应用。

  • 🌹 不需要在本地安装任何复杂的依赖环境,使用 Github Action 云端自动打包。

  • 🧑‍🤝‍🧑 支持国际化,对全球用户都非常友好,并且会自动跟随你的电脑系统语言。

  • 💡 支持自定义 js 注入。你可以编写自己的 js 代码注入到页面中。

  • 🎨 ui 界面更美观更友好对新手更实用,使用更舒适,支持中文名称打包。

  • 📡 支持网页端直接使用,但是客户端功能更强大,更推荐客户端。

  • 🔐 数据安全,你的 token 仅保存在你本地,不会上传服务器,你的项目也都在你自己的 git 中安全存储。

  • 🍀 支持静态文件打包,将 Vue/React 等项目编译后的 dist 目录或者 index.html 丢进来即可成为客户端,何必是网站。

  • 🐞 支持 debug 调试模式,无论是预览阶段还是发布阶段,都可以找到 bug 并消灭 bug



使用场景


你有一个网站,想把它立刻变成跨平台桌面应用和手机APP,立刻高大尚。

你有一个 Vue/React 等项目,不想购买服务器,想把它打包成桌面应用。

你的 Cocos 游戏是不是想要跨平台客户端运行?完全没有问题。

你的 Unity 项目是不是想要跨平台打包为客户端?也完全没有问题。

隐藏你的网站地址,不被随意传播和使用,防止爬虫程序获取你的网站内容。

公司内网平台,不想让别人知道你的网站地址,只允许通过你的客户端访问。

想把某个网站变成自己的客户端,实现自定义功能,比如注入 js 实现自动化操作。

网站广告太多?想把它隐藏起来,用无所不能的 js 来屏蔽它们吧。

需要使用 tauri2 打包,但是依赖环境太复杂,本地电脑硬盘不够用,就用 PakePlus



热门包



PakePLus 支持 arm 和 inter 架构的安装包,流行的程序安装包仅仅包含了 mac 的 arm(M 芯片)版本 和 windows 的 Inter(x64)版本 和 Linux 的 x64 版本,如果需要更多架构的安装包,请使用 PakePlus 单独编译自己需要的安装包。热门包的下载地址请到官方文档下载体验



常见问题


mac提示:应用已随坏



这是因为没有给苹果给钱,所以苹果会拒绝你的应用。


解决办法:


Mac 用户可能在安装时看到“应用已损坏”的警告。 请点击“取消”,然后运行以下命令,输入电脑密码后,再重新打开应用:(这是由于应用需要官方签名,才能避免安装后弹出“应用已损坏”的提示,但官方签名的费用每年 99 美元...因此,需要手动绕过签名以正常使用)


sudo xattr -r -d com.apple.quarantine /Applications/PakePlus.app

 当你打包应用时,Mac 用户可能在安装时看到“应用已损坏”的警告。 请点击“取消”,然后运行以下命令,再重新打开应用:


sudo xattr -r -d com.apple.quarantine /Applications/你的软件名称.app



作者:1024小神
来源:juejin.cn/post/7490876486292389914
收起阅读 »

只需一行代码,任意网页秒变可编辑!

web
大家好,我是石小石! 在我们日常工作中,可能会遇到截图页面的场景,有时页面有些内容不符合要求,我们可能需要进行一些数值或内容的修改。如果你会PS,修改内容难度不高,如果你是前端,打开控制台也能通过修改dom的方式进行简单的文字修改。 今天,我就来分享一个冷门...
继续阅读 »

大家好,我是石小石!




在我们日常工作中,可能会遇到截图页面的场景,有时页面有些内容不符合要求,我们可能需要进行一些数值或内容的修改。如果你会PS,修改内容难度不高,如果你是前端,打开控制台也能通过修改dom的方式进行简单的文字修改。


今天,我就来分享一个冷门又实用的前端技巧 —— 只需一行 JavaScript 代码,让任何网页瞬间变成可编辑的! 先看看效果:


甚至,还可以插入图片等媒体内容



如何实现


很难想象,这么炫酷的功能,居然只需要在控制台输入一条指令:


document.designMode = "on";

打开浏览器控制台(F12),复制粘贴这行代码,回车即可。



如果你想关闭此功能,输入document.designMode = "off"即可。


Document:designMode 属性


MDN是这样介绍的:


document.designMode 控制整个文档是否可编辑。有效值为 "on""off"。根据规范,该属性默认为 "off"。Firefox 遵循这一标准。早期版本的 Chrome 和 IE 默认为 "inherit"。从 Chrome 43 开始,默认为 "off" 并不再支持 "inherit"。在 IE6-10 中,该值为大写。


兼容性方面,基本上所有浏览器都是支持的。



借助次API,我们也能实现Iframe嵌套页面的编辑:


iframeNode.contentDocument.designMode = "on";

关联API


与designMode关联的API其实还有contentEditable和execCommand(已弃用,但部分浏览器还可以使用)。


contentEditabledesignMode功能类似,不过contentEditable可以使特定的 DOM 元素变为可编辑,而designMode只能使整个文档可编辑。


特性contentEditabledocument.designMode
作用范围可以使单个元素可编辑可以使整个文档可编辑
启用方式设置属性为 truefalse设置 document.designMode = "on"
适用场景用于指定某些元素,如 <div>, <span>用于让整个页面变为可编辑
兼容性现代浏览器都支持现代浏览器都支持,部分老旧浏览器可能不支持

document.execCommand() 方法允许我们在网页中对内容进行格式化、编辑或操作。它主要用于操作网页上的可编辑内容(如 <textarea> 或通过设置 contentEditabledesignMode 属性为 "true" 的元素),例如加粗文本、插入链接、调整字体样式等。由于它已经被W3C弃用,所以本文也不再介绍了。


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

老板花一万大洋让我写的艺术工作室官网?! HeroSection 再度来袭!(Three.js)

web
引言.我不是鸽子大王!! 哈喽大家好!距离我上次发文已经过去半个月了,差点又变回了那只熟悉的“老鸽子”。不行,我不能堕落!我还没有将 Web3D 推广到普罗大众,还没有让更多人感受到三维图形的魅力 (其实是还没有捞着米)。怀着这样的心情,我决定重新振作,继续为...
继续阅读 »

引言.我不是鸽子大王!!


哈喽大家好!距离我上次发文已经过去半个月了,差点又变回了那只熟悉的“老鸽子”。不行,我不能堕落!我还没有将 Web3D 推广到普罗大众,还没有让更多人感受到三维图形的魅力 (其实是还没有捞着米)。怀着这样的心情,我决定重新振作,继续为大家带来更多关于 Three.jsShader 的进阶内容。


0.前置条件


欢迎阅读本篇文章!在深入探讨 Three.jsShader (GLSL) 的进阶内容之前,确保您已经具备以下基础知识:



  1. Three.js 基础:您需要熟悉 Three.js 的基本概念和使用方法,包括场景(Scene)、相机(Camera)、渲染器(Renderer)、几何体(Geometry)、材质(Material)和网格(Mesh)等核心组件。如果您还不熟悉这些内容,建议先学习 Three.js 的入门教程。

  2. Shader 语法:本文涉及 GLSL(OpenGL Shading Language)的编写,因此您需要了解 GLSL 的基本语法,包括顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)的编写,以及如何在 Three.js 中使用自定义着色器。


1. Hero Section 概览



Hero Section 是网页设计中的一个术语,通常指页面顶部的一个大型横幅区域。但对于开发人员而言,这个概念可以更直观地理解为用户在访问网站的瞬间所感受到的视觉冲击,或者促使用户停留在该网站的关键原因因素



话说这天老何接到了一个私活


chat.png


起始钱不钱的无所谓!主要是想挑战一下自己(不是)。最后的成品如图所示 (互动方式为鼠标滑动 + 鼠标点击 GIF 压缩太多了内容了,实际要好看很多)。


01.gif


PC端在线预览地址: fluid-light.vercel.app


Debug调试界面: fluid-light.vercel.app/#debug


源码地址: github.com/hexianWeb/f…


2.基础场景搭建


首先我来为你解读一下这个场景里面有什么,他非常简单。只有一个可以接受光照影响的平面几何体以及数个点光源构成,仅此而已。


让我去掉后处理以及一些页面文本元素展示给你看


fluidLight04.gif


构建这样的一个基础场景不难。


2.1 构建平面几何体


让我们先来解决平面几何体


值得注意的是,为了让显示效果更好,我使用了正交相机并让平面覆盖整个视窗大小


    this.geometry = new THREE.PlaneGeometry(2 * 屏幕宽高比, 2);

然后构建相应的物理材质,可以去 polyhaven 下载一些自己喜欢的texture 并下载下来。


Snipaste_2025-03-05_14-26-01.png
根据右边的分类选择纹理大类细分,随后选择想要下载的纹理点击下载。


因为我们本质是需要 Displacement Texture置换贴图 & Normal Texture 法线贴图


所以不需要太在意这个纹理是作用在什么物件上面的


Snipaste_2025-03-05_14-30-09.png


随后将纹理导入后赋予材质相应的属性,并对部分参数进行调整。通常直接赋予displacementMapThreejs 中显示平面的凹凸会特别明显。所以记得通过


displacementScale来调整相应的大小。


    this.material = new THREE.MeshPhysicalMaterial({
color: '#121423',
metalness: 0.59,
roughness: 0.41,
displacementMap: 下载的纹理贴图,
displacementScale: 0.1,
normalMap: 下载的法线贴图,
normalScale: new THREE.Vector2(0.68, 0.75),
side: THREE.FrontSide
});

最后将物体加入场景即可


    this.mesh = new THREE.Mesh(this.geometry, this.material);
scene.add(this.mesh);

(tips:MeshStandardMaterialMeshPhysicalMaterial 适合需要真实感光照和复杂物理特性的场景,但性能消耗较高。如果您的电脑出现卡顿可以选择消耗较少性能的物理材质)


2.2 灯光加入战场


在本案例中,高级感的来源之一就是灯光的变换。如果您足够细心,可能会注意到一些更微妙的细节:场景中的灯光并不是简单地从 A Color 切换到 B Color,而是同时存在多个光源,并且它们的强度也在动态变化。这种设计使得场景的光影效果更加丰富和立体。


03.gif


如果场景中只有一个光源,效果可能会显得单调。而本案例中,灯光的变化呈现出一种层次感:中间是白色,周围还有一层类似年轮的光圈,最后逐渐扩散为纯色背景。这种效果的关键在于同一时间场景中存在多个点光源。虽然多个光源会显著增加性能消耗,但为了实现唯美的视觉效果,这是值得的。


让我们逐步分析灯光是如何实现的。


1. 封装创建点光源的函数


为了简化代码并提高复用性,我们可以先封装一个创建点光源的函数。这个函数会返回一个包含光源对象和目标颜色的对象。


  createPointLight(intensity) {
const light = new THREE.PointLight(
0xff_ff_ff,
intensity,
100,
Math.random() * 10
);
light.position.copy(this.lightPosition); //所有的光源都同步在一个位置
return {
object: light,
targetColor: new THREE.Color()
};
}

2. 生成多个点光源


接下来,我们可以调用这个函数生成多个点光源,并将它们添加到场景中。


this.colors = [
new THREE.Color('orange'),
new THREE.Color('red'),
new THREE.Color('red'),
new THREE.Color('orange'),
new THREE.Color('lightblue'),
new THREE.Color('green'),
new THREE.Color('blue'),
new THREE.Color('blue')
];
this.lights = [
this.createPointLight(2),
this.createPointLight(3),
this.createPointLight(2.5),
this.createPointLight(10),
this.createPointLight(2),
this.createPointLight(3),
];

// 初始化灯光颜色
const numberLights = this.lights.length;
for (let index = 0; index < numberLights; index++) {
const colorIndex = Math.min(index, this.colors.length - 1);
this.lights[index].object.color.copy(this.colors[colorIndex]);
}

for (const light of this.lights) this.scene.add(light.object);

3. 动态调整光源强度


在场景中,所有光源同时存在,但它们的强度会有所不同。每次由光照强度为 10 的光源担任场景的主色。当用户点击场景时,灯光会像上楼梯或者传送带一样逐步切换,即由新的点光源担任场景主色。


aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,
8 8"b, "Ya
8 8 "b, "Ya
8 aa=D光源=a8, "b, "Ya
8 8"b, "Ya "8""""""8
8 8 "
b, "Ya 8 8
8 a=C光源=8, "
b, "Ya8 8
8 8"
b, "Ya "8""""""" 8
8 8 "
b, "Ya 8 8
8 a=B光源=8, "
b, "Ya8 8
8 8"
b, "Ya "8""""""" 8
8 8 "
b, "Ya 8 8
8=A光源=, "
b, "Ya8 8
8"
b, "Ya "8""""""" 8
8 "
b, "Ya 8 8
8, "
b, "Ya8 8
"
Ya "8""""""" 8
"Ya 8 8
"
Ya8 8
"""""""""""""""""""""""""""""""""""""

让我们看看代码是如何实现的吧


    window.addEventListener('click', () => {
// 打乱颜色数组(看个人喜好)
this.colors = [...this.colors.sort(() => Math.random() - 0.5)];
// 标记开始颜色过渡
this.colorTransition = true;

// 为每个灯光设置目标颜色
const numberLights = this.lights.length;
for (let index = 0; index < numberLights; index++) {
const colorIndex = Math.min(index, this.colors.length - 1);
this.lights[index].targetColor = this.colors[colorIndex].clone();
}
});

然后再Render函数中以easeing方式更新颜色


  update() {
// 只在需要时更新颜色
if (this.colorTransition) {
const numberLights = this.lights.length;
const baseSmooth = 0.25;
const smoothIncrement = 0.05;

let allTransitioned = true; // 检查所有颜色是否已完成过渡

for (let index = 0; index < numberLights; index++) {
const smoothTime = baseSmooth + index * smoothIncrement;

// 使用目标颜色进行平滑过渡
const currentColor = this.lights[index].object.color;
const targetColor = this.lights[index].targetColor;

this.dampC(currentColor, targetColor, smoothTime, delta);

// 检查是否还在过渡
if (!this.isColorClose(currentColor, targetColor)) {
allTransitioned = false;
}
}

// 如果所有颜色都已完成过渡,停止更新
if (allTransitioned) {
this.colorTransition = false;
}
}
}

03.gif


3.后处理完善场景


在完成了场景的基本构建之后,我们已经实现了大约 80% 的内容。即使现在加上 UI,效果也不会太差。不过,为了让场景更具视觉冲击力和艺术感,我们可以通过后处理(Post Processing)技术来进一步提升质感。


04.gif


使用 UnrealBloomPassFilmPass


在本文中,我们将使用 UnrealBloomPass(辉光效果)和 FilmPass(电影滤镜)来增强场景的视觉效果。以下是具体的实现步骤:



  1. 引入后处理库:首先,我们需要引入 Three.js 的后处理库 EffectComposer 以及相关的 Pass 类。

  2. 创建 EffectComposerEffectComposer 是后处理的核心类,用于管理和执行各种后处理效果。

  3. 添加 RenderPassRenderPass 用于将场景渲染到后处理管道中。

  4. 添加 UnrealBloomPassUnrealBloomPass 用于实现辉光效果,可以使场景中的亮部区域产生光晕。

  5. 添加 FilmPassFilmPass 用于模拟电影胶片的效果,增加颗粒感和复古风格。


这里的具体参数需要看个人品味进行调试。同款参数可以从这里看我的源码。具体路径位于src\js\world\effect.js


    this.composer = new EffectComposer(this.renderer);
this.composer.addPass(this.renderPass);
this.composer.addPass(this.bloomPass);
this.composer.addPass(this.filmPass);

此时页面的质感是不是一下就上来了呢?


05.gif


最后我们需要添加最关键的一部,就是画面扭曲。


这里我们需要用到 ThreejsShaderPass,让我们来创建一个初始的ShaderPass,仅将 EffectComposer 的读取缓冲区的图像内容复制到其写入缓冲区,而不应用任何效果。


具体内容你可以从 Threejs 后处理中了解到更多


import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';

const BaseShader = {

name: 'BaseShader',

uniforms: {

'tDiffuse': { value: null },
'opacity': { value: 1.0 }

},

vertexShader: /* glsl */`

varying vec2 vUv;

void main() {

vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );

}`
,

fragmentShader: /* glsl */`

uniform float opacity;

uniform sampler2D tDiffuse;

varying vec2 vUv;

void main() {

vec4 texel = texture2D( tDiffuse, vUv );
gl_FragColor = opacity * texel;


}`


};

const BasePass = new ShaderPass( BaseShader );

此时画面不会有任何变化


让我们对uv进行简单操纵,让其读取tDiffuse时可以发生扭曲


      vec2 uv = vUv;
uv.y += sin(uv.x * frequency + offset) * amplitude;
gl_FragColor = texture2D(tDiffuse, uv);

最后得到效果


06.gif


4.最后一些话


技术的未来与前端迁移


随着 AI 技术的快速发展,各类技术的门槛正在大幅降低,以往被视为高门槛的 3D 技术也不例外。与此同时,过去困扰开发者的数字资产构建成本问题,也正在被最新的 3D generation 技术所攻克。这意味着,在不久的将来,前端开发将迎来一次技术迁移,开发者需要掌握更新颖的交互方式和更出色的视觉效果。


为什么选择 Three.js


Three.js 作为最流行的 WebGL 库之一,不仅简化了三维图形的开发流程,还提供了丰富的功能和强大的扩展性。无论是创建复杂的 3D 场景,还是实现炫酷的视觉效果,Three.js 都能帮助开发者快速实现目标。


本专栏的愿景


本专栏的愿景是通过分享 Three.js 的中高级应用和实战技巧,帮助开发者更好地将 3D 技术应用到实际项目中,打造令人印象深刻的 Hero Section。我们希望通过本专栏的内容,能够激发开发者的创造力,推动 Web3D 技术的普及和应用。


加入社区,共同成长


如果您对 Threejs 这个 3D 图像框架很感兴趣,或者您也深信未来国内会涌现越来越多 3D 设计风格的网站,欢迎加入 ice 图形学社区。这里是国内 Web 图形学最全的知识库,致力于打造一个全新的图形学生态体系!您可以在认证达人里找到我这个 Threejs 爱好者和其他大佬。


此外,如果您很喜欢 Threejs 又在烦恼其原生开发的繁琐,那么我诚邀您尝试 TresjsTvTjs, 他们都是基于 VueThreejs 框架。 TvTjs 也为您提供了大量的可使用案例,并且拥有较为活跃的开发社区,在这里你能碰到志同道合的朋友一起做开源!


5.下期预告


未来科技?机器人概念官网来袭 !!!


08.gif


6. 往期回顾


2025 年了,我不允许有前端不会用 Trae 让页面 Hero Section 变得高级!!!(Threejs)


2024年了,前端人是时候给予页面一点 Hero Section 魔法了!!! (Three.js)


2023 年了,还有前端人不知道 commit 规范 ?


作者:何贤
来源:juejin.cn/post/7478403990141796352
收起阅读 »

前端如何优雅通知用户刷新页面?

web
前言老板:新的需求不是上线了嘛,怎么用户看到的还是老的页面呀窝囊废:让用户刷新一下页面,或者清一下缓存老板:那我得告诉用户,刷新一下页面,或者清一下缓存,才能看到新的页面呀,感觉用户体验不好啊,不能直接刷新页面嘛?窝囊废:可以解决(OS:一点改的必要没有,用户...
继续阅读 »

前言

老板:新的需求不是上线了嘛,怎么用户看到的还是老的页面呀
窝囊废:让用户刷新一下页面,或者清一下缓存
老板:那我得告诉用户,刷新一下页面,或者清一下缓存,才能看到新的页面呀,感觉用户体验不好啊,不能直接刷新页面嘛?
窝囊废:可以解决(OS:一点改的必要没有,用户全是大聪明)

产品介绍

c端需要经常进行一些文案调整,一些老版的文字字眼可能会导致一些舆论问题,所以就需要更新之后刷新页面,让用户看到新的页面。

思考问题为什么产生

项目是基于vue的spa应用,通过nginx代理静态资源,配置了index.html协商缓存,js、css等静态文件Cache-Control,按正常前端重新部署后, 用户重新访问系统,已经是最新的页面。

但是绝大部份用户都是访问页面之后一直停留在此页面,这时候前端部署后,用户就无法看到新的页面,需要用户刷新页面。

产生问题

  • 如果后端接口有更新,前端重新部署后,用户访问老的页面,可能会导致接口报错。
  • 如果前端部署后,用户访问老的页面,可能无法看到新的页面,需要用户刷新页面,用户体验不好。
  • 出现线上bug,修复完后,用户依旧访问老的页面,仍会遇到bug。

解决方案

  1. 前后端配合解决
  • WebSocket
  • SSE(Server-Send-Event)
  1. 纯前端方案 以下示例均以vite+vue3为例;
  • 轮询html Etag/Last-Modified

在App.vue中添加如下代码

const oldHtmlEtag = ref();
const timer = ref();
const getHtmlEtag = async () => {
const { protocol, host } = window.location;
const res = await fetch(`${protocol}//${host}`, {
headers: {
"Cache-Control": "no-cache",
},
});
return res.headers.get("Etag");
};

oldHtmlEtag.value = await getHtmlEtag();
clearInterval(timer.value);
timer.value = setInterval(async () => {
const newHtmlEtag = await getHtmlEtag();
console.log("---new---", newHtmlEtag);
if (newHtmlEtag !== oldHtmlEtag.value) {
Modal.destroyAll();
Modal.confirm({
title: "检测到新版本,是否更新?",
content: "新版本内容:",
okText: "更新",
cancelText: "取消",
onOk: () => {
window.location.reload();
},
});
}
}, 30000);
  • versionData.json

自定义plugin,项目根目录创建/plugins/vitePluginCheckVersion.ts

import path from "path";
import fs from "fs";
export function checkVersion(version: string) {
return {
name: "vite-plugin-check-version",
buildStart() {
const now = new Date().getTime();
const version = {
version: now,
};
const versionPath = path.join(__dirname, "../public/versionData.json");
fs.writeFileSync(versionPath, JSON.stringify(version), "utf8", (err) => {
if (err) {
console.log("写入失败");
} else {
console.log("写入成功");
}
});
},
};
}

在vite.config.ts中引入插件

import { checkVersion } from "./plugins/vitePluginCheckVersion";
plugins: [
vue(),
checkVersion(),
]

在App.vue中添加如下代码

const timer = ref()
const checkUpdate = async () => {
let res = await fetch('/versionData.json', {
headers: {
'Cache-Control': 'no-cache',
},
}).then((r) => r.json())
if (!localStorage.getItem('demo_version')) {
localStorage.setItem('demo_version', res.version)
} else {
if (res.version !== localStorage.getItem('demo_version')) {
localStorage.setItem('demo_version', res.version)
Modal.confirm({
title: '检测到新版本,是否更新?',
content: '新版本内容:' + res.content,
okText: '更新',
cancelText: '取消',
onOk: () => {
window.location.reload()
},
})
}
}
}

onMounted(()=>{
clearInterval(timer.value)
timer.value = setInterval(async () => {
checkUpdate()
}, 30000)
})

Use

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { webUpdateNotice } from '@plugin-web-update-notification/vite'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
webUpdateNotice({
logVersion: true,
}),
]
})

作者:李暖阳啊
来源:juejin.cn/post/7439905609312403483

收起阅读 »

部署项目,console.log为什么要去掉?

web
console.log的弊端 1. 影响性能(轻微但可优化) console.log 会占用 内存 和 CPU 资源,尤其是在循环或高频触发的地方(如 mousemove 事件)。 虽然现代浏览器优化了 console,但大量日志仍可能导致 轻微性能下降。 2...
继续阅读 »

console.log的弊端


1. 影响性能(轻微但可优化)


console.log 会占用 内存 和 CPU 资源,尤其是在循环或高频触发的地方(如 mousemove 事件)。
虽然现代浏览器优化了 console,但大量日志仍可能导致 轻微性能下降


2. 暴露敏感信息(安全风险)


可能会 泄露 API 接口、Token、用户数据 等敏感信息,容易被恶意利用。


3. 干扰调试(影响开发者体验)


生产环境日志过多,可能会 掩盖真正的错误信息,增加调试难度。
开发者可能会误以为某些 console.log 是 预期行为,而忽略真正的 Bug。


4. 增加代码体积(影响加载速度)


即使 console.log 本身很小,但 大量日志 会增加打包后的文件体积,影响 首屏加载速度


解决方案:移除生产环境的 console.log


1. 使用 Babel 插件


在 babel.config.js 中配置:


module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
]
,
plugins: [
['@babel/plugin-proposal-optional-chaining']
,
...process.env.NODE_ENV === 'production' ? [['transform-remove-console', { exclude: ['info', 'error', 'warn'] }]] : []
]
}

特点



  • 不影响源码,仅在生产环境生效,开发环境保留完整 console

  • 配置简单直接,适合快速实现基本需求。

  • 依赖 Babel 插件


2. 使用 Terser 压缩时移除(Webpack/Vite 默认支持)


在 vite.config.js 或 webpack.config.js 中配置:


module.exports = {
chainWebpack: (config) => {
config.optimization.minimizer("terser").tap((args) => {
args[0].terserOptions.compress = {
...args[0].terserOptions.compress,
drop_console: true, // 移除所有 console
pure_funcs: ["console.log"], // 只移除 console.log,保留其他
};
return args;
});
},
};

特点



  • 不影响源码,仅在生产环境生效,开发环境保留完整 console

  • 避免 Babel 插件兼容性问题

  • 需要额外配置


3. 自定义 console 包装函数(按需控制)


// utils/logger.js
const logger = {
log: (...args) => {
if (process.env.NODE_ENV !== "production") {
console.log("[LOG]", ...args);
}
},
warn: (...args) => {
console.warn("[WARN]", ...args);
},
error: (...args) => {
console.error("[ERROR]", ...args);
},
};

export default logger;

使用方式


import logger from "./utils/logger";

logger.log("Debug info"); // 生产环境自动不打印
logger.error("Critical error"); // 始终打印

特点



  • 可以精细控制日志,可控性强,可以自定义日志级别。

  • 不影响 console.warn 和 console.error

  • 需要手动替换 console.log


作者:用户2612458340161
来源:juejin.cn/post/7485938326336766003
收起阅读 »

因网速太慢我把20M+的字体压缩到了几KB

web
于水增 故事背景 事情起源于之前做的海报编辑器,自己调试时无意中发现字体渲染好慢,第一反应就是网怎么变慢了,断网了?仔细一看才发现,淦!这几个字体资源咋这么大,难怪网速变慢了呢😁😁。 图片中的海报包含6种字体,其中最大的字体文件超过20M,而最长的网络加载...
继续阅读 »

于水增



故事背景


事情起源于之前做的海报编辑器,自己调试时无意中发现字体渲染好慢,第一反应就是网怎么变慢了,断网了?仔细一看才发现,淦!这几个字体资源咋这么大,难怪网速变慢了呢😁😁。



图片中的海报包含6种字体,其中最大的字体文件超过20M,而最长的网络加载时长已接近20s。所以海报实际效果图展示耗时太久,很影响用户体验。那就趁此机会跟大家聊聊 字体 这件小事。


字体文件为什么那么大?


🙋 DeepSeek同学来回答下大家:


这里所说的大体积的字体资源多数是指中文主要原因下边两点



  • 中文字符数量庞大,英文仅 26 个字母 + 符号,中文(全字符集)包含 70,000+ 字符

  • 字形结构复杂,字体文件需为每个字符存储独立的矢量轮廓数据,而汉字笔画复杂,每个字符需存储数百个控制点坐标(例如「龍」字的轮廓点数量可能是「A」的 10 倍以上)


总结下来就是咱们不光汉字多,书法也是五花八门,它是真小不了。如果你硬要压缩,我们只能从第一点入手,将字符数量进行缩减,比如保留 1000 个常用汉字。


web网站中常见字体格式



由于我司物料部门提供的为TTF格式,所以这里通过 思源黑体 给一个直观的对比:



  • TTF 文件:16.9 MB

  • WOFF2 文件:7.4 MB(压缩率约 60%)


两者为什么会差这么多,其实WOFF2 只是在 TTF/OTF 基础上添加了压缩和 Web 专用元数据,且WOFF2支持增量解码,也就是边下载边解析,文本可更快显示(即使字体未完全加载,不过有待考证)。


TTF有办法优化吗?


回归问题本身


首先来简单回顾下我们自定义的字体是如何在浏览器中完成渲染的


一般情况下我们对字体文件的引用方式为下边三种



  • 通过绝对路径来引用,这种就是将字体文件打包在工程内,所以带来的结果就是工程打包文件体积太大


@font-face {
font-family: 'xxx';
src: url('../../assets/fonts.woff2')
}


  • 第二种就是 CDN 中存放的字体文件,一般是通过这种方式来减少工程的编译后体积


@font-face {
font-family: 'xxx';
src: url('https://xxx.woff2')
}


  • 通过 FontFace 构造一个字体对象


前两种一般是在浏览器构建 CSSOM 时,当遇到**<font style="color:rgba(0, 0, 0, 0.9);background-color:rgb(243, 243, 243);">url()</font>** 引用时会发起资源请求。第三种则是通过 js 来控制字体的加载流程,所以归根结底就是字体文件太大,导致网络资源下载速度慢,我们只能从优化字体大小的方向入手


确定解决方向


下面汇总下查到的具体几个优化方案,诸如提高网络传输效率,增加缓存之类的就不讲了,能够立竿见影的主要下边这两个方案


方案方法/原理适用场景
字体子集化通过工具将字体文件进行提取(支持动态),返回指定的字符集的字体文件,其根本就是减少单次资源请求的体积,需要服务端支持这个方案是所有优化场景的基础
按需加载通过设置 unicode-range 属性,浏览器在进行css样式计算时候,会根据页面中的字符与设置的字符范围进行比对,匹配上会加载对应的字体文件前提是资源已经被子集化,比较适用多语言切换的场景

简单来说,字体子集化可单独食用,按需加载则必须要将字体前置子集化。才能完美实现按需加载。就我的这个项目而言,动态子集化方案不要太完美,毕竟一张海报本身就没几个字儿!所以我们这次将抛弃 CDN,通过动态的将服务本地中的字体资源子集化来实现字体的压缩效果。



这里我们使用python中的一个字体工具库 fontTools 来实现一个动态子集化,类似于 Google Fonts 的实现。核心思路就是将字符传给服务端,通过工具将传入的字符在本地字体文件中提取并返回给客户端,通过fontTools 还可以将TTF格式转化为和Web更搭的WOFF2格式。实现细节如下述代码所示


@app.route('/font/<font_name>', methods=['GET'])
def get_font_subset(font_name):
# 获取本地字体文件路径
font_path = os.path.join(FONTS_DIR, f"{font_name}.ttf")
# 获取子集字符
chars = request.args.get('text', '')
# 字体文件格式
format = request.args.get('format', 'woff2').lower()

# 处理字符,去重
unique_chars = ''.join(sorted(set(chars)))
try:
# 配置子集化选项
options = Options()
options.flavor = format if format in {'woff', 'woff2'} else
options.desubroutinize = True # 增强兼容性
subsetter = Subsetter(options=options)

# 加载字体并生成子集
font = TTFont(font_path)
subsetter.populate(text=unique_chars)
subsetter.subset(font)

# 保存为指定格式
buffer = io.BytesIO()
font.save(buffer)
buffer.seek(0)

# 确定MIME类型
mime_type = {
'woff2': 'font/woff2',
'woff': 'font/woff',
}[format]

# 创建响应并设置
response = Response(buffer.read(), mimetype=mime_type)
# 其他设置...
return response

except Exception as e:
# 子集化失败...


前端代码中增加了一些字符提取的工作,我本身就是通过 FontFace Api 来请求字体资源的,所以我仅需将资源链接替换为子集化字体的接口就可以了,下面代码来描述字体的加载过程


// ...其他逻辑
Toast.loading('字体加载中')
// 遍历海报中的字体对象
[...new Set(fontFamilies)].forEach((fontName) => {
// 在字体库中找到对应字体详细信息
const obj = fontLibrary.find((el) => el?.value === fontName) ?? {};

if (obj.value && obj.src) {
// 处理海报中提取的文案集合
const text = textMap[obj.value].join('');
// 构建字体对象
const font = new FontFace(
obj.value,
`url(http://127.0.0.1:5000/font/${obj.value}?text=${text}&format=woff2)`
);
// 加载字体
font.load();
// 添加到文档字体集中
document.fonts.add(font);
}
});
// 文档所有字体加载完毕后返回成功的 Promise
return document.fonts.ready.finally(() => Toast.destory());

好了,刷新下浏览器,来看看最终的效果:



这这 真立竿见影(主要是基数大😁😁),最终得到的结果就是,实际 22.4M 的字体文件,子集化后缩减到 3.6KB。实际效果图生成的时间由 20s+ 缩减到毫秒级(300ms 以内)。这下就无惧网速了吧!


结语


总的来说,优化字体加载的方案有很多,我们需要结合自己的实际业务场景来进行选型,字体子集化确实是一种高效且实用的优化手段,更多的实践思路可以参考下 Google fonts


作者:古茗前端团队
来源:juejin.cn/post/7490337281866317836
收起阅读 »

面试官:前端倒计时有误差怎么解决

web
前言 去年遇到的一个问题,也是非常经典的面试题了。能聊的东西还蛮多的 倒计时为啥不准 一个最简单的常用倒计时: const [count, setCount] = useState(0) let total = 10 // 倒计时10s const coun...
继续阅读 »

前言


去年遇到的一个问题,也是非常经典的面试题了。能聊的东西还蛮多的


倒计时为啥不准


一个最简单的常用倒计时:


const [count, setCount] = useState(0)
let total = 10 // 倒计时10s
const countDown = ()=>{
if(total > 0){
setCount(total)
total--
setTimeout(countDown ,1000)
}
}

image.png


稍微有几毫秒的误差,但是问题不大。
原因:JavaScript是单线程,setTimeout 的回调函数会被放入事件队列,既然要排队,就可能被前面的任务阻塞导致延迟 。且任务本身从call stack中拿出来执行也要耗时。所以有1000变1002也合理。就算setTimeout的第二个参数设为0,也会有至少有4ms的延迟。


如果切换了浏览器tab,或者最小化了浏览器,那误差就会变得大了。


image.png
倒计时10s,实际时间却经过了15s,误差相当大了。(不失为一种穿越时间去到未来的方法)


原因:当页面处于后台时,浏览器会降低定时器的执行频率以节省资源,导致 setTimeout 的延迟增加。切回来后又正常了


目标:解决切换后台导致的倒计时不准问题


解决方案1


监听 visibilitychange 事件,在切回tab时修正。


页面从后台离开或者切回来,都能触发visibilitychange事件。只需在document.visibilityState === 'visible'时去修正时间,删掉旧的计时器,设置正确的计时,计算下一次触发的差值,然后创建新的计时器。



// 监听页面切换
useEffect(() => {

const handleVisibilityChange = () => {
console.log('Page is visible:', document.visibilityState);
if(document.visibilityState === 'visible'){
updateCount()
}
};

// 添加事件监听器
document.addEventListener('visibilitychange', handleVisibilityChange);

// 清理函数:移除事件监听器
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
// 修正倒计时
const updateCount = ()=>{
clearTimeout(timer) // 清除
const nowStamp = Date.now()
const pastTime = nowStamp - firstStamp

const remainTime = CountSeconds * 1000 - pastTime

if(remainTime > 0){
setCount(Math.floor(remainTime/1000))
total = Math.floor(remainTime/1000)
timer = setTimeout(countDown,remainTime%1000)
}else{
setCount(0)
console.log('最后时间:',new Date().toLocaleString(),'总共耗时:', nowStamp-firstStamp)
}

}

特点:会跳过一些时刻计数,可能会错过一些关键节点上事件触发。如果长时间离开,误差变大,实际时间结束,倒计时仍在,激活页面时才结束。


image.png


解决方案2


修改回调函数,自带修正逻辑,每次执行时都去修正


    // 每次都修正倒计时
const countDown = ()=>{
const nowDate = new Date()
const nowStamp = nowDate.getTime()
firstStamp = firstStamp || nowStamp
lastStamp = lastStamp || nowStamp

const nextTime = firstStamp + (CountSeconds-total) * 1000
const gap = nextTime - nowStamp ;

// 如果当前时间超过了下一次应该执行的时间,就修正时间
if(gap < 1){
clearTimeout(timer)
if(total == 0){
setCount(0)
console.log('最后时间:',nowDate.toLocaleString(),'总共耗时:', nowStamp-firstStamp)
}else{
console.log('left',total, 'time:',nowDate.toLocaleString(),'间隔:',nowStamp-lastStamp)
lastStamp = nowStamp
setCount(total)
total--
countDown()
}
}else{
timer = setTimeout(countDown,gap)
}
}

结果:


image.png


特性:每个倒计时时刻都触发,最后更新更精准。(顺便一提,edge浏览器后台状态timeout间隔最低是1000)


解决方案3


上面的都依赖Date模块,改本地时间就会爆炸,一切都乱套了。(可以用performance.now 来缺相对值判断时间)


有没有方案让时钟像邓紫棋一样一直倒数的



有的,就是用web worker,单独的线程去计时,不会受切tab影响


let intervalId;
let count = 0;
self.onmessage = function (event) {
const data = event.data; // 接收主线程传递的数据
console.log('Worker received:', data);
count = data;
intervalId = setInterval(countDown,1000); // 这里用了interval
};

function countDown() {
count--
self.postMessage(count); // 将结果发送回主线程
if (count == 0) {
clearInterval(intervalId);
}
}

const [worker, setWorker] = useState(null);

// 初始化 Web Worker
useEffect(() => {
const myWorker = new Worker(new URL('./worker.js', import.meta.url));
// 监听 Worker 时钟 返回的消息
myWorker.onmessage = (event) => {
// console.log('Main thread received:', event.data);
const left = event.data
const nowDate = new Date()
const nowStamp = nowDate.getTime()
if(left > 0){
const gap = nowStamp - lastStamp
console.log('left',left, 'time:',nowDate.toLocaleString(),'间隔:',gap)
lastStamp = nowStamp
setCount(left)
}else{
setCount(0)
console.log('最后时间:',nowDate.toLocaleString(),'总共耗时:', nowStamp-firstStamp)
}
};
setWorker(myWorker);
// 清理函数:关闭 Worker
return () => {
myWorker.terminate();
};
}, []);


缺点:worker的缺点 ;优点:精准计时


总结:


方案1 大修正


方案2 小修正


方案3 无修正


三种方式来使倒计时更准确


作者:水下黑化已放电
来源:juejin.cn/post/7478687361737768986
收起阅读 »

Electron实现静默打印小票

web
Electron实现静默打印小票 静默打印流程 1.渲染进程通知主进程打印 //渲染进程 data是打印需要的数据 window.electron.ipcRenderer.send('handlePrint', data) 2.主进程接收消息,创建打印页面...
继续阅读 »

Electron实现静默打印小票


静默打印流程


09c00eb5-f171-4090-a178-37e149d1d0f7.png


1.渲染进程通知主进程打印


//渲染进程 data是打印需要的数据
window.electron.ipcRenderer.send('handlePrint', data)

2.主进程接收消息,创建打印页面


//main.ts
/* 打印页面 */
let printWindow: BrowserWindow | undefined
/**
* @Author: yaoyaolei
* @Date: 2024-06-07 09:27:22
* @LastEditors: yaoyaolei
* @description: 创建打印页面
*/

const createPrintWindow = () => {
return new Promise<void>((resolve) => {
printWindow = new BrowserWindow({
...BASE_WINDOW_CONFIG,
title: 'printWindow',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
nodeIntegration: true,
contextIsolation: false
}
})

printWindow.on('ready-to-show', () => {
//打印页面创建完成后不需要显示,测试时可以调用show查看页面样式(下面有我处理的样式图片)
// printWindow?.show()
resolve()
})

printWindow.webContents.setWindowOpenHandler((details: { url: string }) => {
shell.openExternal(details.url)
return { action: 'deny' }
})

if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
printWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/print.html`)
} else {
printWindow.loadFile(join(__dirname, `../renderer/print.html`))
}
})
}

ipcMain.on('handlePrint', (_, obj) => {
//主进程接受渲染进程消息,向打印页面传递数据
if (printWindow) {
printWindow!.webContents.send('data', obj)
} else {
createPrintWindow().then(() => {
printWindow!.webContents.send('data', obj)
})
}
})

3.打印页面接收消息,拿到数据渲染页面完成后通知主进程开始打印


<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>打印</title>
<style>
</style>
</head>

<body>

</body>
<script>
window.electron.ipcRenderer.on('data', (_, obj) => {
//这里是接受的消息,处理完成后将html片段放在body里面完成后就可以开始打印了
//样式可以写在style里,也可以内联
console.log('event, data: ', obj);
//这里自由发挥
document.body.innerHTML = '处理的数据'
//通知主进程开始打印
window.electron.ipcRenderer.send('startPrint')
})
</script>
</html>



这个是我处理完的数据样式,这个就是print.html
9f17ea7e-3f83-408f-a780-05d50da305de.png
微信图片_20240609102325.jpg



4,5.主进程接收消息开始打印,并且通知渲染进程打印状态


ipcMain.on('startPrint', () => {
//这里如果不指定打印机使用的是系统默认打印机,如果需要指定打印机,
//可以在初始化的时候使用webContents.getPrintersAsync()获取打印机列表,
//然后让用户选择一个打印机,打印的时候将打印机名称传过来
printWindow!.webContents.print(
{
silent: true,
margins: { marginType: 'none' }
//deviceName:如果要指定打印机传入打印机名称
},
(success) => {
//通知渲染进程打印状态
if (success) {
mainWindow.webContents.send('printStatus', 'success')
} else {
mainWindow.webContents.send('printStatus', 'error')
}
}
)
})

aa.jpg



完毕~



作者:彷徨的耗子
来源:juejin.cn/post/7377645747448365091
收起阅读 »

富文本选型太难了,谁来帮帮我!

web
前言 在管理平台中,富文本编辑器是信息输入和内容编辑的重要工具,几乎是必不可少的功能模块。它不仅能帮助用户轻松创建和格式化文档、邮件、模版等内容,还支持多样化的输入方式,提升了内容管理的便捷性和效率。 遇事不决扔个富文本,随心所欲不逾矩 富文本编辑器的实现...
继续阅读 »

前言


在管理平台中,富文本编辑器是信息输入内容编辑的重要工具,几乎是必不可少的功能模块。它不仅能帮助用户轻松创建和格式化文档、邮件、模版等内容,还支持多样化的输入方式,提升了内容管理的便捷性和效率。



遇事不决扔个富文本,随心所欲不逾矩



富文本编辑器的实现虽然为用户提供了极大的便利,但其背后的技术复杂度也不容小觑。实现一个全面且高效的富文本编辑器,涉及到跨浏览器兼容性、复杂的格式化操作、多媒体支持等多个技术难题,往往需要花费大量的人力物力。即便如此,富文本编辑器也不可能满足所有用户的需求。不同的用户对功能有着各自的偏好.对于用户来说,选择富文本编辑器时,也需要在功能性与操作便捷性之间进行取舍,找到最适合自己需求的解决方案。



碰到说做一个和word相同功能的富文本的领导,直接上砖头



我认为选择富文本要考虑以下这些重要的功能:



  1. 页面简洁美观(不难看是前提,不要说功能好用 - 不好看的界面,我连用都不用)

  2. 支持从Word中复制、粘贴

  3. 格式化功能丰富,尽可能的支持各种文本和段落的样式

  4. 多媒体功能丰富,支持对图片大小、位置的处理

  5. 支持html代码与显示切换

  6. 支持并满足复杂的表格功能

  7. 插件拓展

  8. 多端兼容

  9. 编辑器不能基于某一种编程语言,迁移成本小

  10. 多语言支持(对于部分海外客户可能有需求)



你很难想象竟然有不会英文的客户,强烈要求我们更换富文本编辑器



富文本测评


Tinymce


Tinymce是一个老牌做富文本的公司,文档和插件配置的自由度都不错,也支持自定义拓展。功能强大,可以完全作为用户的首选


免费版如下图所示:
image.png


评测一下Tinymce的优缺点:


优点:



  1. 老牌做富文本的公司,且不断保持更新和维护,值得信赖

  2. UI也做的蛮好看的(吐槽一下5.X好丑)

  3. 免费版功能强大,基本能满足日常需要(开源版本支持商用,nice)

  4. 功能强大:如导出,自定义插件,表格功能强大,文件上传,复制粘贴,数学方程等等

  5. 对非技术用户友好: 所见所得,拖动即可完成所有

  6. 支持多端,移动端友好

  7. 社区丰富,文档友好,集成简单

  8. 支持多种语言,阿拉伯这种程序员的噩梦也支持


缺点:



  1. 图片上传需要自定义

  2. 超链接不友好并且很丑

  3. UI不是很好看 - 我们领导吐槽好丑

  4. 复杂的word复制过去格式会变化,需要重新编辑

  5. 打开缓慢,需要开发者和使用者有相当的耐心



我司从5.X开始使用,目前已迭代到7.X(UI好看了好多)


不知道怎么选的情况下,选Tinymce肯定没错,当然也不能对Tinymce有太大要求,你会失望的



CKEditor


CKEditor也是一个老牌做富文本的公司, 5.0版本无论是功能还是UI做的相当不错,毫不夸张的说,这是我见过插件最丰富的富文本了


完整版如下图所示:
image.png


评测一下CKEditor的优缺点:


优点:



  1. 老牌做富文本的公司,且不断保持更新和维护,值得信赖

  2. UI简洁美观

  3. 功能强大:100多个插件,如导出,自定义插件,表格功能强大,文件上传,复制粘贴,数学方程等等

  4. 对非技术用户友好: 所见所得,拖动即可完成所有

  5. 支持多主题配置

  6. 支持多端,移动端友好

  7. 支持多人协作

  8. 支持多种语言,阿拉伯这种程序员的噩梦也支持


缺点:



  1. 价格比较贵,免费版功能太少,可能不会满足日常使用

  2. API太多,文档对于国内不是很友好, 开发成本高

  3. V38.0.0起,免费版会显示“Powered by CKEditor”logo(这也是我司最后放弃使用CKEditor的原因之一)


最基础的免费版如下:


image.png



基于CKEditor的优缺点,推荐复杂文档编辑及格式化的业务使用


白嫖党不推荐,适合人民币玩家



Tiptap


Tiptap是一个模块化的编辑器, 官方解释是tipTap是一个无头编辑器,无头特征决定了完全自由。
自由扩展,自由定义。


它基于Prosemirror,完全可扩展且无渲染。可以轻松地将自定义外观、样式、位置等等


可以像这样:


image.png


也可以像这样:


image.png


灵活的API,不仅可以让你进行天马行空的布局,各种事件也可以让你用到飞起。作为一个后起之秀,迅速占领市场,赢得了大量客户的好评。真心推荐大家去使用一下!


下面评测一下Tiptap富文本


优点:



  1. 高度可定制的UX

  2. 功能强大,插件丰富

  3. 可快速搭建,集成简单

  4. 和element适配度高,使用element-ui不要太爽

  5. 支持多人协作


缺点:



  1. 自由度太高,配置较复杂,需要对API有一定的了解

  2. 不支持markdown



经查阅资料,tiptap插件支持markdown格式,感谢 李里_lexy 和 imber的指正




想要多人协作的,不要考虑了:用它,用它,用它!



Quill


我司最开始使用的一款富文本。配置简单,UI也还不错。中文文档丰富,一天的时间就可以集成到项目中。


image.png


评测一下Quill的优缺点


优点:



  1. UI还可以(虽说排版看起来有点乱)

  2. API和文档简单,一天的时间就可以集成到项目中

  3. 支持word的复制,粘贴操作(加分项)

  4. 持续更新,从发布到至今,已13年之久,不用担心不维护的问题


缺点:



  1. 对图片非常不友好,不支持图片拖拽

  2. 不支持表格

  3. 不支持html与界面的切换(在文档中统一替换很困难,需要技术人员配合)

  4. 在v-if中显示文本到编辑文本切换后,会无法输入文本,官方bug且没有被修复


全局改样式的时候,从控制台中取一下html代码,然后全局替换一下忍忍也就算了。随着图片上传后,文档排版越来越麻烦,真的不能忍了。只能宣布这个富文本的倒计时了!



适合对图片没有太多需求,并且对文档没有太大格式需求的公司


个人开发蛮推荐的



wangEditor


国产之光,个人能做成这个样子,我还是由衷的佩服的。


image.png


有兴趣的掘友们可以看下为什么都说富文本编辑器是天坑?。觉得自己还可以的掘友可以试着做做下面这两个小功能?



  1. 输入一个Hello, world!

  2. Hello加粗,llo斜体,world加粗,ld下划线

  3. 选中hello,取消选择,复制,粘贴,全选,删除

  4. 兼容主流浏览器

  5. 看一下标签至少符合规范(加粗斜体不能嵌套,p标签不能包块元素,是否有空标签...)


不要小看这个小功能,我敢打赌,95%的程序员至少一周的时间或者压根做不出来


赌一包辣条,立贴为证


好了,回到正题。我们评测一下wangEditor富文本


优点:



  1. 简单易用,可以快速集成

  2. 中文文档友好

  3. 支持图片和表格拖拽

  4. 社区友好,可以在github提交意见和反馈

  5. 多语言支持


缺点:



  1. 个人开源,相对专门做富文本的公司,相对配置型和丰富性不足

  2. 移动端不适配,Android下有严重bug,可能会影响使用

  3. 暂停维护了



首先向wangEditor开源作者双越老师表示敬意,因为开源想盈利太不容易了,基本都是为爱发电


不考虑移动端的,并且不介意暂停维护的可以使用


否则直接pass吧,用Jodit或者wangEditor-next替代吧



wangEditor-next


wangEditor-next的作者cycleccc在简介中这样写道:


wangeditor 因作者个人原因短期不再维护,wangEditor-next为fork版本,将在尽量不 Breaking change 的前提下继续维护


此外,wangEditor-next也不再是个人单打独斗了,开始多人团队协作。最近几个月更新也很频繁,近3000次commit也能看出该团队的活跃度和持续产出,这种高频次的更新不仅反映了团队对用户反馈的响应速度,确保项目能够持续迭代并满足用户需求。


image.png



期待wangEditor-next能够在开源和盈利中找到平衡点,期待国人的产品能够获得广泛认可,为用户提供优质的体验。



Jodit


免费版功能如下:
image.png



  1. 风格和wangEditor类似,但功能要比wangEditor强大的多。

  2. 项目持续维护中,不用担心跑路的问题

  3. 支持图片和表格拖拽

  4. 支持word复制和粘贴

  5. 支持打印

  6. 适配移动端,可预览

  7. 支持多语言



白嫖党的福音



大招:收费版来了


image.png


在原有的基础上又增加了以下实用的功能



  1. 支持文档翻译和谷歌地图

  2. 支持预览和导出pdf

  3. 支持自定义button

  4. 支持插入iframe

  5. 支持恢复功能(不是撤销,是真的回档

  6. 支持查找和替换(很nice的功能)


一次性付费99$(一个项目),399$(无限项目)即可解锁,我觉得超值



相比于wangEditor,我更推荐Jodit。无论是免费版,还是付费版,都值得拥有和尝试



Editor.js


Editor也是一个模块化编辑器。与Tiptap有很多相似之处,如模块化设计,可拓展性等等。这里不详细展开了,主要说一下两个编辑器的不同点:



  1. Editor.js 默认生成 JSON 格式的数据,便于解析和存储。适合保存结构化的数据,如文档管理等等等。


    Tiptap 功能更强大,适合需要精细控制的富文本编辑器应用,尤其适合需要所见即所得体验的场景,如博客编辑器等等


  2. Tiptap的社区及文档更友好,非常适合 Vue 或 React 项目集成,更适合初次开发者

  3. Editor功能较少,可能不满足使用

  4. Editorbug比较多,虽然已经修复了好多,但建议还是慎选



Editor默认使用JSON格式的数据,更易于展示和分析,除非有强烈需求,否则慎选



Slate


引用一下原文:


Slate是一个 完全 可定制的富文本编辑器框架。


Slate 让你构建像 MediumDropbox Paper或者是 Google Docs(它们正成为web应用的标杆)这样丰富,直观的编辑器,而不会让你在代码实现上深陷复杂度的泥潭。


文档介绍的很酷,但目前是beta版本,且仍没有计划发布正式版。我没有用过,不做过多的评价,有兴趣的可以自己去体验一下


开源项目,没有大公司的支持,完全是自愿奉献。30k的star用户默默的支持,期待早日发布



很酷的架构设计,推荐大家去体验一下,期待Slate的早日发布



medium-editor


image.png


一款轻量级的编辑器,压缩后约为28KB, 除了工具栏可以显示在文本上方,支持内联编辑外,我没有找到其他的优点。4年没有更新, 插件拓展非常不友好。对不起这16K的star



如果你喜欢这种编辑方式,还可以体验一下。除此外,直接Pass



Squire


image.png


又是一款轻量级的编辑器,压缩完才11.5kb,相对于其它的富文本来说是非常的小了,推荐功能不复杂的场景使用



轻量级的编辑器,相较于medium-editor更符合用户习惯,推荐功能不复杂的场景使用



UEditor


image.png


看到上面这个图片,估计很多用户直接就断绝了使用的想法吧。确实,UI设计的真不好看,不符合当今的审美。可是在小10年前,用百度UEditor的比比皆是,只能说此一时彼一时。


我们评测一下UEditor富文本


优点:



  1. 发布之初,功能强大,但是放在现在,已经有点弱了

  2. 支持从word复制粘贴

  3. 中文文档友好


缺点:



  1. 无力吐槽的UI

  2. 官方已经不维护了,gg

  3. 不支持图片和表格拖拽

  4. ...


不吐槽了,直接用省略号吧。祭奠我的青春



久远的富文本,官方已经不维护了,不推荐使用



UEditor Plus


UEditor Plus 是基于 UEditor 二次开发的富文本编辑器


image.png


让人诟病的UI风格终于焕新了,还算在我的审美点上了,简单评测一下


优点:



  1. UI风格焕新还是蛮清新的(功能平铺还是感觉有点乱,太占空间了)

  2. 支持图片拖拽

  3. 表格功能强大(虽然有些功能我不太会用)

  4. 兼容现有UEditor


缺点:



  1. 作为开发者,很多功能都不会用,更别提普通用户了,估计一脸懵

  2. 为了功能而功能,忽视了用户体验

  3. 更新迭代比较慢,社区不友好,不是很关心用户的反馈



试用了1天,总给我不踏实的感觉,担心某一天又不维护了


编辑器的功能体验不是很好,有些功能是为了功能而功能,真心希望提升一下用户体验



Summernote


image.png


一款韩国人做的开源编辑器。基于 jQuery 和 Bootstrap 构建,支持快捷键操作,提供大量可定制的选项,乍一看,页面挺清新简洁的。但使用下来非常让人之气愤用户提的bug和优化项目完全不理,格式化也是做的很差劲。搞不懂11k的star是怎么出来的



不推荐用。除非你喜欢用hook去擦屁股



lexical


image.png


Facebook出品的编辑器,大厂出品,值得信赖。


lexicaldraft-js的升级版。由于draft-js只作维护,不做开发。整体功能已迁移到Lexical新框架。


github这样写道:



该项目目前处于维护模式。它不会收到任何功能更新,只会收到关键的安全漏洞补丁。2022 年 12 月 31 日,该仓库将完全归档。




对于寻找开源替代方案的用户,Meta 一直致力于迁移到一个名为Lexical的新框架。它仍处于实验阶段,我们正在努力添加迁移指南,但我们相信,它提供了一种更高效、更易于访问的替代方案。



样式见上图,很清爽,很漂亮。多而不乱,我蛮喜欢的。


功能上也很强大,图片拖拽、表格拖拽、导入导出,分享,还能进行涂鸦,绘图。还可以选中文本设置文本样式,同时还能对文本进行评论...功能上没得说,由于React是 Facebook的亲儿子lexical也是基于React的编辑器虽然可以在vue上用,但官方支持主要集中在 React 上,因此在 Vue 中的集成需要额外处理一些兼容性问题。违背了我们选择不依赖框架语言的初衷。因此将lexical放在最后,不做过多的评价,也不会放在简评中。只是让我们多了解一下,尤其是React用户,可以作为备选方案


但要注意:Lexical 不支持 Internet Explorer 或旧版本的 Edge。需要兼容IE以及Edge浏览器的业务就不要选了



Facebook出品,可以作为React全家桶使用



简评


强烈推荐


Tinymce: 不知道怎么去选的时候,就用Tinymce!不求有功,但求无过


CKEditor: 文档格式化内容众多,追求使用效率,并且土豪公司或者人民币玩家就用它


Tiptap: 喜欢diy样式的,追求美观和别具一格的开发者首选


Jodit: 一次性付款,你值得拥有


比较推荐


Quill: 集成简单,小项目够用,大项目不推荐


wangEditor: 国人之光


wangEditor-next: wangEditor的升级版,持续迭代,持续为您服务


Editor: 存储,解析JSON数据的首选,其他慎选


Slate: 很棒的架构设计,期待早上发布


UEditor Plus: 基于 UEditor 二次开发的富文本编辑器,UI和功能都做了升级,初步体验还是蛮不错的,个人建议还是要慎用


不推荐


UEditor: 百度的烂尾项目,不够打了


Squire: 轻量级编辑器,格式文本还是可以的


Summernote: 韩国佬开发的项目,大爷级别的,反人类,还不搭理你


medium-editor 除了在文档上方显示,找不到其他的优点


总结


优秀的富文本编辑器不仅要页面美观,而且要格式化功能强大,使用方便快捷非技术用户也能轻松排版、编辑、上传多媒体内容,从而提升管理平台的整体易用性和用户体验。因此,选择合适的富文本编辑器,不仅关系到内容生产的效率,更直接影响到平台的用户满意度和运营效果。


同时,我们也要认识到,不可能有一个编辑器能百分百满足我们的需求,理解开发富文本编辑器是一个巨难,且很难盈利的项目。只能尽可能的满足我们的痛点,在使用体验和需求功能上找到一个平衡点,权衡选择。


最后,祝开发找到适合自己公司的编辑器,早日脱坑


参考资料



作者:高志小鹏鹏
来源:juejin.cn/post/7434373084747333658
收起阅读 »

十年跨平台开发,Electron 凭什么占据一席之地?

web
大家好,我是徐徐。今天我们来认识认识 Electron。 前言 其实一直想系统的写一写 Electron 相关的文章,之前在掘金上写过,但是现在来看那些文章都写得挺粗糙的,所以现在我决定系统整理相关的知识,输出自己更多 Electron 开发相关的经验。这一节...
继续阅读 »


大家好,我是徐徐。今天我们来认识认识 Electron。


前言


其实一直想系统的写一写 Electron 相关的文章,之前在掘金上写过,但是现在来看那些文章都写得挺粗糙的,所以现在我决定系统整理相关的知识,输出自己更多 Electron 开发相关的经验。这一节我们主要是来认识一下 Electron,这个已经有 10 年历史的跨端开发框架。我将从诞生背景,优劣势,生态,案例以及和其他框架的对比这几个方面带大家来认识 Electron。


Electron 诞生背景



Electron 的背景还是很强劲的,下面我们就来看看它是如何诞生的。


起源


Electron 的前身 Atom Shell,由 GitHub 的开发者在 2013 年创建的,当时 Atom 需要一个能够在桌面环境中运行的跨平台框架,同时能够利用 web 技术构建现代化的用户界面,于是就有了 Electron 的雏形。


需求 & Web 技术的发展


互联网的兴起使得桌面端的需求日益增长,传统的桌面应用开发需要针对每个操作系统(Windows、macOS、Linux)分别编写代码,这增加了开发和维护成本,所以非常需要可以通过一次开发实现多平台支持的框架。


随着 HTML5、CSS3 和 JavaScript 的快速发展,web 技术变得越来越强大和灵活。开发者希望能够利用这些技术构建现代化的用户界面,并且享受 web 开发工具和框架带来的便利。这使得更加需要一款跨端大杀器架来支持开发者,Electron 应运而生。


发展历程




  • 2013 年:Atom Shell 诞生,最初用于 GitHub 的 Atom 编辑器。

  • 2014 年 2 月:Atom 编辑器对外发布,Atom Shell 作为其核心技术。

  • 2015 年 4 月:Atom Shell 更名为 Electron,并作为一个独立项目发布。随着时间的推移,Electron 的功能和社区支持不断增强。

  • 2016 年:Electron 的应用开始广泛传播,许多公司和开发者开始使用 Electron 构建跨平台桌面应用。

  • 2020 年:Electron 发布 10.0 版本,进一步增强了稳定性和性能。

  • 2023 年:Electron 10 周年


更多可以参考:


http://www.electronjs.org/blog/electr…


Electron 优势


Electron 的优势非常的明显,大概总结为下面四个方面。


跨平台支持


Electron 的最大优势在于其跨平台特性。开发者可以编写一次代码,Electron 会处理不同操作系统之间的差异,使应用能够在 Windows、macOS 和 Linux 上无缝运行。


前端技术栈


Electron 应用使用 HTML、CSS 和 JavaScript 构建界面。开发者可以使用流行的前端框架和工具(如 React、Vue.js、Angular)来开发应用,提高开发效率和代码质量。


Node.js 集成


Electron 将 Chromium 和 Node.js 集成在一起,这使得应用不仅可以使用 web 技术构建界面,还可以使用 Node.js 访问底层系统资源,如文件系统、网络、进程等。


强大社区


Electron 拥有丰富的文档、教程、示例和强大的社区支持。开发者可以很容易地找到解决问题的方法和最佳实践,从而加快开发速度。


Electron 劣势


当然,一个东西都有两面性,有优势肯定也有劣势,劣势大概总结为以下几个方面。


性能问题


Electron 应用由于需要运行一个完整的 Chromium 实例,通常会占用较高的内存和 CPU 资源,性能相对较差。这在资源有限的设备上(如老旧计算机)尤为明显。


打包体积大


由于需要包含 Chromium 和 Node.js 运行时,Electron 应用的打包体积较大。一个简单的 Electron 应用的打包体积可能达到几十到上百 MB,这对于一些应用场景来说是不小的负担。


安全性


Electron 应用需要处理 web 技术带来的安全问题,如跨站脚本(XSS)攻击和远程代码执行(RCE)漏洞。开发者需要特别注意安全性,采取适当的防护措施(如使用 contextIsolationsandboxContent Security Policy 等)。


生态


上面谈到了 Electron 的优势和劣势,下面我们来看看 Electron 的生态。对于一款开源框架,生态是非常关键的,社区活跃度以及相应的配套工具非常影响框架的生态,如果有众多的开发者支持和维护这个框架,那么它的生态才会越来越好。Electron 的生态依托于 Node.js 发展出了很多很多开源工具,其生态是相当的繁荣。下面可以看看两张图就知道其生态的繁荣之处。



  • GitHub 情况




  • NPM 情况



下面是一些常见的相关生态工具。


打包和分发工具



  • electron-packager:用于将 Electron 应用打包成可执行文件。支持多平台打包,简单易用。

  • electron-builder:一个功能强大的打包工具,支持自动更新、多平台打包和安装程序制作。


测试工具



  • Spectron:基于 WebDriver,用于 Electron 应用的端到端测试。支持模拟用户操作和验证应用行为。

  • electron-mocha:用于在 Electron 环境中运行 Mocha 测试,适合进行单元测试和集成测试。


开发工具



  • Electron Forge:一个集成开发工具,简化了 Electron 应用的开发、打包和分发流程。支持脚手架、插件系统和自动更新。

  • Electron DevTools:调试和分析 Electron 应用性能的工具,帮助开发者优化应用性能。


案例



用 Electron开发的软件非常多,国内外都有很多知名的软件,有了成功的案例才会吸引更多的开发者使用它,下面是一些举例。


国内



  • QQ

  • 微信开发者工具

  • 百度网盘

  • 语雀

  • 网易灵犀办公

  • 网易云音乐


国外



  • Visual Studio Code

  • Slack

  • Discord

  • GitHub Desktop

  • Postman

  • WhatsApp


其他更多可参考:http://www.electronjs.org/apps


一个小技巧,Mac 电脑检测应用是否是 Electron 框架,在命令行运行如下代码:


for app in /Applications/*; do;[ -d  $app/Contents/Frameworks/Electron\ Framework.framework ] && echo $app; done

和其他跨端框架的对比


画板


一个框架的诞生避免不了与同类型的框架对比,下面是一个对比表格,展示了 Electron 与其他流行的跨端桌面应用开发框架(如 NW.js、Proton Native、Tauri 和 Flutter Desktop)的优缺点和特性:


特性ElectronNW.jsProton NativeTauriFlutter Desktop
开发语言JavaScript, HTML, CSSJavaScript, HTML, CSSJavaScript, ReactRust, JavaScript, HTML, CSSDart
框架大小大(几十到几百 MB)中等(几十 MB)中等(几十 MB)小(几 MB)大(几十到几百 MB)
性能中等中等中等
跨平台支持Windows, macOS, LinuxWindows, macOS, LinuxWindows, macOS, LinuxWindows, macOS, LinuxWindows, macOS, Linux
使用的技术栈Chromium, Node.jsChromium, Node.jsReact, Node.jsRust, WebViewFlutter Engine
生态系统和社区非常活跃,生态丰富活跃停滞了新兴,快速增长活跃,现阶段更新不频繁
开发难度易于上手易于上手需要 React 知识需要 Rust 和前端知识需要 Dart 知识
自动更新支持内置支持需要手动实现需要手动实现需要手动实现需要手动实现
原生功能访问通过 Node.js 模块访问通过 Node.js 模块访问通过 Node.js 和原生模块访问通过 Rust 原生模块访问通过插件和原生模块访问
热重载和开发体验支持(需要配置)支持(需要配置)支持(需要配置)支持(需要配置)支持(内置支持)
打包和发布Electron Builder, Forgenw-builder需要手动配置打包工具Tauri 打包工具Flutter build tools
常见应用场景聊天应用、生产力工具、IDE聊天应用、生产力工具小型工具和实用程序轻量级、性能要求高的应用跨平台移动和桌面应用
知名应用VS Code, Slack, Discord, 知名应用WebTorrent, 其他工具小型 React 工具和应用新兴应用和工具仅少数桌面应用,Flutter主打移动应用

结语


Electron 是一个强大的跨平台开发框架,其诞生对前端开发者的意义非常大,让很多从事前端的开发者也有机会开发桌面客户端,扩大了前端开发工程师的岗位需求。当然,它不一定是最好的框架,因为适合自己的才是最好的,主要还是看自己的业务场景和技术需要,优势和劣势都是需要考虑的,仁者见仁,智者见智。


作者:前端徐徐
来源:juejin.cn/post/7416902812251111476
收起阅读 »

Electron调用dll的新姿势

web
之前旧的系统在浏览器中调用dll都是使用IE的activex控件实现。进而通过dll脚本和硬件发生交互。现在IE浏览器已经不在默认预制在系统中,且对windows的操作系统有限制,win10之前的版本才能正常访问。在不断的业务迭代过程中,已经成了制约系统扩展的...
继续阅读 »

之前旧的系统在浏览器中调用dll都是使用IE的activex控件实现。进而通过dll脚本和硬件发生交互。现在IE浏览器已经不在默认预制在系统中,且对windows的操作系统有限制,win10之前的版本才能正常访问。

在不断的业务迭代过程中,已经成了制约系统扩展的最大阻碍。调研后选择了electron-egg框架来进行业务功能尝试,主要是dll的嵌入调用和设备交互。

ElectronEgg

作为一个对前端不是那么擅长的后端来说,electron-egg已经包装了大部分操作,且拥有非常详尽的中文开发文档。可以无缝切换,低成本代码的开发。

框架设计

具体的业务逻辑还是放在后台服务中,electron只负责透传交互指令和硬件设备进行交互。这里调用dll用的js库是koffi。


Koffi

Koffi 是一个快速且易于使用的 Node.js C FFI 模块。实现了在Node中调用dll的功能。

koffi版本2.8.0

DLL配置

按照官方文档dll文件放置在extraSources文件中。

DLL加载

const lib = koffi.load(path.join(Ps.getExtraResourcesDir(), "dll", "dcrf32.dll"));

DLL调用

dll调用有两种方式。分别为经典语法和类c原型方式。

  • 经典语法

定义函数名,返回参数类型,入参类型constprintf=lib.func('printf','int', ['str','...']);

  • 类C语法

在类中定义方法类似,lib.func('int printf(const char *fmt, ...)');

推荐使用类C语法更加方便,不受参数的限制,更易于修改。

DLL调用类型

  1. 同步调用

本机函数,您就可以像调用任何其他 JS 函数一样简单地调用它。

const atoi = lib.func('int atoi(const char *str)');

let value = atoi('1257');
  1. 异步调用

有一些耗时的操作,可以使用异步调用回调的方式处理。

const atoi = lib.func('int atoi(const char *str)');

atoi.async('1257', (err, res) => {
console.log('Result:', res);
})

JS类型值传递

JS基础类型时不支持值传递的,遇到需要传递指针变量时,直接调用是无法获取到变更后的值。相应的koffi也提供了非常方便的值包装。

  1. 数组包装

项目中采用比较方便的数组包装来进行值传递。包装基础对象到数组中,变更后取出第一位就能获取到变更后的值。

需要定义返回的值的获取长度,防止出现只获取到部分的返回结果。

  1. 引用类型包装

把基础类型包装成引用对象。传递到函数中。

let cardsenr = koffi.alloc('int', 64);
let cardRet = dcCard(icdev, 0, cardsenr);

这种就更方便,调用后也不需要转换。在调用完后需要通过free方法进行内存释放。

  1. Buffer对象

如果遇到接收中文数据时,koffi可以结合Node中的Buffer进行对象传递。

let text = Buffer.alloc(1024);
let ret = read(text);

部分dll默认读出来的编码是gbk格式,需要将buffer对象转换成utf8格式的字符串进行展示。 就需要通过iconv组件进行gbk解码。

iconv.decode(text, 'gbk')

如果需要把utf8转成gbk,使用相反的方式就可以iconv.encode(build/photos/${id_number}.bmp, "gbk")

结构体调用

JS中只有引用对象,如果遇到结构体参数需要进行JS包装。

// C
typedef struct A {
int a;
char b;
const char *c;
struct {
double d1;
double d2;
} d;
} A;
// JS
const A = koffi.struct('A', {
a: 'int',
b: 'char',
c: 'const char *', // Koffi does not care about const, it is ignored
d: koffi.struct({
d1: 'double',
d2: 'double'
})
});

如果调用出现对齐不对的情况,可以使用pack方法进行手动对齐类型。

// This struct is 3 bytes long
const PackedStruct = koffi.pack('PackedStruct', {
a: 'int8_t',
b: 'int16_t'
});

常规dll的调用都可以轻易的在JS中实现。

Node后端

底层调用已经通过koffi来实现。后面就需要借助electron-egg框架能力进行业务代码指令透传。

service层

'use strict';

const { Service } = require('ee-core');

/**
* 示例服务(service层为单例)
* @class
*/

class ExampleService extends Service {

constructor(ctx) {
super(ctx);
}

/**
* test
*/

async test(args) {
let obj = {
status:'ok',
params: args
}

return obj;
}
}

ExampleService.toString = () => '[class ExampleService]';
module.exports = ExampleService;

定义我们需要交互的方法

controller层

'use strict';

const { Controller } = require('ee-core');
const Log = require('ee-core/log');
const Services = require('ee-core/services');

/**
* example
* @class
*/

class ExampleController extends Controller {

constructor(ctx) {
super(ctx);
}


/**
* 所有方法接收两个参数
* @param args 前端传的参数
* @param event - ipc通信时才有值。详情见:控制器文档
*/


/**
* test
*/

async test () {
const result = await Services.get('example').test('electron');
Log.info('service result:', result);

return 'hello electron-egg';
}
}

ExampleController.toString = () => '[class ExampleController]';
module.exports = ExampleController;

JS前端

通过ipc的方式,乡调用api一样调用node后端接口。

定义路由

import { ipc } from '@/utils/ipcRenderer';

const ipcApiRoute = {
test: 'controller.d8.test',
init: 'controller.d8.init',
reset: 'controller.d8.reset',
exit: 'controller.d8.exit',
command:'controller.d8.command'
}

调用

ipc.invoke(this.ipcApiRoute.init).then(r => {
// r为返回的数据
if(r >= 0) {
this.setInitRet(r);
this.scrollToBottom("连接成功,返回码:" + r);
this.connectStr = "连接成功";
this.setDeviceStatus('success');
} else {
this.scrollToBottom("连接失败,返回码:" + r);
}
})

通过ipc的invode方法调用方法即可。后续就可以愉快的编写我们的业务代码了。

参照

Koffi帮助文档

electron-egg帮助文档


作者:zooooooooy
来源:juejin.cn/post/7352075771534868490
收起阅读 »

AI对话的逐字输出:流式返回才是幕后黑手

web
AI应用已经渗透到我们生活的方方面面。其中,AI对话系统因其自然、流畅的交流方式而备受瞩目。前段时间有人在交流群中问到,如何实现AI对话中那种逐字输出的效果,这背后,流式返回技术发挥了关键作用。 欢迎加入前端筱园交流群:点击加入交流群 ​ 其实这背后并不是前...
继续阅读 »

AI应用已经渗透到我们生活的方方面面。其中,AI对话系统因其自然、流畅的交流方式而备受瞩目。前段时间有人在交流群中问到,如何实现AI对话中那种逐字输出的效果,这背后,流式返回技术发挥了关键作用。


image-20250305112300462

欢迎加入前端筱园交流群:点击加入交流群


​ 其实这背后并不是前端做了什么特效,而是采用的流式返回,而不是一次性返回完整的响应。流式返回允许服务器在一次连接中逐步发送数据,而不是一次性返回全部结果。这种方式使得前端可以在等待完整响应的过程中,逐步展示生成的内容,从而极大地提升了用户体验。


​ 那么,前端接收流式返回具体有哪些方式呢?接下来,本文将详细探讨几种常见的技术手段,帮助读者更好地理解并应用流式返回技术。


使用 Axios


大多数场景下,前端用的最多的就是axios来发送请求,但是axios 只有在在Node.js环境中支持设置 responseType: 'stream' 来接收流式响应。


const axios = require('axios');
const fs = require('fs');

axios.get('http://localhost:3000/stream', {
responseType: 'stream', // 设置响应类型为流
})
.then((response) => {
// 将响应流写入文件
response.data.pipe(fs.createWriteStream('output.txt'));
})
.catch((error) => {
console.error('Stream error:', error);
});

特点



  • 仅限 Node.js:浏览器中的 axios 不支持 responseType: 'stream'

  • 适合文件下载:适合处理大文件下载。


使用 WebSocket


WebSocket 是一种全双工通信协议,适合需要双向实时通信的场景。


前端代码:


const socket = new WebSocket('ws://localhost:3000');

socket.onopen = () => {
console.log('WebSocket connected');
};
socket.onmessage = (event) => {
console.log('Received data:', event.data);
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
};
socket.onclose = () => {
console.log('WebSocket closed');
};

服务器代码


const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 3000 });

wss.on('connection', (ws) => {
console.log('Client connected');

let counter = 0;
const intervalId = setInterval(() => {
counter++;
ws.send(JSON.stringify({ message: 'Hello', counter }));

if (counter >= 5) {
clearInterval(intervalId);
ws.close();
}
}, 1000);

ws.on('close', () => {
console.log('Client disconnected');
clearInterval(intervalId);
});
});

虽然WebSocket作为一种在单个TCP连接上进行全双工通信的协议,具有实时双向数据传输的能力,但AI对话情况下可能并不选择它进行通信。主要有以下几点原因:



  • 在AI对话场景中,通常是用户向AI模型发送消息,模型回复消息的单向通信模式,WebSocket的双向通信能力在此场景下并未被充分利用

  • 使用WebSocket可能会引入不必要的复杂性,如处理双向数据流、管理连接状态等,也会增加额外的部署与维护工作


image-20250304165718937

特点



  • 双向通信:适合实时双向数据传输

  • 低延迟:基于 TCP 协议,延迟低

  • 复杂场景:适合聊天、实时游戏等复杂场景


使用 XMLHttpRequest


虽然 XMLHttpRequest 不能直接支持流式返回,但可以通过监听 progress 事件模拟逐块接收数据


const xhr = new XMLHttpRequest();
xhr.open('GET', '/stream', true);

xhr.onprogress = (event) => {
const chunk = xhr.responseText; // 获取当前接收到的数据
console.log(chunk);
};

xhr.onload = () => {
console.log('Request complete');
};

xhr.send();

服务器代码(Koa 示例):


router.get("/XMLHttpRequest", async (ctx, next) => {
ctx.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
// 创建一个 PassThrough 流
const stream = new PassThrough();
ctx.body = stream;
let counter = 0;
const intervalId = setInterval(() => {
counter++;
ctx.res.write(
JSON.stringify({ message: "Hello", counter })
);

if (counter >= 5) {
clearInterval(intervalId);
ctx.res.end();
}
}, 1000);

ctx.req.on("close", () => {
clearInterval(intervalId);
ctx.res.end();
});
});

可以看到以下的输出结果,在onprogress中每次可以拿到当前已经接收到的数据。它并不支持真正的流式响应,用于AI对话场景中,每次都需要将以显示的内容全部替换,或者需要做一些额外的处理。


image-20250305093103230


如果想提前终止请求,可以使用 xhr.abort() 方法;


setTimeout(() => {
xhr.abort();
}, 3000);

image-20250305093253611


特点



  • 兼容性好:支持所有浏览器。

  • 非真正流式XMLHttpRequest 仍然需要等待整个响应完成,progress 事件只是提供了部分数据的访问能力。

  • 内存占用高:不适合处理大文件。


使用 Server-Sent Events (SSE)


SSE 是一种服务器向客户端推送事件的协议,基于 HTTP 长连接。它适合服务器向客户端单向推送实时数据


前端代码:


const eventSource = new EventSource('/sse');

eventSource.onmessage = (event) => {
console.log('Received data:', event.data);
};

eventSource.onerror = (event) => {
console.error('EventSource failed:', event);
};

服务器代码(Koa 示例):


router.get('/sse', (ctx) => {
ctx.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});

let counter = 0;
const intervalId = setInterval(() => {
counter++;
ctx.res.write(`data: ${JSON.stringify({ message: 'Hello', counter })}\n\n`);

if (counter >= 5) {
clearInterval(intervalId);
ctx.res.end();
}
}, 1000);

ctx.req.on('close', () => {
clearInterval(intervalId);
ctx.res.end();
});
});


image-20250304172215134


EventSource 也具有主动关闭请求的能力,在结果没有完全返回前,用户可以提前终止内容的返回。


// 在需要时中止请求
setTimeout(() => {
eventSource.close(); // 主动关闭请求
}, 3000); // 3 秒后中止请求

image-20250304202110200


虽然EventSource支持流式请求,但AI对话场景不使用它有以下几点原因:



  • 单向通信

  • 仅支持 get 请求:在 AI 对话场景中,通常需要发送用户输入(如文本、文件等),这需要使用 POST 请求

  • 无法自定义请求头:EventSource 不支持自定义请求头(如 AuthorizationContent-Type 等),在 AI 对话场景中,通常需要设置认证信息(如 API 密钥)或指定请求内容类型


注意点


返回给 EventSource 的值必须遵循 data: 开头并以 \n\n 结尾的格式,这是因为 Server-Sent Events (SSE) 协议规定了这种格式。SSE 是一种基于 HTTP 的轻量级协议,用于服务器向客户端推送事件。为了确保客户端能够正确解析服务器发送的数据,SSE 协议定义了一套严格的格式规范。SSE 协议规定,服务器发送的每条消息必须遵循以下格式:


field: value\n

其中 field 是字段名,value 是对应的值。常见的字段包括:



  • data::消息的内容(必须)。

  • event::事件类型(可选)。

  • id::消息的唯一标识符(可选)。

  • retry::客户端重连的时间间隔(可选)。


每条消息必须以 两个换行符 (\n\n) 结尾,表示消息结束


以下是一个完整的 SSE 消息示例:


id: 1\n
event: update\n
data: {"message": "Hello", "counter": 1}\n\n

特点



  • 单向通信:适合服务器向客户端推送数据。

  • 简单易用:基于 HTTP 协议,无需额外协议支持。

  • 自动重连EventSource 会自动处理连接断开和重连


使用 fetch API


fetch API 是现代浏览器提供的原生方法,支持流式响应。通过 response.body,可以获取一个 ReadableStream,然后逐块读取数据。


前端代码:


// 发送流式请求
fetch("http://localhost:3000/stream/fetch", {
method: "POST",
signal,
})
.then(async (response: any) => {
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(new TextDecoder().decode(value));
}
})
.catch((error) => {
console.error("Fetch error:", error);
});

服务器代码(Koa 示例):


router.post("/fetch", async (ctx) => {
ctx.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
// 创建一个 PassThrough 流
const stream = new PassThrough();
ctx.body = stream;
let counter = 0;
const intervalId = setInterval(() => {
counter++;
ctx.res.write(JSON.stringify({ message: "Hello", counter }));

if (counter >= 5) {
clearInterval(intervalId);
ctx.res.end();
}
}, 1000);

ctx.req.on("close", () => {
clearInterval(intervalId);
ctx.res.end();
});
});

image-20250305095034960


fetch也同样可以在客户端主动关闭请求。


// 创建一个 AbortController 实例
const controller = new AbortController();
const { signal } = controller;
// 发送流式请求
fetch("http://localhost:3000/stream/fetch", {
method: "POST",
signal,
})
.then(async (response: any) => {
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(new TextDecoder().decode(value));
}
})
.catch((error) => {
console.error("Fetch error:", error);
});

// 在需要时中止请求
setTimeout(() => {
controller.abort(); // 主动关闭请求
}, 3000); // 3 秒后中止请求

打开控制台,可以看到在Response中可以看到返回的全部数据,在EventStream中没有任何内容。


image-20250305095131519


image-20250305095143656


这是由于返回的信息SSE协议规范,具体规范见上文的 Server-Sent Events 模块中有介绍到


ctx.res.write(
`data: ${JSON.stringify({ message: "Hello", counter })}\n\n`
);

image-20250305100732796


但是客户端fetch请求中接收到的数据也包含了规范中的内容,需要前端对数据进一步的处理一下


image-20250305100744376


特点



  • 原生支持:现代浏览器均支持 fetchReadableStream

  • 逐块处理:可以实时处理每个数据块,而不需要等待整个响应完成。

  • 内存效率高:适合处理大文件或实时数据。


总结


综上所述,在 AI 对话场景中,fetch 请求 是主流的技术选择,而不是 XMLHttpRequestEventSource。以下是原因和详细分析:



  • fetch 是现代浏览器提供的原生 API,基于 Promise,代码更简洁、易读

  • fetch 支持 ReadableStream,可以实现流式请求和响应

  • fetch 支持自定义请求头、请求方法(GET、POST 等)和请求体

  • fetch 结合 AbortController 可以方便地中止请求

  • fetch 的响应对象提供了 response.okresponse.status,可以更方便地处理错误


方式特点适用场景
fetch原生支持,逐块处理,内存效率高大文件下载、实时数据推送
XMLHttpRequest兼容性好,非真正流式,内存占用高旧版浏览器兼容
Server-Sent Events (SSE)单向通信,简单易用,自动重连服务器向客户端推送实时数据
WebSocket双向通信,低延迟,适合复杂场景聊天、实时游戏
axios(Node.js)仅限 Node.js,适合文件下载Node.js 环境中的大文件下载

最后来看一个接入deekseek的完整例子:


resource.dengzhanyong.com/mp4/7823928…


服务器代码(Koa 示例):


const openai = new OpenAI({
baseURL: "https://api.deepseek.com",
apiKey: "这里是你申请的deepseek的apiKey",
});


// 流式请求 DeepSeek 接口并流式返回
router.post("/fetchStream", async (ctx) => {
// 设置响应头
ctx.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});

try {
// 创建一个 PassThrough 流
const stream = new PassThrough();
ctx.body = stream;

// 调用 OpenAI API,启用流式输出
const completion = await openai.chat.completions.create({
model: "deepseek-chat", // 或 'gpt-3.5-turbo'
messages: [{ role: "user", content: "请用 100 字介绍 OpenAI" }],
stream: true, // 启用流式输出
});
// 逐块处理流式数据
for await (const chunk of completion) {
const content = chunk.choices[0]?.delta?.content || ""; // 获取当前块的内容
ctx.res.write(content);
process.stdout.write(content); // 将内容输出到控制台
}
ctx.res.end();
} catch (err) {
console.error("Request failed:", err);
ctx.status = 500;
ctx.res.write({ error: "Failed to stream data" });
}
});

前端代码:


const controller = new AbortController();
const { signal } = controller;
const Chat = () => {
const [text, setText] = useState<string>("");
const [message, setMessage] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);

function send() {
if (!message) return;
setText(""); // 创建一个 AbortController 实例
setLoading(true);

// 发送流式请求
fetch("http://localhost:3000/deepseek/fetchStream", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message,
}),
signal,
})
.then(async (response: any) => {
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const data = new TextDecoder().decode(value);
console.log(data);
setText((t) => t + data);
}
})
.catch((error) => {
console.error("Fetch error:", error);
})
.finally(() => {
setLoading(false);
});
}

function stop() {
controller.abort();
setLoading(false);
}

return (
<div>
<Input
value={message}
onChange={(e) =>
setMessage(e.target.value)}
/>
<Button
onClick={send}
type="primary"
loading={loading}
disabled={loading}
>

发送
</Button>
<Button onClick={stop} danger>
停止回答
</Button>
<div>{text}</div>
</div>

);
};

写在最后


欢迎加入前端筱园交流群:点击加入交流群


关注我的公众号【前端筱园】,不错过每一篇推送


作者:前端筱园
来源:juejin.cn/post/7478109057044299810
收起阅读 »

electron+node-serialport串口通信

web
electron+node-serialport串口通信 公司项目需求给客户开发一个能与电子秤硬件通信的Win7客户端,提供数据读取、处理和显示功能。项目为了兼容win7使用了electron 22.0.0版本,串口通信使用了serialport 12.0....
继续阅读 »

electron+node-serialport串口通信



公司项目需求给客户开发一个能与电子秤硬件通信的Win7客户端,提供数据读取、处理和显示功能。项目为了兼容win7使用了electron 22.0.0版本,串口通信使用了serialport 12.0.0版本



serialport文档 electron文档


 //serialport的基本使用方法
 //安装 npm i serialport
 import { SerialPort } from 'serialport'
 SerialPort.list()//获取串口列表
 /** 创建一个串口连接
path(必需):串口设备的路径。例如,'/dev/robot' 或 'COM1'。
baudRate(必需):波特率,即每秒传输的比特数。常见值有 9600、19200、38400、57600、115200 等。
autoOpen(可选):是否在创建对象时自动打开串口。默认为 true。如果设置为 false,你需要手动调用 port.open() 来打开串口。
dataBits(可选):每字节的数据位数。可以是 5、6、7、8。默认值是 8。
stopBits(可选):停止位的位数。可以是 1 或 2。默认值是 1。
parity(可选):校验位类型。可以是 'none'、'even'、'odd'、'mark'、'space'。默认值是 'none'。
rtscts(可选):是否启用硬件流控制(RTS/CTS)。布尔值,默认值是 false。
xon(可选):是否启用软件流控制(XON)。布尔值,默认值是 false。
xoff(可选):是否启用软件流控制(XOFF)。布尔值,默认值是 false。
xany(可选):是否启用软件流控制(XANY)。布尔值,默认值是 false。
highWaterMark(可选):用于流控制的高水位标记。默认值是 16384(16KB)。
lock(可选):是否锁定设备文件,防止其他进程访问。布尔值,默认值是 true。
 **/

 const serialport = new SerialPort({ path: '/dev/example', baudRate: 9600 })

 serialport.open()//打开串口
 serialport.write('ROBOT POWER ON')//向串口发送数据
 serialport.on('data', (data) => {//接收数据
    //data为串口接收到的数据
 })

获取串口列表


 //主进程main.ts
 import { SerialPort, SerialPortOpenOptions } from 'serialport'
 //初始化先获取串口列表,提供给页面选择
 ipcMain.on('initData', async () => {
   const portList = await SerialPort.list()
   mainWindow.webContents.send('initData', {portList})
 })

 //渲染进程
 window.electron.ipcRenderer.once('initData', (_,{portList}) => {
   //获取串口列表后存入本地,登录页直接做弹窗给客户选择串口,配置波特率
   window.localStorage.setItem('portList', JSON.stringify(portList))
 })

串口选择


1720148892968.jpg


波特率配置


1720148971517.jpg


读取数据



公司秤和客户的秤串口配置不一样,所以做了model1和model2区分



 //主进程main.ts
 let P: SerialPort | undefined
 ipcMain.on('beginSerialPort', (_, { path, baudRate }) => {
   //区分配置
   const portConfig: SerialPortOpenOptions<AutoDetectTypes> =
     import.meta.env.VITE_MODE == 'model1'
       ? {
           path: path || 'COM1',
           baudRate: +baudRate || 9600, //波特率
           autoOpen: true,
           dataBits: 8
        }
      : {
           path: path || 'COM1',
           baudRate: +baudRate || 115200, //波特率
           autoOpen: true,
           dataBits: 8,
           stopBits: 1,
           parity: undefined
        }
   if (P) {
     P.close((error) => {
       if (error) {
         console.log('关闭失败:', error)
      } else {
         P = new SerialPort(portConfig)
         P?.write('SIR\r\n', 'ascii')//告诉秤端开始发送信息,具体看每个秤的配置,有的不需要
         P.on('data', (data) => {
           //接收到的data为Buffer类型,直接转为字符串就可以使用了
           mainWindow.webContents.send('readingData', data.toString())
        })
      }
    })
  } else {
     P = new SerialPort(portConfig)
     P?.write('SIR\r\n', 'ascii')
     P.on('data', (data) => {
       mainWindow.webContents.send('readingData', data.toString())
    })
  }
 })

解析数据


 <!--渲染进程解析数据-->
 <template>
   <div class="weight-con">
     <div class="weight-con-main">
       <div>
         <el-text class="wei-title" type="primary"><br /></el-text>
       </div>
       <div class="weight-panel">
         <el-text id="wei-num">{{ weightNum!.toFixed(2) }}</el-text>
         <div class="weight-con-footer">当前最大称重:600公斤</div>
       </div>
       <div>
         <el-text class="wei-title" type="primary"><br /></el-text>
       </div>
     </div>
   </div>
 </template>
 ​
 <script setup lang="ts">
 import { weightModel } from '@/utils/WeightReader'
 const emits = defineEmits(['zeroChange'])
 const weightNum = defineModel<number>()
 window.electron.ipcRenderer.on('readingData', (_, data: string) => {
   //渲染进程接收到主进程数据,根据环境变量解析数据
   weightNum.value = weightModel[import.meta.env.VITE_MODE](data)
   if (weightNum.value == 0) {
     emits('zeroChange')
  }
 })
 
</script>

 //weightReader.ts 解析配置
 export type Mode = 'model1' | 'model2'
 let str = ''
 export const weightModel = {
   model1: (weightStr: string) => {
     const rev = weightStr.split('').reverse().join('')
     return +rev.replace('=', '')
  },
   module2: (weightStr: string) => {
     str += weightStr
     if (str.match(/S\s+[A-Za-z]\s*(-?\d*.?\d+)\s*kg/g)) {
       const num = str.match(/S\s+[A-Za-z]\s*(-?\d*.?\d+)\s*kg/m)![1]
       str = ''
       return Number(num)
    } else {
       return 0
    }
  }
 }

67cd365b0ec45d2ca47e4eb0b597c33f.gif



完活~下班!



作者:彷徨的耗子
来源:juejin.cn/post/7387701265796562980
收起阅读 »

无虚拟DOM到底能快多少?

web
相信大家都跟我一样有这样一个疑问:之前都说虚拟DOM是为了性能,怎么现在又反向宣传了?无虚拟DOM怎么又逆流而上成为了性能的标杆了呢? 下篇文章我们会仔细分析无虚拟DOM与虚拟DOM之间的区别,本篇文章我们先看数据。首先是体积,由于没有了虚拟DOM以及vDOM...
继续阅读 »

相信大家都跟我一样有这样一个疑问:之前都说虚拟DOM是为了性能,怎么现在又反向宣传了?无虚拟DOM怎么又逆流而上成为了性能的标杆了呢?


下篇文章我们会仔细分析无虚拟DOM与虚拟DOM之间的区别,本篇文章我们先看数据。首先是体积,由于没有了虚拟DOM以及vDOM diff算法,所以体积肯定能小不少。当然不是说无虚拟DOM就彻底不需要diff算法了,我看过同为无虚拟DOM框架的SvelteSolid源码,无虚拟DOM只是不需要vDOM间的Diff算法,列表之间还是需要diff的。毕竟再怎么编译,你从后端获取到的数组,编译器也不可能预测得到。


那么官方给出的数据是:



虽然没有想象中的那么多,但33.6%也算是小不少了。当然这个数据指的是纯Vapor模式,如果你把虚拟DOMVapor混着用的话,体积不仅不会减小反而还会增加。毕竟会同时加载Vapor模式的runtime和虚拟DOM模式的runtime,二者一相加就大了。



Vapor模式指的就是无虚拟DOM模式,如果你不太清楚二者之间有何关联的话,可以看一眼这篇:《无虚拟DOM版Vue为什么叫Vapor》



那性能呢?很多人对体积其实并不敏感,觉得多10K10k都无所谓,毕竟现在都是5G时代了。所以我们就来看一眼官方公布的性能数据:


SCR-20250301-scoj.png


SCR-20250301-scsm.png


从左到右依次为:



  • 原生JS:1.01

  • Solid:1.09

  • Svelte:1.11

  • 无虚拟DOMVue:1.24

  • 虚拟DOMVue:1.32

  • React:1.55


数字越小代表性能越高,但无论再怎么高都不可能高的过手动优化过的原生JS,毕竟无论什么框架最终打包编译出来的还是JS。不过框架的性能其实已经挺接近原生的了,尤其是以无虚拟DOM著称的SolidSvelte。但无虚拟DOMVue和虚拟DOMVue之间并没有拉开什么很大的差距,1.241.32这两个数字证明了其实二者的性能差距并不明显,这又是怎么一回事呢?



一个原因是Vue3本来就做了许多编译优化,包括但不限于静态提升、大段静态片段将采用innerHTML渲染、编译时打标记以帮助虚拟DOM走捷径等… 由于本来的性能就已经不错了,所以可提升的空间自然也没多少。


看完上述原因肯定有人会说:可提升的空间没多少可以理解,那为什么还比同为无虚拟DOMSolidSvelte差那么多?如果VaporSolid性能差不多的话,那可提升空间小倒是说得过去。但要是像现在这样差的还挺多的话,那这个理由是不是就有点站不住脚了?之所以会出现这样的情况是因为Vue Vapor的作者在性能优化和快速实现之间选择了后者,毕竟尤雨溪第一次公布要开始做无虚拟DOMVue的时间是在2020年:


c74b06f21c3c437156f444a905debc9e.jpeg


而如今已经是2025年了。也就是说如果一个大一新生第一次满心欢喜的听到无虚拟DOMVue的消息后,那么他现在大概率已经开始毕业工作了都没能等到无虚拟DOMVue的发布。这个时间拖的有点太长了,甚至从Vue2Vue3都没用这么久。


SCR-20250307-okfk.png


可以看到Vue3从立项到发布也就不到两年的时间,而Vapor呢?从立项到现在已经将近5年的光阴了,已经比Vue3所花费的时间多出一倍还多了。所以现在的首要目标是先实现出来一版能用的,后面再慢慢进行优化。我们工作不也是这样么?先优先把功能做出来,优化的事以后再说。那么具体该怎么优化呢:


SCR-20250301-scwz.png


现在的很多渲染逻辑继承自原版Vue3,但无虚拟DOMVue可以采用更优的渲染逻辑。而且现在的Vapor是跟着原版Vue的测试集来做的,这也是为了实现和原版Vue一样的行为。但这可能并不是最优解,之后会慢慢去掉一些不必要的功能,尤其是对性能产生较大影响的功能。


往期精彩文章:



作者:页面魔术
来源:juejin.cn/post/7480069116461088822
收起阅读 »

leaflet+天地图+更换地图主题

web
先弄清楚leaflet和天地图充当的角色 leaflet是用来在绘制、交互地图的 天地图是纯粹用来当个底图的,相当于在leaflet中底部的一个图层而已 进行Marker打点、geojson绘制等操作都是使用leaflet实现 1. 使用天地图当底图 在...
继续阅读 »

在这里插入图片描述


先弄清楚leaflet和天地图充当的角色



  • leaflet是用来在绘制、交互地图的

  • 天地图是纯粹用来当个底图的,相当于在leaflet中底部的一个图层而已

  • 进行Marker打点、geojson绘制等操作都是使用leaflet实现


1. 使用天地图当底图



  • 在token处填自己的token

  • 我这里用的是天地图的影像底图,如果需要可自行更换或添加底图

  • 天地图底图网址:lbs.tianditu.gov.cn/server/MapS…

  • 只用替换我代码里的天地图链接里的http://{s}.tianditu.com/img_c/里的img_c为我图中圈起来的编号,其他不用动


在这里插入图片描述


const token = "填自己的天地图token";
// 底图
const VEC_C ="http://{s}.tianditu.com/img_c/wmts?layer=vec&style=default&tilematrixset=c&Service=WMTS&Request=GetTile&Version=1.0.0&Format=tiles&TileMatrix={z}&TileCol={x}&TileRow={y}&tk=";
// 文字标注
// const CVA_C = "http://{s}.tianditu.com/cia_c/wmts?layer=cva&style=default&tilematrixset=c&Service=WMTS&Request=GetTile&Version=1.0.0&Format=tiles&TileMatrix={z}&TileCol={x}&TileRow={y}&tk=";
let map = L.map("map", {
minZoom: 3,
maxZoom: 17,
center: [34.33213, 109.00945],
zoomSnap: 0.1,
zoom: 3.5,
zoomControl: false,
attributionControl: false,
crs: L.CRS.EPSG4326,
});
L.tileLayer(VEC_C + token, {
zoomOffset: 1,
subdomains: ["t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7"],
opacity: 0.2,
}).addTo(map);
// 添加文字标注
// L.tileLayer(CVA_C + token, {
// tileSize: 256,
// zoomOffset: 1,
// subdomains: ["t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7"],
// }).addTo(map);


2. 绘制中国地图geojson



  • 这里我需要国的边界和省的边界线颜色不一样,所以用了一个国的geojson和另一个包含省的geojson叠加来实现

  • 获取geojson数据网站:datav.aliyun.com/portal/scho…


L.geoJson(sheng, {
style: {
color: "#0c9fb8",
weight: 1,
fillColor: "#028297",
fillOpacity: 0.1,
},
}).addTo(map);
L.geoJson(guo, {
style: {
color: "#b8c3c8",
weight: 2,
fillOpacity: 0,
},
}).addTo(map);

3. 更换背景主题色


我的实现思路比较简单粗暴,直接给天地图的图层设置透明度,对div元素设置背景色,如果UI配合,可以叫UI给个遮罩层的背景图,比如我这里就是用了四周有黑边渐变阴影,中间是透明的背景图。


<div id="map"></div>
<div class="mask"></div>

#map {
height: 100vh;
width: 100vw;
background: #082941;
z-index: 2;
}

.mask {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: url("./mask.png") no-repeat;
background-size: 100% 100%;
z-index: 1;
}

4. 完整代码



  • 写自己天地图的token

  • 自己下载geojson文件

  • 自己看需要搞个遮罩层背景图,不需要就注释掉mask


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<link
href="https://cdn.bootcdn.net/ajax/libs/leaflet/1.9.4/leaflet.css"
rel="stylesheet"
/>

</head>
<style>
* {
margin: 0;
padding: 0;
}
#map {
height: 100vh;
width: 100vw;
background: #082941;
z-index: 2;
}

.mask {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: url("./mask.png") no-repeat;
background-size: 100% 100%;
z-index: 1;
}
</style>
<body>
<div id="map"></div>
<div class="mask"></div>
</body>
<script src="https://cdn.bootcdn.net/ajax/libs/leaflet/1.9.4/leaflet.js"></script>
<script src="./china.js"></script>
<script src="./guo.js"></script>

<script>
const token = "写自己天地图的token";
// 底图
const VEC_C =
"http://{s}.tianditu.com/img_c/wmts?layer=vec&style=default&tilematrixset=c&Service=WMTS&Request=GetTile&Version=1.0.0&Format=tiles&TileMatrix={z}&TileCol={x}&TileRow={y}&tk=";
// 文字标注
// const CVA_C =
// "http://{s}.tianditu.com/cia_c/wmts?layer=cva&style=default&tilematrixset=c&Service=WMTS&Request=GetTile&Version=1.0.0&Format=tiles&TileMatrix={z}&TileCol={x}&TileRow={y}&tk=";

let map = L.map("map", {
minZoom: 3,
maxZoom: 17,
center: [34.33213, 109.00945],
zoomSnap: 0.1,
zoom: 3.5,
zoomControl: false,
attributionControl: false, //版权控制器
crs: L.CRS.EPSG4326,
});
L.tileLayer(VEC_C + token, {
zoomOffset: 1,
subdomains: ["t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7"],
opacity: 0.2,
}).addTo(map);
// L.tileLayer(CVA_C + token, {
// tileSize: 256,
// zoomOffset: 1,
// subdomains: ["t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7"],
// }).addTo(map);

L.geoJson(sheng, {
style: {
color: "#0c9fb8",
weight: 1,
fillColor: "#028297",
fillOpacity: 0.1,
},
}).addTo(map);
L.geoJson(guo, {
style: {
color: "#b8c3c8",
weight: 2,
fillOpacity: 0,
},
}).addTo(map);
</script>
</html>


作者:十一月二十二
来源:juejin.cn/post/7485482994989596722
收起阅读 »

怎么将中文数字转为阿拉伯数字?

web
说在前面 最近实现了一个b站插件,可以通过语音来控制播放页面上指定的视频,在语音识别的过程中遇到了需要将中文数字转为阿拉伯数字的情况,在这里分享一下具体事例和处理过程。 功能背景 先介绍一下功能背景,页面渲染的时候会先对视频进行编号处理,具体如下: 比如...
继续阅读 »

说在前面



最近实现了一个b站插件,可以通过语音来控制播放页面上指定的视频,在语音识别的过程中遇到了需要将中文数字转为阿拉伯数字的情况,在这里分享一下具体事例和处理过程。



功能背景


先介绍一下功能背景,页面渲染的时候会先对视频进行编号处理,具体如下:



比如我们想要播放第4个视频的话,我们只需要说“第4个”,插件就能帮我们选择第四个视频进行播放。


问题描述


功能背景我们已经了解了,那么问题是出在哪里呢?



如上图,这里识别出来的语音文本数字是中文数字,这样跟页面的视频编号无法对应上,因此我们需要实现一个方法来将中文转为阿拉伯数字。


方法实现


1、个位级映射表


const numMap = {
零: 0,一: 1,壹: 1,二: 2,两: 2,
三: 3,叁: 3,四: 4,肆: 4,五: 5,
伍: 5,六: 6,陆: 6,七: 7,柒: 7,
八: 8,捌: 8,九: 9,玖: 9,
};

2、单位映射表


const unitMap = {
十: { value: 10, sec: false },
拾: { value: 10, sec: false },
百: { value: 100, sec: false },
佰: { value: 100, sec: false },
千: { value: 1000, sec: false },
仟: { value: 1000, sec: false },
万: { value: 10000, sec: true },
萬: { value: 10000, sec: true },
亿: { value: 100000000, sec: true },
億: { value: 100000000, sec: true }
};

3、处理流程



  • 遇到数字:先存起来(比如「三」记作3)


if (hasZero && current > 0) {
current *= 10;
hasZero = false;
}
current += numMap[char];


  • 遇到单位



    • 如果是十/百/千:把存着的数字乘上倍数
      (如「三百」→3×100=300)


    current = current === 0 ? unit.value : current * unit.value;
    section += current;
    current = 0;


    • 遇到万/亿:先结算当前数字,将当前数字加到总数上


    processSection();
    section = (section + current) * unit.value;
    total += section;
    section = 0;


  • 遇到零:做个标记,提醒下个数字要占位
    (如「三百零五」→300 + 0 +5=305)


if (char === "零") {
hasZero = true;
continue;
}

4、完整代码


function chineseToArabic(chineseStr) {
// 映射表(支持简繁)
const numMap = {
零: 0,一: 1,壹: 1,二: 2,两: 2,
三: 3,叁: 3,四: 4,肆: 4,五: 5,
伍: 5,六: 6,陆: 6,七: 7,柒: 7,
八: 8,捌: 8,九: 9,玖: 9,
};
//单位映射表
const unitMap = {
十: { value: 10, sec: false },
拾: { value: 10, sec: false },
百: { value: 100, sec: false },
佰: { value: 100, sec: false },
千: { value: 1000, sec: false },
仟: { value: 1000, sec: false },
万: { value: 10000, sec: true },
萬: { value: 10000, sec: true },
亿: { value: 100000000, sec: true },
億: { value: 100000000, sec: true }
};

let total = 0; // 最终结果
let section = 0; // 当前小节
let current = 0; // 当前累加值
let hasZero = false; // 零标记

const processSection = () => {
section += current;
current = 0;
};

for (const char of chineseStr) {
if (numMap.hasOwnProperty(char)) {
if (char === "零") {
hasZero = true;
continue;
}

if (hasZero && current > 0) {
current *= 10;
hasZero = false;
}
current += numMap[char];
} else if (unitMap.hasOwnProperty(char)) {
const unit = unitMap[char];

if (unit.sec) {
// 处理万/亿分段
processSection();
section = (section + current) * unit.value;
total += section;
section = 0;
} else {
current = current === 0 ? unit.value : current * unit.value;
section += current;
current = 0;
}
hasZero = false;
}
}

const last2 = chineseStr.slice(-2)[0];
const last2Unit = unitMap[last2];
if (last2Unit) {
current = (current * last2Unit.value) / 10;
}
return total + section + current;
}

功能测试


柒億零捌拾萬



十萬三十



十萬三



二百五



二百零五





插件信息


对我上述提到的插件感兴趣的同学可以看下我前面发的这篇文章:


juejin.cn/post/748557…


公众号


关注公众号『 前端也能这么有趣 』,获取更多有趣内容。


发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~


说在后面



🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。



作者:JYeontu
来源:juejin.cn/post/7485936146071765030
收起阅读 »

npm和npx的区别

web
npx 和 npm 是 Node.js 生态中两个密切相关的工具,但它们的功能和用途有显著区别: 1. npm(Node Package Manager) 定位:Node.js 的包管理工具,用于安装、管理和发布 JavaScript 包。 核心功能: ...
继续阅读 »

npxnpm 是 Node.js 生态中两个密切相关的工具,但它们的功能和用途有显著区别:




1. npm(Node Package Manager)



  • 定位:Node.js 的包管理工具,用于安装、管理和发布 JavaScript 包。

  • 核心功能

    • 安装依赖:通过 npm install <package> 安装包到本地或全局。

    • 管理项目依赖:通过 package.json 文件记录依赖版本。

    • 运行脚本:通过 npm run <script> 执行 package.json 中定义的脚本。

    • 发布包:通过 npm publish 将代码发布到 npm 仓库。



  • 示例
    npm install lodash         # 安装 lodash 到本地 node_modules
    npm install -g typescript # 全局安装 TypeScript
    npm run start # 运行 package.json 中的 "start" 脚本





2. npx(Node Package Executor)



  • 定位npm 的配套工具,用于直接执行包中的命令,无需全局或本地安装

  • 核心功能

    • 临时执行包:自动下载远程包并运行,完成后删除。

    • 运行本地已安装的包:直接调用本地 node_modules/.bin 中的命令。

    • 切换包版本:指定特定版本运行(如 npx node@14 myscript.js)。



  • 示例
    npx create-react-app my-app  # 临时下载并运行 create-react-app
    npx eslint . # 运行本地安装的 eslint
    npx http-server # 启动一个临时 HTTP 服务器





关键区别


特性npmnpx
主要用途安装和管理依赖直接执行包中的命令
是否需要安装包需要提前安装(本地或全局)可临时下载并执行,无需提前安装
典型场景管理项目依赖、运行脚本、发布包运行一次性命令、测试工具、脚手架
执行本地包命令需通过 npm run 或完整路径调用直接通过 npx <command> 调用
全局包依赖依赖全局安装的包不依赖全局包,可指定版本运行



为什么需要 npx



  1. 避免全局污染

    例如运行 create-react-app 时,无需全局安装,直接通过 npx 临时调用最新版本。

  2. 简化本地包调用

    本地安装的工具(如 eslintjest)可以直接用 npx 执行,无需配置 package.json 脚本或输入冗长路径。

  3. 兼容多版本

    可指定版本运行,如 npx node@14 myscript.js,避免全局版本冲突。




使用建议



  • npm

    管理项目依赖、定义脚本、发布包。

  • npx

    运行脚手架工具(如 create-react-app)、临时工具(如 http-server)或本地已安装的命令。




示例场景


# 使用 npm 安装依赖
npm install axios

# 使用 npx 运行一次性工具
npx json-server db.json # 临时启动一个 REST API 服务器

# 使用 npm 运行脚本(需在 package.json 中定义 "scripts")
npm run build

# 使用 npx 调用本地已安装的包
npx webpack --config webpack.config.js

通过合理使用 npmnpx,可以更高效地管理依赖和执行命令。


作者:溪森堡
来源:juejin.cn/post/7484992785952096267
收起阅读 »

TypeScript 官方宣布弃用 Enum?Enum 何罪之有?

web
1. 官方真的不推荐 Enum 了吗? 1.1 事情的起因 起因是看到 科技爱好者周刊(第 340 期) 里面推荐了一篇文章,说是官方不再推荐使用 enum 语法,原文链接在 An ode to TypeScript enums,大致是说 TS 新出了一个 -...
继续阅读 »

1. 官方真的不推荐 Enum 了吗?


1.1 事情的起因


起因是看到 科技爱好者周刊(第 340 期) 里面推荐了一篇文章,说是官方不再推荐使用 enum 语法,原文链接在 An ode to TypeScript enums,大致是说 TS 新出了一个 --erasableSyntaxOnly 配置,只允许使用可擦除语法,但是 enum 不可擦除,因此推断官方已不再推荐使用 enum 了。官方并没有直接表达不推荐使用,那官方为什么要出这个配置项呢?


image.png


1.2 什么是可擦除语法


就在上周,TypeScript 发布了 5.8 版本,其中有一个改动是添加了 --erasableSyntaxOnly 配置选项,开启后仅允许使用可擦除语法,否则会报错enum 就是一个不可擦除语法,开启 erasableSyntaxOnly 配置后,使用 enum 会报错。


例如,如果在 tsconfig 文件中配置 "erasableSyntaxOnly": true(只允许可擦除语法),此时使用不可擦除语法,将会得到报错:
image.png


可擦除语法就是可以直接去掉的、仅在编译时存在、不会生成额外运行时代码的语法,例如 typeinterface。不可擦除语法就是不能直接去掉的、需要编译为JS且会生成额外运行时代码的语法,例如 enumnamesapce(with runtime code)。 具体举例如下:


可擦除语法,不生成额外运行时代码,比如 typelet n: numberinterfaceas number 等:
image.png


不可擦除语法,生成额外运行时代码,比如 enumnamespace(具有运行时行为的)、类属性参数构造语法糖(Class Parameter properties)等:


// 枚举类型
enum METHOD {
ADD = 'add'
}

// 类属性参数构造
class A {
constructor(public x: number) {}
}
let a: number = 1
console.log(a)

image.png


需要注意,具有运行时行为的 namespace 才属于不可擦除语法。


// 不可擦除,具有运行时逻辑
namespace MathUtils {
  export function add(a: number, b: number): number {
    return a + b;
  }
}

// 可擦除,仅用于类型声明,不包含任何运行时逻辑
namespace Shapes {
  export interface Rectangle {
    width: number;
    height: number;
  }
}

image.png


1.3 TS 官方为什么要出 erasableSyntaxOnly?


官方既然没有直接表达不推荐 enum,那为什么要出 erasableSyntaxOnly 配置来排除 enum 呢?


我找到了 TS 官方文档(The --erasableSyntaxOnly Option)说明:


image.png


大致意思是说之前 Node 新版本中支持了执行 TS 代码的能力,可以直接运行包含可擦除语法的 TypeScript 文件。Node 将用空格替换 TypeScript 语法,并且不执行类型检查。总结下来就是:


在 Node 22 版本:



  • 需要配置 --experimental-transform-types 执行支持 TS 文件

  • 要禁用 Node 这种特性,使用参数 --no-experimental-strip-types


在 Node 23.6.0 版本:



  • 默认支持直接运行可擦除语法的 TS 文件,删除参数 --no-experimental-strip-types

  • 对于不可擦除语法,使用参数 --experimental-transform-types


综上所述,TS 官方为了配合 Node.js 这次改动(即默认允许直接执行不可擦除语法的 TS 代码),才添加了一个配置项 erasableSyntaxOnly,只允许可擦除语法。


2. Enum 的三大罪行


自 Enum 从诞生以来,它一直是前端界最具争议的特性之一,许多前端开发者乃至不少大佬都对其颇有微词,纷纷发起了 DO NOT USE TypeScript Enum 的吐槽。那么enum 真的有那么难用吗?我认为是的,这玩意坑还挺多的,甲级战犯 Enum,出列!


2.1 枚举默认值


enum 默认的枚举值从 0 开始,这还不是最关键的,你传入了默认枚举值时,居然是合法的,这无形之中带来了类型安全问题。


enum METHOD {
ADD
}

function doAction(method: METHOD) {
// some code
}

doAction(METHOD.ADD) // ✅ 可以
doAction(0) // ✅ 可以

2.2 不支持枚举值字面量


还有一种场景,我要求既可以传入枚举类型,又要求传入枚举值字面量,如下所示,但是他又不合法了?(有人说你定义传枚举类型就要传相应的枚举,这没问题,但是上面提到的问题又是怎么回事呢?这何尝不是 Enum 的双标?)


enum METHOD {
ADD = 'add'
}

function doAction(method: METHOD) {
// some code
}

doAction(METHOD.ADD) // ✅ 可以
doAction('add') // ❌ 不行

2.3 增加运行时开销


TypeScript 的 enum 在编译后会生成额外的 JavaScript 双向映射数据,这会增加运行时的开销。


image.png


3. Enum 的替代方案


众所周知,TS 一大特性是类型变换,我们可以通过类型操作组合不同类型来达到目标类型,又称为类型体操。下面的四种解决方案,可以根据实际需求来选择。


3.1 const enum


const enum 是解决产生额外生成的代码和额外的间接成本有效且快捷的方法,但不推荐使用。



const enum 由于编译时内联带来了性能优化,但在 .d.ts 文件、isolatedModules 兼容性、版本不匹配及运行时缺少 .js 文件等场景下存在隐藏陷阱,可能导致难以发现的 bug。详见官方说明:const-enum-pitfalls



const enum METHOD {
ADD = 'add',
DELETE = 'delete',
UPDATE = 'update',
QUERY = 'query',
}

function doAction(method: METHOD) {
// some code
}

doAction(METHOD.ADD) // ✅ 可行
doAction('delete') // ❌ 不行

const enum 解析后的代码中引用 enum 的地方将直接被替换为对应的枚举值:


image.png


3.2 模板字面量类型


将枚举类型包装为模板字面量类型(Template Literal Types),从而即支持枚举类型,又支持枚举值字面量,但是没有解决运行时开销问题。


enum METHOD {
ADD = 'add',
DELETE = 'delete',
UPDATE = 'update',
QUERY = 'query',
}

type METHOD_STRING = `${METHOD}`

function doAction(method: METHOD_STRING) {
// some code
}

doAction(METHOD.ADD) // ✅ 可行
doAction('delete') // ✅ 可行
doAction('remove') // ❌ 不行


image.png


3.3 联合类型(Union Types)


使用联合类型,引用时可匹配的值限定为指定的枚举值了,但同时也没有一个地方可以统一维护枚举值,如果一旦枚举值有调整,其他地方都需要改。


type METHOD =
| 'add'
/**
* @deprecated 不再支持删除
*/

| 'delete'
| 'update'
| 'query'


function doAction(method: METHOD) {
// some code
}

doAction('delete') // ✅ 可行,没有 TSDoc 提示
doAction('remove') // ❌ 不行


image.png


3.4 类型字面量 + as const(推荐)


类型字面量就是一个对象,将一个对象断言(Type Assertion)为一个 const,此时这个对象的类型就是对象字面量类型,然后通过类型变换,达到即可以传入枚举值,又可以传入枚举类型的目的。


const METHOD = {
ADD:'add',
/**
* @deprecated 不再支持删除
*/

DELETE:'delete',
UPDATE: 'update',
QUERY: 'query'
} as const

type METHOD_TYPE = typeof METHOD[keyof typeof METHOD]

function doAction(method: METHOD_TYPE) {
// some code
}

doAction(METHOD.DELETE) // ✅ 可行,有 TSDoc 提示
doAction('delete') // ✅ 可行
doAction('remove') // ❌ 不行

image.png


3.5 Class 类静态属性自定义实现


还有一种方法,参考了 @Hamm 提供的方法,即利用 TS 面向对象的特性,自定义实现一个枚举类,实际上这很类似于后端定义枚举的一般方式。这种方式具有很好的扩展性,自定义程度更高。



  1. 定义枚举基类


    /**
    * 枚举基类
    */

    export default class EnumBase {
    /**
    * 枚举值
    */

    private value!: string

    /**
    * 枚举描述
    */

    private label!: string

    /**
    * 记录枚举
    */

    private static valueMap: Map<string, EnumBase> = new Map();

    /**
    * 构造函数
    * @param value 枚举值
    * @param label 枚举描述
    */

    public constructor(value: string, label: string) {
    this.value = value
    this.label = label
    const cls = this.constructor as typeof EnumBase
    if (!cls.valueMap.has(value)) {
    cls.valueMap.set(value, this)
    }
    }

    /**
    * 获取枚举值
    * @param value
    * @returns
    */

    public getValue(): string | null {
    return this.value
    }

    /**
    * 获取枚举描述
    * @param value
    * @returns
    */

    public getLabel(): string | null {
    return this.label
    }

    /**
    * 根据枚举值转换为枚举
    * @param this
    * @param value
    * @returns
    */

    static convert<E extends EnumBase>(this: new(...args: any[]) => E, value: string): E | null {
    return (this as any).valueMap.get(value) || null
    }
    }


  2. 继承实现具体的枚举(可根据需要扩展)


    /**
    * 审核状态
    */

    export class ENApproveState extends EnumBase {
    /**
    * 未审核
    */

    static readonly NOTAPPROVED = new ENApproveState('1', '未审核')
    /**
    * 已审核
    */

    static readonly APPROVED = new ENApproveState('2', '已审核')
    /**
    * 审核失败
    */

    static readonly FAILAPPROVE = new ENApproveState('3', '审核失败')
    /***
    * 审核中
    */

    static readonly APPROVING = new ENApproveState('4', '审核中')
    }


  3. 使用


    test('ENCancelState.NOCANCEL equal 1', () => {
    expect(ENApproveState.NOTAPPROVED.getValue()).toBe('1')
    expect(ENApproveState.APPROVING.getValue()).toBe('4')
    expect(ENApproveState.FAILAPPROVE.getLabel()).toBe('审核失败')
    expect(ENApproveState.convert('2')).toBe(ENApproveState.APPROVED)
    expect(ENApproveState.convert('99')).toBe(null)
    })

    image.png



4. 总结



  • TS 可擦除语法 是指 typeinterfacen:number 等可以直接去掉的、仅在编译时存在、不会生成额外运行时代码的语法

  • TS 不可擦除语法 是指 enumconstructor(public x: number) {} 等不可直接去除且会生成额外运行时代码的语法

  • Node.js 23.6.0 版本开始 默认支持直接执行可擦除语法 的 TS 文件

  • enum 的替代方案有多种,取决于实际需求。用字面量类型 + as const 是比较常用的一种方案。


TS 官方为了兼容 Node.js 23.6.0 这种可执行 TS 文件特性,出了 erasableSyntaxOnly 配置禁用不可擦除语法,反映了 TypeScript 官方对减少运行时开销和优化编译输出的关注,而不是要放弃 enum


但或许未来就是要朝着这个方向把 enum 优化掉也说不定呢?


5. 参考链接



作者:MurphyChen
来源:juejin.cn/post/7478980680183169078
收起阅读 »

AI时代下,我用陌生技术栈两天开发完一个App后的总结

web
AI时代下,我用陌生技术栈两天开发完一个App后的总结 今年的 AI 应用赛道简直是热火朝天,大街上扔一把核桃能砸到三个在讨论 AI 的,更有不少类似于零编程经验用 AI 三天开发 App 登榜 App Store的标题在各大平台的AI话题圈子里刷屏,作为一个...
继续阅读 »

AI时代下,我用陌生技术栈两天开发完一个App后的总结


今年的 AI 应用赛道简直是热火朝天,大街上扔一把核桃能砸到三个在讨论 AI 的,更有不少类似于零编程经验用 AI 三天开发 App 登榜 App Store的标题在各大平台的AI话题圈子里刷屏,作为一个在互联网行业摸爬滚打多年的程序员,我做过开源项目,也做过多个小型独立商业项目,最近两年也是在 AI 相关公司任职,对此我既感到兴奋又难免焦虑——为什么我还没遇到这样的机遇?


刚好最近想到了一个点子,是一个结合了屏幕呼吸灯 + 轻音乐 + 白噪声的辅助睡眠移动端 A 应用,我将其命名为“音之梦”,是我某天晚上睡不着看到路由器闪烁的灯光照耀在墙壁上之后突然爆发的灵感。


这是个纯客户端应用,没有太多外部依赖,体量小,正好拿来试一下是不是真的有可能完全让 AI 来将它实现,而为了尽量模拟“编程小白”这个身份,这次我选择用我比较陌生的 Swift UI。


先上结论:


对于小体量的应用,或者只考虑业务实现而不需要考虑架构合理性/可维护性的稍大体量的应用,在纯编码层面确实是完全可以实现的,作为一个不会 Swift UI 的开发者,我确实在不到 2 天时间内完全借助 AI 完成了这个应用的开发,而且已经上架苹果App Store。


以下是应用截图:


98shots_so.png


感兴趣的朋友们也访问下面的链接或者上App Store搜索 ”音之梦“ 下载体验。


App Store链接


我做了哪些事情?


工具准备


开发工具使用的是Cursor + XCode,开发语言选的 Swift UI,模型自然选择最适合编码的Claude 3.7。



为什么不选择Trae?因为下一个开坑项目准备用Trae + Deepseek来进行效果对比。



产品设计


上面截图展示的其实是第二版, UI和交互流程是我根据产品需求仔细思考琢磨设计出来的。


而第一版则完全由AI生成的,我只是描述了我期望的功能,交互方式和UI效果都是AI来生成的,那自然和我心目中期望的差距很大,不过最开始只是想验证AI的快速编码能力,所以首次上架的还是还是用的这一版的样式,可以看下面的截图:


478shots_so.png


而因为国区上架需要备案,在等待备案的过程中,我又诞生了很多新的想法,加上对于第一版的UI和交互流程也是越看越不爽,所以就重新思考了整个应用的UI和交互流程,并重新用figma画了设计稿,然后交由AI来实现。


当然每个人的审美和需求都不一样,也并不是每个人都有不错的UI和交互设计能力,对于大部分人来说现阶段的AI设计水平已经是能满足需要了的。


开发过程


使用AI来进行开发,那最重要的就是提示词,而如何编写提示词来让AI更了解你的需求、尽可能不走弯路去实现,其实是很不容易的。


这里我的经验是,先自己思考清楚,用markdown整理好需求,包括主要功能、需要有哪些页面、每个页面的大致布局,以及一些需要额外强调的细节等等,然后让AI先根据你整理的需求文档来描述一下它对这个需求的理解,也可以让它反过来问你一些在需求文档无法确定的问题,补全到文档中,直到他能八九不离十的把你想要的结果描述出来。



此外,你也可以先在chat模式里面简单一句话描述需求,选择claude 3.7 thinking模型,或者deepseek r1模型,然后你们俩一起交流来把需求逐步完善和明确。



需求明确之后,也不要马上就让AI直接开始开发,因为如果整个过程很长的话,大模型目前的上下文肯定是不够的,就算是基于codebase,后续也必然会丢失细节,甚至完全忘记了你们之前定好的需求。


这里的建议是,先让AI根据需求文档把目录和文件创建好,并为每个代码文件建立一个md文件,用于标记每个代码文件里面包含的关键变量、方法名、和其他模块的依赖关系等,这样相比完整的代码文件,数据量要小很多,后续觉得大模型忘事儿了,就让他根据md来进行分析,这要比让他分析完整的代码文件要靠谱很多。另外在后续开发过程中也一定要让AI及时更新md文件。



可以在Cursor的规则配置文件中明确上面的要求。



由于Cursor中的Claude 3.7不支持输入图片作为参考,所以如果你需要基于现有的设计图进行开发,可以先选择Claude 3.5,传入参考图,让它先帮你把不带交互的UI代码实现,然后再使用claude 3.7 来进一步完善设计的业务逻辑。


开发过程中,每一次迭代可能都大幅改动之前已经实现的部分,所以切记一定要及时git commit,尤其是已经完成了某个小功能之后,一定要提交代码!这也是我使用AI进行全流程开发的最大的教训!


音频资源的获取


这个App中有很多音频资源,包括轻音乐、环境声、白噪声等,在以往,个人开发者要获取这些资源其实是很费时间的,而且需要付出的成本也比较高,但是随着AI的发展,现在获取这些资源已经变得非常容易了。


比如轻音乐,我用的是Suno AI来生成的,十几美元一个月,就能生成足够多的轻音乐,而且质量还不错,可以满足大部分场景的需求。


白噪声,则是让AI帮我编写的nodejs脚本来生成的,直接本地生成mp3音乐文件。


环境声、物品音效之类的,可以使用Eleven Lab来生成,价格也很便宜,不过我这里是先用的开源项目Moodist中的资源,而且也对作者进行了捐赠支持。


另外,在讲这些音频资源打包到应用的时候,从体积角度考虑,我对这些音频都做了压缩,在以往可能需要找一些格式工厂之类的软件来完成,现在直接让AI帮你基于Macos内置的音频处理模块生成命令行脚本,轻松完成格式转换和比率压缩,非常方便。


非AI的部分


虽然我全程几乎没有写一行代码,但是还是有一些非AI的部分,需要手动来完成。


比如应用的启动图标设计、App Store上架资料的准备、关键词填写、技术支持网址、隐私协议内容、应用截图的准备等等,虽然这其中有一些也能借助AI辅助进行,但是最终还是免不了要手动进行处理。



隐私协议内容可以让AI生成,不过一定要自己过一遍,而技术支持网站,可以用在线问卷的形式,不用自己准备网站。



在App Store上架审核的时候,也需要时刻关注审核进度和反馈,一般来说新手第一次上架审核就过审的概率很低,苹果那边也有很多规范或者要求甚至是红线,需要踩坑多次才能了解清楚。我之前已经上架过好几款应用了,这一次提审第一次没过居然是因为内购项目忘记一并提审了,笑死,不然就是一把过了,后面更新的几个版本最快半小时不到就过审了。


另外还有国区上架备案的问题,实际要准备的资料也不多,流程其实也不复杂,但是就是需要等待,而且等待的时间不确定,我这次等了近5天才通过。


有朋友可能会咨询安卓版的问题,我只能说一言难尽,目前安卓的上架流程和资质要求对独立开发者太不友好了,不确定项目有商业价值之前,不建议考虑安卓(就算是出海上google play,也比上苹果应用商店麻烦多了)。


总结


以往我作为全栈工程师,在开发产品的时候,编码始终是我最核心的工作,而这一次,我最重要的编码过程交给了 AI,我则是充当了产品设计师和测试工程师的角色,短短几天,我已经体会了很多专职产品设计师在和开发人员沟通时候的感受,也体会到了测试工程师在测试产品时候的感受,这确实是一个非常有趣和有意义的过程。


作为产品设计师,我需要能够准备描述需求、考虑到尽可能多的场景和细节,才能让 AI 更加敏捷和高质量的完成需求的开发,而作为测试工程师,我需要学会如何准确地描述问题的表现和复现步骤,才能让 AI 更加精准的给出解决方案。


虽然我确实没有写一行代码,但是在开发过程中,遇到一些复杂场景或者问题,Cursor 也会原地踏步甚至把问题越改越严重,这个时候还是需要我去分析一下它实现的代码,跳出它的上下文来来给他提示,然后他就会恍然大悟一般迅速解决问题,这确确实实依赖了我多年来的开发经验和直觉,我相信在开发复杂应用的时候,这是必不可少的。


而且开发 App 是一回事,上架 App 又是另外一回事了,备案、审核、隐私协议准备、上架配图准备等等等等,这些可能要花的时间比开发的时间还长。


在这次实践的过程中,我虽然是借助了自己的以往的独立开发经验解决了很多问题,并因此缩短了从开始开发到真正完成审核上架的周期,但我相信最核心的编码问题已经完全能交给AI了的话,那对于大部想要做一个自己的应用的人来说,真正的技术门槛确实已经不存在了。


因此我可以对大家说,准备好面对一个人人都能编程做应用的新时代吧!


再回到关于零编程经验用 AI 三天开发 App 登榜 App Store这个话题,我只能说,这确实是一个非常吸引眼球的话题,但是它也确实存在一定的误导性,不管用 AI 还是纯人工编码,做出来好的 APP 和成为爆款 APP 其实是两回事。


事实上我体验过一些此类爆款产品,在产品完成度和交互设计上实际上还很初级,甚至可以说很粗糙,但是它们却能够获得巨大的成功,除了运气和时机之外,营销上的成功其实是更重要的。


需要清醒认识的是,App Store 榜单是产品力、运营策略和市场机遇的综合产物。作为开发者,更应该关注 AI 如何重构我们的能力边界,而非简单对标营销案例。


最后再说点


总有人会问AI会不会取代程序员,我觉得不会,被AI淘汰的,只会是那些害怕使用AI,不愿意学习使用AI的人。我相信经常逛掘金的朋友们也都是不甘于只做一颗螺丝钉的,快让借助AI来拓展你的能力边界吧,


最后再再再说一句


一定要记得及时git commit!!!


作者:Margox
来源:juejin.cn/post/7484530047866355766
收起阅读 »

🔥🔥什么?LocalStorage 也能被监听?为什么我试了却不行?

web
引言:最近,团队的伙伴需要实现监听 localStorage 数据变化,但开发中却发现无法直接监听。 在团队的一个繁重项目中,我们发现一个新功能的实现成本很大,因此开始思考:能否通过实时监听 LocalStorage 的变化并自动触发相关操作。我们尝试使用 ...
继续阅读 »

引言:最近,团队的伙伴需要实现监听 localStorage 数据变化,但开发中却发现无法直接监听。



在团队的一个繁重项目中,我们发现一个新功能的实现成本很大,因此开始思考:能否通过实时监听 LocalStorage 的变化并自动触发相关操作。我们尝试使用 addEventListener 来监听 localStorage 的变化,但令人意外的是,这种方法仅在不同浏览器标签页之间有效,而在同一标签页内却无法实现监听。这是怎么回事?





经过调研了解到,浏览器确实提供了 storage 事件机制,但它仅适用于同源的不同标签页之间。对于同一标签页内的 LocalStorage 变化,却没有直接的方法来实现实时监听。最初,我们考虑使用 setInterval 进行定时轮询来获取变化,但这种方式要么导致性能开销过大,要么无法第一时间捕捉到变化。


今天,我们探讨下几种高效且实用的解决方案,是否可以帮助轻松应对LocalStorage这种监听需求?希望对你有所帮助,有所借鉴!


传统方案的痛点🎯🎯


先来看看浏览器是如何帮助我们处理不同页签的 LocalStorage 变化:


window.addEventListener("storage", (event) => {      
if (event.key === "myKey") {
// 执行相应操作
}
});

通过监听 storage 事件,当在其他页签修改 LocalStorage 时,你可以在当前页签捕获到这个变化。但问题是:这种方法只适用于跨页签的 LocalStorage 修改,在同一页签下无法触发该事件。于是,很多开发者开始寻求替代方案,比如:


1、轮询(Polling)


轮询是一种最直观的方式,它定期检查 localStorage 的值是否发生变化。然而,这种方法性能较差,尤其在高频轮询时会对浏览器性能产生较大的影响,因此不适合作为长期方案。


let lastValue = localStorage.getItem('myKey');

setInterval(() => {
const newValue = localStorage.getItem('myKey');
if (newValue !== lastValue) {
lastValue = newValue;
console.log('Detected localStorage change:', newValue);
}
}, 1000); // 每秒检查一次

这种方式实现简单,不依赖复杂机制。但是性能较差,频繁轮询会影响浏览器性能。


2、监听代理(Proxy)或发布-订阅模式


这种方式通过创建一个代理来拦截 localStorage.setItem 的调用。每次数据变更时,我们手动发布一个事件,通知其他监听者。


(function() {
const originalSetItem = localStorage.setItem;
const subscribers = [];

localStorage.setItem = function(key, value) {
originalSetItem.apply(this, arguments);
subscribers.forEach(callback => callback(key, value));
};

function subscribe(callback) {
subscribers.push(callback);
}

subscribe((key, value) => {
if (key === 'myKey') {
console.log('Detected localStorage change:', value);
}
});

localStorage.setItem('myKey', 'newValue');
})();

这种比较灵活,可以用于复杂场景。但是需要手动拦截 setItem,维护成本高(但也是值得推荐的)。





然而,这些方案往往存在性能问题或者开发的复杂度,在高频数据更新的情况下,有一定的性能问题,而且存在一定的风险性。那么有没有可以简单快速,风险性还小的方案呢?


高效的解决方案 🚀🚀


既然浏览器不支持同一页签的 storage 事件,我们可以手动触发事件,以此来实现同一页签下的 LocalStorage 变化监听。


1、自定义 Storage 事件


通过手动触发 StorageEvent,你可以在 LocalStorage 更新时同步分发事件,从而实现同一页签下的监听。


localStorage.setItem('myKey', 'value');

// 手动创建并分发 StorageEvent
const storageEvent = new StorageEvent('storage', {
key: 'myKey',
url: window.location.href
});
window.dispatchEvent(storageEvent);

你可以使用相同的监听逻辑来处理数据变化,无论是同一页签还是不同页签:


window.addEventListener("storage", (event) => {
if (event.key === "myKey") {
// 处理 LocalStorage 更新
}
});

这种实现简单、轻量、快捷。但是需要手动触发事件。


2、基于 CustomEvent 的自定义事件


StorageEvent 类似,你可以使用 CustomEvent 手动创建并分发事件,实现 localStorage 的同步监听。


localStorage.setItem('myKey', 'newValue');

const customEvent = new CustomEvent('localStorageChange', {
detail: { key: 'myKey', value: 'newValue' }
});
window.dispatchEvent(customEvent);


这种方式适合更加灵活的事件触发场景。CustomEvent不局限于 localStorage 事件,可以扩展到其他功能。


window.addEventListener('localStorageChange', (event) => {
const { key, value } = event.detail;
if (key === 'myKey') {
console.log('Detected localStorage change:', value);
}
});

3、MessageChannel(消息通道)


MessageChannel API 可以在同一个浏览器上下文中发送和接收消息。我们可以通过 MessageChannellocalStorage 的变化信息同步到其他部分,起到类似事件监听的效果。


const channel = new MessageChannel();

channel.port1.onmessage = (event) => {
console.log('Detected localStorage change:', event.data);
};

localStorage.setItem('myKey', 'newValue');
channel.port2.postMessage(localStorage.getItem('myKey'));

适合组件通信和复杂应用场景,消息机制较为灵活。相对复杂的实现,可能不适合简单场景。


4、BroadcastChannel


BroadcastChannel 提供了一种更高级的浏览器通信机制,允许多个窗口或页面之间广播消息。你可以通过这个机制将 localStorage 变更同步到多个页面或同一页面的不同部分。


const channel = new BroadcastChannel('storage_channel');

channel.onmessage = (event) => {
console.log('Detected localStorage change:', event.data);
};

localStorage.setItem('myKey', 'newValue');
channel.postMessage({ key: 'myKey', value: 'newValue' });

支持跨页面通信,方便在不同页面间同步数据,易于实现。适用场景较为具体,通常用于复杂的页面通信需求。


这4个方法,主打的就是一个见缝插针,简单快速,风险性低。但是客观角度来讲,每种方案都是有各自优势的。


优势对比


方案优点缺点适用场景
轮询实现简单,适合低频监控需求性能差,频繁轮询影响浏览器性能简单场景或临时方案
监听代理/发布-订阅模式灵活扩展,适合复杂项目需要手动拦截 setItem,维护成本高需要手动事件发布的场景
自定义 StorageEvent实现简单,原生支持 storage 事件监听需要手动触发事件同页签下 localStorage 监听
自定义事件灵活的事件管理,适合不同场景需要手动触发事件需要自定义触发条件的场景
MessageChannel适合组件通信和复杂应用场景实现复杂,不适合简单场景高级组件通信需求
BroadcastChannel跨页面通信,适合复杂通信需求使用场景较具体复杂的多窗口通信

如何在 React / Vue 使用


在主流前端框架(如 React 和 Vue)中,监听 LocalStorage 变化并不困难。无论是 React 还是 Vue,你都可以使用自定义的 StorageEvent 或其他方法来实现监听。在此,我们以自定义 StorageEvent 为例,展示如何在 React 和 Vue 中实现 LocalStorage 的监听。





1. 在 React 中使用自定义 StorageEvent


React 是一个基于组件的框架,我们可以使用 React 的生命周期函数(如 useEffect)来监听和处理 LocalStorage 的变化。


import React, { useEffect } from 'react';

const LocalStorageListener = () => {
useEffect(() => {
// 定义 storage 事件监听器
const handleStorageChange = (event) => {
if (event.key === 'myKey') {
console.log('Detected localStorage change:', event.newValue);
}
};

// 添加监听器
window.addEventListener('storage', handleStorageChange);

// 模拟触发自定义的 StorageEvent
const triggerCustomStorageEvent = () => {
const storageEvent = new StorageEvent('storage', {
key: 'myKey',
newValue: 'newValue',
url: window.location.href,
});
window.dispatchEvent(storageEvent);
};

// 组件卸载时移除监听器
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []); // 空依赖数组表示该 effect 只会在组件挂载时运行

return (
<div>
<button onClick={() => localStorage.setItem('myKey', 'newValue')}>
修改 localStorage
</button>
<button onClick={() => window.dispatchEvent(new StorageEvent('storage', {
key: 'myKey',
newValue: localStorage.getItem('myKey'),
url: window.location.href,
}))}>
手动触发 StorageEvent
</button>
</div>

);
};

export default LocalStorageListener;


  • useEffect 是 React 的一个 Hook,用来处理副作用,在这里我们用它来注册和清除事件监听器。

  • 我们手动触发了 StorageEvent,以便在同一页面中监听 LocalStorage 的变化。


2. 在 Vue 中使用自定义 StorageEvent


在 Vue 3 中,我们可以使用 onMountedonUnmounted 这两个生命周期钩子来管理事件监听器。(Vue 3 Composition API):


<template>
<div>
<button @click="updateLocalStorage">修改 localStorage</button>
<button @click="triggerCustomStorageEvent">手动触发 StorageEvent</button>
</div>
</template>

<script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue';

const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'myKey') {
console.log('Detected localStorage change:', event.newValue);
}
};

const updateLocalStorage = () => {
localStorage.setItem('myKey', 'newValue');
};

const triggerCustomStorageEvent = () => {
const storageEvent = new StorageEvent('storage', {
key: 'myKey',
newValue: 'newValue',
url: window.location.href,
});
window.dispatchEvent(storageEvent);
};

onMounted(() => {
window.addEventListener('storage', handleStorageChange);
});

onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange);
});
</script>


  • 使用了 Vue 的 Composition API,其中 onMountedonUnmounted 类似于 React 的 useEffect,用于在组件挂载和卸载时管理副作用。

  • 同样手动触发了 StorageEvent 来监听同一页面中的 LocalStorage 变化。


提炼封装一下 🚀🚀


无论是 React 还是 Vue,将自定义 StorageEvent 实现为一个组件或工具函数是常见的做法。你可以将上面的逻辑提取到一个独立的 hook 或工具函数中,方便在项目中多次使用。


在 React 中提取为 Hook


import { useEffect } from 'react';

const useLocalStorageListener = (key, callback) => {
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === key) {
callback(event.newValue);
}
};

window.addEventListener('storage', handleStorageChange);

return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [key, callback]);
};

export default useLocalStorageListener;

在 Vue 中提取为工具函数


import { onMounted, onUnmounted } from 'vue';

export const useLocalStorageListener = (key: string, callback: (value: string | null) => void) => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key) {
callback(event.newValue);
}
};

onMounted(() => {
window.addEventListener('storage', handleStorageChange);
});

onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange);
});
};


  • 在 React 中,我们创建了一个自定义 Hook useLocalStorageListener,通过传入监听的 key 和回调函数来捕获 LocalStorage 的变化。

  • 在 Vue 中,我们创建了一个工具函数 useLocalStorageListener,同样通过传入 key 和回调函数来监听变化。


总结





在同一个浏览器页签中监听 localStorage 的变化并非难事,但不同场景下需要不同的方案。从简单的轮询到高级的 BroadcastChannel,本文介绍的几种方案各有优缺点。根据你的实际需求,选择合适的方案可以帮助你更高效地解决问题。



  • 简单需求:可以考虑使用自定义 StorageEventCustomEvent 实现监听。

  • 复杂需求:对于更高级的场景,如跨页面通信,MessageChannelBroadcastChannel 是更好的选择。


如果你有其他的优化技巧或问题,欢迎在评论区分享,让我们一起交流更多的解决方案!


作者:Sailing
来源:juejin.cn/post/7418117491720323081
收起阅读 »

CSS换行最容易出现的bug,半天被提了两个😭😭

web
引言 大家好,我是石小石! 文字超出换行应该是大家项目中高频的css样式了,它也是非常容易出现样式bug的一个地方。 分享一下我工作中遇到的问题。前几天开发代码的时候,突然收到两个测试提的bug: bug的内容大致就是我的文字换行出现了问题 我的第一反应就...
继续阅读 »

引言


大家好,我是石小石!


文字超出换行应该是大家项目中高频的css样式了,它也是非常容易出现样式bug的一个地方。


分享一下我工作中遇到的问题。前几天开发代码的时候,突然收到两个测试提的bug:



bug的内容大致就是我的文字换行出现了问题



我的第一反应就是线上代码不是最新的,因为自测的时候,我注意过这个问题,我在本地还测试过



然而经过验证,最后我还是被打脸了,确实是自己的问题!


问题原因分析


在上述的问题代码中,我没有做任何换行的规则


.hover-content {
max-width: 420px;
max-height: 420px;
}

因此,此时弹框内的换行规则遵循的是浏览器的默认换行规则(white-space:normal):


浏览器换行遵循 单词完整性优先 的规则,即尽可能不在单词或数字序列内部断行;而中文是固定宽度字符,每个汉字视为独立的可断点,因此换行非常自然,展示不下浏览器会将其移动到下一行。


那么,出现上述bug的原因就非常简单了,基于浏览器的默认换行规则,这种胡乱输入、没有规则的连续纯英文或数字不换行,而汉字会换行。


white-space:normal 指定文本能够换行,是css的默认值,后文我们会继续讲解


解决方案


解决上述问题其实非常简单,一行css就可以解决🤗


word-break: break-all

word-break: break-all 可以打破浏览器的默认规则,允许在任何字符间断行(包括英文单词和数字序列)。


word-break



  • 作用:指定如何对单词进行断行。

  • 取值





    • normal(默认值):使用浏览器默认规则,中文按字断行,英文按单词断行。

    • break-all:强制在任何字符间断行(适用于中文、英文、数字)。

    • keep-all:中文按字断行,英文和数字不允许在单词或数字中断行。




与换行关联的css属性


除了word-break,你可能还会对white-space、word-wrap有疑问,他们与文本换行又有什么关系呢?


white-space


white-space大家一定不陌生,做文本超出显示...的时候,它是老熟人了。


white-space: nowrap; /* 禁止文本换行 */
overflow: hidden; /* 隐藏溢出内容 */
text-overflow: ellipsis; /* 使用省略号表示溢出内容 */

white-space 用于指定如何处理元素内的空白字符和如何控制文本的换行。简单来说,它的作用就是是否应该允许文本自动换行,它的默认值normal,代表文本允许换行。



所有能够换行的文本,一定拥有此默认属性white-space:normal,如果你设置nowrap,那么不管是中文还是数字或者英文,都是不会出现换行的。


white-space的换行遵循的是单词完整性优先 的规则,如果我们要使单词可以在其内部被截断,就需要使用 overflow-wrapword-breakhyphens


word-break我们已经说过了,我们介绍下


overflow-wrap


这个属性原本属于微软扩展的一个非标准、无前缀的属性,叫做 word-wrap,后来在大多数浏览器中以相同的名称实现。目前它已被更名为 overflow-wrap,word-wrap 相当于其别称。


作用:控制单词过长时是否允许断行。


常用值



  • normal:单词超出容器宽度时不换行。

  • break-word:允许在单词中断行以防止溢出。

  • anywhere:类似 break-word,但优先级更高。


实际开发中,overflow-wrap:break-word的效果同word-break: break-all



但他们存在一点点差异


换行方式





    • overflow-wrap: break-word 允许在单词内部进行断行,但会尽量保持单词的完整性。

    • word-break: break-all 则强制在任意字符间进行换行,不考虑单词的完整性。




因此,使用verflow-wrap: break-word 会比word-break: break-all更好一些!


推荐实践


通过本文,相信大家对css文本换行有了新的认识,个人比较推荐的实践如下:





    • 中文为主:可以默认使用 word-break: normal;

    • 中英文混排overflow-wrap: break-word;

    • 主要为英文或数字:需要强制换行时,使用 word-break: break-all;




考虑到场景的复杂新,大家可以word-break: break-all走天下。


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

token泄漏产生的悲剧!Vant和Rspack被注入恶意代码,全网大面积被感染

web
一、事件 2024年12月19号,在前端圈发生了一件匪夷所思的事情,前端开源项目Vant的多个版本被注入恶意代码后,发布到了npm上,导致全网大面积被感染。 随后Vant团队人员出来发声说:经排查可能是我们团队一名成员的 token 被盗用,他们正在紧急处理。...
继续阅读 »

一、事件


2024年12月19号,在前端圈发生了一件匪夷所思的事情,前端开源项目Vant的多个版本被注入恶意代码后,发布到了npm上,导致全网大面积被感染


随后Vant团队人员出来发声说:经排查可能是我们团队一名成员的 token 被盗用,他们正在紧急处理。 What?token还能被别人盗用的么,这安全性真的是差点惊掉我的下巴。



然后Vant团队人员废弃了有问题的版本,并在几个大版本2、3、4上都发布了新的安全版本2.13.63.6.164.9.15,我刚试了下,现在使用npm i vant@latest安装的是最新的4.9.15版本,事件就算是告一段落了。



二、关联事件:Rspack躺枪


攻击者拿到了vant成员的token后,进一步拿到了同个GitHub组织下另一个成员的token,并发布了同样带有恶意代码的Rspack@1.1.7版本。


这里简单介绍下Rspack,它是一个基于Rust编写打的高性能javascript打包工具,相比于webpackrollup等打包工具,它的构建性能有很大的提升,是字节团队为了解决构建性能的问题而研发的,后开源在github


Rspack这波属实是躺枪了,不过Rspack团队反应很快,已经在一小时内完成该版本的废弃处理,并发布了1.1.8修复版本,字节的问题处理速度还是可以的。目前Rspack1.1.7版本在npm上已经删除了,无法安装。



三、带来的影响


Vant作为一个老牌的国产移动端组件库,由有赞团队负责开发和维护,在github上已经拥有23.4kStar,是一款很优秀的组件库,其在国内的前端项目中应用是非常广泛的,几乎是开发H5项目的首选组件库。vant官方目前提供了 Vue 2 版本、Vue 3 版本和微信小程序版本,微信小程序版本本次不受影响,遭受攻击的是Vue2Vue3版本。


如果在发布恶意版本还未修复的期间,正好这时候有项目发布安装到了这些恶意版本,那后果不堪设想。要知道Vant可是面向用户端(C端)的组件库,其杀伤力是十分大的。


我们公司很多前端移动端项目也用了Vant组件库,不过我们项目都用了package-lock.json,所以对于我们来说问题不大,这里也简单介绍下package-lock.json,也推荐大家都用一下。


四、package-lock.json介绍


比如你在package.json中写了一个依赖^3.7.0,你用npm install安装到了3.7.0版本,然后过了一段时间后,你同事把这个代码克隆到本地,然后也执行npm install,由于此时依赖包已经更新到了3.8.0版本,所以此时你同事安装到的是3.8.0版本。


这时候问题就来了,这个依赖的开发团队“不讲武德”,在3.8.0对一个API做了改动,并且做好向后兼容,于是代码报错了,项目跑不起来了,你同事找了半天,发现是依赖更新了,很无语,浪费半天开发时间,又得加班赶项目了!


按理来说,npm install就应该向纯函数(相同的输入产生相同的输入,无副作用的函数)一样,产出相同node_modules,然而依赖是会更新的,这会导致npm install产出的结果不一样,而如果依赖开发人员不按规范升级版本,或者升级后的新版本有bug,尽管业务代码一行没改,项目再次发布时也可能会出现问题。


package-lock.json就是为了解决这个问题的,在npm install的时候,会根据package.json中的依赖版本,默认生成一份package-lock.json文件,你可以把这个lock文件上传到git仓库上,下次npm install的时候,会根据一定规则选择最终安装的版本:



  • npm v5.0.x版本:不管package.json文件中的依赖项是否有更新,都会按照package-lock.json文件中的依赖版本进行下载。

  • npm v5.1.0 - v5.4.2:当package.json的依赖版本有符合的更新版本时,会忽略package-lock.json,按照package.json安装,并更新package-lock.json文件。

  • npm v5.4.2以上:当package.json声明的的依赖版本与package-lock.json中的版本兼容时,会按照package-lock.json的版本安装,反之,如果不兼容则按照package.json安装,并更新package-lock.json文件。


npm v5.4.2这个版本已经很旧了,我们用的npm版本几乎都在这之上,所以如果用了package-lock.json文件,应该就能避免这个问题,不要怕麻烦,我觉得为了项目的稳定性,这个还是需要用的。


这个事件就介绍到这里了,对此大家怎么看呢?


作者:程序员小寒
来源:juejin.cn/post/7450080841546121243
收起阅读 »

你知道JS中有哪些“好用到爆”的一行代码?

web
哈喽,各位小伙伴们,你们好呀,我是喵手。   今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。   我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都...
继续阅读 »

哈喽,各位小伙伴们,你们好呀,我是喵手。



  今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。


  我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都尽量抽业余时间把自己所学到所会的,通过文章的形式进行输出,希望以这种方式帮助到更多的初学者或者想入门的小伙伴们,同时也能对自己的技术进行沉淀,加以复盘,查缺补漏。



小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦。三连即是对作者我写作道路上最好的鼓励与支持!



前言


  偶尔帮同事开发点前端页面,每次写代码,总会遇到一些能让人直呼nb的代码。今天,我们就来盘点一下那些 “好用到爆”的 JavaScript 一行代码。省去复杂的多行代码,直接用一行或者简洁的代码进行实现。也能在同事面前秀一波(当然是展示技术实力,而不是装X 🤓)。


  也许你会问:“一行代码真的能有这么强吗?” 别急,接着往下看,保证让你大呼—— 这也行?! 哈哈,待看完之后,你可能会心一笑,原来一行代码还能发挥的如此优雅!核心就是要简洁高效快速实现。


目录



  1. 妙用之美:一行代码的魅力

  2. 实用案例:JS 一行代码提升开发效率

    • 生成随机数

    • 去重数组

    • 检查变量类型

    • 深拷贝对象

    • 交换两个变量的值

    • 生成 UUID



  3. 延伸知识:一行代码背后的原理

  4. 总结与感悟


妙用之美:一行代码的魅力


  为什么“一行代码”如此让人着迷?因为它是 简洁、高效、优雅 的化身。在日常开发中,我们总希望能用更少的代码实现更多的功能,而“一行代码”就像是开发者智慧的结晶,化繁为简,带来极致的编码体验。


  当然,别以为一行代码就等同于简单。事实上,这些代码往往利用了 JavaScript 中的高级技巧,比如 ES6+ 的特性、函数式编程的思维、甚至对底层机制的深入理解。它们既是技巧的体现,也是对语言掌控力的证明。


  接下来,让我们通过一些实用案例,感受“一行代码”的高优雅吧!


实用案例:JS 一行代码提升开发效率


1. 生成随机数


在日常开发中,生成随机数是非常常见的需求。但是我可以一句代码就能搞定,示例如下:


const random = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;

用法示例


console.log(random(1, 100)); // 输出 1 到 100 之间的随机整数

解析:代码核心是 Math.random(),它生成一个 0 到 1 的随机数。通过数学公式将其映射到指定范围,并利用 Math.floor() 确保返回的是整数。


2. 数组去重


数组去重的方法有很多种,但下面这种方式极其优雅,不信你看!


const unique = (arr) => [...new Set(arr)];

用法示例


console.log(unique([1, 2, 2, 3, 4, 4, 5])); // [1, 2, 3, 4, 5]

解析Set 是一种集合类型,能自动去重。而 ... 是扩展运算符,可以将 Set 转换为数组,省去手动遍历的步骤。


3. 检查变量类型


判断变量类型也是日常开发中的常见操作,但是下面这一行代码就足够满足你的需求:


const type = (value) => Object.prototype.toString.call(value).slice(8, -1).toLowerCase();

用法示例


console.log(type(123)); // 'number'
console.log(type([])); // 'array'
console.log(type(null)); // 'null'

解析:通过 Object.prototype.toString 可以准确获取变量的类型信息,而 slice(8, -1) 是为了提取出 [object Type] 中的 Type 部分。


4. 深拷贝对象


经常会碰到拷贝的场景,但是对于需要深拷贝的对象,下面的一行代码简单且高效:


const deepClone = (obj) => JSON.parse(JSON.stringify(obj));

用法示例


const obj = { a: 1, b: { c: 2 } };
const copy = deepClone(obj);
console.log(copy); // { a: 1, b: { c: 2 } }

注意:这种方法不适用于循环引用的对象。如果需要处理复杂对象,建议使用 Lodash 等库。


5. 交换两个变量的值


日常中,如果是传统写法,可能会采用需要引入临时变量,但是,今天,我可以教你一个新写法,使用解构赋值就简单多了:


let a = 1, b = 2;
[a, b] = [b, a];

用法示例


console.log(a, b); // 2, 1

解析:利用 ES6 的解构赋值语法,可以轻松实现两个变量的值交换,代码简洁且直观。


6. 生成 UUID


这个大家都不陌生,且基本所有的项目中都必须有,UUID ,它是开发中常用的唯一标识符,下面这段代码可以快速生成一个符合规范的 UUID,对,就一行搞定:


const uuid = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return c === 'x' ? r.toString(16) : ((r & 0x3) | 0x8).toString(16);
});

用法示例


console.log(uuid()); // 类似 'e4e6c7c4-d5ad-4cc1-9be8-d497c1a9d461'

解析:通过正则匹配字符 xy,并利用 Math.random() 生成随机数,再将其转换为符合 UUID 规范的十六进制格式。


延伸知识


  如上这些“一行代码”的实现主要得益于 ES6+ 的特性,如:



  • 箭头函数:让函数表达更简洁。

  • 解构赋值:提升代码的可读性。

  • 扩展运算符:操作数组和对象时更加优雅。

  • SetMap:提供高效的数据操作方式。


  所以说,深入理解这些特性,不仅能让你更轻松地掌握这些代码,还能将它们灵活地应用到实际开发中,在日常开发中游刃有余,用最简洁的代码实现最复杂的也无需求。


总结与感悟


  一行代码的背后,藏着开发者的智慧和对 JavaScript 代码的深入理解。通过这些代码,我们不仅能提升开发效率,还能在细节中感受代码的优雅与美感,这个也是我们一致的追求。


  前端开发的乐趣就在于此——简单的代码,却能带来无限可能。如果你有更好用的一行代码,欢迎分享,让我们一起玩耍 JavaScript 的更多妙用!体验其中的乐趣。


... ...


文末


好啦,以上就是我这期的全部内容,如果有任何疑问,欢迎下方留言哦,咱们下期见。


... ...


学习不分先后,知识不分多少;事无巨细,当以虚心求教;三人行,必有我师焉!!!


wished for you successed !!!




⭐️若喜欢我,就请关注我叭。


⭐️若对您有用,就请点赞叭。


⭐️若有疑问,就请评论留言告诉我叭。


作者:喵手
来源:juejin.cn/post/7444829930175905855
收起阅读 »

做定时任务,一定要用这个神库!!

web
说实话,作为前端开发者,我们经常需要处理一些定时任务,比如轮询接口、定时刷新数据、自动登出等功能。过去我总是用 setTimeout 和 setInterval,但这些方案在复杂场景下并不够灵活。我寻找了更可靠的方案,最终发现了 ...
继续阅读 »

说实话,作为前端开发者,我们经常需要处理一些定时任务,比如轮询接口、定时刷新数据、自动登出等功能。

过去我总是用 setTimeout 和 setInterval,但这些方案在复杂场景下并不够灵活。

我寻找了更可靠的方案,最终发现了 cron 这个 npm 包,为我的前端项目(特别是 Node.js 环境下运行的那部分)带来了专业级的定时任务能力。

cron 包:不只是个定时器

安装超级简单:

npm install cron

基础用法也很直观:

import { CronJob } from 'cron';

const job = new CronJob(
'0 */30 * * * *', // 每30分钟执行一次
function () {
console.log('刷新用户数据...');
// 这里放刷新数据的代码
},
null, // 完成时的回调
true, // 是否立即启动
'Asia/Shanghai' // 时区
);

看起来挺简单的,对吧?

但这个小包却能解决前端很多定时任务的痛点。

理解 cron 表达式,这个"魔法公式"

刚开始接触 cron 表达式时,我觉得这简直像某种加密代码。* * * * * * 这六个星号到底代表什么?

在 npm 的 cron 包中,表达式有六个位置(比传统的 cron 多一个),分别是:

秒 分 时 日 月 周

比如 0 0 9 * * 1 表示每周一早上 9 点整执行。

我找到一个特别好用的网站 crontab.guru 来验证表达式。

不过注意,那个网站是 5 位的表达式,少了"秒"这个位置,所以用的时候需要自己在前面加上秒的设置。

月份和星期几还可以用名称来表示,更直观:

// 每周一、三、五的下午5点执行
const job = new CronJob('0 0 17 * * mon,wed,fri', function () {
console.log('工作日提醒');
});

前端开发中的实用场景

作为前端开发者,我在这些场景中发现 cron 特别有用:

1. 在 Next.js/Nuxt.js 等同构应用中刷新数据缓存

// 每小时刷新一次产品数据缓存
const cacheRefreshJob = new CronJob(
'0 0 * * * *',
async function () {
try {
const newData = await fetchProductData();
updateProductCache(newData);
console.log('产品数据缓存已更新');
} catch (error) {
console.error('刷新缓存失败:', error);
}
},
null,
true,
'Asia/Shanghai'
);

2. Electron 应用中的定时任务

// 在 Electron 应用中每5分钟同步一次本地数据到云端
const syncJob = new CronJob(
'0 */5 * * * *',
async function () {
if (navigator.onLine) {
// 检查网络连接
try {
await syncDataToCloud();
sendNotification('数据已同步');
} catch (err) {
console.error('同步失败:', err);
}
}
},
null,
true
);

3. 定时检查用户会话状态

// 每分钟检查一次用户活动状态,30分钟无活动自动登出
const sessionCheckJob = new CronJob(
'0 * * * * *',
function () {
const lastActivity = getLastUserActivity();
const now = new Date().getTime();

if (now - lastActivity > 30 * 60 * 1000) {
console.log('用户30分钟无活动,执行自动登出');
logoutUser();
}
},
null,
true
);

踩过的那些坑

使用 cron 包时我踩过几个坑,分享给大家:

  1. 时区问题:有次我设置了一个定时提醒功能,但总是提前 8 小时触发。一查才发现是因为没设置时区。所以国内用户一定要设置 'Asia/Shanghai'
// 这样才会在中国时区的下午6点执行
const job = new CronJob('0 0 18 * * *', myFunction, null, true, 'Asia/Shanghai');
  1. this 指向问题:如果你用箭头函数作为回调,会发现无法访问 CronJob 实例的 this。
// 错误示范
const job = new CronJob('* * * * * *', () => {
console.log('执行任务');
this.stop(); // 这里的 this 不是 job 实例,会报错!
});

// 正确做法
const job = new CronJob('* * * * * *', function () {
console.log('执行任务');
this.stop(); // 这样才能正确访问 job 实例
});
  1. v3 版本变化:如果你从 v2 升级到 v3,要注意月份索引从 0-11 变成了 1-12。

实战案例:构建一个智能通知系统

这是我在一个电商前端项目中实现的一个功能,用 cron 来管理各种用户通知:

import { CronJob } from 'cron';
import { getUser, getUserPreferences } from './api/user';
import { sendNotification } from './utils/notification';

class NotificationManager {
constructor() {
this.jobs = [];
this.initialize();
}

initialize() {
// 新品上架提醒 - 每天早上9点
this.jobs.push(
new CronJob(
'0 0 9 * * *',
async () => {
if (!this.shouldSendNotification('newProducts')) return;

const newProducts = await this.fetchNewProducts();
if (newProducts.length > 0) {
sendNotification('新品上架', `今天有${newProducts.length}款新品上架啦!`);
}
},
null,
true,
'Asia/Shanghai'
)
);

// 限时优惠提醒 - 每天中午12点和晚上8点
this.jobs.push(
new CronJob(
'0 0 12,20 * * *',
async () => {
if (!this.shouldSendNotification('promotions')) return;

const promotions = await this.fetchActivePromotions();
if (promotions.length > 0) {
sendNotification('限时优惠', '有新的限时优惠活动,点击查看详情!');
}
},
null,
true,
'Asia/Shanghai'
)
);

// 购物车提醒 - 每周五下午5点提醒周末特价
this.jobs.push(
new CronJob(
'0 0 17 * * 5',
async () => {
if (!this.shouldSendNotification('cartReminder')) return;

const cartItems = await this.fetchUserCart();
if (cartItems.length > 0) {
sendNotification('周末将至', '别忘了查看购物车中的商品,周末特价即将开始!');
}
},
null,
true,
'Asia/Shanghai'
)
);

console.log('通知系统已初始化');
}

async shouldSendNotification(type) {
const user = getUser();
if (!user) return false;

const preferences = await getUserPreferences();
return preferences?.[type] === true;
}

// 其他方法...

stopAll() {
this.jobs.forEach(job => job.stop());
console.log('所有通知任务已停止');
}
}

export const notificationManager = new NotificationManager();

写在最后

作为前端开发者,我们的工作不只是构建漂亮的界面,还需要处理各种复杂的交互和时序逻辑。

npm 的 cron 包为我们提供了一种专业而灵活的方式来处理定时任务,特别是在 Node.js 环境下运行的前端应用(如 SSR 框架、Electron 应用等)。

它让我们能够用简洁的表达式设定复杂的执行计划,帮助我们构建更加智能和用户友好的前端应用。


作者:Immerse
来源:juejin.cn/post/7486390904992890895
收起阅读 »

Browser.js:轻松模拟浏览器环境

web
什么是Browser.js Browser.js是一个小巧而强大的JavaScript库,它模拟浏览器环境,使得原本只能在浏览器中运行的代码能够在Node.js环境中执行。这意味着你可以在服务器端运行前端代码,而不需要依赖真实浏览器 Browser.js的核心...
继续阅读 »

什么是Browser.js


Browser.js是一个小巧而强大的JavaScript库,它模拟浏览器环境,使得原本只能在浏览器中运行的代码能够在Node.js环境中执行。这意味着你可以在服务器端运行前端代码,而不需要依赖真实浏览器


Browser.js的核心原理


Browser.js通过实现与浏览器兼容的API(如windowdocumentnavigator等)来创建一个近似真实的浏览器上下文。它还支持fetch API用于网络请求,支持Promise,使得异步操作更加方便


Browser.js的用途


Browser.js主要用于以下场景:



  • 服务器端测试:在服务端运行前端单元测试,无需依赖真实浏览器,从而提高测试效率


    // 示例:使用Browser.js进行服务器端测试
    const browser = require('browser.js');
    const window = browser.window;
    const document = browser.document;

    // 在Node.js中模拟浏览器环境
    console.log(window.location.href);


  • 构建工具:编译或预处理只能在浏览器运行的库,例如基于DOM的操作,如CSS处理器或模板引擎


    // 示例:使用Browser.js处理CSS
    const browser = require('browser.js');
    const document = browser.document;

    // 创建一个CSS样式表
    const style = document.createElement('style');
    style.textContent = 'body { background-color: #f2f2f2; }';
    document.head.appendChild(style);


  • 离线应用:将部分业务逻辑放在客户端和服务端之外,在本地环境中执行1


    // 示例:使用Browser.js在本地环境中执行业务逻辑
    const browser = require('browser.js');
    const window = browser.window;

    // 在本地环境中执行JavaScript代码
    window.alert('Hello, World!');


  • 自动化脚本:对网页进行自动化操作,如爬虫、数据提取等,而不必依赖真实浏览器1


    // 示例:使用Browser.js进行网页爬虫
    const browser = require('browser.js');
    const fetch = browser.fetch;

    // 发送HTTP请求获取网页内容
    fetch('https://example.com')
    .then(response => response.text())
    .then(html => console.log(html));



解决的问题


Browser.js解决了以下问题:



  • 跨环境执行:使得原本只能在浏览器中运行的JavaScript代码能够在Node.js环境中执行,扩展了JavaScript的应用边界

  • 兼容性问题:通过模拟浏览器环境,减少了不同浏览器之间的兼容性问题,提高了代码的可移植性

  • 测试效率:提高了前端代码在服务端的测试效率,减少了对真实浏览器的依赖


Browser.js的特点



  • 轻量级:体积小,引入方便,不会过多影响项目整体性能

  • 兼容性:模拟的浏览器环境高度兼容Web标准,能够运行大部分浏览器代码

  • 易用性:提供简单直观的API接口,快速上手

  • 可扩展:支持自定义插件,可以根据需求扩展功能

  • 无依赖:不依赖其他大型库或框架,降低项目复杂度


作者:Y11_推特同名
来源:juejin.cn/post/7486845198485585935
收起阅读 »

Vue 首个 AI 组件库发布!

web
人工智能(AI)已深度融入我们的生活,如今 Vue 框架也迎来了首个 AI 组件库 —— Ant Design X Vue,为开发者提供了强大的 AI 开发工具。 Ant Design X Vue 概述 Ant Design X Vue 是基于 Vue.js ...
继续阅读 »

人工智能(AI)已深度融入我们的生活,如今 Vue 框架也迎来了首个 AI 组件库 —— Ant Design X Vue,为开发者提供了强大的 AI 开发工具。


Ant Design X Vue 概述


Ant Design X Vue 是基于 Vue.js 的 AI 组件库,旨在简化 AI 集成开发。



它包含高度定制化的 AI 组件和 API 解决方案,支持无缝接入 AI 服务,是构建智能应用的理想选择。


组件库亮点


丰富多样的 AI 组件


通用组件



  • Bubble:显示会话消息气泡,支持多种布局。

  • Conversations:管理多个会话,查看历史记录。


唤醒组件



  • Welcome:会话加载时插入欢迎语。

  • Prompts:展示上下文相关的问题或建议。


表达组件



  • Sender:构建会话输入框,支持自定义样式。

  • Attachments:展示和管理附件信息。

  • Suggestion:提供快捷输入提示。


确认组件



  • ThoughtChain:展示 AI 的思维过程或结果。


工具组件



  • useXAgent:对接 AI 模型推理服务。

  • useXChat:管理 AI 对话应用的数据流。

  • XStream:处理数据流,支持流式传输。

  • XRequest:向 AI 服务发起请求。

  • XProvider:全局化配置管理。


RICH 设计范式


基于 RICH 设计范式,提供丰富、沉浸式、连贯和人性化的交互体验,适应不同 AI 场景。


AGI 混合界面(Hybrid-UI)


融合 GUI 和自然会话交互,用户可在同一应用中自由切换交互方式,提升体验。


适用场景



  • 智能聊天应用:构建多轮对话界面,支持复杂会话逻辑。

  • 企业级 AI 系统:快速搭建智能客服、知识管理等系统。


如何使用 Ant Design X Vue


安装与引入


npm install ant-design-x-vue --save

引入组件库及样式:


import Vue from 'vue';
import Antd from 'ant-design-x-vue';
import 'ant-design-x-vue/dist/antd.css';

Vue.use(Antd);

使用组件


示例:使用 Bubble 组件展示对话气泡


<template>
<div>
<a-bubble content="欢迎使用 Ant Design X Vue!" />
</div>
</template>

官方文档与示例



访问 Ant Design X Vue 官方文档:https://antd-design-x-vue.netlify.app/ 获取更多信息。


Ant Design X Vue 为 Vue 开发者提供了强大的 AI 组件库,助力高效构建智能应用。


无论是聊天应用还是企业级系统,都值得一试。


最后



如果觉得本文对你有帮助,希望能够给我点赞支持一下哦 💪 也可以关注wx公众号:前端开发爱好者 回复加群,一起学习前端技能 公众号内包含很多实战精选资源教程,欢迎关注



作者:前端开发爱好者
来源:juejin.cn/post/7475978280841543716
收起阅读 »

前端如何彻底解决重复请求问题

web
背景 保存按钮点击多次,造成新增多个单据 列表页疯狂刷新,导致服务器压力大 如何彻底解决 方案:我的思路从请求层面判断相同请求只发送一次,将结果派发给各个订阅者 实现思路 对请求进行数据进行hash 添加store 存储 hash => Array...
继续阅读 »

背景



  1. 保存按钮点击多次,造成新增多个单据

  2. 列表页疯狂刷新,导致服务器压力大


如何彻底解决


方案:我的思路从请求层面判断相同请求只发送一次,将结果派发给各个订阅者


实现思路



  1. 对请求进行数据进行hash

  2. 添加store 存储 hash => Array promise

  3. 相同请求,直接订阅对应的promise

  4. 请求取消,则将store中对应的promise置为null

  5. 请求返回后,调用所有未取消的订阅


核心代码



private handleFinish(key: string, index: number) {
const promises = this.store.get(key);
// 只有一个promise时则删除store
if (promises?.filter((item) => item).length === 1) {
this.store.delete(key);
} else if (promises && promises[index]) {
// 还有其他请求,则将当前取消的、或者完成的置为null
promises[index] = null;
}
}

private async handleRequest(config: any) {
const hash = sha256.create();
hash.update(
JSON.stringify({
params: config.params,
data: config.data,
url: config.url,
method: config.method,
}),
);
const fetchKey = hash.hex().slice(0, 40);
const promises = this.store.get(fetchKey);
const index = promises?.length || 0;
let promise = promises?.find((item) => item);
const controller = new AbortController();

if (config.signal) {
config.signal.onabort = (reason: any) => {
const _promises = this.store.get(fetchKey)?.filter((item) => item);
if (_promises?.length === 1) {
controller.abort(reason);
this.handleFinish(fetchKey, index);
}
};
}

if (!promise) {
promise = this.instance({
...config,
signal: controller.signal,
headers: {
...config.headers,
fetchKey,
},
}).catch((error) => {
console.log(error, "请求错误");
// 失败的话,立即删除,可以重试
this.handleFinish(fetchKey, index);
return { error };
});
}

const newPromise = Promise.resolve(promise)
.then((result: any) => {
if (config.signal?.aborted) {
this.handleFinish(fetchKey, index);
return result;
}
return result;
})
.finally(() => {
setTimeout(() => {
this.handleFinish(fetchKey, index);
}, 500);
});
this.store.set(fetchKey, [...(promises || []), newPromise]);
return newPromise;
}

以下为完整代码(仅供参考)


index.ts


import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import { sha256 } from "js-sha256";
import transformResponseValue, { updateObjTimeToUtc } from "./utils";

type ErrorInfo = {
message: string;
status?: number;
traceId?: string;
version?: number;
};

type MyAxiosOptions = AxiosRequestConfig & {
goLogin: (type?: string) => void;
onerror: (info: ErrorInfo) => void;
getHeader: () => any;
};

export type MyRequestConfigs = AxiosRequestConfig & {
// 是否直接返回服务端返回的数据,默认false, 只返回 data
useOriginData?: boolean;
// 触发立即更新
flushApiHook?: boolean;
ifHandleError?: boolean;
};

type RequestResult<T, U> = U extends { useOriginData: true }
? T
: T extends { data?: infer D }
? D
: never;

class LmAxios {
private instance: AxiosInstance;

private store: Map<string, Array<Promise<any> | null>>;

private options: MyAxiosOptions;

constructor(options: MyAxiosOptions) {
this.instance = axios.create(options);

this.options = options;
this.store = new Map();
this.interceptorRequest();
this.interceptorResponse();
}

// 统一处理为utcTime
private interceptorRequest() {
this.instance.interceptors.request.use(
(config) => {
if (config.params) {
config.params = updateObjTimeToUtc(config.params);
}
if (config.data) {
config.data = updateObjTimeToUtc(config.data);
}
return config;
},
(error) => {
console.log("intercept request error", error);
Promise.reject(error);
},
);
}

// 统一处理为utcTime
private interceptorResponse() {
this.instance.interceptors.response.use(
(response): any => {
// 对响应数据做处理,以下根据实际数据结构改动!!...
const [checked, errorInfo] = this.checkStatus(response);

if (!checked) {
return Promise.reject(errorInfo);
}

const disposition =
response.headers["content-disposition"] ||
response.headers["Content-Disposition"];
// 文件处理
if (disposition && disposition.indexOf("attachment") !== -1) {
const filenameReg =
/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?;?/g;
const filenames: string[] = [];
disposition.replace(filenameReg, (r: any, r1: string) => {
filenames.push(decodeURIComponent(r1));
});
return Promise.resolve({
filename: filenames[filenames.length - 1],
data: response.data,
});
}
if (response) {
return Promise.resolve(response.data);
}
},
(error) => {
console.log("request error", error);
if (error.message.indexOf("timeout") !== -1) {
return Promise.reject({
message: "请求超时",
});
}
const [checked, errorInfo] = this.checkStatus(error.response);
return Promise.reject(errorInfo);
},
);
}

private checkStatus(
response: AxiosResponse<any>,
): [boolean] | [boolean, ErrorInfo] {
const { code, message = "" } = response?.data || {};
const { headers, status } = response || {};

if (!status) {
return [false];
}

// 单地登录判断,弹出不同提示
if (status === 401) {
this.options?.goLogin();
return [false];
}

if (code === "ECONNABORTED" && message?.indexOf("timeout") !== -1) {
return [
false,
{
message: "请求超时",
},
];
}

if ([108, 109, 401].includes(code)) {
this.options.goLogin();
return [false];
}
if ((code >= 200 && code < 300) || code === 304) {
// 如果http状态码正常,则直接返回数据
return [true];
}

if (!code && ((status >= 200 && status < 300) || status === 304)) {
return [true];
}

let errorInfo = "";
const _code = code || status;
switch (_code) {
case -1:
errorInfo = "远程服务响应失败,请稍后重试";
break;
case 400:
errorInfo = "400: 错误请求";
break;
case 401:
errorInfo = "401: 访问令牌无效或已过期";
break;
case 403:
errorInfo = message || "403: 拒绝访问";
break;
case 404:
errorInfo = "404: 资源不存在";
break;
case 405:
errorInfo = "405: 请求方法未允许";
break;
case 408:
errorInfo = "408: 请求超时";
break;
case 500:
errorInfo = message || "500: 访问服务失败";
break;
case 501:
errorInfo = "501: 未实现";
break;
case 502:
errorInfo = "502: 无效网关";
break;
case 503:
errorInfo = "503: 服务不可用";
break;
default:
errorInfo = "连接错误";
}

return [
false,
{
message: errorInfo,
status: _code,
traceId: response?.data?.requestId,
version: response.data.ver,
},
];
}

private handleFinish(key: string, index: number) {
const promises = this.store.get(key);
if (promises?.filter((item) => item).length === 1) {
this.store.delete(key);
} else if (promises && promises[index]) {
promises[index] = null;
}
}

private async handleRequest(config: any) {
const hash = sha256.create();
hash.update(
JSON.stringify({
params: config.params,
data: config.data,
url: config.url,
method: config.method,
}),
);
const fetchKey = hash.hex().slice(0, 40);
const promises = this.store.get(fetchKey);
const index = promises?.length || 0;
let promise = promises?.find((item) => item);
const controller = new AbortController();

if (config.signal) {
config.signal.onabort = (reason: any) => {
const _promises = this.store.get(fetchKey)?.filter((item) => item);
if (_promises?.length === 1) {
controller.abort(reason);
this.handleFinish(fetchKey, index);
}
};
}

if (!promise) {
promise = this.instance({
...config,
signal: controller.signal,
headers: {
...config.headers,
fetchKey,
},
}).catch((error) => {
console.log(error, "请求错误");
// 失败的话,立即删除,可以重试
this.handleFinish(fetchKey, index);
return { error };
});
}

const newPromise = Promise.resolve(promise)
.then((result: any) => {
if (config.signal?.aborted) {
this.handleFinish(fetchKey, index);
return result;
}
return result;
})
.finally(() => {
setTimeout(() => {
this.handleFinish(fetchKey, index);
}, 500);
});
this.store.set(fetchKey, [...(promises || []), newPromise]);
return newPromise;
}

// add override type
public async request<T = unknown, U extends MyRequestConfigs = {}>(
url: string,
config: U,
): Promise<RequestResult<T, U> | null> {
// todo
const options = {
url,
// 是否统一处理接口失败(提示)
ifHandleError: true,
...config,
headers: {
...this.options.getHeader(),
...config?.headers,
},
};

const res = await this.handleRequest(options);

if (!res) {
return null;
}

if (res.error) {
if (res.error.message && options.ifHandleError) {
this.options.onerror(res.error);
}
throw new Error(res.error);
}

if (config.useOriginData) {
return res;
}

if (config.headers?.feTraceId) {
window.dispatchEvent(
new CustomEvent<{ flush?: boolean }>(config.headers.feTraceId, {
detail: {
flush: config?.flushApiHook,
},
}),
);
}

// 默认返回res.data
return transformResponseValue(res.data)
}
}

export type MyRequest = <T = unknown, U extends MyRequestConfigs = {}>(
url: string,
config: U,
) =>
Promise<RequestResult<T, U> | null>;

export default LmAxios;


utils.ts(这里主要用来处理utc时间,你可能用不到删除相关代码就好)


import moment from 'moment';

const timeReg =
/^\d{4}([/:-])(1[0-2]|0?[1-9])\1(0?[1-9]|[1-2]\d|30|31)($|( |T)(?:[01]\d|2[0-3])(:[0-5]\d)?(:[0-5]\d)?(\..*\d)?Z?$)/;

export function formatTimeValue(time: string, format = 'YYYY-MM-DD HH:mm:ss') {
if (typeof time === 'string' || typeof time === 'number') {
if (timeReg.test(time)) {
return moment(time).format(format);
}
}
return time;
}

// 统一转化如参
export const updateObjTimeToUtc = (obj: any) => {
if (typeof obj === 'string') {
if (timeReg.test(obj)) {
return moment(obj).utc().format();
}
return obj;
}
if (toString.call(obj) === '[object Object]') {
const newObj: Record<string, any> = {};
Object.keys(obj).forEach((key) => {
newObj[key] = updateObjTimeToUtc(obj[key]);
});
return newObj;
}
if (toString.call(obj) === '[object Array]') {
obj = obj.map((item: any) => updateObjTimeToUtc(item));
}
return obj;
};

const utcReg = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/;

const transformResponseValue = (res: any) => {
if (!res) {
return res;
}
if (typeof res === 'string') {
if (utcReg.test(res)) {
return moment(res).format('YYYY-MM-DD HH:mm:ss');
}
return res;
}
if (toString.call(res) === '[object Object]') {
const result: any = {};
Object.keys(res).forEach((key) => {
result[key] = transformResponseValue(res[key]);
});
return result;
}
if (toString.call(res) === '[object Array]') {
return res.map((item: any) => transformResponseValue(item));
}
return res;
};
export default transformResponseValue;


作者:枫荷
来源:juejin.cn/post/7484202915390718004
收起阅读 »

[译]为什么我选择用Tauri来替代Electron

web
原文地址:Why I chose Tauri instead of Electron 以下为正文。 关于我为什么在Tauri上创建Aptakube的故事,我与Electron的斗争以及它们之间的简短比较。 大约一年前,我决定尝试构建一个桌面应用程序。 我对...
继续阅读 »

原文地址:Why I chose Tauri instead of Electron
以下为正文。



关于我为什么在Tauri上创建Aptakube的故事,我与Electron的斗争以及它们之间的简短比较。



大约一年前,我决定尝试构建一个桌面应用程序。


我对自己开发的小众应用并不满意,我想我可以开发出更好的应用。我作为全栈开发人员工作了很长时间,但我以前从未构建过桌面应用程序。


我的第一个想法是用 SwiftUI 来构建。开发者喜欢原生应用,我也一直想学习 Swift。然而,在 SwiftUI 上构建会将我的受众限制为 macOS 用户。虽然我有一种感觉,大多数用户无论如何都会使用 macOS,但当我可以构建跨平台应用程序时,为什么要限制自己呢?


现在回想起来,我真的很庆幸当初放弃了 SwiftUI。看看人们在不同的操作系统上使用我的应用就知道了。


Pasted image 20240321161648.png



Windows和Linux代表了超过35%的用户。这相当于放弃了35%的收入。



那么 Electron 怎么样呢?


我不是与世隔绝的人,所以我知道 Electron 是个好东西,我每天使用的很多流行应用都是基于 Electron 开发的,包括我现在用来写这篇文章的编辑器。它似乎非常适合我想做的事情,因为:



  • 单个代码库可以针对多个平台。

  • 支持 React + TypeScript + Tailwind 配合使用,每个我都非常熟练。

  • 非常受欢迎 = 很多资源和指南。

  • NPM 是最大的(是吧?)软件包社区,这意味着我可以更快地发布。


在 Electron 上开发的另一个好处是,我可以专注于开发应用程序,而不是学习一些全新的东西。我喜欢学习新的语言和框架,但我想快速构建一些有用的东西。我仍然需要学习 Electron 本身,但它不会像学习 Swift 和 SwiftUI 那样困难。


好了,我们开始吧!


我决定了。Aptakube 将使用 Electron 来构建。


我通常不看文档。我知道我应该看,但我没有。不过,每当我第一次选择一个框架时,我总会阅读 “入门(Getting Started)” 部分。


流行的框架都有一个 npx create {framework-name},可以为我们快速一个应用程序。Next.js、Expo、Remix 和许多其他框架都有这样的功能。我发现这非常有用,因为它们可以让你快速上手,而且通常会给你提供很多选项,例如:



  • 你想使用 TypeScript 还是 JavaScript?

  • 你想使用 CSS 框架吗?那 Tailwind 呢?

  • Prettier 和/或 ESLint?

  • 你要这个还是那个?


这样的例子不胜枚举。这是一种很棒的开发体验,我希望每个框架都有一个。


我可以直接 npx create electron-app 吗?


显然,我不能,或者至少我还没有找到一种方法来做到这一点,除了在 Getting Started 里。


相反,我找到的是一个“快速启动(quick-start)”模板,我可以从 Git 上克隆下来,安装依赖项,然后就可以开始我的工作了。


然而,它不是 TypeScript,没有打包工具,没有 CSS 框架,没有检查,没有格式化,什么都没有。它只是一个简单的打开窗口的应用程序。


我开始用这个模板构建,并添加了所有我想让它使用的东西。我以为会很容易,但事实并非如此。


一个 electron 应用程序有三个入口点: mainpreload(预加载),和render(渲染)。把所有这些和 Vite 连接起来是很痛苦的。我花了大约两周的空闲时间试图让所有东西都工作起来。我失败了,这让我很沮丧。


之后我为 Electron 找到了几十个其他的样板。我试了大约五个。有些还可以,但大多数模板都太固执己见了,而且安装了太多的依赖项,我甚至不知道它们是用来做什么的,这让我不太喜欢。有些甚至根本不工作,因为它们已经被遗弃多年了。


总之,对于刚接触 Electron 的人来说,开发体验低于平均水平。Next.js 和 Expo 将标准设置得如此之高,以至于我开始期待每个框架都能提供类似的体验。


那现在怎么办?


在漫无目的刷 Twitter 的时候,我看到了一条来自 Tauri 的有关 1.0 版本的推文。那时他们已经成立 2 年了,但我完全不知道 Tauri 是什么。我去看了他们的网站,我被震撼了 🤯 这似乎就是我正在寻找的东西。


你知道最棒的是什么吗?他们把一条 npm create tauri-app 命令放在了主页上。


Pasted image 20240327173829.png



Tauri 用 npx create Tauri -app 命令从一开始就抓住了开发体验。



我决定尝试一下。我运行了 create the tauri-app 命令,体验与 Next.js 非常相似。它问了我几个问题,然后根据我的选择为我创建了一个新项目。


在这之后,我可以简单地运行 npm run dev,然后我就有了一个带有热加载、TypeScript、Vite 和 Solid.js 的可以运行的应用程序,几乎包含了我开始使用所需的一切。这让我印象深刻,并且很想了解更多。我仍然不得不添加 Prettier、Linters、Tailwind 等类似的东西,但我已经习惯了,而且它比 Electron 容易太多了。


Pasted image 20240328093731.png


开始(再一次😅),但与 Tauri 一起


虽然在 Electron 中,我可以只用 JavaScript/HTML/CSS 构建整个应用程序,但在 Tauri 中,后端是 Rust,只有前端是 JavaScript。这显然意味着我必须学习 Rust,这让我很兴奋,但也不想花太多时间,因为我想快速构建原型。


我在使用过 7 种以上专业的编程语言,所以我认为学习 Rust 是轻而易举的事。


我错了。我大错特错了。Rust 是很难的,真的很难,至少对我来说是这样!


一年后,在我的应用发布了 20 多个版本之后,我仍然不能说我真正了解 Rust。我知道要不断地定期发布新功能,但每次我必须用 Rust 写一些东西时,我仍然能学到很多新知识。GitHub Copilot 和 ChatGPT 帮了我大忙,我现在还经常使用它们。


Pasted image 20240403164309.png



像使用字符串这样简单的事情在Rust中要比在其他语言中复杂得多🤣



不过,Tauri 中有一些东西可以让这一过程变得简单许多。


Tauri 有一个“command 命令”的概念,它就像前端和后端之间的桥梁。你可以用 Rust 在你的 Tauri 后端定义“命令”,然后在 JavaScript 中调用它们。Tauri 本身提供了一系列可以开箱即用的命令。例如,你可以通过 JavaScript 打开一个文件选择器(file dialog),读取/更新/删除文件,发送 HTTP 请求,以及其他很多与操作系统进行的交互,而无需编写任何 Rust 代码。


那么,如果你需要做一些在 Tauri 中没有的事情呢?这就是“Plugins插件”的用武之地。插件是 Rust 库,它定义了你可以在 Tauri 应用中使用的命令。稍后我会详细介绍插件,但现在你只需将它们视为扩展 Tauri 功能的一种方式就可以了。


事实上,我已经询问了很多使用 Tauri 构建应用程序的人,问他们是否必须编写 Rust 代码来构建他们的应用程序。他们中的大多数表示,他们只需要为一些特定的使用情况编写非常少的 Rust 代码。完全可以在不编写任何 Rust 代码的情况下构建一个 Tauri 应用程序!


那么 Tauri 与 Electron 相比又如何呢?


1. 编程语言和社区


在 Electron 中,你的后端是一个 Node.js 进程,而前端是 Chromium,这意味着 Web 开发人员可以仅使用 JavaScript/HTML/CSS 来构建桌面应用程序。NPM 上有着庞大的库社区,并且在互联网上有大量与此相关的内容,这使得学习过程变得更加容易。


然而,尽管通常认为能够在后端和前端之间共享代码是一件好事,但也可能会导致混淆,因为开发人员可能会尝试在前端使用后端函数,反之亦然。因此,你必须小心不要混淆。


相比之下,Tauri 的后端是 Rust,前端也是一个 Webview(稍后会详细介绍)。虽然有大量的 Rust 库,但它们远远不及 NPM 的规模。Rust 社区也比 JavaScript 社区小得多,这意味着关于它的内容在互联网上较少。但正如上面提到的,取决于你要构建的内容,你甚至可能根本不需要编写太多的 Rust 代码。


我的观点: 我只是喜欢我们在 Tauri 中得到的明确的前后端的分离。如果我在 Rust 中编写一段代码,我知道它将作为一个操作系统进程运行,并且我可以访问网络、文件系统和许多其他内容,而我在 JavaScript 中编写的所有内容都保证在一个 Webview 上运行。学习 Rust 对我来说并不容易,但我很享受这个过程,而且总的来说我学到了很多新东西!Rust 开始在我心中生根了。😊


2. Webview


在 Electron 中,前端是一个与应用程序捆绑在一起的 Chromium Webview。这意味着无论操作系统如何,您都可以确定应用程序使用的 Node.js 和 Chromium 版本。这带来了重大的好处,但也有一些缺点。


最大的好处是开发和测试的便利性,您知道哪些功能可用,如果某些功能在 macOS 上可用,那么它很可能也可以在 Windows 和 Linux 上使用。然而,缺点是由于所有这些二进制文件捆绑在一起,您的应用程序大小会更大。


Tauri 采用了截然不同的方法。它不会将 Chromium 与您的应用程序捆绑在一起,而是使用操作系统的默认 Webview。这意味着在 macOS 上,您的应用程序将使用 WebKit(Safari 的引擎),在 Windows 上将使用 WebView2(基于 Chromium),在 Linux 上将使用WebKitGTK(与 Safari 相同)。


最终结果是一个感觉非常快速的极小型应用程序!


作为参考,我的 Tauri 应用程序在 macOS 上只有 24.7MB 大小,而我的竞争对手的应用程序(Electron)则达到了 1.3GB。


为什么这很重要?



  • 下载和安装速度快得多。

  • 主机和分发成本更低(我在 AWS 上运行,所以我需要支付带宽和存储费用)。

  • 我经常被问到我的应用是否使用 Swift 构建,因为用户通常在看到如此小巧且快速的应用时会有一种“这感觉像是本地应用”的时候。

  • 安全性由操作系统处理。如果 WebKit 存在安全问题,苹果将发布安全更新,我的应用将简单地使用它。我不必发布我的应用的更新版本来修复它。


我的观点: 我喜欢我的应用如此小巧且快速。起初,我担心操作系统之间缺乏一致性会导致我需要在所有 3 个操作系统上测试我的应用,但到目前为止我没有遇到任何问题。无论如何,Web开发人员已经习惯了这种情况,因为我们长期以来一直在构建多浏览器应用程序。打包工具和兼容性填充也在这方面提供了很大帮助!


3. 插件


我之前简要提到过这一点,但我认为它值得更详细地讨论,因为在我看来,这是 Tauri 最好的特性之一。插件是由 Rust 编写的一组命令集,可以从 JavaScript 中调用。它允许开发人员通过组合不同的插件来构建应用程序,这些插件可以是开源的,也可以在您的应用程序中定义。


这是一种很好的应用程序组织结构的方式,它也使得在不同应用程序之间共享代码变得容易!


在 Tauri 社区中,您会找到一些插件的示例:



这些特性本来可能可以成为 Tauri 本身的一部分,但将它们单独分开意味着您可以挑选和选择您想要使用的功能。这也意味着它们可以独立演变,并且如果有更好的替代品发布,可以被替换。


插件系统是我选择 Tauri 的第二大原因;它让开发者的体验提升了 1000 倍!


4. 功能对比


就功能而言,Electron 和 Tauri 非常相似。Electron 仍然具有一些更多的功能,但 Tauri 正在迅速赶上。至少对于我的使用情况来说,Tauri 具有我所需要的一切。


唯一给我带来较大不便的是缺乏一个“本地上下文菜单”API。这是社区强烈要求的功能,它将使 Tauri 应用程序感觉更加本地化。我目前是用 JS/HTML/CSS 来实现这一点,虽然可以,但还有提升的空间。希望我们能在 Tauri 2 中看到这个功能的实现 🤞


但除此之外,Tauri 还有很多功能。开箱即用,您可以得到通知、状态栏、菜单、对话框、文件系统、网络、窗口管理、自动更新、打包、代码签名、GitHub actions、辅助组件等。如果您需要其他功能,您可以编写一个插件,或者使用现有的插件之一。


5. 移动端


这个消息让我感到惊讶。在我撰写这篇文章时,Tauri 已经实验性地支持 iOS 和 Android。似乎这一直是计划的一部分,但当我开始我的应用程序时并不知道这一点。我不确定自己是否会使用它,但知道它存在感到很不错。


这是 Electron 所不可能实现的,并且可能永远也不会。因此,如果您计划构建跨平台的移动和桌面应用程序,Tauri 可能是一种不错的选择,因为您可能能够在它们之间共享很多代码。利用网络技术设计移动优先界面多年来变得越来越容易,因此构建一个既可以作为桌面应用程序又可以作为移动应用程序运行的单一界面并不像听起来那么疯狂。


我只是想提一句,让大家对 Tauri 的未来感到兴奋。


Pasted image 20240517144955.png
watchOS 上的 Tauri 程序?🤯


正如 Jonas 在他的推文中所提到的,这只是实验性的和折衷的;它可能需要很长时间才能达到生产状态,但看到这个领域的创新仍然非常令人兴奋!


结论


我对选择使用 Tauri 感到非常满意。结合 Solid.js,我能够制作出一个真正快速的应用程序,人们喜欢它!我不是说它总是比 Electron 好,但如果它具有您需要的功能,我建议尝试一下!如前所述,您甚至可能不需要写那么多 Rust 代码,所以不要被吓倒!您会惊讶地发现,只用 JavaScript 就能做的事情有多少。


如果你对 Kubernetes 感兴趣,请查看 Aptakube,这是一个使用 Tauri 构建的 Kubernetes 桌面客户端 😊


我现在正在开发一个面向桌面和移动应用的开源且注重隐私的分析平台。它已经具有各种框架的 SDK,包括 Tauri 和 Electron。顺便说一句,Tauri SDK 被打包为一个 Tauri 插件! 😄


最后,我也活跃在 Twitter 上。如果您有任何问题或反馈,请随时联系我。我喜欢谈论 Tauri!


感谢阅读!👋


作者:qwei
来源:juejin.cn/post/7386115583845744649
收起阅读 »

前端の骚操作代码合集 | 让你的网页充满恶趣味

web
1️⃣ 永远点不到的幽灵按钮 效果描述:按钮会跟随鼠标指针,但始终保持微妙距离 <button id="ghostBtn" style="position:absolute">点我试试?</button> <script> ...
继续阅读 »

1️⃣ 永远点不到的幽灵按钮


效果描述:按钮会跟随鼠标指针,但始终保持微妙距离


<button id="ghostBtn" style="position:absolute">点我试试?</button>
<script>
const btn = document.getElementById('ghostBtn');
document.addEventListener('mousemove', (e) => {
btn.style.left = `${e.clientX + 15}px`;
btn.style.top = `${e.clientY + 15}px`;
});
</script>



Desktop2025.03.04-12.25.56.17-ezgif.com-video-to-gif-converter.gif


2️⃣ 极简黑客帝国数字雨


代码亮点:仅用 20 行代码实现经典效果


<canvas id="matrix"></canvas>
<script>
const canvas = document.getElementById('matrix');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const chars = '01';
const drops = Array(Math.floor(canvas.width/20)).fill(0);

function draw() {
ctx.fillStyle = 'rgba(0,0,0,0.05)';
ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.fillStyle = '#0F0';
drops.forEach((drop, i) => {
ctx.fillText(chars[Math.random()>0.5?0:1], i*20, drop);
drops[i] = drop > canvas.height ? 0 : drop + 20;
});
}
setInterval(draw, 100);
</script>


运行建议:按下 F11 进入全屏模式效果更佳
Desktop2025.03.04-12.28.02.18-ezgif.com-video-to-gif-converter.gif




下面是优化版:


<script>
const canvas = document.getElementById('matrix');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

const chars = '01'; // 显示的字符
const columns = Math.floor(canvas.width / 20); // 列数
const drops = Array(columns).fill(0); // 每列的起始位置
const speeds = Array(columns).fill(0).map(() => Math.random() * 10 + 5); // 每列的下落速度

function draw() {
// 设置背景颜色并覆盖整个画布,制造渐隐效果
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
ctx.fillRect(0, 0, canvas.width, canvas.height);

// 设置字符颜色
ctx.fillStyle = '#0F0'; // 绿色
ctx.font = '20px monospace'; // 设置字体

// 遍历每一列
drops.forEach((drop, i) => {
// 随机选择一个字符
const char = chars[Math.random() > 0.5 ? 0 : 1];
// 绘制字符
ctx.fillText(char, i * 20, drop);
// 更新下落位置
drops[i] += speeds[i];
// 如果超出画布高度,重置位置
if (drops[i] > canvas.height) {
drops[i] = 0;
speeds[i] = Math.random() * 10 + 5; // 重置速度
}
});
}

// 每隔100毫秒调用一次draw函数
setInterval(draw, 100);
</script>

3️⃣ 元素融化动画


交互效果:点击元素后触发扭曲消失动画


<div onclick="melt(this)" 
style="cursor:pointer; padding:20px; background:#ff6666;">

点我融化!
</div>

<script>
function melt(element) {
let pos = 0;
const meltInterval = setInterval(() => {
element.style.borderRadius = `${pos}px`;
element.style.transform = `skew(${pos}deg) scale(${1 - pos/100})`;
element.style.opacity = 1 - pos/100;
pos += 2;
if(pos > 100) clearInterval(meltInterval);
}, 50);
}
</script>



Desktop 2025.03.04 - 12.28.43.19.gif


4️⃣ 控制台藏宝图


彩蛋效果:在开发者工具中埋入神秘信息


console.log('%c🔮 你发现了秘密通道!', 
'font-size:24px; color:#ff69b4; text-shadow: 2px 2px #000');
console.log('%c输入咒语 %c"芝麻开门()" %c获得力量',
'color:#666', 'color:#0f0; font-weight:bold', 'color:#666');
console.debug('%c⚡ 警告:前方高能反应!',
'background:#000; color:#ff0; padding:5px;');



5️⃣ 重力反转页面


魔性交互:让页面滚动方向完全颠倒


window.addEventListener('wheel', (e) => {
e.preventDefault();
window.scrollBy(-e.deltaX, -e.deltaY);
}, { passive: false });

慎用警告:此功能可能导致用户怀疑人生 ( ̄▽ ̄)"




6️⃣ 实时 ASCII 摄像头


技术亮点:将摄像头画面转为字符艺术


<pre id="asciiCam" style="font-size:8px; line-height:8px;"></pre>
<script>
navigator.mediaDevices.getUserMedia({ video: true })
.then(stream => {
const video = document.createElement('video');
video.srcObject = stream;
video.play();

const chars = '@%#*+=-:. ';
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

video.onplaying = () => {
canvas.width = 80;
canvas.height = 40;

setInterval(() => {
ctx.drawImage(video, 0, 0, 80, 40);
const imgData = ctx.getImageData(0,0,80,40).data;
let ascii = '';

for(let i=0; i<imgData.length; i+=4) {
const brightness = (imgData[i]+imgData[i+1]+imgData[i+2])/3;
ascii += chars[Math.floor(brightness/25.5)]
+ (i%(80*4) === (80*4-4) ? '\n' : '');
}

document.getElementById('asciiCam').textContent = ascii;
}, 100);
};
});
</script>



⚠️ 使用注意事项



  1. 摄像头功能需 HTTPS 环境或 localhost 才能正常工作

  2. 反向滚动代码可能影响用户体验,建议仅在整蛊场景使用

  3. 数字雨效果会持续消耗 GPU 资源

  4. 控制台彩蛋要确保不会暴露敏感信息




这些代码就像前端的"复活节彩蛋",适度使用能让网站充满趣味性,但千万别用在生产环境哦!(≧∇≦)ノ


https://codepen.io/  链接 CodePen)
希望这篇博客能成为程序员的快乐源泉!🎉

作者:一天睡25小时
来源:juejin.cn/post/7477573759254675507
收起阅读 »

别再追逐全新框架了,先打好基础再说......

web
Hello,大家好,我是 Sunday 如果大家做过一段时间的前端开发,就会发现相比其他的技术圈而言,前端圈总显得 “乱” 的很。 因为,每隔几个月,圈里就会冒出一个“闪闪发光的全新 JavaScript 框架”,声称能解决你所有问题,并提供各种数据来证明它拥...
继续阅读 »

Hello,大家好,我是 Sunday


如果大家做过一段时间的前端开发,就会发现相比其他的技术圈而言,前端圈总显得 “乱” 的很。


因为,每隔几个月,圈里就会冒出一个“闪闪发光的全新 JavaScript 框架”,声称能解决你所有问题,并提供各种数据来证明它拥有:更快的性能!更简洁的语法!更多的牛批特性!


而对应的,很多同学都会开始 “追逐” 这些全新的框架,并大多数情况下都会得出一个统一的评论 “好牛批......”


但是,根据我的经验来看,通常情况下 过于追逐全新的框架,毫无意义。 特别是对于 前端初学者 而言,打好基础会更加的重要!



PS:我这并不是在反对新框架的创新,出现更多全新的框架,全新的创新方案肯定是好的。但是,我们需要搞清楚一点,这一个所谓的全新框架 究竟是创新,还是只是通过一个不同的方式,重复的造轮子?



全新的框架是追逐不完的


我们回忆一下,是不是很多所谓的全新框架,总是按照以下的方式在不断的轮回?



  • 首先,网上出现了某个“全新 JS 框架”发布,并提供了:更小、更快、更优雅 的方案,从而吸引了大量关注

  • 然后,很多技术人开始追捧,从 掘金、抖音、B 站 开始纷纷上线各种 “教程”

  • 再然后,几乎就没有然后了。国内大厂不会轻易使用这种新的框架作为生产工具,因为大厂会更加看重框架的稳定性

  • 最后,无非会出现两种结果,第一种就是:热度逐渐消退,最后停止维护。第二种就是:不断的适配何种业务场景,直到这种全新的框架也开始变得“臃肿不堪”,和它当年要打败的框架几乎一模一样。

  • 重新开始轮回:另一个“热门”框架出现,整个循环再次启动。


Svelte 火了那么久,大家有见到过国内有多少公司在使用吗?可能有很多同学会说“国外有很多公司在使用 Svelte 呀?” 就算如此,它对比 Vue 、React、Angular(国外使用的不少) 市场占有率依然是寥寥无几的。并且大多数同学的主战场还不在国外。


很多框架只是语法层面发生了变化


咱们以一个 “点击计数” 的功能为例,分别来看下在 Vue、React、Svelte 三个框架中的实现(别问为啥没有 angular,问就是不会😂)


Vue3 实现


<template>
<button @click="count++">点击了 {{ count }} 次</button>
</template>

<script setup>
import { ref } from 'vue';

const count = ref(0);
</script>

React 实现


import { useState } from 'react';

function Counter() {
const [count, setCount] = useState(0);

return (
<button onClick={() => setCount(count + 1)}>
点击了 {count} 次
</button>

);
}

export default Counter;

Svelte 实现


<script>
let count = 0;
</script>

<button on:click={() => count += 1}>
点击了 {count} 次
</button>

这三个版本的核心逻辑完全一样,只是语法不同。


那么这就意味着:如果换框架,都要重新学习这些新的语法细节(哪里要写冒号、哪里要写大括号、哪里要写中括号)。


如果你把时间都浪费着这些地方上(特别是前端初学者),是不是就意味着 毫无意义,浪费时间呢?


掌握好基础才是王道


如果我们去看了大量的 国内大厂的招聘面经之后,就会发现,无论是 校招 || 社招,大厂的考察重点 永远不在框架,而在于 JS 基础、网络、算法、项目 这四个部分。至于你会的是 vue || react 并没有那么重要!



PS:对于大厂来说 vue 和 react 都有不同的团队在使用。所以不用担心你学的框架是什么,这并不影响你进大厂



因此,掌握好基础就显得太重要了


所以说:不用过于追逐新的技术框架


针对于 在校生 而言 打好基础,练习算法,多去做更多有价值的项目,研究框架底层源码 ,并且一定要注意 练习表达,这才是对你将来校招最重要的事情!


而对于 社招的同学 而言 多去挖掘项目的重难点,尝试通过 输出知识的方式 帮助你更好的梳理技术。多去思考 技术如何解决业务问题,这才是关键!


作者:程序员Sunday
来源:juejin.cn/post/7484960608782336027
收起阅读 »

让你辛辛苦苦选好的筛选条件刷新页面后不丢失,该怎么做?

web
你有遇到过同样的需求吗?告诉我你的第一想法。存 Session storage ?可以,但是我更建议你使用 router.replace 。 为什么建议使用 router.replace 而不是浏览器自带的存储空间呢? 增加实用性,你有没有考虑过这种场景,也就...
继续阅读 »

你有遇到过同样的需求吗?告诉我你的第一想法。存 Session storage ?可以,但是我更建议你使用 router.replace


为什么建议使用 router.replace 而不是浏览器自带的存储空间呢?


增加实用性,你有没有考虑过这种场景,也就是当我筛选好之后进行搜索,我需要将它发给我的同事。当使用storage时是实现不了的,同事只会得到一个初始的页面。那我们将这个筛选结果放入url中是不是就可以解决这个问题了。


router.replace


先给大家介绍一下 router.replace 的用法吧。


router.replace 是 Vue Router 提供的一个方法,用于替换当前的历史记录条目。与 router.push 不同的是,replace 不会在浏览器历史记录中添加新记录,而是替换当前的记录。这对于需要在 URL 中保存状态但不想影响浏览器导航历史的场景非常有用。


// 假设我们正在使用 Vue 2 和 Vue Router
methods: {
updateFilters(newFilters) {
// 将筛选条件编码为查询字符串参数
const query = {
...this.$route.query,
...newFilters,
};

// 使用 router.replace 更新 URL
this.$router.replace({ query });
}
}

在这个示例中,updateFilters 方法接收新的筛选条件,并将它们合并到当前的查询参数中。然后使用 router.replace 更新 URL,而不会在历史记录中添加新条目。


具体实现


将筛选条件转换为适合 URL 的格式,例如 JSON 字符串或简单的键值对。以下是一个更详细的实现:


methods: {
applyFilters(filters) {
const encodedFilters = JSON.stringify(filters);

this.$router.replace({
path: this.$route.path,
query: { ...this.$route.query, filters: encodedFilters },
});
},

getFiltersFromUrl() {
const filters = this.$route.query.filters;
return filters ? JSON.parse(filters) : {};
}
}

在这个实现中,applyFilters 方法将筛选条件编码为 JSON 字符串,并将其存储在 URL 的查询参数中。getFiltersFromUrl 方法用于从 URL 中读取筛选条件,并将其解析回 JavaScript 对象。


注意事项



  • 编码和解码:在将复杂对象存储到 URL 时,确保使用 encodeURIComponent 和 decodeURIComponent 来处理特殊字符。

  • URL 长度限制:浏览器对 URL 长度有一定的限制,确保不要在 URL 中存储过多数据。

  • 数据安全性:考虑 URL 中数据的敏感性,避免在 URL 中存储敏感信息。

  • url重置:不要忘了在筛选条件重置时也将 url 重置,在取消筛选时同时去除 url 上的筛选。


一些其他的应用场景



  1. 重定向用户



    • 当用户访问一个不再存在或不推荐使用的旧路径时,可以使用 router.replace 将他们重定向到新的路径。这避免了用户点击“返回”按钮时再次回到旧路径。



  2. 处理表单提交后清理 URL



    • 在表单提交后,可能会在 URL 中附加查询参数。使用 router.replace 可以在处理完表单数据后清理这些参数,保持 URL 的整洁。



  3. 登录后跳转



    • 在用户登录后,将他们重定向到一个特定的页面(如用户主页或仪表盘),并且不希望他们通过“返回”按钮回到登录页面。使用 router.replace 可以实现这一点。



  4. 错误页面处理



    • 当用户导航到一个不存在的页面时,可以使用 router.replace 将他们重定向到一个错误页面(如 404 页面),并且不希望这个错误路径保留在浏览历史中。



  5. 动态内容加载



    • 在需要根据用户操作动态加载内容时,使用 router.replace 更新 URL,而不希望用户通过“返回”按钮回到之前的状态。例如,在单页应用中根据选项卡切换更新 URL。



  6. 多步骤流程



    • 在多步骤的用户流程中(如注册或购买流程),使用 router.replace 可以在用户完成每一步时更新 URL,而不希望用户通过“返回”按钮回到上一步。



  7. 清理查询参数



    • 在用户操作完成后,使用 router.replace 清理不再需要的查询参数,保持 URL 简洁且易于阅读。




小结



简单来说就是把你的 url 当成浏览器的 sessionstorage 了。其实这就是我上周收到的任务,当时我甚至纠结的是该用localStorage还是sessionStorage,忙活半天,不停转类型,然后在开周会我讲了下我的思路。我的tl便说出了我的问题,讲了更加详细的需求,我才开始尝试 router.replace ,又是一顿忙活。。



作者:一颗苹果OMG
来源:juejin.cn/post/7424034641379098663
收起阅读 »

做了个渐变边框的input输入框,领导和客户很满意!

web
需求简介 前几天需求评审的时候,产品说客户希望输入框能够好看一点。由于我们UI框架用的是Elemnt Plus,input输入框的样式也比较中规中矩,所以我们组长准备拒绝这个需求 但是,喜欢花里胡哨的我立马接下了这个需求!我自信的告诉组长,放着我来,包满意! ...
继续阅读 »

需求简介


前几天需求评审的时候,产品说客户希望输入框能够好看一点。由于我们UI框架用的是Elemnt Plus,input输入框的样式也比较中规中矩,所以我们组长准备拒绝这个需求


但是,喜欢花里胡哨的我立马接下了这个需求!我自信的告诉组长,放着我来,包满意!


经过一番折腾,我通过 CSS 的技巧实现了一个带有渐变边框的 Input 输入框,而且当鼠标悬浮在上面时,边框颜色要更加炫酷并加深渐变效果。



最后,领导和客户对最后的效果都非常满意~我也成功获得了老板给我画的大饼,很开心!


下面就来分享我的实现过程和代码方案,满足类似需求的同学可以直接拿去用!


实现思路


实现渐变边框的原理其实很简单,首先实现一个渐变的背景作为底板,然后在这个底板上加上一个纯色背景就好了。



当然,我们在实际写代码的时候,不用专门写两个div来这么做,利用css的 background-clip 就可以实现上面的效果。



background-clip 属性详解


background-clip 是一个用于控制背景(background)绘制范围的 CSS 属性。它决定了背景是绘制在 内容区域内边距区域、还是 边框区域


background-clip: border-box | padding-box | content-box | text;


代码实现


背景渐变


<template>
<div class="input-container">
<input type="text" placeholder="请输入内容" class="gradient-input" />
</div>

</template>

<script setup>
</script>


<style>
/* 输入框容器 */
.input-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
background: #f4f4f9;
}

/* 渐变边框输入框 */
.gradient-input {
width: 400px;
height: 40px;
padding: 5px 12px;
font-size: 16px;
color: #333;
outline: none;
/* 渐变边框 */
border: 1px solid transparent;
background: linear-gradient(white, white) padding-box,
linear-gradient(45deg, #ff7eb3, #65d9ff, #c7f464, #ff7eb3) border-box;
border-radius: 20px;
}

/* Placeholder 样式 */
.gradient-input::placeholder {
color: #aaa;
font-style: italic;
}

</style>



通过上面的css方法,我们成功实现了一个带有渐变效果边框的 Input 输入框,它的核心代码是


background: linear-gradient(white, white) padding-box, 
linear-gradient(45deg, #ff7eb3, #65d9ff, #c7f464, #ff7eb3) border-box;

padding-box:限制背景在内容区域显示,防止覆盖输入框内容。


border-box:渐变背景会显示在边框位置,形成渐变边框效果。


这段代码分为两层背景:



  1. 第一层背景

    linear-gradient(white, white) 是一个纯白色的线性渐变,用于覆盖输入框的内容区域(padding-box)。

  2. 第二层背景

    linear-gradient(45deg, #ff7eb3, #65d9ff, #c7f464, #ff7eb3) 是一个多色的渐变,用于显示在输入框的边框位置(border-box)。


背景叠加后,最终效果是:内层内容是白色背景,边框区域显示渐变颜色。


Hover 效果


借助上面的思路,我们在添加一些hover后css样式,通过 :hover 状态改变渐变的颜色和 box-shadow 的炫光效果:


/* Hover 状态 */
.gradient-input:hover {
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, #ff0076, #1eaeff, #28ffbf, #ff0076) border-box;
box-shadow: 0 0 5px rgba(255, 0, 118, 0.5), 0 0 20px rgba(30, 174, 255, 0.5);
}


过渡似乎有点生硬,没关系,加个过渡样式


/* 渐变边框输入框 */
.gradient-input {
// .....

/* 平滑过渡 */
transition: background 0.3s ease, box-shadow 0.3s ease;
}

非常好看流畅~



激活样式


最后,我们再添加一个激活的Focus 状态:当用户聚焦输入框时,渐变变得更加灵动,加入额外的光晕。


/* Focus 状态 */
.gradient-input:focus {
background: linear-gradient(white, white) padding-box,
linear-gradient(45deg, #ff0076, #1eaeff, #28ffbf, #ff0076) border-box;
box-shadow: 0 0 15px rgba(255, 0, 118, 0.7), 0 0 25px rgba(30, 174, 255, 0.7);
color: #000; /* 聚焦时文本颜色 */
}

现在,我们就实现了一个渐变边框的输入框,是不是非常好看?



完整代码


<template>
<div class="input-container">
<input type="text" placeholder="请输入内容" class="gradient-input" />
</div>
</template>

<style>
/* 输入框容器 */
.input-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
background: #f4f4f9;
}

/* 渐变边框输入框 */
.gradient-input {
width: 400px;
height: 40px;
padding: 5px 12px;
font-size: 16px;
font-family: 'Arial', sans-serif;
color: #333;
outline: none;

/* 渐变边框 */
border: 1px solid transparent;
background: linear-gradient(white, white) padding-box,
linear-gradient(45deg, #ff7eb3, #65d9ff, #c7f464, #ff7eb3) border-box;
border-radius: 20px;

/* 平滑过渡 */
transition: background 0.3s ease, box-shadow 0.3s ease;
}
/*

/* Hover 状态 */

.gradient-input:hover {
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, #ff0076, #1eaeff, #28ffbf, #ff0076) border-box;
box-shadow: 0 0 5px rgba(255, 0, 118, 0.5), 0 0 20px rgba(30, 174, 255, 0.5);
}

/* Focus 状态 */
.gradient-input:focus {
background: linear-gradient(white, white) padding-box,
linear-gradient(45deg, #ff0076, #1eaeff, #28ffbf, #ff0076) border-box;
box-shadow: 0 0 15px rgba(255, 0, 118, 0.7), 0 0 25px rgba(30, 174, 255, 0.7);
color: #000; /* 聚焦时文本颜色 */
}

/* Placeholder 样式 */
.gradient-input::placeholder {
color: #aaa;
font-style: italic;
}

</style>

总结


通过上述方法,我们成功实现了一个带有渐变效果边框的 Input 输入框,并且在 Hover 和 Focus 状态下增强了炫彩效果。


大家可以根据自己的需求调整渐变的方向、颜色或动画效果,让你的输入框与众不同!


作者:快乐就是哈哈哈
来源:juejin.cn/post/7442216034751545394
收起阅读 »

几行代码,优雅的避免接口重复请求!同事都说好!

web
背景简介 我们日常开发中,经常会遇到点击一个按钮或者进行搜索时,请求接口的需求。 如果我们不做优化,连续点击按钮或者进行搜索,接口会重复请求。 首先,这会导致性能浪费!最重要的,如果接口响应比较慢,此时,我们在做其他操作会有一系列bug! 那么,我们该如...
继续阅读 »

背景简介


我们日常开发中,经常会遇到点击一个按钮或者进行搜索时,请求接口的需求。


如果我们不做优化,连续点击按钮或者进行搜索,接口会重复请求。




首先,这会导致性能浪费!最重要的,如果接口响应比较慢,此时,我们在做其他操作会有一系列bug!



那么,我们该如何规避这种问题呢?


如何避免接口重复请求


防抖节流方式(不推荐)


使用防抖节流方式避免重复操作是前端的老传统了,不多介绍了


防抖实现


<template>
<div>
<button @click="debouncedFetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const timeoutId = ref(null);

function debounce(fn, delay) {
return function(...args) {
if (timeoutId.value) clearTimeout(timeoutId.value);
timeoutId.value = setTimeout(() => {
fn(...args);
}, delay);
};
}

function fetchData() {
axios.get('http://api/gcshi) // 使用示例API
.then(response => {
console.log(response.data);
})
}

const debouncedFetchData = debounce(fetchData, 300);
</script>

防抖(Debounce)



  • 在setup函数中,定义了timeoutId用于存储定时器ID。

  • debounce函数创建了一个闭包,清除之前的定时器并设置新的定时器,只有在延迟时间内没有新调用时才执行fetchData。

  • debouncedFetchData是防抖后的函数,在按钮点击时调用。


节流实现


<template>
<div>
<button @click="throttledFetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const lastCall = ref(0);

function throttle(fn, delay) {
return function(...args) {
const now = new Date().getTime();
if (now - lastCall.value < delay) return;
lastCall.value = now;
fn(...args);
};
}

function fetchData() {
axios.get('http://api/gcshi') //
.then(response => {
console.log(response.data);
})
}

const throttledFetchData = throttle(fetchData, 1000);
</script>

节流(Throttle)



  • 在setup函数中,定义了lastCall用于存储上次调用的时间戳。

  • throttle函数创建了一个闭包,检查当前时间与上次调用时间的差值,只有大于设定的延迟时间时才执行fetchData。

  • throttledFetchData是节流后的函数,在按钮点击时调用。


节流防抖这种方式感觉用在这里不是很丝滑,代码成本也比较高,因此,很不推荐!


请求锁定(加laoding状态)


请求锁定非常好理解,设置一个laoding状态,如果第一个接口处于laoding中,那么,我们不执行任何逻辑!


<template>
<div>
<button @click="fetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const laoding = ref(false);

function fetchData() {
// 接口请求中,直接返回,避免重复请求
if(laoding.value) return
laoding.value = true
axios.get('http://api/gcshi') //
.then(response => {
laoding.value = fasle
})
}

const throttledFetchData = throttle(fetchData, 1000);
</script>


这种方式简单粗暴,十分好用!


但是也有弊端,比如我搜索A后,接口请求中;但我此时突然想搜B,就不会生效了,因为请求A还没响应



因此,请求锁定这种方式无法取消原先的请求,只能等待一个请求执行完才能继续请求。


axios.CancelToken取消重复请求


基本用法


axios其实内置了一个取消重复请求的方法:axios.CancelToken,我们可以利用axios.CancelToken来取消重复的请求,爆好用!


首先,我们要知道,aixos有一个config的配置项,取消请求就是在这里面配置的。


<template>
<div>
<button @click="fetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

let cancelTokenSource = null;


function fetchData() {
if (cancelTokenSource) {
cancelTokenSource.cancel('取消上次请求');
cancelTokenSource = null;
}
cancelTokenSource = axios.CancelToken.source();

axios.get('http://api/gcshi',{cancelToken: cancelTokenSource.token}) //
.then(response => {
laoding.value = fasle
})
}

</script>


我们测试下,如下图:可以看到,重复的请求会直接被终止掉!



CancelToken官网示例



官网使用方法传送门:http://www.axios-http.cn/docs/cancel…



const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
cancelToken: source.token
}).catch(function (thrown) {
if (axios.isCancel(thrown)) {
console.log('Request canceled', thrown.message);
} else {
// 处理错误
}
});

axios.post('/user/12345', {
name: 'new name'
}, {
cancelToken: source.token
})

// 取消请求(message 参数是可选的)
source.cancel('Operation canceled by the user.');

也可以通过传递一个 executor 函数到 CancelToken 的构造函数来创建一个 cancel token:


const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
cancelToken: new CancelToken(function executor(c) {
// executor 函数接收一个 cancel 函数作为参数
cancel = c;
})
});

// 取消请求
cancel();

注意: 可以使用同一个 cancel token 或 signal 取消多个请求。


在过渡期间,您可以使用这两种取消 API,即使是针对同一个请求:


const controller = new AbortController();

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
cancelToken: source.token,
signal: controller.signal
}).catch(function (thrown) {
if (axios.isCancel(thrown)) {
console.log('Request canceled', thrown.message);
} else {
// 处理错误
}
});

axios.post('/user/12345', {
name: 'new name'
}, {
cancelToken: source.token
})

// 取消请求 (message 参数是可选的)
source.cancel('Operation canceled by the user.');
// 或
controller.abort(); // 不支持 message 参数

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

最近 React Scan 太火了,做了个 Vue Scan

web
在 React Scan 的 github 有这么一张 gif 图片。当用户在页面上操作时,对应的组件会有一个闪烁,表示当前组件更新了。用这样的方式来排查程序的性能是一个很直观的方式。 根据 React Scan 自己的介绍,React Scan 可以 通过...
继续阅读 »

在 React Scan 的 github 有这么一张 gif 图片。当用户在页面上操作时,对应的组件会有一个闪烁,表示当前组件更新了。用这样的方式来排查程序的性能是一个很直观的方式。


React Scan


根据 React Scan 自己的介绍,React Scan 可以 通过自动检测和突出显示导致性能问题的渲染


Vue Scan


但是我主要使用 vue 来开发我的应用,看到这个功能非常眼馋,所以就动手自己做了一个 demo,目前也构建了一个 chrome 扩展,不过扩展仅支持识别 vue3 项目 现在已经支持 vue2 和 vue3 项目了。


项目地址:Vue Scan


简单介绍,Vue Scan 通过组件的 onBeforUpdate 钩子,当组件更新时,在组件对应位置绘制一个闪烁的边框。看起来的效果就像这样。


image.png


用法


我更推荐在开发环境使用它,Vue Scan 提供一个 vue plugin,允许你在 mount 之前注入相关的内容。


// vue3
import { createApp } from 'vue'
import VueScan, { type VueScanOptions } from 'z-vue-scan/src'

import App from './App.vue'

const app = createApp(App)
app.use<VueScanOptions>(VueScan, {})
app.mount('#app')

// vue2
import Vue from 'vue'
import VueScan, { type VueScanBaseOptions } from 'z-vue-scan/vue2'
import App from './App.vue'

Vue.use<VueScanBaseOptions>(VueScan, {})

new Vue({
render: h => h(App),
}).$mount('#app')

浏览器扩展


如果你觉得看自己的网站没什么意思,那么我还构建了一个浏览器扩展,允许你注入相关方法到别人的 vue 程序中。


你可以在 Github Release 寻找一下最新版的安装包,然后解压安装到浏览器中。


安装完成后,你的扩展区域应该会多一个图标,点击之后会展开一个面板,允许你控制是否注入相关的内容。


image.png


这是如果你进入一个使用 vue 构建的网站,可以看控制台看到相关的信息,当你在页面交互时,页面应该也有相应的展示。


image.png


缺陷


就像 React Scan 的介绍中提到的,它能自动识别性能问题,单目前 Vue Scan 只是真实地反映组件的更新,并不会区分和识别此次更新是否有性能问题。


结语


通过观察网站交互时组件的更新状态,来尝试发现网站的性能问题,我觉得这是一个很好的方式。希望这个工具可以给大家带来一点乐趣和帮助。


作者:huali
来源:juejin.cn/post/7444449353165488168
收起阅读 »

Electron 启动白屏解决方案

web
对于 Web 开发者使用 Electron 构建桌面应用程序时,经常会遇到如上图所示的一个问题 —— 窗口加载过程中长时间白屏。在应用窗口创建完成到页面加载出来的这段时间里,出现了长时间的白屏,这个问题对于前端开发来说是一个老生常谈的问题,纯 Web...
继续阅读 »

对于 Web 开发者使用 Electron 构建桌面应用程序时,经常会遇到如上图所示的一个问题 —— 窗口加载过程中长时间白屏。在应用窗口创建完成到页面加载出来的这段时间里,出现了长时间的白屏,这个问题对于前端开发来说是一个老生常谈的问题,纯 Web 端可能就是异步加载、静态资源压缩、CDN 以及骨架屏等等优化方案,但是如果是开发 Electron 应用,场景又有些许不同,因此我们也不能完全按照通用的前端解决白屏的方案进行处理,本文就来探索基于 Electron 场景下启动白屏的解决方案。

问题原因分析

1. Electron 主进程加载时间过长

Electron 应用在启动时,需要先加载主进程,然后由主进程去创建浏览器窗口和加载页面。如果主进程加载时间过长,就会导致应用一直停留在空白窗口,出现白屏。

主进程加载时间长的原因可以有:

  • 初始化逻辑复杂,比如加载大量数据、执行计算任务等
  • 主进程依赖的模块加载时间长,例如 Native 模块编译耗时
  • 主进程代码进行了大量同步 I/O 操作,阻塞了事件循环

2. Web 部分性能优化不足

浏览器窗口加载 HTML、JavaScript、CSS 等静态资源是一个渐进的过程,如果资源体积过大,加载时间过长,在加载过程中就会短暂出现白屏,这一点其实就是我们常说的前端首屏加载时间过长的问题。导致 Web 加载时间过长的原因可以是:

  • 页面体积大,如加载过多图片、视频等大资源
  • 没有代码拆分,一次加载全部 Bundles
  • 缺乏缓存机制,资源无法命中缓存
  • 主线程运算量大,频繁阻塞渲染

解决方案

1. 常规 Web 端性能优化

Web 端加载渲染过程中的白屏,可以采用常规前端的性能优化手段:

  1. 代码拆分,异步加载,避免大包导致的加载时间过长
  2. 静态资源压缩合并、CDN 加速,减少资源加载时间
  3. 使用骨架屏技术,先提供页面骨架,优化用户体验
  4. 减少主线程工作量,比如使用 Web Worker 进行复杂计算
  5. 避免频繁布局重排,优化 DOM 操作

以上优化可以明显减少 HTML 和资源加载渲染的时,缩短白屏现象。还是那句话,纯 Web 端的性能优化对于前端开发来说老生常谈,我这边不做详细的赘述,不提供实际代码,开发者可以参考其他大佬写的性能优化文章,本文主要针对的是 Electron 启动白屏过长的问题,因为体验下来 Electron 白屏的本质问题还是要通过 Electron 自身来解决~

2. 控制 Electron 主进程加载时机

Electron 启动长时间白屏的本质原因,前面特意强调了,解决方案还是得看 Electron 自身的加载时机,因为我这边将 Web 部分的代码打包启动,白屏时间是非常短的,与上面动图里肉眼可见的白屏时间形成了鲜明的对比。所以为了解决这个问题,我们还是要探寻 Electron 的加载时机,通过对 Electron 的启动流程分析,我们发现:

  • 如果在主进程准备就绪之前就创建并显示浏览器窗口,由于此时渲染进程和页面还未开始加载,窗口内自然就是空白,因此需要确保在合适的时机创建窗口。
  • 反之如果创建窗口后,又长时间不调用 window.show() 显示窗口,那么窗口会一直在后台加载页面,用户也会看不到,从而出现白屏的效果。

因此我们可以通过控制主进程的 Ready 事件时机以及 Window 窗口的加载时机来对这个问题进行优化,同样的关于加载时机我们也可以有两种方案进行优化:

  1. 通过监听 BrowserWindow 上面的 ready-to-show 事件控制窗口显示
// 解决白屏问题
app.whenReady().then(() => {
// 将创建窗口的代码放在 `app.whenReady` 事件回调中,确保主进程启动完成后再创建窗口
const mainWindow = new BrowserWindow({ show:false });
// 加载页面
mainWindow.loadURL('index.html');
// 在 ready-to-show 事件中显示窗口
mainWindow..once("ready-to-show", () => {
mainWindow.show();
});
});

上述代码通过操作 app.whenReady() 和 BrowserWindow 的 mainWindow.once('ready-to-show') 这几个 Electron 核心启动 API,优雅地处理了窗口隐藏 + 页面加载 + 窗口显示等问题,详细流程如下:

  • 将创建窗口的代码放在 app.whenReady 事件回调中,确保主进程启动完成后再创建窗口
  • 创建窗口的时候让窗口隐藏不显示{ show: false },避免页面没加载完成导致的白屏
  • 窗口加载页面 win.loadURL,也就是说窗口虽然隐藏了,但是不耽误加载页面
  • 通过 ready-to-show 事件来判断窗口是否已经准备好,这个事件其实就代表页面已经加载完成了,因此此时调用 mainWidnow.show() 让窗口显示就解决了白屏的问题
  1. 通过监听 BrowserWindow.webContents 上面的 did-finish-load 或者 dom-ready 事件来控制窗口显示
app.whenReady().then(() => {
// 将创建窗口的代码放在 `app.whenReady` 事件回调中,确保主进程启动完成后再创建窗口
const mainWindow = new BrowserWindow({ show:false });
// 加载页面
mainWindow.loadURL(indexPage);
// 通过 webContents 对应事件来处理窗口显示
mainWindow.webContents.on("did-finish-load", () => {
mainWindow.show();
});
});

此方案与上述方案的唯一区别就是,第一个使用的是 BrowserWindow 的事件来处理,而此方案通过判断 BrowserWindow.webContents 这个对象,这个对象是 Electron 中用来渲染以及控制 Web 页面的,因此我们可以更直接的使用 did-finish-load 或者直接 dom-ready 这两个事件来判断页面是否加载完成,这两个 API 的含义相信前端开发者都不陌生,页面加载完成以及 DOM Ready 都是前端的概念,通过这种方式也是可以解决启动白屏的。

相关文档:BrowserWindowwebCotnents

最后解决完成的效果如下:

white-screen-fix.gif

总结

从上图来看最终的效果还是不错的,当窗口出现的一瞬间页面就直接加载完成了,不过细心的小伙伴应该会发现,这个方案属于偷梁换柱,给用户的感觉是窗口出现的时候页面就有内容了,但是其实窗口没出现的时间是有空档期的,大概就是下面这个意思:

白屏流程.png

从上图以及实际效果来看,其实我们的启动时间是没有发生改变的,但是因为端上应用和我们纯 Web 应用的使用场景不同,它自身就是有应用的启动时间,所以空档期如果不长,这个方案的体验还是可以的。但是如果前面的空档期过长,那么可能就是 Electron 启动的时候加载资源过多造成的了,就需要其他优化方案了。由此也可以见得其实对于用户体验来说,可能我们的产品性能并不一定有提升,只要从场景出发从用户角度去考虑问题,其实就能提升整个应用的体验。

回归本篇文章,我们从问题入手分析了 Electron 启动白屏出现的原因并提供了对应的解决方案,笔者其实对 Electron 的开发也并不深入,只是解决了一个需求一个问题用文章做记录,欢迎大家交流心得,共同进步~


作者:前端周公子
来源:juejin.cn/post/7371386534179520539
收起阅读 »

用electron写个浏览器给自己玩

web
浏览器这种东西工程量很唬人,但是有了electron+webview我们就相当于只需要干组装的活就可以了,而且产品目标就是给自己玩, 成品的效果 😄本来想写成专业的技术博客,但是发现大家好像对那种密密麻麻,全是代码的技术博客不感兴趣,我就挑重点来写吧。 下...
继续阅读 »

浏览器这种东西工程量很唬人,但是有了electron+webview我们就相当于只需要干组装的活就可以了,而且产品目标就是给自己玩,
成品的效果
image.png
😄本来想写成专业的技术博客,但是发现大家好像对那种密密麻麻,全是代码的技术博客不感兴趣,我就挑重点来写吧。
image.png


下载拦截功能


image.png


下载逻辑如果不做拦截处理的话,默认就是我们平常写web那种弹窗的方式,既然是浏览器肯定不能是那样的。
electron中可以监听BrowserWindow的页面下载事件,并把拿到的下载状态传给渲染线程,实现类似浏览器的下载器功能。


//这个global.WIN =   global.WIN = new BrowserWindow({ ...})
global.WIN.webContents.session.on('will-download', (evt, item) => {
//其他逻辑
item.on('updated', (evt, state) => {
//实时的下载进度传递给渲染线程
})
})

页面搜索功能


当时做这个功能的时候我就觉得完了,这个玩意看起来太麻烦了,还要有一个的功能这不是头皮发麻啊。


image.png
查资料和文档发现这个居然是webview内置的功能,瞬间压力小了很多,我们只需要出来ctrl+f的时候把搜索框弹出来这个UI就可以了,关键字变色和下一个都是内部已经实现好了的。


function toSearch() {
let timer
return () => {
if (timer) {
clearTimeout(timer)
}

timer = setTimeout(() => {
if (keyword.value) {
webviewRef.value.findInPage(keyword.value, { findNext: true })
} else {
webviewRef.value.stopFindInPage('clearSelection')
}
}, 200)
}
}
function closeSearch() {
showSearch.value = false
webviewRef.value.stopFindInPage('clearSelection')
}

function installFindPage(webview) {
webviewRef.value = webview
webviewRef.value.addEventListener('found-in-page', (e) => {
current.value = e.result.activeMatchOrdinal
total.value = e.result.matches
})
}

当前标签页打开功能


就是因为chrome和edge这些浏览器每次使用的时候开非常多的标签,挤在一起,所以我想这个浏览器不能主动开标签,打开了一个标签后强制所有的标签都在当前标签覆盖。


app.on('web-contents-created', (event, contents) => {
contents.setWindowOpenHandler((info) => {
global.WIN?.webContents.send('webview-url-is-change')
if (info.disposition === 'new-window') {
return { action: 'allow' }
} else {
global.WIN?.webContents.send('webview-open-url', info.url)
return { action: 'deny' }
}
})
})

渲染线程监听到webview-open-url后也就是tart="_blank"的情况,强制覆盖当前不打开新窗口


ipcRenderer.on('webview-open-url', (event, url) => {
try {
let reg = /http|https/g
if (webviewRef.value && reg.test(url)) {
webviewRef.value.src = url
}
} catch (err) {
console.log(err)
}
})

标签页切换功能


这里的切换是css的显示隐藏,借助了vue-router
image.png


这里我们看dom就能清晰的看出来。


image.png


地址栏功能


地址栏支持输入url直接访问链接、支持关键字直接打开收藏的网站、还支持关键字搜索。优先级1打开收藏的网页 2访问网站 3关键字搜索


function toSearch(keyword) {
if (`${keyword}`.length === 0) {
return false
}
// app搜索
if (`${keyword}`.length < 20) {
let item = null
const list = [...deskList.value, ...ALL_DATA]
for (let i = 0; i < list.length; i++) {
if (
list[i].title.toUpperCase().search(keyword.toUpperCase()) !== -1 &&
list[i].type !== 'mini-component'
) {
item = list[i]
break
}
}
if (item) {
goApp(item)
return false
}
}

// 网页访问
let url
if (isUrl(keyword)) {
if (!/^https?:\/\//i.test(keyword)) {
url = 'http://' + keyword
} else {
url = keyword
}
goAppNewTab(url)
return false
} else {
// 关键字搜索
let searchEngine = localStorage.getItem('searchEngine')
searchEngine = searchEngine || CONFIG.searchEngine
url = searchEngine + keyword

if (!router.hasRoute('search')) {
router.addRoute({
name: 'search',
path: '/search',
meta: {
title: '搜索',
color: 'var(--app-icon-bg)',
icon: 'search.svg',
size: 1
},
component: WebView
})
keepAliveInclude.value.push('search')
}

router.push({
path: '/search',
query: { url }
})

setTimeout(() => {
Bus.$emit('toSearch', url)
}, 20)
}
}

桌面图标任意位置拖动


这个问题困扰了我很久,因为它不像电脑桌面大小是固定的,浏览器可以全屏也可以小窗口,如果最开始是大窗口然后拖成小窗口,那么图标就看不到了。后来想到我干脆给个中间区域固定大小,就可以解决这个问题了。因为固定大小出来起来就方便多了。这个桌面是上下两层


//背景格子
<div v-show="typeActive === 'me'" class="bg-boxs">
<div
v-for="(item, i) in 224" //这里有点不讲究了直接写死了
:key="item"
class="bg-box"
@dragenter="enter($event, { x: (i % 14) + 1, y: Math.floor(i / 14) + 1 })"
@dragover="over($event)"
@dragleave="leave($event)"
@drop="drop($event)"
>
</div>
</div>
// 桌面层
// ...

import { ref, computed } from 'vue'
import useDesk from '@/store/deskList'
import { storeToRefs } from 'pinia'

export default function useDrag() {
const dragging = ref(null)
const currentTarget = ref()
const desk = useDesk()

const { deskList } = storeToRefs(desk)
const { setDeskList, updateDeskData } = desk

function start(e, item) {
e.target.classList.add('dragging')
e.dataTransfer.effectAllowed = 'move'
dragging.value = item
currentTarget.value = e
console.log('开始')
}
let timer2
function end(e) {
dragging.value = null
e.target.classList.remove('dragging')
setDeskList(deskList.value)

if (timer2) {
clearTimeout(timer2)
}
timer2 = setTimeout(() => {
updateDeskData()
}, 2000)
}
function over(e) {
e.preventDefault()
}

let timer
function enter(e, item) {
e.dataTransfer.effectAllowed = 'move'
if (timer) {
clearTimeout(timer)
}

timer = setTimeout(() => {
if (item?.x) {
dragging.value.x = item.x
dragging.value.y = item.y
}
}, 100)
}
function leave(e) {}

function drop(e) {
e.preventDefault()
}

return { start, end, over, enter, leave, drop }
}


image.png


image.png


东西太多了就先介绍这些了


安装包地址


github.com/jddk/aweb-b…


也可以到官网后aweb123.com 如何进入微软商店下载,mac版本因为文件大于100mb没有传上去所以暂时还用不了。


作者:九段刀客
来源:juejin.cn/post/7395389351641612300
收起阅读 »

大声点回答我:token应该存储在cookie还是localStorage上?

web
背景 前置文章:浏览器: cookie机制完全解析 在考虑token是否应该存储在cookie或localStorage中时,我们需要综合考虑安全性、便利性、两者的能力边界以及设计目的等因素。 安全性: Cookies的优势: Set-Cookie: to...
继续阅读 »

背景


前置文章:浏览器: cookie机制完全解析


在考虑token是否应该存储在cookie或localStorage中时,我们需要综合考虑安全性、便利性、两者的能力边界以及设计目的等因素。
截屏2024-10-14 15.59.32.png


安全性:


Cookies的优势:


Set-Cookie: token=abc123; HttpOnly;Secure;SameSite=Strict;Domain=example.com; Path=/


  • HttpOnly:将 HttpOnly 属性设置为 true 可以防止 JavaScript 读取 cookie,从而有效防止 XSS(跨站脚本)攻击读取 token。这一特性使得 cookies 在敏感信息存储上更具安全性。

  • Secure:设置 Secure 属性后,cookie 只会在 HTTPS 连接时发送,从而防止中间人攻击。这确保了即使有人截获请求,token 也不会被明文传输。

  • SameSite:SameSite 属性减少了 CSRF(跨站请求伪造)攻击的风险,通过指示浏览器在同一站点请求时才发送 cookie。

  • Domain 和 Path:这些属性限制了 cookie 的作用范围,例如仅在特定子域或者路径下生效,进一步提高安全性。


localStorage的缺点:

XSS 风险:localStorage 对 JavaScript 代码完全可见,这意味着如果应用存在 XSS 漏洞,攻击者即可轻易获取存储在 localStorage 中的 token。


能力层面


Cookies可以做到更前置更及时的页面访问控制,服务器可以在接收到页面请求时,立即通过读取 cookie 判断用户身份,返回响应的页面(例如重定向到登录页)。


// 示例:后端在接收到请求时可以立即判断 
if (!request.cookies.token) {
response.redirect('/login');
}

和cookie相比 localStorage具有一定的滞后性,浏览器必须先加载 HTML 和 JavaScript资源,解析执行后 才能通过在localStorage取到数据后 经过ajax网络请求 发送给服务端判断用户身份,这种方式有滞后性,可能导致临时显示不正确的内容。


管理的便利性


Cookies是由服务端设置的 由浏览器自动管理生命周期的一种方式

服务器可以直接通过 HTTP 响应头设置 cookie,浏览器会自动在后续请求中携带,无需在客户端手动添加。减少了开发和维护负担,且降低了人为错误的风险。


localStorage需要客户端手动管理

使用 localStorage 需要在客户端代码管理 token,你得确保在每个请求中手动添加和删除token,增加了代码复杂度及出错的可能性。


设计目的:


HTTP协议是无状态的 一个用户第二次请求和一个新用户第一次请求 服务端是识别不出来的,cookie是为了让服务端记住客户端而被设计的。

Cookie 设计的初衷就是帮助服务器标识用户的会话状态(如登录状态),因而有很多内建的安全和管理机制,使其特别适合承载 token 等这些用户状态的信息。


localStorage 主要用于存储客户端关心的、较大体积的数据(如用户设置、首选项等),而不是设计来存储需要在每次请求时使用的认证信息。


总结


在大多数需要处理用户身份认证的应用中,将 token 存储在设置了合适属性的 cookie 中,不仅更安全,还更符合 cookie 的设计目的。


通过 HTTP 响应头由服务端设置并自动管理,极大简化了客户端代码,并确保在未经身份验证的情况下阻断对敏感页面的访问。


因此 我认为 在大多数情况下,将 token 存储在 cookies 中更为合理和安全。


补充


然鹅 现实的业务场景往往是复杂多变的 否则也不会有token应该存储在cookie还是localStorage上?这个问题出现了。


localStorage更具灵活性: 不同应用有不同的安全需求,有时 localStorage 可以提供更加灵活和精细化的控制。 开发者可以在 JavaScript 中手动管理 localStorage,包括在每次请求时显式设置认证信息。这种 灵活性 对于一些高级用例和性能优化场景可能非常有用。


所以一般推荐使用cookie 但是在合适的场景下使用localStorage完全没问题。


作者:某某某人
来源:juejin.cn/post/7433079710382571558
收起阅读 »

这次终于轮到前端给后端兜底了🤣

web
需求交代 最近我们项目组开发了个互联网采集的功能,也就是后端合理抓取了第三方的文章,数据结构大致如下: <h1>前端人</h1> <p>学好前端,走遍天下都不怕</p> 数据抓取到后,存储到数据库,然后前端请求...
继续阅读 »

封面.png


需求交代


最近我们项目组开发了个互联网采集的功能,也就是后端合理抓取了第三方的文章,数据结构大致如下:


<h1>前端人</h1>
<p>学好前端,走遍天下都不怕</p>

数据抓取到后,存储到数据库,然后前端请求接口获取到数据,直接在页面预览


<div v-html='articleContent'></div>

image.png


整个需求已经交代清楚


这个需求有点为难后端了


前天,客户说要新增一个文章的pdf导出功能,但就是这么一个合情合理的需求,却把后端为难住了,原因是部分数据采集过来的结构可能是这样的:


<h1>前端人</h1>
<p>学好前端,走遍天下都不怕</p>
<div>前端强,前端狂,交互特效我称王!</div
<p>JS 写得好,需求改不了!</p>
<p>React Vue 两手抓,高薪 offer 到你家!</p>
<p>浏览器里横着走, bug 见我都绕道!</p>
<p>Chrome 调试一声笑, IE 泪洒旧时光!</p>
<span>Git 提交不留情,版本回退我最行!

仔细的人就能发现问题了,很多html元素存在没有完整的闭合情况


image.png


但浏览器是强大的,丝毫不影响渲染效果,原来浏览器自动帮我们补全结构了


image.png


可后端处理这件事就没那么简单了,爬取到的数据也比我举例的要复杂的多,使用第三方插件将html转pdf时会识别标签异常等问题,因此程序会抛异常


来自后端的建议


苦逼的后端折腾了很久,还是没折腾出来,终于他发现前端页面有个右键打印的功能,也就是:


image.png


于是他说:浏览器这玩意整挺好啊,前端能不能研究研究,尝试从前端实现导出


那就研究研究


我印象中,确实有个叫vue-print-nb的前端插件,可以实现这个功能


但.......等等,这个插件仅仅是唤起打印的功能,我总不能真做成这样,让用户另存为pdf吧


于是,只能另辟蹊径,终于我找到了这么个仓库:github.com/burc-li/vue…


里面实现了dom元素导出pdf的功能


image.png


image.png


效果很不错,技术用到了jspdfhtml2canvas这两个第三方库,代码十分简单


const downLoadPdfA4Single = () => {
const pdfContaniner = document.querySelector('#pdfContaniner')
html2canvas(pdfContaniner).then(canvas => {
// 返回图片dataURL,参数:图片格式和清晰度(0-1)
const pageData = canvas.toDataURL('image/jpeg', 1.0)

// 方向纵向,尺寸ponits,纸张格式 a4 即 [595.28, 841.89]
const A4Width = 595.28
const A4Height = 841.89 // A4纸宽
const pageHeight = A4Height >= A4Width * canvas.height / canvas.width ? A4Height : A4Width * canvas.height / canvas.width
const pdf = new jsPDF('portrait', 'pt', [A4Width, pageHeight])

// addImage后两个参数控制添加图片的尺寸,此处将页面高度按照a4纸宽高比列进行压缩
pdf.addImage(
pageData,
'JPEG',
0,
0,
A4Width,
A4Width * canvas.height / canvas.width,
)
pdf.save('下载一页PDF(A4纸).pdf')
})
}

技术流程大致就是:



  • dom -> canvas

  • canvas -> image

  • image -> pdf


似乎一切都将水到渠成了


困在眼前的难题


这个技术栈,最核心的就是:必须要用到dom元素渲染


如果你尝试将打印的元素设置样式:


display: none;


visibility: hidden;



opacity: 0;

执行导出功能都将抛异常或者只能导出一个空白的pdf


这时候有人会问了:为什么要设置dom元素为不可见?


试想一下,你做了一个导出功能,总不能让客户必须先打开页面等html渲染完后,再导出吧?


客户的理想状态是:在列表的操作列里,有个导出按钮,点击就可以导出pdf了


何况还需要实现批量勾选导出的功能,总不能程序控制,导出一个pdf就open一个窗口渲染html吧


寻找新方法


此路不通,就只能重新寻找新的方向,不过也没费太多功夫,就找到了另外一个插件html2pdf.js解决了这事


这插件用起来也极其简单


npm install html2pdf.js

<template>
<div class="container">
<button @click="generatePDF">下载PDF</button>
</div>
</template>
<script setup>
import html2pdf from 'html2pdf.js'

// 使用示例
let element = `
<h1>前端人</h1>
<p>学好前端,走遍天下都不怕</p>
<div>前端强,前端狂,交互特效我称王!</div
<p>JS 写得好,需求改不了!</p>
<p>React Vue 两手抓,高薪 offer 到你家!</p>
<p>浏览器里横着走, bug 见我都绕道!</p>
<p>Chrome 调试一声笑, IE 泪洒旧时光!</p>
<span>Git 提交不留情,版本回退我最行!
`
;

function generatePDF() {
// 配置选项
const opt = {
margin: 10,
filename: 'hello_world.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
};
// 生成PDF并导出
html2pdf().from(element).set(opt).save();
}
</script>


功能正常,似乎一切都完美


image.png


问题没有想的那么简单


如果我们的html是纯文本元素,这程序跑起来没有任何问题,但我们抓取的信息都源于互联网,html结构怎么可能会这么简单?如果我们的html中包含图片信息,例如:


// 使用示例
let element = `
<div>
<img src='http://t13.baidu.com/it/u=2041049195,1001882902&fm=224&app=112&f=JPEG?w=500&h=500' style="width: 300px;" />
<p>职业:前端</p>
<p>技能:唱、跳、rap</p>
</div>
`
;

此时你会发现,导出来的pdf,图片占位处是个空白块


image.png



思考一下:类似案例中的图片加载方式,都是get方式的异步请求,而异步请求就会导致图片还没渲染完成,但导出的程序已经执行完成情况(最直接的观察方式就是,把这个元素放到浏览器上渲染,会发现图片也是过一会才慢慢加载完成的)


不过我不确定html2pdf.js这个插件是否会发起图片请求,但不管发不发起,导出的行为明显是在图片渲染前完成的,就导致了这个空白块的存在



问题分析完了,那就解决吧


既然图片异步加载不行,那就使用图片同步加载吧


不是吧,你问我:什么是图片同步加载?我也不晓得,这个词是我自己当下凭感觉造的,如有雷同,纯属巧合了


那我理解的图片同步加载是什么意思呢?简单来说,就是将图片转成Base64,因为这种方式,即使说无网的情况也能正常加载图片,因此我凭感觉断定,这就是图片同步加载


基于这个思路,我写了个demo


<template>
<div class="container">
<button @click="generatePDF">下载PDF</button>
</div>
</template>
<script setup>
import html2pdf from 'html2pdf.js'

async function convertImagesToBase64(htmlString) {
// 创建一个临时DOM元素来解析HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlString;

// 获取所有图片元素
const images = tempDiv.querySelectorAll('img');

// 遍历每个图片并转换
for (const img of images) {
try {
const base64 = await getBase64FromUrl(img.src);
img.src = base64;
} catch (error) {
console.error(`无法转换图片 ${img.src}:`, error);
// 保留原始URL如果转换失败
}
}

// 返回转换后的HTML
return tempDiv.innerHTML;
}

// 图片转base64
function getBase64FromUrl(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous'; // 处理跨域问题

img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;

const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);

// 获取Base64数据
const dataURL = canvas.toDataURL('image/png');
resolve(dataURL);
};

img.onerror = () => {
reject(new Error('图片加载失败'));
};

img.src = url;
});
}

// 使用示例
let element = `
<div>
<img src='http://t13.baidu.com/it/u=2041049195,1001882902&fm=224&app=112&f=JPEG?w=500&h=500' style="width: 300px;" />
<p>职业:前端</p>
<p>技能:唱、跳、rap</p>
</div>
`
;

function generatePDF() {
convertImagesToBase64(element)
.then(convertedHtml => {
// 配置选项
const opt = {
margin: 10,
filename: '前端大法好.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
};

// 生成PDF并导出
html2pdf().from(convertedHtml).set(opt).save();
})
.catch(error => {
console.error('转换过程中出错:', error);
});
}
</script>


此时就大功告成啦!不过得提一句:图片的URL链接必须是同源或者允许跨越的,否则就会存在图片加载异常的问题


修复图片过大的问题


部分图片的宽度会过大,导致图片加载不全的问题,这在预览的情况下也存在


image.png


因为需要加上样式限定


img {
max-width: 100%;
max-height: 100%;
vertical-align: middle;
height: auto !important;
width: auto !important;
margin: 10px 0;
}

这样就正常啦


image.png


故此需要在导出pdf前,给元素添加一个图片的样式限定


element =`<style>
img {
max-width: 100%;
max-height: 100%;
vertical-align: middle;
height: auto !important;
width: auto !important;
margin: 10px 0;
}
</style>`
+ element;

完整代码:


<template>
<div class="container">
<button @click="generatePDF">下载PDF</button>
</div>
</template>
<script setup>
import html2pdf from 'html2pdf.js'
async function convertImagesToBase64(htmlString) {
// 创建一个临时DOM元素来解析HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlString;

// 获取所有图片元素
const images = tempDiv.querySelectorAll('img');

// 遍历每个图片并转换
for (const img of images) {
try {
const base64 = await getBase64FromUrl(img.src);
img.src = base64;
} catch (error) {
console.error(`无法转换图片 ${img.src}:`, error);
// 保留原始URL如果转换失败
}
}

// 返回转换后的HTML
return tempDiv.innerHTML;
}
// 图片转base64
function getBase64FromUrl(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous'; // 处理跨域问题

img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;

const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);

// 获取Base64数据
const dataURL = canvas.toDataURL('image/png');
resolve(dataURL);
};

img.onerror = () => {
reject(new Error('图片加载失败'));
};

img.src = url;
});
}

// 使用示例
let element = `
<div>
<img src='http://t13.baidu.com/it/u=2041049195,1001882902&fm=224&app=112&f=JPEG?w=500&h=500' style="width: 300px;" />
<p>职业:前端</p>
<p>技能:唱、跳、rap</p>
</div>
`
;

function generatePDF() {
element =`<style>
img {
max-width: 100%;
max-height: 100%;
vertical-align: middle;
height: auto !important;
width: auto !important;
margin: 10px 0;
}
</style>`
+ element;
convertImagesToBase64(element)
.then(convertedHtml => {
// 配置选项
const opt = {
margin: 10,
filename: '前端大法好.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
};

// 生成PDF
html2pdf().from(convertedHtml).set(opt).save();
})
.catch(error => {
console.error('转换过程中出错:', error);
});
}
</script>


后话


前天提的需求,昨天兜的底,今天写的文章记录


这种问题,理应该后端处理,但后端和我吐槽过他处理起来的困难与问题,寻求前端帮助时,我也会积极配合。可在现实中,我遇过很多后端,死活不愿意配合前端,例如日期格式化、数据id类型bigint过大不字符化返回给前端等等,主打一个本着前端可以做就前端做的原则,说实在:属实下头


前后端本应该就是相互打配合的关系,谁方便就行个方便,没必要僵持不下


今天的分享就到此结束,如果你对技术/行业交流有兴趣,欢迎添加howcoder微信,邀你进群交流


往期精彩


《你不了解的Grid布局》


《就你小子还不会 Grid布局是吧?》


《超硬核:从零到一部署指南》


《私活2年,我赚到了人生的第一桶金》


《接入AI后,开源项目瞬间有趣了😎》


《肝了两个月,我们无偿开源了》


《彻底不NG前端路由》


《vue项目部署自动检测更新》


《一个公告滚动播放功能引发的背后思考》


《前端值得学习的开源socket应用》


作者:howcode
来源:juejin.cn/post/7486440418139652137
收起阅读 »

React 官方推荐使用 Vite

web
“技术更替不是一场革命,而是一场漫长的进化过程。” Hello,大家好,我是 三千。 React 官方已明确建议开发者逐步淘汰 Create React App (CRA) ,转而使用 Vite 等现代框架或工具来创建新项目。 那官方为什么要这样做呢? 一...
继续阅读 »

“技术更替不是一场革命,而是一场漫长的进化过程。”



Hello,大家好,我是 三千。


React 官方已明确建议开发者逐步淘汰 Create React App (CRA) ,转而使用 Vite 等现代框架或工具来创建新项目。


那官方为什么要这样做呢?




一、CRA 被淘汰的背景与原因



  1. 历史局限性

    CRA 诞生于 2016 年,旨在简化 React 项目的初始化配置,但其底层基于 Webpack 和 Babel 的架构在性能、扩展性和灵活性上逐渐无法满足现代开发需求。随着项目规模扩大,CRA 的启动和构建速度显著下降,且默认配置难以优化生产包体积。

  2. 维护停滞与兼容性问题

    React 团队于 2023 年宣布停止积极维护 CRA,且 CRA 的最新版本(v5.0.1)已无法兼容 React 19 等新特性,导致其在生产环境中逐渐不适用。

  3. 缺乏对现代开发模式的支持

    CRA 仅提供客户端渲染(CSR)的默认配置,无法满足服务端渲染(SSR)、静态生成(SSG)等需求。此外,其“零配置”理念限制了路由、状态管理等常见需求的灵活实现。




二、Vite 成为 React 官方推荐的核心优势



  1. 性能提升



    • 开发速度:Vite 基于原生 ESM 模块和 esbuild(Go 语言编写)实现秒级启动与热更新,显著优于 CRA 的 Webpack 打包机制。

    • 生产构建:通过 Rollup 优化代码体积,支持 Tree Shaking 和懒加载,减少冗余代码。



  2. 灵活性与生态兼容



    • 配置自由:允许开发者按需定制构建流程,支持 TypeScript、CSS 预处理器、SVG 等特性,无需繁琐的 eject 操作。

    • 框架无关性:虽与 React 深度集成,但也可用于 Vue、Svelte 等项目,适应多样化技术栈。



  3. 现代化开发体验



    • 原生浏览器支持:利用现代浏览器的 ESM 特性,无需打包即可直接加载模块。

    • 插件生态:丰富的 Vite 插件(如 @vitejs/plugin-react)简化了 React 项目的开发与调试。






三、迁移至 Vite 的具体步骤 



  1. 卸载 CRA 依赖


    npm uninstall react-scripts
    npm install vite @vitejs/plugin-react --save-dev


  2. 调整项目结构



    • 将 index.html 移至项目根目录,并更新脚本引用为 ESM 格式:


      <script type="module" src="/src/main.jsx"></script>


    • 将 .js 文件扩展名改为 .jsx(如 App.js → App.jsx)。



  3. 配置 Vite

    创建 vite.config.js 文件:


    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";

    export default defineConfig({
    plugins: [react()],
    });


  4. 更新环境变量

    环境变量前缀需从 REACT_APP_ 改为 VITE_(如 VITE_API_KEY=123)。

  5. 运行与调试

    修改 package.json 脚本命令:


    "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
    }





四、其他官方推荐的 React 框架 



  1. Next.js



    • 适用场景:服务端渲染(SSR)、静态生成(SSG)及全栈应用开发。

    • 优势:内置路由、API 路由、图像优化等功能,适合企业级应用与 SEO 敏感项目。



  2. Remix



    • 适用场景:嵌套路由驱动的全栈应用,注重数据加载优化与渐进增强。

    • 优势:集成数据预加载机制,减少请求瀑布问题。



  3. Astro



    • 适用场景:内容型静态网站(如博客、文档站)。

    • 优势:默认零客户端 JS 开销,通过“岛屿架构”按需激活交互组件。






五、总结与建议



  • 新项目:优先选择 Vite(轻量级 CSR 项目)或 Next.js(复杂全栈应用)。

  • 现有 CRA 项目:逐步迁移至 Vite,或根据需求转向 Next.js/Remix 等框架。

  • 学习曲线:Vite 对 React 核心概念干扰较小,适合初学者;Next.js 功能全面但学习成本较高。


React 生态正朝着  “库+框架”协同发展 的方向演进,开发者需结合项目需求选择工具链,以平衡性能、灵活性与开发效率。


结语


以上就是今天与大家分享的全部内容,你的支持是我更新的最大动力,我们下期见!


打工人肝 文章/视频 不易,期待你一键三连的鼓励 !!!



😐 这里是【程序员三千】,💻 一个喜欢捣鼓各种编程工具新鲜玩法的啊婆主。


🏠 已入驻:抖爸爸、b站、小红书(都叫【程序员三千】)


💽 编程/AI领域优质资源推荐 👉 http://www.yuque.com/xiaosanye-o…



作者:程序员三千_
来源:juejin.cn/post/7472008189976461346
收起阅读 »

async/await 必须使用 try/catch 吗?

web
前言 在 JavaScript 开发者的日常中,这样的对话时常发生: 👨💻 新人:"为什么页面突然白屏了?" 👨🔧 老人:"异步请求没做错误处理吧?" async/await 看似优雅的语法糖背后,隐藏着一个关键问题:错误处理策略的抉择。 在 JavaSc...
继续阅读 »

前言


在 JavaScript 开发者的日常中,这样的对话时常发生:



  • 👨💻 新人:"为什么页面突然白屏了?"

  • 👨🔧 老人:"异步请求没做错误处理吧?"


async/await 看似优雅的语法糖背后,隐藏着一个关键问题:错误处理策略的抉择


在 JavaScript 中使用 async/await 时,很多人会问:“必须使用 try/catch 吗?”


其实答案并非绝对,而是取决于你如何设计错误处理策略和代码风格。


接下来,我们将探讨 async/await 的错误处理机制、使用 try/catch 的优势,以及其他可选的错误处理方法。


async/await 的基本原理


异步代码的进化史


// 回调地狱时代
fetchData(url1, (data1) => {
process(data1, (result1) => {
fetchData(url2, (data2) => {
// 更多嵌套...
})
})
})

// Promise 时代
fetchData(url1)
.then(process)
.then(() => fetchData(url2))
.catch(handleError)

// async/await 时代
async function workflow() {
const data1 = await fetchData(url1)
const result = await process(data1)
return await fetchData(url2)
}

async/await 是基于 Promise 的语法糖,它使异步代码看起来更像同步代码,从而更易读、易写。一个 async 函数总是返回一个 Promise,你可以在该函数内部使用 await 来等待异步操作完成。


如果在异步操作中出现错误(例如网络请求失败),该错误会使 Promise 进入 rejected 状态


async function fetchData() {
const response = await fetch("https://api.example.com/data");
const data = await response.json();
return data;
}

使用 try/catch 捕获错误


打个比喻,就好比铁路信号系统


想象 async 函数是一列高速行驶的列车:



  • await 是轨道切换器:控制代码执行流向

  • 未捕获的错误如同脱轨事故:会沿着铁路网(调用栈)逆向传播

  • try/catch 是智能防护系统

    • 自动触发紧急制动(错误捕获)

    • 启动备用轨道(错误恢复逻辑)

    • 向调度中心发送警报(错误日志)




为了优雅地捕获 async/await 中出现的错误,通常我们会使用 try/catch 语句。这种方式可以在同一个代码块中捕获同步和异步抛出的错误,使得错误处理逻辑更集中、直观。



  • 代码逻辑集中,错误处理与业务逻辑紧密结合。

  • 可以捕获多个 await 操作中抛出的错误。

  • 适合需要在出错时进行统一处理或恢复操作的场景。


async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching data:", error);
// 根据需要,可以在此处处理错误,或者重新抛出以便上层捕获
throw error;
}
}

不使用 try/catch 的替代方案


虽然 try/catch 是最直观的错误处理方式,但你也可以不在 async 函数内部使用它,而是在调用该 async 函数时捕获错误


在 Promise 链末尾添加 .catch()


async function fetchData() {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}

// 调用处使用 Promise.catch 捕获错误
fetchData()
.then(data => {
console.log("Data:", data);
})
.catch(error => {
console.error("Error fetching data:", error);
});

这种方式将错误处理逻辑移至函数调用方,适用于以下场景:



  • 当多个调用者希望以不同方式处理错误时。

  • 希望让 async 函数保持简洁,将错误处理交给全局统一的错误处理器(例如在 React 应用中可以使用 Error Boundary)。


将 await 与 catch 结合


async function fetchData() {
const response = await fetch('https://api.example.com/data').catch(error => {
console.error('Request failed:', error);
return null; // 返回兜底值
});
if (!response) return;
// 继续处理 response...
}

全局错误监听(慎用,适合兜底)


// 浏览器端全局监听
window.addEventListener('unhandledrejection', event => {
event.preventDefault();
sendErrorLog({
type: 'UNHANDLED_REJECTION',
error: event.reason,
stack: event.reason.stack
});
showErrorToast('系统异常,请联系管理员');
});

// Node.js 进程管理
process.on('unhandledRejection', (reason, promise) => {
logger.fatal('未处理的 Promise 拒绝:', reason);
process.exitCode = 1;
});

错误处理策略矩阵


决策树分析


graph TD
A[需要立即处理错误?] -->|是| B[使用 try/catch]
A -->|否| C{错误类型}
C -->|可恢复错误| D[Promise.catch]
C -->|致命错误| E[全局监听]
C -->|批量操作| F[Promise.allSettled]

错误处理体系



  1. 基础层:80% 的异步操作使用 try/catch + 类型检查

  2. 中间层:15% 的通用错误使用全局拦截 + 日志上报

  3. 战略层:5% 的关键操作实现自动恢复机制


小结


我的观点是:不强制要求,但强烈推荐



  • 不强制:如果不需要处理错误,可以不使用 try/catch,但未捕获的 Promise 拒绝(unhandled rejection)会导致程序崩溃(在 Node.js 或现代浏览器中)。

  • 推荐:90% 的场景下需要捕获错误,因此 try/catch 是最直接的错误处理方式。


所有我个人观点:使用 async/await 尽量使用 try/catch好的错误处理不是消灭错误,而是让系统具备优雅降级的能力


你的代码应该像优秀的飞行员——在遇到气流时,仍能保持平稳飞行。大家如有不同意见,还请评论区讨论,说出自己的见解。


作者:雨夜寻晴天
来源:juejin.cn/post/7482013975077928995
收起阅读 »

告别龟速删除!前端老司机教你秒删node_modules的黑科技

web
引言:每个前端的痛——node_modules删除噩梦 “npm install一时爽,删包火葬场。”这几乎是所有Node.js开发者都经历过的痛。尤其是当项目依赖复杂时,动辄几百MB甚至几个G的node_modules文件夹,手动删除时转圈圈的进度条简直让人...
继续阅读 »

引言:每个前端的痛——node_modules删除噩梦


“npm install一时爽,删包火葬场。”这几乎是所有Node.js开发者都经历过的痛。尤其是当项目依赖复杂时,动辄几百MB甚至几个G的node_modules文件夹,手动删除时转圈圈的进度条简直让人抓狂。


如何高效解决这个问题?今天我们就来揭秘几种秒删node_modules的硬核技巧,让你从此告别龟速删除!




一、为什么手动删除node_modules这么慢?


node_modules的目录结构复杂,层级深、文件数量庞大(比如一个中型项目可能有上万个小文件)。手动删除时,操作系统需要逐个处理这些文件,导致效率极低,尤其是Windows系统表现更差。核心原因包括:



  1. 文件系统限制:Windows的NTFS和macOS的HFS+对超多小文件的删除并未优化,系统需要频繁更新索引和缓存,资源占用高。

  2. 权限问题:某些文件可能被进程占用或权限不足,导致删除失败或卡顿。

  3. 递归删除效率低:系统自带的删除命令(如右键删除)是单线程操作,而node_modules的嵌套结构会让递归删除耗时剧增。




二、终极方案:用rimraf实现“秒删”


如果你还在手动拖拽删除,赶紧试试这个Node.js社区公认的神器——rimraf!它的原理是封装了rm -rf命令,通过减少系统调用和优化递归逻辑,速度提升可达10倍以上。


操作步骤



  1. 全局安装rimraf(仅需一次):
    npm install rimraf -g


  2. 一键删除

    进入项目根目录,执行:
    rimraf node_modules

    实测:一个5GB的node_modules,10秒内删干净!


进阶用法



  • 集成到npm脚本:在package.json中添加脚本,直接运行npm run clean
    {
    "scripts": {
    "clean": "rimraf node_modules"
    }
    }


  • 跨平台兼容:无论是Windows、Linux还是macOS,命令完全一致,团队协作无压力。




三、其他高效删除方案


如果不想安装额外工具,系统原生命令也能解决问题:


1. Windows用户:用命令行暴力删除



  • CMD命令
    rmdir /s /q node_modules

    /s表示递归删除,/q表示静默执行(不弹窗确认)。

  • PowerShell(更快)
    Remove-Item -Force -Recurse node_modules



2. Linux/macOS用户:终端直接起飞


rm -rf ./node_modules



四、避坑指南:删不干净怎么办?


有时即使删了node_modules,重新安装依赖仍会报错。此时需要彻底清理残留



  1. 清除npm缓存
    npm cache clean --force


  2. 删除锁文件

    手动移除package-lock.jsonyarn.lock

  3. 重启IDE:确保没有进程占用文件。




五、总结:选对工具,效率翻倍


方案适用场景速度对比
rimraf跨平台、大型项目⚡⚡⚡⚡⚡
系统命令临时快速操作⚡⚡⚡
手动删除极小项目(不推荐)

推荐组合拳:日常使用rimraf+脚本,遇到权限问题时切换系统命令。




互动话题

你遇到过最离谱的node_modules有多大?评论区晒出你的经历!




作者:LeQi
来源:juejin.cn/post/7477926585087606820
收起阅读 »

AI 赋能 Web 页面,图像识别超越想象

web
前言在信息时代,Web 页面成为我们与世界交互的重要窗口。如今,AI 程序的出现,为 Web 页面带来了新的变革。通过在 Web 页面上实现图片识别,我们即将迈入一个更加智能与便捷的时代。它将赋予网页全新的能力,使其能够理解图片的内容,为用户带来前所未有的体验...
继续阅读 »

前言

在信息时代,Web 页面成为我们与世界交互的重要窗口。如今,AI 程序的出现,为 Web 页面带来了新的变革。通过在 Web 页面上实现图片识别,我们即将迈入一个更加智能与便捷的时代。它将赋予网页全新的能力,使其能够理解图片的内容,为用户带来前所未有的体验。让我们一同踏上这充满无限可能的探索之旅。

具体步骤

html部分

我们可以试试通过输入:

main.container>(label.custom-file-upload>input#file-upload)+#image-container+p#status

再按tab键就可以快速生成以下的html框架。

<main class="container">
<label for="file-upload" class="custom-file-upload">
<input type="file" accept="image/*" id="file-upload">
上传图片
label>
<div id="image-container">div>
<p id="status">p>
main>
  • 我们选择使用main标签而不是选择div,是因为main比div更具有语义化。main标签可以表示页面的主体内容。
  • label标签用于将表单中的输入元素(如文本输入框、单选按钮、复选框等)与相关的文本描述关联起来。label标签通过将 for 属性值设置为输入元素的 id 属性值,建立与输入元素的关联。
  • input的type属性值为file,表示是文件输入框;accept属性值为image/*,表示该文件输入框只接收图像类型的文件。

JavaScript部分

这个部分是这篇文章的重点。

第一部分

首先我们通过JavaScript在远程的transformers库中引入pipleline和env模块。我们需要禁止使用本地的模块,而使用远程的模块。

import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0"
env.allowLocalModels = false;

接下来我们要读取用户输入的文件,并且输出在页面上。我们可以这样做:

const fileUpload = document.getElementById('file-upload');
const imageContainer = document.getElementById('image-container')
//添加事件监听器来响应元素上的特定事件
//在fileUpload元素上添加一个change事件监听。当用户更改上传文件时,触发事件处理函数。
fileUpload.addEventListener('change', function (e) {
//e是事件对象
//e.target是触发事件的文件上传元素。
//e.target.files[0]是获取选中的第一个文件
const file = e.target.files[0];
// 新建一个FileReader 对象用于读取文件(异步)
//FileReader 是 JavaScript 中的一个对象,用于异步读取用户选择的文件或输入的内容。
const reader = new FileReader();
//reader.onload就相当于FileReader。onload;FileReader。onload是一个事件,在读取操作完成时触发function(e2)
reader.onload = function (e2) {
//创建一个新的html元素,也就是创建一个img标签
const image = document.createElement('img');
//将image的scr属性的url值改为e2.target.result
image.src = e2.target.result;
//将 image 元素添加作为子节点到 imageContainer 元素中
imageContainer.appendChild(image)
detect(image) // 启动AI任务。是功能模块化,要封装出去
}
//FileReader.readAsDataURL():开始读取指定的 Blob 中的内容。一旦完成,result 属性中将包含一个 data: URL 格式的 Base64 字符串以表示所读取文件的内容。
//用于将指定的文件读取为 DataURL 格式的数据。
reader.readAsDataURL(file)
})

我们先梳理一下我们的思路。

  • 我们要对文件输入框里元素进行操作,所以我们要获取那个元素。我们可以创建一个fileUpload对象获取这个元素。
  • 我们可以对fileUpload对象添加一个change事件监听。这个监听器实现当用户更改上传文件时,触发事件处理函数function(e)实现读取文件并且在页面输出文件的功能。
  • function(e)的功能是用于异步读取用户选择的文件,并且在页面输出它。

    • 因为我们要读取整个图片文件,所以我们可以创建一个FileReader对象读取图片文件。在文件读取完成时我们可以用reader.onload实现在文件读取完毕时在id为mage-container的div标签内添加一个img标签输出图片。
      • 因为要输出图片就要先知道图片的URL,于是我们用e2.target.result获取图片的URL。
      • 因为要在div里添加一个img标签,于是创建一个imageContainer对象获取ID 为 image-container 的元素后用imageContainer.appendChild(image)实现将img标签添加为div的子标签。
  • 我们用detect函数封装AI图片识别任务。

第二部分

接下来我们要完成ai任务———检测图片。

我们要通过ai进行对象检测并且获得检测到的元素的参数。

const status = document.getElementById('status');
const detect = async (image) => {
//在id为status的p标签中添加“分析中...请稍等一会”的文本内容
status.textContent = "分析中...请稍等一会"
//object-detection是对象检查任务;Xenova/detr-resnet-50是指定用于对象检测的模型
const detector = await pipeline("object-detection","Xenova/detr-resnet-50") // model 实例化了detector对象
//使用检测器对象对指定的图像源进行检测,并传入了一些参数:
const output = await detector(image.src, {
threshold: 0.1,
percentage: true
})
//forEach 方法用于遍历数组或类似数组的对象,并对每个元素执行指定的函数
output.forEach(renderBox)
}

分析检测图片和获取检测元素的参数的思路。

  • 首先,对图片进行分析需要一些时间,为了让用户知道分析需要时间并且让用户等待,我们可以用我们html中id="status"的p标签输入一段文字。
    • 首先我们用document.getElementById('status')获取id="status"的元素,并且用textContent方法动态添加id="status"的p标签的文本内容,然后输出在页面上。

detect异步函数

  • 为了实现对象检测我们采用了async/await编写异步代码的方法。使用async关键字来标记一个函数为异步函数,并在其中使用await关键字来等待一个Promise对象的结果。await表达式会暂停当前async function的执行,等待Promise处理完成。
  • 创建一个detector对象接收对象分析结果。再创建output对象接收对象分析结果的参数

output.forEach(renderBox)是让output的所有元素遍历进行渲染。

第三部分

我们用renderBox函数将框出图片中的物品的区域,并且在区域内输出ai识别得出的物品名称。

function renderBox({ box, label }) {
console.log(box, label);
const { xmax, xmin, ymax, ymin } = box
//创建一个盒子
const boxElement = document.createElement("div");
//动态添加div的类名
boxElement.className = "bounding-box"
//通过执行 Object.assign(boxElement.style, {}),可以将一个空对象的属性合并到 boxElement.style 对象中
//准备为 boxElement 设置新的样式属性
Object.assign(boxElement.style, {
borderColor: '#123123',
borderWidth: '1px',
borderStyle: 'solid',
//最左侧
left: 100 * xmin + '%',
//最上侧
top: 100 * ymin + '%',
//宽度
width: 100 * (xmax - xmin) + "%",
//高度
height: 100 * (ymax - ymin) + "%"
})

const labelElement = document.createElement('span');
labelElement.textContent = label;
labelElement.className = "bounding-box-label"
labelElement.style.backgroundColor = '#000000'
//在图片上增加的物品区域范围内添加物品的种类名称的文本内容
boxElement.appendChild(labelElement);
//在图片上增加物品的区域范围
imageContainer.appendChild(boxElement);

}

分析renderBox函数思路。

  • output元素中有许多参数,我们只需要label参数(检测得出的物品名称)和box参数(图片物品的区域参数,分别为xmax、xmin、ymax、ymin)。
  • 实现框出图片中的物品的区域(每一个新建的div都表示一个图片物体)
    • 首先我们创建{ xmax, xmin, ymax, ymin }对象获取位置信息。
    • 通过document.createElement("div")创建一个新的div标签,再用className方法动态得给新建的div标签添加一个bounding-box类。
    • 通过
    • Object.assign(boxElement.style, { })对新建的div添加css样式,并且通过测量图片物体的最左侧和最上侧以及高度和宽度框出图片物体的区域。
    • 再通过imageContainer.appendChild(boxElement)代码添加到id="image-container"的div标签中。
  • 实现在图片物体区域添加ai识别得出的label参数文本
    • 通过document.createElement('span')创建span标签,再用textContent方法将label内容动态输入新建的span标签
    • 再通过 boxElement.appendChild(labelElement)添加到刚刚创建的div标签中,也就是添加到图片物体区域中。

JavaScript部分总结

我们将图片识别功能分别分成三个部分实现。首先通过第一部分读取图片,通过第二部分进行ai分析,再通过第三部分输出结果。

我们要有灵活的封装思想。

css部分

该部分不做过度解释,直接上代码。

.container {
margin: 40px auto;
width: max(50vw, 400px);
display: flex;
flex-direction: column;
align-items: center;
}

.custom-file-upload {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
border: 2px solid black;
padding: 8px 16px;
border-radius: 6px;
}

#file-upload {
display: none;
}

#image-container {
width: 100%;
margin-top: 20px;
position: relative;
}

#image-container>img {
width: 100%;
}

.bounding-box {
position: absolute;
box-sizing: border-box;
}

.bounding-box-label {
position: absolute;
color: white;
font-size: 12px;
}

效果展示

  1. 选择图片上传

屏幕截图 2024-04-19 022250.png

  1. 选择图片后进行分析的过程

屏幕截图 2024-04-19 024403.png

控制台输出的是e2.target.result的内容。

  1. 分析结果

屏幕截图 2024-04-19 024528.png

代码

html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nlp之图片识别title>
<style>
.container {
margin: 40px auto;
width: max(50vw, 400px);
display: flex;
flex-direction: column;
align-items: center;
}

.custom-file-upload {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
border: 2px solid black;
padding: 8px 16px;
border-radius: 6px;
}

#file-upload {
display: none;
}

#image-container {
width: 100%;
margin-top: 20px;
position: relative;
}

#image-container>img {
width: 100%;
}

.bounding-box {
position: absolute;
box-sizing: border-box;
}

.bounding-box-label {
position: absolute;
color: white;
font-size: 12px;
}
style>
head>

<body>

<main class="container">

<label for="file-upload" class="custom-file-upload">

<input type="file" accept="image/*" id="file-upload">
上传图片
label>
<div id="image-container">div>
<p id="status">p>
main>
<script type="module">
// transformers提供js库(https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0)
//从 https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0 导入 pipeline 和 env
import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0"
//禁止加载本地模型,加载远程模型
env.allowLocalModels = false;
//document.getElementById 是 js 中用于通过元素的 ID 来获取特定 HTML 元素的方法
const fileUpload = document.getElementById('file-upload');
const imageContainer = document.getElementById('image-container')
//添加事件监听器来响应元素上的特定事件
//在fileUpload元素上添加一个change事件监听。当用户更改上传文件时,触发事件处理函数。
fileUpload.addEventListener('change', function (e) {
//e是事件对象
//e.target是触发事件的文件上传元素。
//e.target.files[0]是获取选中的第一个文件
const file = e.target.files[0];
// 新建一个FileReader 对象用于读取文件(异步)
//FileReader 是 JavaScript 中的一个对象,用于异步读取用户选择的文件或输入的内容。
const reader = new FileReader();
//reader.onload是一个事件,在读取操作完成时触发
reader.onload = function (e2) {
//用createElement创建一个新的html元素
const image = document.createElement('img');
console.log(e2.target.result);
//将image的scr属性的url值改为e2.target.result
image.src = e2.target.result;
//将 image 元素添加作为子节点到 imageContainer 元素中
imageContainer.appendChild(image)
detect(image) // 启动AI任务 功能模块化,封装出去
}
//用于将指定的文件读取为 DataURL 格式的数据。
reader.readAsDataURL(file)
})


//ai任务
const status = document.getElementById('status');
const detect = async (image) => {
//在id为status的p标签中添加“分析中...请稍等一会”的文本内容
status.textContent = "分析中...请稍等一会"
//object-detection是对象检查任务;Xenova/detr-resnet-50是指定用于对象检测的模型
const detector = await pipeline("object-detection","Xenova/detr-resnet-50") // model 实例化了detector对象
//使用检测器对象对指定的图像源进行检测,并传入了一些参数:
const output = await detector(image.src, {
threshold: 0.1,
percentage: true
})
//forEach 方法用于遍历数组或类似数组的对象,并对每个元素执行指定的函数
output.forEach(renderBox)
}



//封装;渲染盒子
function renderBox({ box, label }) {
console.log(box, label);
const { xmax, xmin, ymax, ymin } = box
//创建一个盒子
const boxElement = document.createElement("div");
//动态添加div的类名
boxElement.className = "bounding-box"
//通过执行 Object.assign(boxElement.style, {}),可以将一个空对象的属性合并到 boxElement.style 对象中
//准备为 boxElement 设置新的样式属性
Object.assign(boxElement.style, {
borderColor: '#123123',
borderWidth: '1px',
borderStyle: 'solid',
//最左侧
left: 100 * xmin + '%',
//最上侧
top: 100 * ymin + '%',
//宽度
width: 100 * (xmax - xmin) + "%",
//高度
height: 100 * (ymax - ymin) + "%"
})

const labelElement = document.createElement('span');
labelElement.textContent = label;
labelElement.className = "bounding-box-label"
labelElement.style.backgroundColor = '#000000'
//在图片上增加的物品区域范围内添加物品的种类名称的文本内容
boxElement.appendChild(labelElement);
//在图片上增加物品的区域范围
imageContainer.appendChild(boxElement);

}
script>
body>

html>

结尾

整个代码还存在一些不足之处,还需要不断完善。希望我的文章可以帮助到你们。欢迎点赞评论加关注。


作者:睡着学
来源:juejin.cn/post/7359084330121789452

收起阅读 »

我的 Electron 客户端被第三方页面入侵了...

web
问题描述 公司有个内部项目是用 Electron 来开发的,有个功能需要像浏览器一样加载第三方站点。 本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面。 这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码...
继续阅读 »

问题描述


公司有个内部项目是用 Electron 来开发的,有个功能需要像浏览器一样加载第三方站点。


本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面


这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码。


    if (window.top !== window.self) {
window.top.location = window.location;
}

翻译一下就是:如果当前窗口不是顶级窗口的话,将当前窗口设置为顶级窗口。


奇怪的是两者不是 跨域 了吗,为什么 iframe 还可以影响顶级窗口。


先说一下我当时的一些解决办法:



  1. webview 替换 iframe

  2. iframe 添加 sandbox 属性


后续内容就是一点复盘工作。


场景复现(Web端)


一开始怀疑是客户端的问题,所以我用在纯 Web 上进行了一次对比验证。


这里我们新建两个文件:1.html2.html,我们称之为 页面A页面B


然后起了两个本地服务器来模拟同源与跨域的情况。


页面A:http://127.0.0.1:5500/1.html


页面B:http://127.0.0.1:5500/2.htmlhttp://localhost:3000/2.html


符合同源策略


<body>
<h1>这是页面A</h1>
<!-- 这是同源的情况 -->
<iframe id="iframe" src="http://127.0.0.1:5500/2.html" />

<script>
iframe.onload = () => {
console.log('iframe loaded..')
console.log('子窗口路径', iframe.contentWindow.location.href)
}
</script>
</body>

<body>
<h2>这是页面B</h2>

<script>
console.log('page2...')
console.log(window === window.top)
console.log('顶部窗口路径', window.top.location.href)
</script>
</body>

我们打开控制台可以看到 页面A 和 页面B 是可以 互相访问 到对方窗口的路径。


image.png


如果这个时候在 页面B 加上文章开头提到的 代码片段,那么显然页面将会发生变化。


image.png


跨域的情况


这时候我们修改 页面A 加载 页面B 的地址,使其不符合同源策略。


image.png


理所应当的是,两个页面不能够相互访问了,这才是正常的,否则内嵌第三方页面可以互相修改,那就太不安全了。


场景复现(客户端)


既然 Web 端是符合预期的,那是不是 Electron 自己的问题呢?


我们通过 electron-vite 快速搭建了一个 React模板的electron应用,版本为:electron@22.3.27,并且在 App 中也嵌入了刚才的 页面B。


function App(): JSX.Element {
return (
<>
<h1>这是Electron页面</h1>
<iframe id="iframe" src="http://localhost:3000/2.html"/>
</>

)
}
export default App

image.png


对不起,干干净净的 Electron 根本不背这个锅,在它身上的表现如同 Web端 一样,也受同源策略的限制。


那么肯定是我的项目里有什么特殊的配置,通过对比主进程的代码,答案终于揭晓。


new BrowserWindow({
...,
webPreferences: {
...,
webSecurity: false // 就是因为它
}
})

Electron 官方文档 里是这么描述 webSecurity 这个配置的。



webSecurity boolean (可选) - 当设置为 false, 它将禁用同源策略 (通常用来测试网站), 如果此选项不是由开发者设置的,还会把 allowRunningInsecureContent设置为 true. 默认值为 true



也就是说,Electron本身是有一层屏障的,但当该属性设置为 false 的时候,我们的客户端将会绕过同源策略的限制,这层屏障也就消失了,因此 iframe 的行为表现得像是嵌套了同源的站点一样。


解决方案


把这个配置去掉,确实是可以解决这个问题,但考虑到可能对其他功能造成的影响,只能采取其他方案。


如文章开头提到的,用 webview 替换 iframe


webviewElectron的一个自定义元素(标签),可用于在应用程序中嵌入第三方网页,它默认开启安全策略,直接实现了主应用与嵌入页面的隔离。


因为目前这个需求是仅作展示,不需要与嵌套页面进行交互以及复杂的通信,因此在一开始的开发过程中,并没有使用它,而是直接采用了 iframe


iframe 也能够实现类似的效果,只需要添加一个 sandbox 属性可以解决。


MDN 中提到,sandbox 控制应用于嵌入在 <iframe> 中的内容的限制。该属性的值可以为空以应用所有限制,也可以为空格分隔的标记以解除特定的限制。


如此一来,就算是同源的,两者也不会互相干扰。


总结


这不是一个复杂的问题,发现后及时修复了,并没有造成很大的影响(还好是自己人用的平台)。


写这篇文章的主要目的是为了记录这次事件,让我意识到在平时开发过程中,把注意力过多的放在了 业务样式性能等这些看得见的问题上,可能很少关注甚至忽略了 安全 这一要素,以为前端框架能够防御像 XSS 这样的攻击就能安枕无忧。


谨记,永远不要相信第三方,距离产生美。


如有纰漏,欢迎在评论区指出。


作者:小陈同学吗
来源:juejin.cn/post/7398418805971877914
收起阅读 »

Electron 30.0.0

web
作者: clavin / VerteDinde 译者: ylduang Electron 30.0.0 已发布! 它包括对 Chromium 124.0.6367.49、V8 12.4 和 Node.js 20.11.1 的升级。 Electron 团队很...
继续阅读 »

作者: clavin / VerteDinde


译者: ylduang



Electron 30.0.0 已发布! 它包括对 Chromium 124.0.6367.49、V8 12.4 和 Node.js 20.11.1 的升级。




Electron 团队很高兴发布了 Electron 30.0.0 ! 你可以通过 npm install electron@latest 或者从我们的发布网站下载它。继续阅读此版本的详细信息。


如果您有任何反馈,请在 TwitterMastodon 上与我们分享,或加入我们的 Discord 社区! Bug 和功能请求可以在 Electron 的问题跟踪器中报告。


重要变化


重点内容



  • Windows 现在支持 ASAR 完整性检查 (#40504)

    • 启用 ASAR 完整性的现有应用程序如果配置不正确,可能无法在 Windows 上工作。使用 Electron 打包工具的应用应该升级到 @electron/packager@18.3.1@electron/forge@7.4.0

    • 查看我们的 ASAR Integrity 教程 以获取更多信息。



  • 添加了 WebContentsViewBaseWindow 主进程模块,废弃并替换 BrowserView (#35658)

    • BrowserView 现在是 WebContentsView 的一个壳,并且旧的实现已被移除。

    • 查看 我们的 Web Embeds 文档 以便将新的 WebContentsView API 和其他类似 API 进行比较。



  • 实现了对 File System API 的支持 (#41827)


架构(Stack)更新



Electron 30 将 Chromium 从 122.0.6261.39 升级到 124.0.6367.49, Node 从 20.9.0 升级到 20.11.1 以及 V8 从 12.2 升级到 12.4


新特性



  • 在 webviews 中添加了 transparent 网页偏好设置。(#40301)

  • 在 webContents API 上添加了一个新的实例属性 navigationHistory,配合 navigationHistory.getEntryAtIndex 方法,使应用能够检索浏览历史中任何导航条目的 URL 和标题。(#41662)

  • 新增了 BrowserWindow.isOccluded() 方法,允许应用检查窗口是否被遮挡。(#38982)

  • 为工具进程中 net 模块发出的请求添加了代理配置支持。(#41417)

  • 添加了对 navigator.serial 中的服务类 ID 请求的蓝牙端口的支持。(#41734)

  • 添加了对 Node.js NODE_EXTRA_CA_CERTS 命令行标志的支持。(#41822)


重大更改


行为变更:跨源 iframe 现在使用 Permission Policy 来访问功能。


跨域 iframe 现在必须通过 allow 属性指定一个给定 iframe 可以访问的功能。


有关更多信息,请参见 文档


移除:--disable-color-correct-rendering 命令行开关


此开关从未正式文档化,但无论如何这里都记录了它的移除。Chromium 本身现在对颜色空间有更好的支持,因此不再需要该标志。


行为变更:BrowserView.setAutoResize 在 macOS 上的行为


在 Electron 30 中,BrowserView 现在是围绕新的 WebContentsView API 的包装器。


以前,BrowserView API 的 setAutoResize 功能在 macOS 上由 autoresizing 支持,并且在 Windows 和 Linux 上由自定义算法支持。
对于简单的用例,比如使 BrowserView 填充整个窗口,在这两种方法的行为上是相同的。
然而,在更高级的情况下,BrowserViews 在 macOS 上的自动调整大小与在其他平台上的情况不同,因为 Windows 和 Linux 的自定义调整大小算法与 macOS 的自动调整大小 API 的行为并不完全匹配。
自动调整大小的行为现在在所有平台上都标准化了。


如果您的应用使用 BrowserView.setAutoResize 做的不仅仅是使 BrowserView 填满整个窗口,那么您可能已经有了自定义逻辑来处理 macOS 上的这种行为差异。
如果是这样,在 Electron 30 中不再需要这种逻辑,因为自动调整大小的行为是一致的。


移除:WebContentscontext-menuparams.inputFormType 属性


WebContentscontext-menu 事件中 params 对象的 inputFormType 属性已被移除。请改用新的 formControlType 属性。


移除:process.getIOCounters()


Chromium 已删除对这些信息的访问。


终止对 27.x.y 的支持


根据项目的支持政策,Electron 27.x.y 已经达到了支持的终点。我们鼓励开发者将应用程序升级到更新的 Electron 版本。


E30(24 年 4 月)E31 (24 年 6 月)E26(24 年 8 月)
30.x.y31.x.y32.x.y
29.x.y30.x.y31.x.y
28.x.y29.x.y30.x.y

接下来


在短期内,您可以期待团队继续专注于跟上构成 Electron 的主要组件的开发,包括 Chromium、Node 和 V8。


您可以在此处找到 Electron 的公开时间表


有关这些和未来变化的更多信息可在计划的突破性变化页面找到。



原文: Electron 30.0.0


Electron China 社区



作者:Electron
来源:juejin.cn/post/7361426249380397068
收起阅读 »

我的 Electron 客户端也可以全量/增量更新了

web
前言 本文主要介绍 Electron 客户端应用的自动更新,包括全量和增量这两种方式。 全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。 增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。 本文并没有拿真实项目来举例子,而是起一个 新的项目 从...
继续阅读 »

前言


本文主要介绍 Electron 客户端应用的自动更新,包括全量和增量这两种方式。


全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。


增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。


本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。


如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。


前置说明:



  1. 由于业务场景的限制,本文介绍的更新仅支持 Windows 操作系统,其余操作系统未作兼容处理。

  2. 更新流程全部由前端完成,不涉及到后端,没有轮询 更新的机制。

  3. 发布方式限制为 generic,线上服务需要配置 nginx 确保访问到资源文件。


准备工作


脚手架搭建项目


我们通过 electron-vite 快速搭建一个基于 Vite + React + TSElectron 项目。


1.jpg


该模板已经包括了我们需要的核心第三方库:electron-builderelectron-updater


前者是用来打包客户端程序的,后者是用来实现自动更新的。


在项目根目录下,已经自动生成了两份配置文件:electron-builder.ymldev-app-update.yml


electron-builder.yml


该文件描述了一些 打包配置,更多信息可参考 官网


在这些配置项中,publish 字段比较重要,因为它关系到更新源。


publish:
provider: generic // 使用一个通用的 HTTP 服务器作为更新源。
url: https://example.com/auto-updates // 存放安装包和描述文件的地址

provider 字段还有其他可选项,但是本文只介绍 generic 这种方式,即把安装包放在 HTTP 服务器里。


dev-app-update.yml


provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: electron-update-demo-updater

其中,updaterCacheDirName 定义下载目录,也就是安装包存放的位置。全路径是C:\Users\用户名\AppData\Local\electron-update-demo-updater,不配置则在C:\Users\用户名\AppData\Local下自动创建文件夹,开发环境下为项目名,生产环境下为项目名-updater


image.png


模拟服务器


我们直接运行 npm run build:win,在默认 dist 文件夹下就出现了打包后的一些资源。


image.png


其实更新的基本思路就是对比版本号,即对比本地版本和 线上服务器 版本是否一致,若不一致则需要更新。


image.png


因此我们在开发调试的时候,需要起一个本地服务器,来模拟真实的情况。


新建一个文件夹 mockServer,把打包后的 setup.exe 安装包和 latest.yml 文件粘贴进去,然后通过 serve 命令默认起了一个 http://localhose:3000 的本地服务器。


既然有了存放资源的本地地址,在开发调试的时候,我们更新下 dev-app-update.yml 文件的 url 字段,也就是修改为 http://localhose:3000


注:如果需要测试打包后的更新功能,需要同步修改打包配置文件。


全量更新


与主进程文件同级,创建 update.ts 文件,之后我们的更新逻辑将在这里展开。


import { autoUpdater } from 'electron-updater' //核心库

需要注意的是,在我们开发过程中,通过 npm run dev 起来的 Electron 程序其实不能算是打包后的状态。


你会发现在调用 autoUpdater 的一些方法会提示下面的错误:


Skip checkForUpdates because application is not packed and dev update config is not forced


因此我们需要在开发环境去做一些动作,并且手动指定 updateConfigPath


// update.ts
if (isDev) {
Object.defineProperty(app, 'isPackaged', {
get: () => true
})
autoUpdater.updateConfigPath = path.join(__dirname, '../../dev-app-update.yml')
}

核心对象 autoUpdater 有很多可以主动调用的方法,也有一些监听事件,同时还有一些属性可以设置。


这里只展示了本人项目场景所需的一些配置。


autoUpdater.autoDownload = false // 不允许自动下载更新
autoUpdater.allowDowngrade = true // 允许降级更新(应付回滚的情况)

autoUpdater.checkForUpdates()
autoUpdater.downloadUpdate()
autoUpdater.quitAndInstall()

autoUpdater.on('checking-for-update', () => {
console.log('开始检查更新')
})
autoUpdater.on('update-available', info => {
console.log('发现更新版本')
})
autoUpdater.on('update-not-available', info => {
console.log('不需要全量更新', info.version)
})
autoUpdater.on('download-progress', progressInfo => {
console.log('更新进度信息', progressInfo)
})
autoUpdater.on('update-downloaded', () => {
console.log('更新下载完成')
})
autoUpdater.on('error', errorMessage => {
console.log('更新时出错了', errorMessage)
})

在监听事件里,我们可以拿到下载新版本 整个生命周期 需要的一些信息。


// 新版本的版本信息(已筛选)
interface UpdateInfo {
readonly version: string;
releaseName?: string | null;
releaseNotes?: string | Array<ReleaseNoteInfo> | null;
releaseDate: string;
}

// 下载安装包的进度信息
interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}

在写完这些基本的方法之后,我们需要决定 检验更新 的时机,一般是在应用程序真正启动之后,即 mainWindow 创建之后。


运行项目,预期会提示 不需要全量更新,因为刚才复制到本地服务器的 latest.yml 文件里的版本信息与本地相同。修改 version 字段,重启项目,主进程就会提示有新版本需要更新了。


频繁启动应用太麻烦,除了应用初次启动时主进程主动帮我们检验更新外,还需要用户 手动触发 版本更新检测,此外,由于产品场景需要,当发现有新版本的时候,需要在 渲染进程 通知用户,包括版本更新时间和更新内容。因此我们需要加上主进程与渲染进程的 IPC通信 来实现这个功能。


其实需要的数据主进程都能够提供了,渲染进程具体的展示及交互方式就自由发挥了,这里简单展示下我做的效果。


1. 发现新版本
GIF 2024-9-19 16-48-44.gif


2. 无需更新


image.png


增量更新


为什么要这么做


其实全量更新是一种比较简单粗暴的更新方式,我没有花费太大的篇幅去介绍,基本上就是使用 autoUpdater 封装的一些方法,开发过程中更多的注意力则是放在了渲染进程的交互上。


此外,我们的更新交互用的是一种 用户感知 的方式,即更不更新是由用户自己决定的。而 静默更新 则是用户无感知,在监测到更新后不用去通知用户,而是自行在后台下载,在某个时刻执行安装的逻辑,这种方式往往是一键安装,不会弹出让用户去选择安装路径的窗口。接下去要介绍的增量更新其实是这种 用户无感知 的形式,只不过我们更新的不是整个应用程序。


由于产品迭代比较频繁,我们的业务方经常收到更新提示,意味着他们要经常手动更新应用,以便使用到最新的功能特性。众所周知,Electron 给前端开发提供了一个比较容易上手的客户端开发解决方案,但在享受 跨平台 等特性的同时,还得忍受 臃肿的安装包


带宽、流量在当前不是什么大问题,下载和安装的速度也挺快,但这频繁的下载覆盖一模一样的资源文件还是挺糟心的,代码改动量小时,全量更新完全没有必要。我们希望的是,开发更新了什么代码,应用程序就替换掉这部分代码就好了,这很优雅。在 我们的场景 中,这部分代码指的是 —— main、preload、renderer,并不包括 dll第三方SDK等资源。


网上有挺多种增量更新的 解决方案,例如:



  1. 通过 win.loadURL(一个线上地址) 实现,相当于就套了一层客户端的壳子,与加载的Web 端同步更新。这种方法对于简单应用来说是最省心的,但是也有一定的局限性,例如不能使用 node 去操作一些底层的东西。

  2. 设置 asar 的归档方式,替换app.asarapp.asar.unpack来实现。但后者在我实践过程中存在文件路径不存在的问题。

  3. 禁用 asar 归档,解压渲染进程压缩包后实现替换。简单尝试过,首次执行安装包的时候写入文件很慢,不知道是不是这个方式引起的。

  4. 欢迎补充。


本文我们采用较普遍的 替换asar 来实现。


优化 app.asar 体积



asar是 Electron提供的一种将多个文件合并成一个文件的类 tar 风格的归档格式,不仅可以缓解Windows下路径名过长的问题, 还能够略微加快一下 require的速度, 并且可以隐藏你的源代码。(并非完全隐藏,有方法可以找出来)



Electron 应用程序启动的时候,会读取 app.asar.unpacked 目录中的内容合并到 app.asar 的目录中进行加载。在此之前我们已经打包了一份 Demo 项目,安装到本地后,我们可以根据安装路径找到 app.asar 这个文件。


例如:D:\你的安装路径\electron-update-demo\resources


image.png


在这个 Demo 项目中,脚手架生成的模板将图标剥离出来,因此出现了一个 app.asar.unpacked 文件夹。我们不难发现,app.asar 这个文件竟然有 66 MB,再回过头看我们真正的代码文件,甚至才 1 MB !那么到底是什么原因导致的这个文件这么大呢,刚才提到了,asar 其实是一种压缩格式,因此我们只要解压看看就知道了。


npm i -g asar // 全局安装

asar e app.asar folder // 解压到folder文件夹

image.png


解压后我们不难发现,out 文件夹里的才是我们打包出来的资源,罪魁祸首竟然是 node_modules,足足有 62.3 MB


查阅资料得知,Electron 在打包的时候,会把 dependencies 字段里的依赖也一起打包进去。而我们的渲染进程是用 React开发的,这些第三方依赖早就通过 Vite 等打包工具打到资源文件里了,根本不需要再打包一次,不然我们重新下载的 app.asar 文件还是很大的,因此需要尽可能减少体积。


优化应用程序体积 == 减少 node_modules 文件夹的大小 == 减少需要打包的依赖数量 == 减少 dependencies 中的依赖。


1. 移除 dependencies


最开始我想的是把 package.json 中的 dependencies 全都移到 devDependencies,打包后的体积果然减小了,但是在测试过程中会发现某些功能依赖包会缺失,比如 @electron/remote。因此在修改这部分代码的时候,需要特别留意,哪些依赖是需要保留下来的。


由于不想影响 package.json 的版本结构,我只是在写了一个脚本,在 npm i 之后,执行打包命令前修改 devDependencies 就好了。


2. 双 package.json 结构


这是 electron-builder 官网上看到的一种技巧,传送门, 创建 app 文件夹,再创建第二个 package.json,在该文件中配置我们应用的名称、版本、主进程入口文件等信息。这样一来,electron-builder 在打包时会以该文件夹作为打包的根文件夹,即只会打包这个文件夹下的文件。


但是我原项目的主进程和预加载脚本也放在这,尝试修改后出现打包错误的情况,感兴趣的可以试试


image.png


这是我们优化之后的结果,1.32 MB,后面只需要替换这个文件就实现了版本的更新。


校验增量更新


全量更新的校验逻辑是第三方库已经封装好了的,那么对于增量更新,就得由我们自己来实现了。


首先明确一下 校验的时机package.jsonversion 字段表示应用程序的版本,用于全量更新的判断,那也就意味着,当这个字段保持不变的时候,会触发上文提到的 update-not-available 事件。所以我们可以在这个事件的回调函数里来进行校验。


autoUpdater.on('update-not-available', info => { console.log('不需要全量更新', info.version) })

然后就是 如何校验,我们回过头来看 electron-builder 的打包配置,在 releaseInfo 字段里描述了发行版本的一些信息,目前我们用到了 releaseNotes 来存储更新日志,查阅官网得知还有个 releaseName 好像对于我们而言是没什么用的,那么我们就可以把它作为增量更新的 热版本号。(但其实 官网 配置项还有个 vendor 字段,可是我在用的时候打包会报错,可能是版本的问题,感兴趣的可以试试。)


每次发布新版本的时候,只要不是 Electron自身版本变化 等重大更新,我们都可以通过修改 releaseInforeleaseName 来发布我们的热版本号。现在我们已经有了线上的版本,那 本地热版本号 该如何获取呢。这里我们在每次热更新完成之后,就会把最新的版本号存储在本地的一个配置文件中,通常是 userData 文件夹,用于存储我们应用程序的用户信息,即使程序卸载 了也不会影响里面的资源。在 Windows 下,路径如下:
C:\Users\用户名\AppData\Roaming\electron-update-demo


因此,整个 校验流程 就是,在打开程序的时候,autoUpdater 触发 update-not-available 事件,拿到线上 latest.yml 描述的 releaseName 作为热版本号,与本地配置文件(我们命名为 config.json)里存储的热版本号(我们命名为 hotVersion)进行对比,若不同就去下载最新的 app.asar 文件。


我们使用 electron-log 来记录日志,代码如下所示。


// 本地配置文件
const localConfigFile = path.join(app.getPath('userData'), 'config.json')

const checkHotVersion = (latestHotVersion: string): boolean => {
let needDownload = false
if (!fs.existsSync(localConfigFile)) {
fs.writeFileSync(localConfigFile, '{}', 'utf8')
log.info('配置文件不存在,已自动创建')
} else {
log.info('监测到配置文件')
}

try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
// 对比版本号
if (latestHotVersion !== configData.hotVersion) {
log.info('当前版本不是最新热更版本')
needDownload = true
} else {
log.info('当前为最新版本')
}
} catch (error) {
log.error('读取配置文件时出错', error)
}

return needDownload
}

下载增量更新包


通过刚才的校验,我们已经知道了什么时候该去下载增量更新包。


在开发调试的时候,我们可以把新版本的 app.asar 也放到起了本地服务器的 mockServer 文件夹里来模拟真实的情况。有很多方式可以去下载文件,这里我用了 nodejshttp 模块去实现,如果是 https 的需要引用 https 模块。


下载到本地的时候,我是放在了与 app.asar 同级目录的 resources 文件夹,我们不能直接覆盖原文件。一是因为进程权限占用的问题,二是为了容错,所以我们需要以另一个名字来命名下载后的文件(这里我们用的是 app.asar-temp),也就不需要去备份原文件了,代码如下。


const resourcePath = isDev
? path.join('D:\\测试\\electron-update-demo\\resources')
: process.resourcesPath

const localAsarTemp = path.join(resourcePath, 'app.asar-temp')

const asarUrl = 'http://localhost:3000/app.asar'

downloadAsar() {
const fileStream = fs.createWriteStream(localAsarTemp)
http.get(asarUrl, res => {
res.pipe(fileStream)
fileStream
.on('finish', () => {
log.info('asar下载完成')
})
.on('error', error => {
// 删除文件
fs.unlink(localAsarTemp, () => {
log.error('下载出现异常,已中断', error)
})
})
})
}

因此,我们的流程更新为:发现新版本后,下载最新的 app.asarresources 目录,并重命名为 app.asar-temp。这个时候我们启动项目进行调试,找到记录在本地的日志—— C:\Users\用户名\AppData\Roaming\electron-update-demo\logs,会有以下的记录:


[2024-09-20 13:49:22.456] [info]  监测到配置文件
[2024-09-20 13:49:22.462] [info] 当前版本不是最新热更版本
[2024-09-20 13:49:23.206] [info] asar下载完成

在看看项目 resources 文件夹,多了一个 app.asar-temp文件。


image.png


至此,下载这一步已经完成了,但这样还会有一个问题,因为文件名没有加上版本号,也没有重复性校验,倘若一直没有更新,那么每次启动应用需要增量更新的时候都需要再次下载。


替换 app.asar 文件


好了,新文件也有了,接下来就是直接替换掉老文件就可以了,但是 替换的时机 很重要。


Windows 操作系统下,直接替换 app.asar 会提示程序被占用而导致失败,所以我们应该在程序关闭的时候实现替换。



  1. 进入应用,下载新版本,自动关闭应用,替换资源,重启应用。

  2. 进入应用,下载新版本,不影响使用,用户 手动关闭 后替换,下次启动 就是新版本了。


我们有上面两种方案,最终采用了 方案2


在主进程监听 app.on('quit') 事件,在应用退出的时候,判断 app.asarapp.asar-temp 是否同时存在,若同时存在,就去替换。这里替换不能直接用 nodejs 在主进程里去写,因为主进程执行的时候,资源还是被占用的。因此我们需要额外起一个 子进程 去做。


nodejs 可以通过 spawnexec 等方法创建一个子进程。子进程可以执行一些自定义的命令,我们并没有使用子进程去执行 nodejs,因为业务方的机器上不一定有这个环境,而是采用了启动 exe 可执行文件的方式。可能有人问为什么不直接运行 .bat 批处理文件,因为我调试关闭应用的时候,会有一个 命令框闪烁 的现象,这不太优雅,尽管已经设置 spawnwindowsHide: true


那么如何获得这个 exe 可执行文件呢,其实是通过 bat 文件去编译的,命令如下:


@echo off
timeout /T 1 /NOBREAK
del /f /q /a %1\app.asar
ren %1\app.asar-temp app.asar

我们加了延时,等待父进程完全关闭后,再去执行后续命令。其中 %1 为运行脚本传入的参数,在我们的场景里就是 resources 文件夹的地址,因为每个人的安装地址应该是不一样的,所以需要作为参数传进去。


转换文件的工具一开始用的是 Bat To Exe Converter 下载地址,但是在实际使用的过程中,我的电脑竟然把转换后的 exe 文件 识别为病毒,但在其他同事电脑上没有这种表现,这肯定就不行了,最后还是同事使用 python 帮我转换生成了一份可用的文件(replace.exe)。


这里我们可以选择不同的方式把 replace.exe 存放到 用户目录 上,要么是从线上服务器下载,要么是安装的时候就已经存放在本地了。我们选择了后者,这需要修改 electron-builder 打包配置,指定 asarUnpack, 这样就会存放在 app.asar.unpacked 文件夹中,不经常修改的文件都可以放在这里,不会随着增量更新而替换掉。


有了这个替换脚本之后,开始编写子进程相关的代码。


import { spawn } from 'child_process'

cosnt localExePath = path.join(resourcePath, 'app.asar.unpacked/resources/replace.exe')

replaceAsar() {
if (fs.existsSync(localAsar) && fs.existsSync(localAsarTemp)) {
const command = `${localExePath} ${resourcePath}`
log.info(command)
const logPath = app.getPath('logs')
const childOut = fs.openSync(path.join(logPath, './out.log'), 'a')
const childErr = fs.openSync(path.join(logPath, './err.log'), 'a')
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true, // 允许子进程独立
shell: true,
stdio: ['ignore', childOut, childErr]
})
child.on('spawn', () => log.info('子进程触发'))
child.on('error', err => log.error('child error', err))
child.on('close', code => log.error('child close', code))
child.on('exit', code => log.error('child exit', code))
child.stdout?.on('data', data => log.info('stdout', data))
child.stderr?.on('data', data => log.info('stderr', data))
child.unref()
}
}

app.on('quit', () => {
replaceAsar()
})

在这块代码中,创建子进程的配置项比较重要,尤其是路径相关的。因为用户安装路径是不同的,可能会存在 文件夹名含有空格 的情况,比如 Program Files,这会导致在执行的时候将空格识别为分隔符,导致命令执行失败,因此需要加上 双引号。此外,detached: true 可以让子进程独立出来,也就是父进程退出后可以执行,而shell: true 可以将路径名作为参数传过去。


const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true,
shell: true,
stdio: ['ignore', childOut, childErr]
})

但这块有个 疑惑,为什么我的 closeexit 以及 stdout 都没有触发,以至于不能拿到命令是否执行成功的最终结果,了解的同学可以评论区交流一下。


至此,在关闭应用之后,app.asar 就已经被替换为最新版本了,还差最后一步,更新本地配置文件里的 hotVersion,防止下次又去下载更新包了。


child.on('spawn', () => {
log.info('子进程触发')
updateHotVersion()
})

updateHotVersion() {
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion }, null, 2))
}

增量更新日志提示


既然之前提到的全量更新有日志说明,那增量更新可以不用,也应该需要具备这个能力,不然我们神不知鬼不觉帮用户更新了,用户都不知道 新在哪里


至于更新内容,我们可以复用 releaseInforeleaseNotes 字段,把更新日志写在这里,增量更新完成后展现给用户就好了。


但是总不能每次打开都展示,这里需要用户有个交互,比如点击 知道了 按钮,或者关闭 Modal 后,把当前的热更新版本号保存在本地配置文件,记录为 logVersion。在下次打开程序或者校验更新的时候,我们判断一下 logVersion === hotVersion若不同,再去提示更新日志。


日志版本 校验和修改的代码如下所示:


checkLogVersion() {
let needShowLog = false
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion, logVersion } = configData
if (hotVersion !== logVersion) {
log.info('日志版本与当前热更新版本不同,需要提示更新日志')
needShowLog = true
} else {
log.info('日志已是最新版本,无需提示更新日志')
}
} catch (error) {
log.error('读取配置文件失败', error)
}
return needShowLog
}

updateLogVersion() {
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion } = configData
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion, logVersion: hotVersion }, null, 2))
log.info('日志版本已更新')
} catch (error) {
log.error('读取配置文件失败', error)
}
}

读取 config.json 文件的方法自行封装一下,我这里就重复使用了。主进程 ipcMain.on 监听一下用户传递过来的事件,再去调用 updateLogVersion 即可,渲染进程效果如下:


提示增量更新日志


image.png


点击 知道了 后,再次打开应用后就不会提示日志了,因为本地配置文件已经被修改了。


image.png


当然可能也有这么个场景,开发就改了一点点无关功能的代码,无需向用户展示日志,我们只需要加一个判断 releaseNotes 是否为空的逻辑就好了,也做到了 静默更新


小结


不足之处


本文提出的增量更新方案应该算是比较简单的,可能并不适用于所有的场景,考虑也不够全面,例如:



  1. dll第三方SDK 等资源的更新。

  2. 增量更新失败后应该通过全量更新 兜底

  3. 用户在使用过程中发布新版本,得等到 第二次打开 才能用到新版本。


流程图


针对本文的解决方案,我简单画了一个 流程图


未命名绘图.png


参考文章


网上其实有不少关于 Electron 自动更新的文章,在做这个需求之前也是浏览了好多,但也有些没仔细看完,所以就躺在我的标签页没有关闭,在这罗列出来,也当作收藏了。


写这篇文章的目的也是为了记录和复盘,如果能为你提供一些思路那就再好不过了。


鸣谢:



作者:小陈同学吗
来源:juejin.cn/post/7416311252580352034
收起阅读 »

我的 Electron 客户端也可以全量/增量更新了

web
前言 本文主要介绍 Electron 客户端应用的自动更新,包括全量和增量这两种方式。 全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。 增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。 本文并没有拿真实项目来举例子,而是起一个 新的项目 从...
继续阅读 »

前言


本文主要介绍 Electron 客户端应用的自动更新,包括全量和增量这两种方式。


全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。


增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。


本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。


如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。


前置说明:



  1. 由于业务场景的限制,本文介绍的更新仅支持 Windows 操作系统,其余操作系统未作兼容处理。

  2. 更新流程全部由前端完成,不涉及到后端,没有轮询 更新的机制。

  3. 发布方式限制为 generic,线上服务需要配置 nginx 确保访问到资源文件。


准备工作


脚手架搭建项目


我们通过 electron-vite 快速搭建一个基于 Vite + React + TSElectron 项目。


1.jpg


该模板已经包括了我们需要的核心第三方库:electron-builderelectron-updater


前者是用来打包客户端程序的,后者是用来实现自动更新的。


在项目根目录下,已经自动生成了两份配置文件:electron-builder.ymldev-app-update.yml


electron-builder.yml


该文件描述了一些 打包配置,更多信息可参考 官网


在这些配置项中,publish 字段比较重要,因为它关系到更新源。


publish:
provider: generic // 使用一个通用的 HTTP 服务器作为更新源。
url: https://example.com/auto-updates // 存放安装包和描述文件的地址

provider 字段还有其他可选项,但是本文只介绍 generic 这种方式,即把安装包放在 HTTP 服务器里。


dev-app-update.yml


provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: electron-update-demo-updater

其中,updaterCacheDirName 定义下载目录,也就是安装包存放的位置。全路径是C:\Users\用户名\AppData\Local\electron-update-demo-updater,不配置则在C:\Users\用户名\AppData\Local下自动创建文件夹,开发环境下为项目名,生产环境下为项目名-updater


image.png


模拟服务器


我们直接运行 npm run build:win,在默认 dist 文件夹下就出现了打包后的一些资源。


image.png


其实更新的基本思路就是对比版本号,即对比本地版本和 线上服务器 版本是否一致,若不一致则需要更新。


image.png


因此我们在开发调试的时候,需要起一个本地服务器,来模拟真实的情况。


新建一个文件夹 mockServer,把打包后的 setup.exe 安装包和 latest.yml 文件粘贴进去,然后通过 serve 命令默认起了一个 http://localhose:3000 的本地服务器。


既然有了存放资源的本地地址,在开发调试的时候,我们更新下 dev-app-update.yml 文件的 url 字段,也就是修改为 http://localhose:3000


注:如果需要测试打包后的更新功能,需要同步修改打包配置文件。


全量更新


与主进程文件同级,创建 update.ts 文件,之后我们的更新逻辑将在这里展开。


import { autoUpdater } from 'electron-updater' //核心库

需要注意的是,在我们开发过程中,通过 npm run dev 起来的 Electron 程序其实不能算是打包后的状态。


你会发现在调用 autoUpdater 的一些方法会提示下面的错误:


Skip checkForUpdates because application is not packed and dev update config is not forced


因此我们需要在开发环境去做一些动作,并且手动指定 updateConfigPath


// update.ts
if (isDev) {
Object.defineProperty(app, 'isPackaged', {
get: () => true
})
autoUpdater.updateConfigPath = path.join(__dirname, '../../dev-app-update.yml')
}

核心对象 autoUpdater 有很多可以主动调用的方法,也有一些监听事件,同时还有一些属性可以设置。


这里只展示了本人项目场景所需的一些配置。


autoUpdater.autoDownload = false // 不允许自动下载更新
autoUpdater.allowDowngrade = true // 允许降级更新(应付回滚的情况)

autoUpdater.checkForUpdates()
autoUpdater.downloadUpdate()
autoUpdater.quitAndInstall()

autoUpdater.on('checking-for-update', () => {
console.log('开始检查更新')
})
autoUpdater.on('update-available', info => {
console.log('发现更新版本')
})
autoUpdater.on('update-not-available', info => {
console.log('不需要全量更新', info.version)
})
autoUpdater.on('download-progress', progressInfo => {
console.log('更新进度信息', progressInfo)
})
autoUpdater.on('update-downloaded', () => {
console.log('更新下载完成')
})
autoUpdater.on('error', errorMessage => {
console.log('更新时出错了', errorMessage)
})

在监听事件里,我们可以拿到下载新版本 整个生命周期 需要的一些信息。


// 新版本的版本信息(已筛选)
interface UpdateInfo {
readonly version: string;
releaseName?: string | null;
releaseNotes?: string | Array<ReleaseNoteInfo> | null;
releaseDate: string;
}

// 下载安装包的进度信息
interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}

在写完这些基本的方法之后,我们需要决定 检验更新 的时机,一般是在应用程序真正启动之后,即 mainWindow 创建之后。


运行项目,预期会提示 不需要全量更新,因为刚才复制到本地服务器的 latest.yml 文件里的版本信息与本地相同。修改 version 字段,重启项目,主进程就会提示有新版本需要更新了。


频繁启动应用太麻烦,除了应用初次启动时主进程主动帮我们检验更新外,还需要用户 手动触发 版本更新检测,此外,由于产品场景需要,当发现有新版本的时候,需要在 渲染进程 通知用户,包括版本更新时间和更新内容。因此我们需要加上主进程与渲染进程的 IPC通信 来实现这个功能。


其实需要的数据主进程都能够提供了,渲染进程具体的展示及交互方式就自由发挥了,这里简单展示下我做的效果。


1. 发现新版本
GIF 2024-9-19 16-48-44.gif


2. 无需更新


image.png


增量更新


为什么要这么做


其实全量更新是一种比较简单粗暴的更新方式,我没有花费太大的篇幅去介绍,基本上就是使用 autoUpdater 封装的一些方法,开发过程中更多的注意力则是放在了渲染进程的交互上。


此外,我们的更新交互用的是一种 用户感知 的方式,即更不更新是由用户自己决定的。而 静默更新 则是用户无感知,在监测到更新后不用去通知用户,而是自行在后台下载,在某个时刻执行安装的逻辑,这种方式往往是一键安装,不会弹出让用户去选择安装路径的窗口。接下去要介绍的增量更新其实是这种 用户无感知 的形式,只不过我们更新的不是整个应用程序。


由于产品迭代比较频繁,我们的业务方经常收到更新提示,意味着他们要经常手动更新应用,以便使用到最新的功能特性。众所周知,Electron 给前端开发提供了一个比较容易上手的客户端开发解决方案,但在享受 跨平台 等特性的同时,还得忍受 臃肿的安装包


带宽、流量在当前不是什么大问题,下载和安装的速度也挺快,但这频繁的下载覆盖一模一样的资源文件还是挺糟心的,代码改动量小时,全量更新完全没有必要。我们希望的是,开发更新了什么代码,应用程序就替换掉这部分代码就好了,这很优雅。在 我们的场景 中,这部分代码指的是 —— main、preload、renderer,并不包括 dll第三方SDK等资源。


网上有挺多种增量更新的 解决方案,例如:



  1. 通过 win.loadURL(一个线上地址) 实现,相当于就套了一层客户端的壳子,与加载的Web 端同步更新。这种方法对于简单应用来说是最省心的,但是也有一定的局限性,例如不能使用 node 去操作一些底层的东西。

  2. 设置 asar 的归档方式,替换app.asarapp.asar.unpack来实现。但后者在我实践过程中存在文件路径不存在的问题。

  3. 禁用 asar 归档,解压渲染进程压缩包后实现替换。简单尝试过,首次执行安装包的时候写入文件很慢,不知道是不是这个方式引起的。

  4. 欢迎补充。


本文我们采用较普遍的 替换asar 来实现。


优化 app.asar 体积



asar是 Electron提供的一种将多个文件合并成一个文件的类 tar 风格的归档格式,不仅可以缓解Windows下路径名过长的问题, 还能够略微加快一下 require的速度, 并且可以隐藏你的源代码。(并非完全隐藏,有方法可以找出来)



Electron 应用程序启动的时候,会读取 app.asar.unpacked 目录中的内容合并到 app.asar 的目录中进行加载。在此之前我们已经打包了一份 Demo 项目,安装到本地后,我们可以根据安装路径找到 app.asar 这个文件。


例如:D:\你的安装路径\electron-update-demo\resources


image.png


在这个 Demo 项目中,脚手架生成的模板将图标剥离出来,因此出现了一个 app.asar.unpacked 文件夹。我们不难发现,app.asar 这个文件竟然有 66 MB,再回过头看我们真正的代码文件,甚至才 1 MB !那么到底是什么原因导致的这个文件这么大呢,刚才提到了,asar 其实是一种压缩格式,因此我们只要解压看看就知道了。


npm i -g asar // 全局安装

asar e app.asar folder // 解压到folder文件夹

image.png


解压后我们不难发现,out 文件夹里的才是我们打包出来的资源,罪魁祸首竟然是 node_modules,足足有 62.3 MB


查阅资料得知,Electron 在打包的时候,会把 dependencies 字段里的依赖也一起打包进去。而我们的渲染进程是用 React开发的,这些第三方依赖早就通过 Vite 等打包工具打到资源文件里了,根本不需要再打包一次,不然我们重新下载的 app.asar 文件还是很大的,因此需要尽可能减少体积。


优化应用程序体积 == 减少 node_modules 文件夹的大小 == 减少需要打包的依赖数量 == 减少 dependencies 中的依赖。


1. 移除 dependencies


最开始我想的是把 package.json 中的 dependencies 全都移到 devDependencies,打包后的体积果然减小了,但是在测试过程中会发现某些功能依赖包会缺失,比如 @electron/remote。因此在修改这部分代码的时候,需要特别留意,哪些依赖是需要保留下来的。


由于不想影响 package.json 的版本结构,我只是在写了一个脚本,在 npm i 之后,执行打包命令前修改 devDependencies 就好了。


2. 双 package.json 结构


这是 electron-builder 官网上看到的一种技巧,传送门, 创建 app 文件夹,再创建第二个 package.json,在该文件中配置我们应用的名称、版本、主进程入口文件等信息。这样一来,electron-builder 在打包时会以该文件夹作为打包的根文件夹,即只会打包这个文件夹下的文件。


但是我原项目的主进程和预加载脚本也放在这,尝试修改后出现打包错误的情况,感兴趣的可以试试


image.png


这是我们优化之后的结果,1.32 MB,后面只需要替换这个文件就实现了版本的更新。


校验增量更新


全量更新的校验逻辑是第三方库已经封装好了的,那么对于增量更新,就得由我们自己来实现了。


首先明确一下 校验的时机package.jsonversion 字段表示应用程序的版本,用于全量更新的判断,那也就意味着,当这个字段保持不变的时候,会触发上文提到的 update-not-available 事件。所以我们可以在这个事件的回调函数里来进行校验。


autoUpdater.on('update-not-available', info => { console.log('不需要全量更新', info.version) })

然后就是 如何校验,我们回过头来看 electron-builder 的打包配置,在 releaseInfo 字段里描述了发行版本的一些信息,目前我们用到了 releaseNotes 来存储更新日志,查阅官网得知还有个 releaseName 好像对于我们而言是没什么用的,那么我们就可以把它作为增量更新的 热版本号。(但其实 官网 配置项还有个 vendor 字段,可是我在用的时候打包会报错,可能是版本的问题,感兴趣的可以试试。)


每次发布新版本的时候,只要不是 Electron自身版本变化 等重大更新,我们都可以通过修改 releaseInforeleaseName 来发布我们的热版本号。现在我们已经有了线上的版本,那 本地热版本号 该如何获取呢。这里我们在每次热更新完成之后,就会把最新的版本号存储在本地的一个配置文件中,通常是 userData 文件夹,用于存储我们应用程序的用户信息,即使程序卸载 了也不会影响里面的资源。在 Windows 下,路径如下:
C:\Users\用户名\AppData\Roaming\electron-update-demo


因此,整个 校验流程 就是,在打开程序的时候,autoUpdater 触发 update-not-available 事件,拿到线上 latest.yml 描述的 releaseName 作为热版本号,与本地配置文件(我们命名为 config.json)里存储的热版本号(我们命名为 hotVersion)进行对比,若不同就去下载最新的 app.asar 文件。


我们使用 electron-log 来记录日志,代码如下所示。


// 本地配置文件
const localConfigFile = path.join(app.getPath('userData'), 'config.json')

const checkHotVersion = (latestHotVersion: string): boolean => {
let needDownload = false
if (!fs.existsSync(localConfigFile)) {
fs.writeFileSync(localConfigFile, '{}', 'utf8')
log.info('配置文件不存在,已自动创建')
} else {
log.info('监测到配置文件')
}

try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
// 对比版本号
if (latestHotVersion !== configData.hotVersion) {
log.info('当前版本不是最新热更版本')
needDownload = true
} else {
log.info('当前为最新版本')
}
} catch (error) {
log.error('读取配置文件时出错', error)
}

return needDownload
}

下载增量更新包


通过刚才的校验,我们已经知道了什么时候该去下载增量更新包。


在开发调试的时候,我们可以把新版本的 app.asar 也放到起了本地服务器的 mockServer 文件夹里来模拟真实的情况。有很多方式可以去下载文件,这里我用了 nodejshttp 模块去实现,如果是 https 的需要引用 https 模块。


下载到本地的时候,我是放在了与 app.asar 同级目录的 resources 文件夹,我们不能直接覆盖原文件。一是因为进程权限占用的问题,二是为了容错,所以我们需要以另一个名字来命名下载后的文件(这里我们用的是 app.asar-temp),也就不需要去备份原文件了,代码如下。


const resourcePath = isDev
? path.join('D:\\测试\\electron-update-demo\\resources')
: process.resourcesPath

const localAsarTemp = path.join(resourcePath, 'app.asar-temp')

const asarUrl = 'http://localhost:3000/app.asar'

downloadAsar() {
const fileStream = fs.createWriteStream(localAsarTemp)
http.get(asarUrl, res => {
res.pipe(fileStream)
fileStream
.on('finish', () => {
log.info('asar下载完成')
})
.on('error', error => {
// 删除文件
fs.unlink(localAsarTemp, () => {
log.error('下载出现异常,已中断', error)
})
})
})
}

因此,我们的流程更新为:发现新版本后,下载最新的 app.asarresources 目录,并重命名为 app.asar-temp。这个时候我们启动项目进行调试,找到记录在本地的日志—— C:\Users\用户名\AppData\Roaming\electron-update-demo\logs,会有以下的记录:


[2024-09-20 13:49:22.456] [info]  监测到配置文件
[2024-09-20 13:49:22.462] [info] 当前版本不是最新热更版本
[2024-09-20 13:49:23.206] [info] asar下载完成

在看看项目 resources 文件夹,多了一个 app.asar-temp文件。


image.png


至此,下载这一步已经完成了,但这样还会有一个问题,因为文件名没有加上版本号,也没有重复性校验,倘若一直没有更新,那么每次启动应用需要增量更新的时候都需要再次下载。


替换 app.asar 文件


好了,新文件也有了,接下来就是直接替换掉老文件就可以了,但是 替换的时机 很重要。


Windows 操作系统下,直接替换 app.asar 会提示程序被占用而导致失败,所以我们应该在程序关闭的时候实现替换。



  1. 进入应用,下载新版本,自动关闭应用,替换资源,重启应用。

  2. 进入应用,下载新版本,不影响使用,用户 手动关闭 后替换,下次启动 就是新版本了。


我们有上面两种方案,最终采用了 方案2


在主进程监听 app.on('quit') 事件,在应用退出的时候,判断 app.asarapp.asar-temp 是否同时存在,若同时存在,就去替换。这里替换不能直接用 nodejs 在主进程里去写,因为主进程执行的时候,资源还是被占用的。因此我们需要额外起一个 子进程 去做。


nodejs 可以通过 spawnexec 等方法创建一个子进程。子进程可以执行一些自定义的命令,我们并没有使用子进程去执行 nodejs,因为业务方的机器上不一定有这个环境,而是采用了启动 exe 可执行文件的方式。可能有人问为什么不直接运行 .bat 批处理文件,因为我调试关闭应用的时候,会有一个 命令框闪烁 的现象,这不太优雅,尽管已经设置 spawnwindowsHide: true


那么如何获得这个 exe 可执行文件呢,其实是通过 bat 文件去编译的,命令如下:


@echo off
timeout /T 1 /NOBREAK
del /f /q /a %1\app.asar
ren %1\app.asar-temp app.asar

我们加了延时,等待父进程完全关闭后,再去执行后续命令。其中 %1 为运行脚本传入的参数,在我们的场景里就是 resources 文件夹的地址,因为每个人的安装地址应该是不一样的,所以需要作为参数传进去。


转换文件的工具一开始用的是 Bat To Exe Converter 下载地址,但是在实际使用的过程中,我的电脑竟然把转换后的 exe 文件 识别为病毒,但在其他同事电脑上没有这种表现,这肯定就不行了,最后还是同事使用 python 帮我转换生成了一份可用的文件(replace.exe)。


这里我们可以选择不同的方式把 replace.exe 存放到 用户目录 上,要么是从线上服务器下载,要么是安装的时候就已经存放在本地了。我们选择了后者,这需要修改 electron-builder 打包配置,指定 asarUnpack, 这样就会存放在 app.asar.unpacked 文件夹中,不经常修改的文件都可以放在这里,不会随着增量更新而替换掉。


有了这个替换脚本之后,开始编写子进程相关的代码。


import { spawn } from 'child_process'

cosnt localExePath = path.join(resourcePath, 'app.asar.unpacked/resources/replace.exe')

replaceAsar() {
if (fs.existsSync(localAsar) && fs.existsSync(localAsarTemp)) {
const command = `${localExePath} ${resourcePath}`
log.info(command)
const logPath = app.getPath('logs')
const childOut = fs.openSync(path.join(logPath, './out.log'), 'a')
const childErr = fs.openSync(path.join(logPath, './err.log'), 'a')
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true, // 允许子进程独立
shell: true,
stdio: ['ignore', childOut, childErr]
})
child.on('spawn', () => log.info('子进程触发'))
child.on('error', err => log.error('child error', err))
child.on('close', code => log.error('child close', code))
child.on('exit', code => log.error('child exit', code))
child.stdout?.on('data', data => log.info('stdout', data))
child.stderr?.on('data', data => log.info('stderr', data))
child.unref()
}
}

app.on('quit', () => {
replaceAsar()
})

在这块代码中,创建子进程的配置项比较重要,尤其是路径相关的。因为用户安装路径是不同的,可能会存在 文件夹名含有空格 的情况,比如 Program Files,这会导致在执行的时候将空格识别为分隔符,导致命令执行失败,因此需要加上 双引号。此外,detached: true 可以让子进程独立出来,也就是父进程退出后可以执行,而shell: true 可以将路径名作为参数传过去。


const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true,
shell: true,
stdio: ['ignore', childOut, childErr]
})

但这块有个 疑惑,为什么我的 closeexit 以及 stdout 都没有触发,以至于不能拿到命令是否执行成功的最终结果,了解的同学可以评论区交流一下。


至此,在关闭应用之后,app.asar 就已经被替换为最新版本了,还差最后一步,更新本地配置文件里的 hotVersion,防止下次又去下载更新包了。


child.on('spawn', () => {
log.info('子进程触发')
updateHotVersion()
})

updateHotVersion() {
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion }, null, 2))
}

增量更新日志提示


既然之前提到的全量更新有日志说明,那增量更新可以不用,也应该需要具备这个能力,不然我们神不知鬼不觉帮用户更新了,用户都不知道 新在哪里


至于更新内容,我们可以复用 releaseInforeleaseNotes 字段,把更新日志写在这里,增量更新完成后展现给用户就好了。


但是总不能每次打开都展示,这里需要用户有个交互,比如点击 知道了 按钮,或者关闭 Modal 后,把当前的热更新版本号保存在本地配置文件,记录为 logVersion。在下次打开程序或者校验更新的时候,我们判断一下 logVersion === hotVersion若不同,再去提示更新日志。


日志版本 校验和修改的代码如下所示:


checkLogVersion() {
let needShowLog = false
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion, logVersion } = configData
if (hotVersion !== logVersion) {
log.info('日志版本与当前热更新版本不同,需要提示更新日志')
needShowLog = true
} else {
log.info('日志已是最新版本,无需提示更新日志')
}
} catch (error) {
log.error('读取配置文件失败', error)
}
return needShowLog
}

updateLogVersion() {
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion } = configData
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion, logVersion: hotVersion }, null, 2))
log.info('日志版本已更新')
} catch (error) {
log.error('读取配置文件失败', error)
}
}

读取 config.json 文件的方法自行封装一下,我这里就重复使用了。主进程 ipcMain.on 监听一下用户传递过来的事件,再去调用 updateLogVersion 即可,渲染进程效果如下:


提示增量更新日志


image.png


点击 知道了 后,再次打开应用后就不会提示日志了,因为本地配置文件已经被修改了。


image.png


当然可能也有这么个场景,开发就改了一点点无关功能的代码,无需向用户展示日志,我们只需要加一个判断 releaseNotes 是否为空的逻辑就好了,也做到了 静默更新


小结


不足之处


本文提出的增量更新方案应该算是比较简单的,可能并不适用于所有的场景,考虑也不够全面,例如:



  1. dll第三方SDK 等资源的更新。

  2. 增量更新失败后应该通过全量更新 兜底

  3. 用户在使用过程中发布新版本,得等到 第二次打开 才能用到新版本。


流程图


针对本文的解决方案,我简单画了一个 流程图


未命名绘图.png


参考文章


网上其实有不少关于 Electron 自动更新的文章,在做这个需求之前也是浏览了好多,但也有些没仔细看完,所以就躺在我的标签页没有关闭,在这罗列出来,也当作收藏了。


写这篇文章的目的也是为了记录和复盘,如果能为你提供一些思路那就再好不过了。


鸣谢:



作者:小陈同学吗
来源:juejin.cn/post/7416311252580352034
收起阅读 »

基于英雄联盟人物的加载动画,奇怪的需求又增加了!

web
1、背景 前两天老板找到我说有一个需求,要求使用英雄联盟的人物动画制作一个加载中的组件,类似于下面这样: 我定眼一看:这个可以实现,但是需要UI妹子给切图。 老板:UI? 咱们啥时候招的UI ! 我:老板,那不中呀,不切图弄不成呀。 老板:下个月绩效给你A。...
继续阅读 »

1、背景


前两天老板找到我说有一个需求,要求使用英雄联盟的人物动画制作一个加载中的组件,类似于下面这样:


iShot_2024-06-06_18.09.55.gif


我定眼一看:这个可以实现,但是需要UI妹子给切图。


老板:UI? 咱们啥时候招的UI !


我:老板,那不中呀,不切图弄不成呀。


老板:下个月绩效给你A。


我:那中,管管管。


2、调研


发动我聪明的秃头,实现这个需求有以下几种方案:



  • 切动画帧,没有UI不中❎。

  • 去lol客户端看看能不能搞到什么美术素材,3D模型啥的,可能行❓

  • 问下 gpt4o,有没有哪个老表收集的有lol英雄的美术素材,如果有那就更得劲了✅。


经过我一番搜索,发现了这个网站:model-viewer,收集了很多英雄联盟的人物模型,模型里面还有各种动画,还给下载。老表,这个需求稳了50%了!


image-20240606182312802.png


接下来有几种选择:



  • 将模型动画转成动画帧,搞成雪碧图,较为麻烦,且动画不支持切换。

  • 直接加载模型,将模型放在进度条上,较为简单,支持切换不同动画,而且可以自由过渡。就是模型文件有点大,初始化加载可能耗时较长。但是后续缓存一下就好了。


聪明的我肯定先选第二个方案呀,你糊弄我啊,我糊弄你。


3、实现


web中加载模型可以使用谷歌基于threejs封装的 model-viewer, 使用现代的 web component 技术。简单易用。


先初始化一个vue工程


 npm create vue@latest

然后将里面的初始化的组件和app.vue里面的内容都删除。


安装model-viewer依赖:


npm i three // 前置依赖
npm i @google/model-viewer

修改vite.config.js,将model-viewer视为自定义元素,不进行编译


import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
 plugins: [
   vue({
     template: {
       // 添加以下内容
       compilerOptions: {
         isCustomElement: (tag) => ['model-viewer'].includes(tag)
      }
    }
  })
],
 resolve: {
   alias: {
     '@': fileURLToPath(new URL('./src', import.meta.url))
  }
},
 assetsInclude: ['./src/assets/heros/*.glb']
})


新建 src/components/LolProgress.vue


<template>
 <div class="progress-container">
   <model-viewer
     :src="hero.src"
     disable-zoom
     shadow-intensity="1"
     :camera-orbit="hero.cameraOrbit"
     class="model-viewer"
     :style="heroPosition"
     :animation-name="animationName"
     :camera-target="hero.cameraTarget"
     autoplay
     ref="modelViewer"
   >
</model-viewer>
   <div
     class="progress-bar"
     :style="{ height: strokeWidth + 'px', borderRadius: strokeWidth / 2 + 'px' }"
   >

     <div class="progress-percent" :style="currentPercentStyle"></div>
   </div>
 </div>

</template>

<script setup lang="ts">
import { computed, onMounted, ref, watch, type PropType } from 'vue'
/** 类型 */
interface Hero {
 src: string
 cameraOrbit: string
 progressAnimation: string
 finishAnimation: string
 finishAnimationIn: string
 cameraTarget: string
 finishDelay: number
}
type HeroName = 'yasuo' | 'yi'

type Heros = {
[key in HeroName]: Hero
}
const props = defineProps({
 hero: {
   type: String as PropType<HeroName>,
   default: 'yasuo'
},
 percentage: {
   type: Number,
   default: 100
},
 strokeWidth: {
   type: Number,
   default: 10
},
 heroSize: {
   type: Number,
   default: 150
}
})

const modelViewer = ref(null)

const heros: Heros = {
 yasuo: {
   src: '/src/components/yasuo.glb',
   cameraOrbit: '-90deg 90deg',
   progressAnimation: 'Run2',
   finishAnimationIn: 'yasuo_skin02_dance_in',
   finishAnimation: 'yasuo_skin02_dance_loop',
   cameraTarget: 'auto auto 0m',
   finishDelay: 2000
},
 yi: {
   src: '/src/components/yi.glb',
   cameraOrbit: '-90deg 90deg',
   progressAnimation: 'Run',
   finishAnimationIn: 'Dance',
   finishAnimation: 'Dance',
   cameraTarget: 'auto auto 0m',
   finishDelay: 500
}
}

const heroPosition = computed(() => {
 const percentage = props.percentage > 100 ? 100 : props.percentage
 return {
   left: `calc(${percentage + '%'} - ${props.heroSize / 2}px)`,
   bottom: -props.heroSize / 10 + 'px',
   height: props.heroSize + 'px',
   width: props.heroSize + 'px'
}
})

const currentPercentStyle = computed(() => {
 const percentage = props.percentage > 100 ? 100 : props.percentage
 return { borderRadius: `calc(${props.strokeWidth / 2}px - 1px)`, width: percentage + '%' }
})

const hero = computed(() => {
 return heros[props.hero]
})

const animationName = ref('')

watch(
() => props.percentage,
(percentage) => {
   if (percentage < 100) {
     animationName.value = hero.value.progressAnimation
  } else if (percentage === 100) {
     animationName.value = hero.value.finishAnimationIn
     setTimeout(() => {
       animationName.value = hero.value.finishAnimation
    }, hero.value.finishDelay)
  }
}
)
onMounted(() => {
 setTimeout(() => {
   console.log(modelViewer.value.availableAnimations)
}, 2000)
})
</script>
<style scoped>
.progress-container {
 position: relative;
 width: 100%;
}
.model-viewer {
 position: relative;
 background: transparent;
}
.progress-bar {
 border: 1px solid #fff;
 background-color: #666;
 width: 100%;
}
.progress-percent {
 background-color: aqua;
 height: 100%;
 transition: width 100ms ease;
}
</style>


组件非常简单,核心逻辑如下:



  • 根据传入的英雄名称加载模型

  • 指定每个英雄的加载中的动画,

  • 加载100%,切换完成动作进入动画和完成动画即可。

  • 额外的细节处理。


    最后修改 app.vue:


    <script setup lang="ts">
    import { ref } from 'vue'
    import LolProgress from './components/LolProgress.vue'
    const percentage = ref(0)
    setInterval(() => {
     percentage.value = percentage.value + 1
    }, 100)
    </script>

    <template>
     <main>
       <LolProgress
         :style="{ width: '200px' }"
         :percentage="percentage"
         :heroSize="200"
         hero="yasuo"
       />

     </main>
    </template>

    <style scoped></style>




这不就完成了吗,先拿给老板看看。


老板:换个女枪的看看。


我:好嘞。


iShot_2024-06-06_19.08.49.gif


老板:弄类不赖啊小伙,换个俄洛伊的看看。


4、总结


通过本次需求,了解到了 model-viewer组件。


老板招个UI妹子吧。


在线体验:github-pages


作者:盖伦大王
来源:juejin.cn/post/7377217883305279526
收起阅读 »

组长说:公司的国际化就交给你了,下个星期给我

web
从“跑路程序员”到“摸鱼仙人”,我用这插件把国际化的屎山代码盘活了! tips:使用有道翻译,朋友们,要去有道官网注册一下,有免费额度,github demo的key已经被用完了。 一、命运的齿轮开始转动 “小王啊,海外业务要上线了,国际化你搞一下,下个月...
继续阅读 »

从“跑路程序员”到“摸鱼仙人”,我用这插件把国际化的屎山代码盘活了!



tips:使用有道翻译,朋友们,要去有道官网注册一下,有免费额度,github demo的key已经被用完了。



一、命运的齿轮开始转动


“小王啊,海外业务要上线了,国际化你搞一下,下个月验收。”组长轻描淡写的一句话,让我盯着祖传代码陷入沉思——


翻译代码注释.png
(脑补画面:满屏中文硬编码,夹杂着"确定""取消""加载中..."


正当我准备打开BOSS直聘时,GitHub Trending上一个项目突然闪现——

auto-i18n-translation-plugins

项目简介赫然写着:“不改代码,三天交付国际化需求,摸鱼率提升300%”




二、极限操作:48小时从0到8国语言


🔧 第1步:安装插件(耗时5分钟)


祖训“工欲善其事,必先装依赖”


# 如果你是Vite玩家(比如Vue3项目)
npm install vite-auto-i18n-plugin --save-dev

# 如果你是Webpack钉子户(比如React老项目)
npm install webpack-auto-i18n-plugin --save-dev

🔧 第2步:配置插件(关键の10分钟)


Vue3 + Vite の 摸鱼配置


// vite.config.js
import { defineConfig } from 'vite';
import vitePluginAutoI18n from 'vite-auto-i18n-plugin';

export default defineConfig({
plugins: [
vue(),
vitePluginAutoI18n({
targetLangList: ['en', 'ja', 'ko'], // 要卷就卷8国语言!
translator: new YoudaoTranslator({ // 用有道!不用翻墙!
appId: '你的白嫖ID', // 去官网申请,10秒搞定
appKey: '你的密钥' // 别用示例里的,会炸!
})
})
]
});

🔧 第3步:注入灵魂——配置文件(生死攸关の5分钟)


在项目入口文件(如main.js)的第一行插入


// 这是插件的生命线!必须放在最前面!
import '../lang/index.js'; // 运行插件之后会自动生成引入即可



三、见证奇迹的时刻


🚀 第一次运行(心脏骤停の瞬间)


输入npm run dev,控制台开始疯狂输出:


[插件日志] 检测到中文文本:"登录" → 生成哈希键:a1b2c3  
[插件日志] 调用有道翻译:"登录" → 英文:Login,日文:ログイン...
[插件日志] 生成文件:lang/index.json(翻译の圣杯)

突然!页面白屏了!

别慌!这是插件在首次翻译时需要生成文件,解决方法:



  1. 立即执行一次 npm run build (让插件提前生成所有翻译)

  2. 再次npm run dev → 页面加载如德芙般丝滑




四、效果爆炸:我成了全组の神


1. 不可置信の48小时


当我打开浏览器那一刻——\


Untitled.gif


(瞳孔地震):“卧槽…真成了?!”

组长(凑近屏幕):“这…这是你一个人做的?!”(眼神逐渐迷茫)

产品经理(掏出手机拍照):“快!发朋友圈!《我司技术力碾压硅谷!》”


2. 插件の超能力



  • 构建阶段:自动扫描所有中文 → 生成哈希键 → 调用API翻译

  • 运行时:根据用户语言动态加载对应翻译

  • 维护期:改个JSON文件就能更新所有语言版本


副作用



  • 测试妹子开始怀疑人生:“为什么一个bug都找不到?”

  • 后端同事偷偷打听:“你这插件…能翻译Java注释吗?”




五、职场生存指南:如何优雅甩锅


🔨 场景1:测试妹子提着40米大刀来了!


问题:俄语翻译把“注册”译成“Регистрация”(原意是“登记处”)

传统应对



  • 熬夜改代码 → 重新打包 → 提交测试 → 被骂效率低

    插件玩家



  1. 打开lang/index.json

  2. Регистрация改成Зарегистрироваться(深藏功与名)

  3. 轻描淡写:“这是有道翻译的锅,我手动修正了。”


🔨 场景2:产品经理临时加语言


需求:“老板说下周要加印地语!”

传统灾难



  • 重新配框架 → 人肉翻译 → 测试 → 加班到秃头

    插件玩家



  1. 配置加一行代码:targetLangList: ['hi']

  2. 运行npm run build → 自动生成印地语翻译

  3. 告诉产品经理:“这是上次预留的技术方案。”(其实只改了1行)


🔨 场景3:组长怀疑你摸鱼


质问:“小王啊,你这效率…是不是有什么黑科技?”

标准话术

“组长,这都是因为:



  1. 您制定的开发规范清晰

  2. 公司技术栈先进(Vue3真香)

  3. 我参考了国际前沿方案(打开GitHub页面)”




六、高级摸鱼の奥义


🎯 秘籍1:把翻译文件变成团队武器



  1. lang/index.json扔给产品经理:“这是国际化核心资产!”

  2. 对方用Excel修改后,你直接git pull → 无需动代码

  3. 出问题直接甩锅:“翻译是市场部给的,我只负责技术!”




(脑补画面:产品经理在Excel里疯狂改翻译,程序员在刷剧)


🎯 秘籍2:动态加载の神操作


痛点:所有语言打包进主文件 → 体积爆炸!

解决方案


// 在index.js里搞点骚操作
const loadLanguage = async (lang) => {
const data = await import(`../../lang/${lang}.json`); // 动态加载翻译文件
window.$t.locale(data, 'lang');
};

// 切换语言时调用
loadLanguage('ja'); // 瞬间切换日语,深藏功与名

🎯 秘籍3:伪装成AI大神



  1. 周会汇报:“我基于AST实现了自动化国际翻译中台”

  2. 实际:只是配了个插件

  3. 老板评价:“小王这技术深度,值得加薪!”(真相只有你知道)




七、终局:摸鱼の神,降临!


当组长在庆功会上宣布“国际化项目提前两周完成”时,我正用手机刷着《庆余年2》。


测试妹子:“你怎么一点都不激动?”

(收起手机):“常规操作,要习惯。”(心想:插件干活,我躺平,这才叫真正的敏捷开发!)




立即行动(打工人自救指南)



  1. GitHub搜auto-i18n-translation-plugins(点星解锁摸鱼人生)

  2. 复制我的配置 → 运行 → 见证魔法

  3. 加开发者社群:遇到问题发红包喊“大哥救命!”


终极警告

⚠️ 过度使用此插件可能导致——



  • 你的摸鱼时间超过工作时间,引发HR关注

  • 产品经理产生“国际化需求可以随便加”的幻觉

  • 老板误以为你是隐藏的技术大佬(谨慎处理!)




文末暴击

“自从用了这插件,我司翻译团队的工作量从3周变成了3分钟——现在他们主要工作是帮我选中午吃啥。” —— 匿名用户の真实反馈




常见问题汇总


常见问题汇总


作者:wenps
来源:juejin.cn/post/7480267450286800911
收起阅读 »