十年跨平台开发,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)漏洞。开发者需要特别注意安全性,采取适当的防护措施(如使用 contextIsolation
、sandbox
、Content 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开发的软件非常多,国内外都有很多知名的软件,有了成功的案例才会吸引更多的开发者使用它,下面是一些举例。
国内
- 微信开发者工具
- 百度网盘
- 语雀
- 网易灵犀办公
- 网易云音乐
国外
- Visual Studio Code
- Slack
- Discord
- GitHub Desktop
- Postman
其他更多可参考: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)的优缺点和特性:
特性 | Electron | NW.js | Proton Native | Tauri | Flutter Desktop |
---|---|---|---|---|---|
开发语言 | JavaScript, HTML, CSS | JavaScript, HTML, CSS | JavaScript, React | Rust, JavaScript, HTML, CSS | Dart |
框架大小 | 大(几十到几百 MB) | 中等(几十 MB) | 中等(几十 MB) | 小(几 MB) | 大(几十到几百 MB) |
性能 | 中等 | 中等 | 中等 | 高 | 高 |
跨平台支持 | Windows, macOS, Linux | Windows, macOS, Linux | Windows, macOS, Linux | Windows, macOS, Linux | Windows, macOS, Linux |
使用的技术栈 | Chromium, Node.js | Chromium, Node.js | React, Node.js | Rust, WebView | Flutter Engine |
生态系统和社区 | 非常活跃,生态丰富 | 活跃 | 停滞了 | 新兴,快速增长 | 活跃,现阶段更新不频繁 |
开发难度 | 易于上手 | 易于上手 | 需要 React 知识 | 需要 Rust 和前端知识 | 需要 Dart 知识 |
自动更新支持 | 内置支持 | 需要手动实现 | 需要手动实现 | 需要手动实现 | 需要手动实现 |
原生功能访问 | 通过 Node.js 模块访问 | 通过 Node.js 模块访问 | 通过 Node.js 和原生模块访问 | 通过 Rust 原生模块访问 | 通过插件和原生模块访问 |
热重载和开发体验 | 支持(需要配置) | 支持(需要配置) | 支持(需要配置) | 支持(需要配置) | 支持(内置支持) |
打包和发布 | Electron Builder, Forge | nw-builder | 需要手动配置打包工具 | Tauri 打包工具 | Flutter build tools |
常见应用场景 | 聊天应用、生产力工具、IDE | 聊天应用、生产力工具 | 小型工具和实用程序 | 轻量级、性能要求高的应用 | 跨平台移动和桌面应用 |
知名应用 | VS Code, Slack, Discord, 知名应用 | WebTorrent, 其他工具 | 小型 React 工具和应用 | 新兴应用和工具 | 仅少数桌面应用,Flutter主打移动应用 |
结语
Electron 是一个强大的跨平台开发框架,其诞生对前端开发者的意义非常大,让很多从事前端的开发者也有机会开发桌面客户端,扩大了前端开发工程师的岗位需求。当然,它不一定是最好的框架,因为适合自己的才是最好的,主要还是看自己的业务场景和技术需要,优势和劣势都是需要考虑的,仁者见仁,智者见智。
来源:juejin.cn/post/7416902812251111476
Electron调用dll的新姿势
之前旧的系统在浏览器中调用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调用类型
- 同步调用
本机函数,您就可以像调用任何其他 JS 函数一样简单地调用它。
const atoi = lib.func('int atoi(const char *str)');
let value = atoi('1257');
- 异步调用
有一些耗时的操作,可以使用异步调用回调的方式处理。
const atoi = lib.func('int atoi(const char *str)');
atoi.async('1257', (err, res) => {
console.log('Result:', res);
})
JS类型值传递
JS基础类型时不支持值传递的,遇到需要传递指针变量时,直接调用是无法获取到变更后的值。相应的koffi也提供了非常方便的值包装。
- 数组包装
项目中采用比较方便的数组包装来进行值传递。包装基础对象到数组中,变更后取出第一位就能获取到变更后的值。
需要定义返回的值的获取长度,防止出现只获取到部分的返回结果。
- 引用类型包装
把基础类型包装成引用对象。传递到函数中。
let cardsenr = koffi.alloc('int', 64);
let cardRet = dcCard(icdev, 0, cardsenr);
这种就更方便,调用后也不需要转换。在调用完后需要通过free方法进行内存释放。
- 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方法调用方法即可。后续就可以愉快的编写我们的业务代码了。
参照
来源:juejin.cn/post/7352075771534868490
AI对话的逐字输出:流式返回才是幕后黑手
AI应用已经渗透到我们生活的方方面面。其中,AI对话系统因其自然、流畅的交流方式而备受瞩目。前段时间有人在交流群中问到,如何实现AI对话中那种逐字输出的效果,这背后,流式返回技术发挥了关键作用。

欢迎加入前端筱园交流群:点击加入交流群
其实这背后并不是前端做了什么特效,而是采用的流式返回,而不是一次性返回完整的响应。流式返回允许服务器在一次连接中逐步发送数据,而不是一次性返回全部结果。这种方式使得前端可以在等待完整响应的过程中,逐步展示生成的内容,从而极大地提升了用户体验。
那么,前端接收流式返回具体有哪些方式呢?接下来,本文将详细探讨几种常见的技术手段,帮助读者更好地理解并应用流式返回技术。
使用 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可能会引入不必要的复杂性,如处理双向数据流、管理连接状态等,也会增加额外的部署与维护工作

特点
- 双向通信:适合实时双向数据传输
- 低延迟:基于 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对话场景中,每次都需要将以显示的内容全部替换,或者需要做一些额外的处理。
如果想提前终止请求,可以使用 xhr.abort()
方法;
setTimeout(() => {
xhr.abort();
}, 3000);
特点
- 兼容性好:支持所有浏览器。
- 非真正流式:
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();
});
});
EventSource 也具有主动关闭请求的能力,在结果没有完全返回前,用户可以提前终止内容的返回。
// 在需要时中止请求
setTimeout(() => {
eventSource.close(); // 主动关闭请求
}, 3000); // 3 秒后中止请求
虽然EventSource支持流式请求,但AI对话场景不使用它有以下几点原因:
- 单向通信
- 仅支持
get
请求:在 AI 对话场景中,通常需要发送用户输入(如文本、文件等),这需要使用 POST 请求 - 无法自定义请求头:
EventSource
不支持自定义请求头(如Authorization
、Content-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();
});
});
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中没有任何内容。
这是由于返回的信息SSE协议规范,具体规范见上文的 Server-Sent Events
模块中有介绍到
ctx.res.write(
`data: ${JSON.stringify({ message: "Hello", counter })}\n\n`
);
但是客户端fetch请求中接收到的数据也包含了规范中的内容,需要前端对数据进一步的处理一下
特点
- 原生支持:现代浏览器均支持
fetch
和ReadableStream
。 - 逐块处理:可以实时处理每个数据块,而不需要等待整个响应完成。
- 内存效率高:适合处理大文件或实时数据。
总结
综上所述,在 AI 对话场景中,fetch
请求 是主流的技术选择,而不是 XMLHttpRequest
或 EventSource
。以下是原因和详细分析:
fetch
是现代浏览器提供的原生 API,基于 Promise,代码更简洁、易读fetch
支持ReadableStream
,可以实现流式请求和响应fetch
支持自定义请求头、请求方法(GET、POST 等)和请求体fetch
结合AbortController
可以方便地中止请求fetch
的响应对象提供了response.ok
和response.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串口通信
electron+node-serialport串口通信
公司项目需求给客户开发一个能与电子秤硬件通信的Win7客户端,提供数据读取、处理和显示功能。项目为了兼容win7使用了
electron 22.0.0
版本,串口通信使用了serialport 12.0.0版本
//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))
})
串口选择
波特率配置
读取数据
公司秤和客户的秤串口配置不一样,所以做了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
}
}
}
完活~下班!
来源:juejin.cn/post/7387701265796562980
无虚拟DOM到底能快多少?
相信大家都跟我一样有这样一个疑问:之前都说虚拟DOM
是为了性能,怎么现在又反向宣传了?无虚拟DOM
怎么又逆流而上成为了性能的标杆了呢?
下篇文章我们会仔细分析无虚拟DOM
与虚拟DOM
之间的区别,本篇文章我们先看数据。首先是体积,由于没有了虚拟DOM
以及vDOM diff
算法,所以体积肯定能小不少。当然不是说无虚拟DOM
就彻底不需要diff
算法了,我看过同为无虚拟DOM
框架的Svelte
和Solid
源码,无虚拟DOM
只是不需要vDOM
间的Diff
算法,列表之间还是需要diff
的。毕竟再怎么编译,你从后端获取到的数组,编译器也不可能预测得到。
那么官方给出的数据是:
虽然没有想象中的那么多,但33.6%
也算是小不少了。当然这个数据指的是纯Vapor
模式,如果你把虚拟DOM
和Vapor
混着用的话,体积不仅不会减小反而还会增加。毕竟会同时加载Vapor
模式的runtime
和虚拟DOM
模式的runtime
,二者一相加就大了。
Vapor
模式指的就是无虚拟DOM
模式,如果你不太清楚二者之间有何关联的话,可以看一眼这篇:《无虚拟DOM版Vue为什么叫Vapor》
那性能呢?很多人对体积其实并不敏感,觉得多10K
少10k
都无所谓,毕竟现在都是5G
时代了。所以我们就来看一眼官方公布的性能数据:
从左到右依次为:
- 原生
JS
:1.01 Solid
:1.09Svelte
:1.11- 无虚拟
DOM
版Vue
:1.24 - 虚拟
DOM
版Vue
:1.32 React
:1.55
数字越小代表性能越高,但无论再怎么高都不可能高的过手动优化过的原生JS
,毕竟无论什么框架最终打包编译出来的还是JS
。不过框架的性能其实已经挺接近原生的了,尤其是以无虚拟DOM
著称的Solid
和Svelte
。但无虚拟DOM
版Vue
和虚拟DOM
版Vue
之间并没有拉开什么很大的差距,1.24
和1.32
这两个数字证明了其实二者的性能差距并不明显,这又是怎么一回事呢?
一个原因是Vue3
本来就做了许多编译优化,包括但不限于静态提升、大段静态片段将采用innerHTML
渲染、编译时打标记以帮助虚拟DOM
走捷径等… 由于本来的性能就已经不错了,所以可提升的空间自然也没多少。
看完上述原因肯定有人会说:可提升的空间没多少可以理解,那为什么还比同为无虚拟DOM
的Solid
和Svelte
差那么多?如果Vapor
和Solid
性能差不多的话,那可提升空间小倒是说得过去。但要是像现在这样差的还挺多的话,那这个理由是不是就有点站不住脚了?之所以会出现这样的情况是因为Vue Vapor
的作者在性能优化和快速实现之间选择了后者,毕竟尤雨溪第一次公布要开始做无虚拟DOM
版Vue
的时间是在2020
年:
而如今已经是2025
年了。也就是说如果一个大一新生第一次满心欢喜的听到无虚拟DOM
版Vue
的消息后,那么他现在大概率已经开始毕业工作了都没能等到无虚拟DOM
版Vue
的发布。这个时间拖的有点太长了,甚至从Vue2
到Vue3
都没用这么久。
可以看到Vue3
从立项到发布也就不到两年的时间,而Vapor
呢?从立项到现在已经将近5
年的光阴了,已经比Vue3
所花费的时间多出一倍还多了。所以现在的首要目标是先实现出来一版能用的,后面再慢慢进行优化。我们工作不也是这样么?先优先把功能做出来,优化的事以后再说。那么具体该怎么优化呢:
现在的很多渲染逻辑继承自原版Vue3
,但无虚拟DOM
版Vue
可以采用更优的渲染逻辑。而且现在的Vapor
是跟着原版Vue
的测试集来做的,这也是为了实现和原版Vue
一样的行为。但这可能并不是最优解,之后会慢慢去掉一些不必要的功能,尤其是对性能产生较大影响的功能。
往期精彩文章:
来源:juejin.cn/post/7480069116461088822
leaflet+天地图+更换地图主题
先弄清楚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
怎么将中文数字转为阿拉伯数字?
说在前面
最近实现了一个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;
}
功能测试
柒億零捌拾萬
十萬三十
十萬三
二百五
二百零五
八
插件信息
对我上述提到的插件感兴趣的同学可以看下我前面发的这篇文章:
公众号
关注公众号『 前端也能这么有趣 』,获取更多有趣内容。
发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~
说在后面
🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『
前端也能这么有趣
』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。
来源:juejin.cn/post/7485936146071765030
npm和npx的区别
npx
和 npm
是 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 服务器
关键区别
特性 | npm | npx |
---|---|---|
主要用途 | 安装和管理依赖 | 直接执行包中的命令 |
是否需要安装包 | 需要提前安装(本地或全局) | 可临时下载并执行,无需提前安装 |
典型场景 | 管理项目依赖、运行脚本、发布包 | 运行一次性命令、测试工具、脚手架 |
执行本地包命令 | 需通过 npm run 或完整路径调用 | 直接通过 npx <command> 调用 |
全局包依赖 | 依赖全局安装的包 | 不依赖全局包,可指定版本运行 |
为什么需要 npx
?
- 避免全局污染:
例如运行create-react-app
时,无需全局安装,直接通过npx
临时调用最新版本。 - 简化本地包调用:
本地安装的工具(如eslint
、jest
)可以直接用npx
执行,无需配置package.json
脚本或输入冗长路径。 - 兼容多版本:
可指定版本运行,如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
通过合理使用 npm
和 npx
,可以更高效地管理依赖和执行命令。
来源:juejin.cn/post/7484992785952096267
TypeScript 官方宣布弃用 Enum?Enum 何罪之有?
1. 官方真的不推荐 Enum 了吗?
1.1 事情的起因
起因是看到 科技爱好者周刊(第 340 期) 里面推荐了一篇文章,说是官方不再推荐使用 enum 语法,原文链接在 An ode to TypeScript enums,大致是说 TS 新出了一个 --erasableSyntaxOnly
配置,只允许使用可擦除语法,但是 enum 不可擦除,因此推断官方已不再推荐使用 enum 了。官方并没有直接表达不推荐使用,那官方为什么要出这个配置项呢?
1.2 什么是可擦除语法
就在上周,TypeScript 发布了 5.8 版本,其中有一个改动是添加了 --erasableSyntaxOnly
配置选项,开启后仅允许使用可擦除语法,否则会报错。enum
就是一个不可擦除语法,开启 erasableSyntaxOnly
配置后,使用 enum
会报错。
例如,如果在 tsconfig 文件中配置 "erasableSyntaxOnly": true
(只允许可擦除语法),此时使用不可擦除语法,将会得到报错:
可擦除语法就是可以直接去掉的、仅在编译时存在、不会生成额外运行时代码的语法,例如 type
,interface
。不可擦除语法就是不能直接去掉的、需要编译为JS且会生成额外运行时代码的语法,例如 enum
,namesapce(with runtime code
)。 具体举例如下:
可擦除语法,不生成额外运行时代码,比如 type
、let n: number
、interface
、as number
等:
不可擦除语法,生成额外运行时代码,比如 enum
、namespace
(具有运行时行为的)、类属性参数构造语法糖(Class Parameter properties)等:
// 枚举类型
enum METHOD {
ADD = 'add'
}
// 类属性参数构造
class A {
constructor(public x: number) {}
}
let a: number = 1
console.log(a)
需要注意,具有运行时行为的 namespace
才属于不可擦除语法。
// 不可擦除,具有运行时逻辑
namespace MathUtils {
export function add(a: number, b: number): number {
return a + b;
}
}
// 可擦除,仅用于类型声明,不包含任何运行时逻辑
namespace Shapes {
export interface Rectangle {
width: number;
height: number;
}
}
1.3 TS 官方为什么要出 erasableSyntaxOnly?
官方既然没有直接表达不推荐 enum,那为什么要出 erasableSyntaxOnly
配置来排除 enum
呢?
我找到了 TS 官方文档(The --erasableSyntaxOnly Option)说明:
大致意思是说之前 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 双向映射数据,这会增加运行时的开销。
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 的地方将直接被替换为对应的枚举值:
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') // ❌ 不行
3.3 联合类型(Union Types)
使用联合类型,引用时可匹配的值限定为指定的枚举值了,但同时也没有一个地方可以统一维护枚举值,如果一旦枚举值有调整,其他地方都需要改。
type METHOD =
| 'add'
/**
* @deprecated 不再支持删除
*/
| 'delete'
| 'update'
| 'query'
function doAction(method: METHOD) {
// some code
}
doAction('delete') // ✅ 可行,没有 TSDoc 提示
doAction('remove') // ❌ 不行
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') // ❌ 不行
3.5 Class 类静态属性自定义实现
还有一种方法,参考了 @Hamm 提供的方法,即利用 TS 面向对象的特性,自定义实现一个枚举类,实际上这很类似于后端定义枚举的一般方式。这种方式具有很好的扩展性,自定义程度更高。
- 定义枚举基类
/**
* 枚举基类
*/
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
}
}
- 继承实现具体的枚举(可根据需要扩展)
/**
* 审核状态
*/
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', '审核中')
}
- 使用
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)
})
4. 总结
- TS 可擦除语法 是指
type
、interface
、n:number
等可以直接去掉的、仅在编译时存在、不会生成额外运行时代码的语法 - TS 不可擦除语法 是指
enum
、constructor(public x: number) {}
等不可直接去除且会生成额外运行时代码的语法 - Node.js 23.6.0 版本开始 默认支持直接执行可擦除语法 的 TS 文件
enum
的替代方案有多种,取决于实际需求。用字面量类型 +as const
是比较常用的一种方案。
TS 官方为了兼容 Node.js 23.6.0 这种可执行 TS 文件特性,出了 erasableSyntaxOnly
配置禁用不可擦除语法,反映了 TypeScript 官方对减少运行时开销和优化编译输出的关注,而不是要放弃 enum
。
但或许未来就是要朝着这个方向把 enum 优化掉也说不定呢?
5. 参考链接
来源:juejin.cn/post/7478980680183169078
AI时代下,我用陌生技术栈两天开发完一个App后的总结
AI时代下,我用陌生技术栈两天开发完一个App后的总结
今年的 AI 应用赛道简直是热火朝天,大街上扔一把核桃能砸到三个在讨论 AI 的,更有不少类似于零编程经验用 AI 三天开发 App 登榜 App Store的标题在各大平台的AI话题圈子里刷屏,作为一个在互联网行业摸爬滚打多年的程序员,我做过开源项目,也做过多个小型独立商业项目,最近两年也是在 AI 相关公司任职,对此我既感到兴奋又难免焦虑——为什么我还没遇到这样的机遇?
刚好最近想到了一个点子,是一个结合了屏幕呼吸灯 + 轻音乐 + 白噪声的辅助睡眠移动端 A 应用,我将其命名为“音之梦”,是我某天晚上睡不着看到路由器闪烁的灯光照耀在墙壁上之后突然爆发的灵感。
这是个纯客户端应用,没有太多外部依赖,体量小,正好拿来试一下是不是真的有可能完全让 AI 来将它实现,而为了尽量模拟“编程小白”这个身份,这次我选择用我比较陌生的 Swift UI。
先上结论:
对于小体量的应用,或者只考虑业务实现而不需要考虑架构合理性/可维护性的稍大体量的应用,在纯编码层面确实是完全可以实现的,作为一个不会 Swift UI 的开发者,我确实在不到 2 天时间内完全借助 AI 完成了这个应用的开发,而且已经上架苹果App Store。
以下是应用截图:
感兴趣的朋友们也访问下面的链接或者上App Store搜索 ”音之梦“ 下载体验。
我做了哪些事情?
工具准备
开发工具使用的是Cursor + XCode,开发语言选的 Swift UI,模型自然选择最适合编码的Claude 3.7。
为什么不选择Trae?因为下一个开坑项目准备用Trae + Deepseek来进行效果对比。
产品设计
上面截图展示的其实是第二版, UI和交互流程是我根据产品需求仔细思考琢磨设计出来的。
而第一版则完全由AI生成的,我只是描述了我期望的功能,交互方式和UI效果都是AI来生成的,那自然和我心目中期望的差距很大,不过最开始只是想验证AI的快速编码能力,所以首次上架的还是还是用的这一版的样式,可以看下面的截图:
而因为国区上架需要备案,在等待备案的过程中,我又诞生了很多新的想法,加上对于第一版的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
!!!
来源:juejin.cn/post/7484530047866355766
🔥🔥什么?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 可以在同一个浏览器上下文中发送和接收消息。我们可以通过 MessageChannel
将 localStorage
的变化信息同步到其他部分,起到类似事件监听的效果。
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 中,我们可以使用 onMounted
和 onUnmounted
这两个生命周期钩子来管理事件监听器。(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,其中
onMounted
和onUnmounted
类似于 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
,本文介绍的几种方案各有优缺点。根据你的实际需求,选择合适的方案可以帮助你更高效地解决问题。
- 简单需求:可以考虑使用自定义
StorageEvent
或CustomEvent
实现监听。 - 复杂需求:对于更高级的场景,如跨页面通信,
MessageChannel
或BroadcastChannel
是更好的选择。
如果你有其他的优化技巧或问题,欢迎在评论区分享,让我们一起交流更多的解决方案!
来源:juejin.cn/post/7418117491720323081
CSS换行最容易出现的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-wrap、word-break 或 hyphens。
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走天下。
来源:juejin.cn/post/7450110698728816655
token泄漏产生的悲剧!Vant和Rspack被注入恶意代码,全网大面积被感染
一、事件
2024年12月19号,在前端圈发生了一件匪夷所思的事情,前端开源项目Vant
的多个版本被注入恶意代码后,发布到了npm
上,导致全网大面积被感染。
随后Vant
团队人员出来发声说:经排查可能是我们团队一名成员的 token 被盗用,他们正在紧急处理。 What?token
还能被别人盗用的么,这安全性真的是差点惊掉我的下巴。
然后Vant
团队人员废弃了有问题的版本,并在几个大版本2、3、4上都发布了新的安全版本2.13.6
、3.6.16
、4.9.15
,我刚试了下,现在使用npm i vant@latest
安装的是最新的4.9.15
版本,事件就算是告一段落了。
二、关联事件:Rspack躺枪
攻击者拿到了vant成员的token
后,进一步拿到了同个GitHub
组织下另一个成员的token
,并发布了同样带有恶意代码的Rspack@1.1.7
版本。
这里简单介绍下Rspack
,它是一个基于Rust
编写打的高性能javascript
打包工具,相比于webpack
、rollup
等打包工具,它的构建性能有很大的提升,是字节团队为了解决构建性能的问题而研发的,后开源在github
。
Rspack
这波属实是躺枪了,不过Rspack
团队反应很快,已经在一小时内完成该版本的废弃处理,并发布了1.1.8
修复版本,字节的问题处理速度还是可以的。目前Rspack
的1.1.7
版本在npm上已经删除了,无法安装。
三、带来的影响
Vant
作为一个老牌的国产移动端组件库,由有赞团队负责开发和维护,在github
上已经拥有23.4k的Star
,是一款很优秀的组件库,其在国内的前端项目中应用是非常广泛的,几乎是开发H5项目的首选组件库。vant
官方目前提供了 Vue 2 版本、Vue 3 版本和微信小程序版本,微信小程序版本本次不受影响,遭受攻击的是Vue2
和Vue3
版本。
如果在发布恶意版本还未修复的期间,正好这时候有项目发布安装到了这些恶意版本,那后果不堪设想。要知道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中有哪些“好用到爆”的一行代码?
哈喽,各位小伙伴们,你们好呀,我是喵手。
今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。
我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都尽量抽业余时间把自己所学到所会的,通过文章的形式进行输出,希望以这种方式帮助到更多的初学者或者想入门的小伙伴们,同时也能对自己的技术进行沉淀,加以复盘,查缺补漏。
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦。三连即是对作者我写作道路上最好的鼓励与支持!
前言
偶尔帮同事开发点前端页面,每次写代码,总会遇到一些能让人直呼nb的代码。今天,我们就来盘点一下那些 “好用到爆”的 JavaScript 一行代码。省去复杂的多行代码,直接用一行或者简洁的代码进行实现。也能在同事面前秀一波(当然是展示技术实力,而不是装X 🤓)。
也许你会问:“一行代码真的能有这么强吗?” 别急,接着往下看,保证让你大呼—— 这也行?! 哈哈,待看完之后,你可能会心一笑,原来一行代码还能发挥的如此优雅!核心就是要简洁高效快速实现。
目录
- 妙用之美:一行代码的魅力
- 实用案例:JS 一行代码提升开发效率
- 生成随机数
- 去重数组
- 检查变量类型
- 深拷贝对象
- 交换两个变量的值
- 生成 UUID
- 延伸知识:一行代码背后的原理
- 总结与感悟
妙用之美:一行代码的魅力
为什么“一行代码”如此让人着迷?因为它是 简洁、高效、优雅 的化身。在日常开发中,我们总希望能用更少的代码实现更多的功能,而“一行代码”就像是开发者智慧的结晶,化繁为简,带来极致的编码体验。
当然,别以为一行代码就等同于简单。事实上,这些代码往往利用了 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'
解析:通过正则匹配字符 x
或 y
,并利用 Math.random()
生成随机数,再将其转换为符合 UUID 规范的十六进制格式。
延伸知识
如上这些“一行代码”的实现主要得益于 ES6+ 的特性,如:
- 箭头函数:让函数表达更简洁。
- 解构赋值:提升代码的可读性。
- 扩展运算符:操作数组和对象时更加优雅。
- Set 和 Map:提供高效的数据操作方式。
所以说,深入理解这些特性,不仅能让你更轻松地掌握这些代码,还能将它们灵活地应用到实际开发中,在日常开发中游刃有余,用最简洁的代码实现最复杂的也无需求。
总结与感悟
一行代码的背后,藏着开发者的智慧和对 JavaScript 代码的深入理解。通过这些代码,我们不仅能提升开发效率,还能在细节中感受代码的优雅与美感,这个也是我们一致的追求。
前端开发的乐趣就在于此——简单的代码,却能带来无限可能。如果你有更好用的一行代码,欢迎分享,让我们一起玩耍 JavaScript 的更多妙用!体验其中的乐趣。
... ...
文末
好啦,以上就是我这期的全部内容,如果有任何疑问,欢迎下方留言哦,咱们下期见。
... ...
学习不分先后,知识不分多少;事无巨细,当以虚心求教;三人行,必有我师焉!!!
wished for you successed !!!
⭐️若喜欢我,就请关注我叭。
⭐️若对您有用,就请点赞叭。
⭐️若有疑问,就请评论留言告诉我叭。
来源:juejin.cn/post/7444829930175905855
做定时任务,一定要用这个神库!!
说实话,作为前端开发者,我们经常需要处理一些定时任务,比如轮询接口、定时刷新数据、自动登出等功能。
过去我总是用 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 包时我踩过几个坑,分享给大家:
- 时区问题:有次我设置了一个定时提醒功能,但总是提前 8 小时触发。一查才发现是因为没设置时区。所以国内用户一定要设置
'Asia/Shanghai'
!
// 这样才会在中国时区的下午6点执行
const job = new CronJob('0 0 18 * * *', myFunction, null, true, 'Asia/Shanghai');
- this 指向问题:如果你用箭头函数作为回调,会发现无法访问 CronJob 实例的 this。
// 错误示范
const job = new CronJob('* * * * * *', () => {
console.log('执行任务');
this.stop(); // 这里的 this 不是 job 实例,会报错!
});
// 正确做法
const job = new CronJob('* * * * * *', function () {
console.log('执行任务');
this.stop(); // 这样才能正确访问 job 实例
});
- 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 应用等)。
它让我们能够用简洁的表达式设定复杂的执行计划,帮助我们构建更加智能和用户友好的前端应用。
来源:juejin.cn/post/7486390904992890895
Browser.js:轻松模拟浏览器环境
什么是Browser.js
Browser.js是一个小巧而强大的JavaScript库,它模拟浏览器环境,使得原本只能在浏览器中运行的代码能够在Node.js环境中执行。这意味着你可以在服务器端运行前端代码,而不需要依赖真实浏览器
Browser.js的核心原理
Browser.js通过实现与浏览器兼容的API(如window
、document
、navigator
等)来创建一个近似真实的浏览器上下文。它还支持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接口,快速上手
- 可扩展:支持自定义插件,可以根据需求扩展功能
- 无依赖:不依赖其他大型库或框架,降低项目复杂度
来源:juejin.cn/post/7486845198485585935
Vue 首个 AI 组件库发布!
人工智能(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
前端如何彻底解决重复请求问题
背景
- 保存按钮点击多次,造成新增多个单据
- 列表页疯狂刷新,导致服务器压力大
如何彻底解决
方案:我的思路从请求层面判断相同请求只发送一次,将结果派发给各个订阅者
实现思路
- 对请求进行数据进行hash
- 添加store 存储 hash => Array promise
- 相同请求,直接订阅对应的promise
- 请求取消,则将store中对应的promise置为null
- 请求返回后,调用所有未取消的订阅
核心代码
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
原文地址:Why I chose Tauri instead of Electron
以下为正文。
关于我为什么在Tauri上创建Aptakube的故事,我与Electron的斗争以及它们之间的简短比较。
大约一年前,我决定尝试构建一个桌面应用程序。
我对自己开发的小众应用并不满意,我想我可以开发出更好的应用。我作为全栈开发人员工作了很长时间,但我以前从未构建过桌面应用程序。
我的第一个想法是用 SwiftUI 来构建。开发者喜欢原生应用,我也一直想学习 Swift。然而,在 SwiftUI 上构建会将我的受众限制为 macOS 用户。虽然我有一种感觉,大多数用户无论如何都会使用 macOS,但当我可以构建跨平台应用程序时,为什么要限制自己呢?
现在回想起来,我真的很庆幸当初放弃了 SwiftUI。看看人们在不同的操作系统上使用我的应用就知道了。
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 应用程序有三个入口点: main
、preload(预加载)
,和render(渲染)
。把所有这些和 Vite 连接起来是很痛苦的。我花了大约两周的空闲时间试图让所有东西都工作起来。我失败了,这让我很沮丧。
之后我为 Electron 找到了几十个其他的样板。我试了大约五个。有些还可以,但大多数模板都太固执己见了,而且安装了太多的依赖项,我甚至不知道它们是用来做什么的,这让我不太喜欢。有些甚至根本不工作,因为它们已经被遗弃多年了。
总之,对于刚接触 Electron 的人来说,开发体验低于平均水平。Next.js 和 Expo 将标准设置得如此之高,以至于我开始期待每个框架都能提供类似的体验。
那现在怎么办?
在漫无目的刷 Twitter 的时候,我看到了一条来自 Tauri 的有关 1.0 版本的推文。那时他们已经成立 2 年了,但我完全不知道 Tauri 是什么。我去看了他们的网站,我被震撼了 🤯 这似乎就是我正在寻找的东西。
你知道最棒的是什么吗?他们把一条 npm create tauri-app
命令放在了主页上。
Tauri 用
npx create Tauri -app
命令从一开始就抓住了开发体验。
我决定尝试一下。我运行了 create the tauri-app
命令,体验与 Next.js 非常相似。它问了我几个问题,然后根据我的选择为我创建了一个新项目。
在这之后,我可以简单地运行 npm run dev
,然后我就有了一个带有热加载、TypeScript、Vite 和 Solid.js 的可以运行的应用程序,几乎包含了我开始使用所需的一切。这让我印象深刻,并且很想了解更多。我仍然不得不添加 Prettier、Linters、Tailwind 等类似的东西,但我已经习惯了,而且它比 Electron 容易太多了。
开始(再一次😅),但与 Tauri 一起
虽然在 Electron 中,我可以只用 JavaScript/HTML/CSS 构建整个应用程序,但在 Tauri 中,后端是 Rust,只有前端是 JavaScript。这显然意味着我必须学习 Rust,这让我很兴奋,但也不想花太多时间,因为我想快速构建原型。
我在使用过 7 种以上专业的编程语言,所以我认为学习 Rust 是轻而易举的事。
我错了。我大错特错了。Rust 是很难的,真的很难,至少对我来说是这样!
一年后,在我的应用发布了 20 多个版本之后,我仍然不能说我真正了解 Rust。我知道要不断地定期发布新功能,但每次我必须用 Rust 写一些东西时,我仍然能学到很多新知识。GitHub Copilot 和 ChatGPT 帮了我大忙,我现在还经常使用它们。
像使用字符串这样简单的事情在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-plugin-log - 可配置的日志记录。
- tauri-plugin-store - 存储用户偏好/设置。
- tauri-plugin-window-state - 保存窗口大小和位置。
- window-vibrancy - 使您的窗口生动起来。
- tauri-plugin-sql - 连接任何 SQL 数据库。
- tauri-plugin-aptabase - 用于 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 的未来感到兴奋。
watchOS 上的 Tauri 程序?🤯
正如 Jonas 在他的推文中所提到的,这只是实验性的和折衷的;它可能需要很长时间才能达到生产状态,但看到这个领域的创新仍然非常令人兴奋!
结论
我对选择使用 Tauri 感到非常满意。结合 Solid.js,我能够制作出一个真正快速的应用程序,人们喜欢它!我不是说它总是比 Electron 好,但如果它具有您需要的功能,我建议尝试一下!如前所述,您甚至可能不需要写那么多 Rust 代码,所以不要被吓倒!您会惊讶地发现,只用 JavaScript 就能做的事情有多少。
如果你对 Kubernetes 感兴趣,请查看 Aptakube,这是一个使用 Tauri 构建的 Kubernetes 桌面客户端 😊
我现在正在开发一个面向桌面和移动应用的开源且注重隐私的分析平台。它已经具有各种框架的 SDK,包括 Tauri 和 Electron。顺便说一句,Tauri SDK 被打包为一个 Tauri 插件! 😄
最后,我也活跃在 Twitter 上。如果您有任何问题或反馈,请随时联系我。我喜欢谈论 Tauri!
感谢阅读!👋
来源:juejin.cn/post/7386115583845744649
前端の骚操作代码合集 | 让你的网页充满恶趣味
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>
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 进入全屏模式效果更佳
下面是优化版:
<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>
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>
⚠️ 使用注意事项
- 摄像头功能需 HTTPS 环境或 localhost 才能正常工作
- 反向滚动代码可能影响用户体验,建议仅在整蛊场景使用
- 数字雨效果会持续消耗 GPU 资源
- 控制台彩蛋要确保不会暴露敏感信息
这些代码就像前端的"复活节彩蛋",适度使用能让网站充满趣味性,但千万别用在生产环境哦!(≧∇≦)ノ
https://codepen.io/ 链接 CodePen)
希望这篇博客能成为程序员的快乐源泉!🎉
来源:juejin.cn/post/7477573759254675507
别再追逐全新框架了,先打好基础再说......
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
都有不同的团队在使用。所以不用担心你学的框架是什么,这并不影响你进大厂
因此,掌握好基础就显得太重要了。
所以说:不用过于追逐新的技术框架。
针对于 在校生
而言 打好基础,练习算法,多去做更多有价值的项目,研究框架底层源码 ,并且一定要注意 练习表达,这才是对你将来校招最重要的事情!
而对于 社招的同学
而言 多去挖掘项目的重难点,尝试通过 输出知识的方式 帮助你更好的梳理技术。多去思考 技术如何解决业务问题,这才是关键!
来源:juejin.cn/post/7484960608782336027
让你辛辛苦苦选好的筛选条件刷新页面后不丢失,该怎么做?
你有遇到过同样的需求吗?告诉我你的第一想法。存 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
上的筛选。
一些其他的应用场景
- 重定向用户:
- 当用户访问一个不再存在或不推荐使用的旧路径时,可以使用
router.replace
将他们重定向到新的路径。这避免了用户点击“返回”按钮时再次回到旧路径。
- 当用户访问一个不再存在或不推荐使用的旧路径时,可以使用
- 处理表单提交后清理 URL:
- 在表单提交后,可能会在 URL 中附加查询参数。使用
router.replace
可以在处理完表单数据后清理这些参数,保持 URL 的整洁。
- 在表单提交后,可能会在 URL 中附加查询参数。使用
- 登录后跳转:
- 在用户登录后,将他们重定向到一个特定的页面(如用户主页或仪表盘),并且不希望他们通过“返回”按钮回到登录页面。使用
router.replace
可以实现这一点。
- 在用户登录后,将他们重定向到一个特定的页面(如用户主页或仪表盘),并且不希望他们通过“返回”按钮回到登录页面。使用
- 错误页面处理:
- 当用户导航到一个不存在的页面时,可以使用
router.replace
将他们重定向到一个错误页面(如 404 页面),并且不希望这个错误路径保留在浏览历史中。
- 当用户导航到一个不存在的页面时,可以使用
- 动态内容加载:
- 在需要根据用户操作动态加载内容时,使用
router.replace
更新 URL,而不希望用户通过“返回”按钮回到之前的状态。例如,在单页应用中根据选项卡切换更新 URL。
- 在需要根据用户操作动态加载内容时,使用
- 多步骤流程:
- 在多步骤的用户流程中(如注册或购买流程),使用
router.replace
可以在用户完成每一步时更新 URL,而不希望用户通过“返回”按钮回到上一步。
- 在多步骤的用户流程中(如注册或购买流程),使用
- 清理查询参数:
- 在用户操作完成后,使用
router.replace
清理不再需要的查询参数,保持 URL 简洁且易于阅读。
- 在用户操作完成后,使用
小结
简单来说就是把你的
url
当成浏览器的sessionstorage
了。其实这就是我上周收到的任务,当时我甚至纠结的是该用localStorage还是sessionStorage,忙活半天,不停转类型,然后在开周会我讲了下我的思路。我的tl便说出了我的问题,讲了更加详细的需求,我才开始尝试router.replace
,又是一顿忙活。。
来源:juejin.cn/post/7424034641379098663
做了个渐变边框的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
:渐变背景会显示在边框位置,形成渐变边框效果。
这段代码分为两层背景:
- 第一层背景:
linear-gradient(white, white)
是一个纯白色的线性渐变,用于覆盖输入框的内容区域(padding-box
)。 - 第二层背景:
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
几行代码,优雅的避免接口重复请求!同事都说好!
背景简介
我们日常开发中,经常会遇到点击一个按钮或者进行搜索时,请求接口的需求。
如果我们不做优化,连续点击按钮或者进行搜索,接口会重复请求。
首先,这会导致性能浪费!最重要的,如果接口响应比较慢,此时,我们在做其他操作会有一系列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 参数
来源:juejin.cn/post/7380185173689204746
最近 React Scan 太火了,做了个 Vue Scan
在 React Scan 的 github 有这么一张 gif 图片。当用户在页面上操作时,对应的组件会有一个闪烁,表示当前组件更新了。用这样的方式来排查程序的性能是一个很直观的方式。
根据 React Scan 自己的介绍,React Scan 可以 通过自动检测和突出显示导致性能问题的渲染
。
Vue Scan
但是我主要使用 vue 来开发我的应用,看到这个功能非常眼馋,所以就动手自己做了一个 demo,目前也构建了一个 chrome 扩展,不过扩展仅支持识别 vue3 项目 现在已经支持 vue2 和 vue3 项目了。
项目地址:Vue Scan
简单介绍,Vue Scan 通过组件的 onBeforUpdate 钩子,当组件更新时,在组件对应位置绘制一个闪烁的边框。看起来的效果就像这样。
用法
我更推荐在开发环境使用它,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 寻找一下最新版的安装包,然后解压安装到浏览器中。
安装完成后,你的扩展区域应该会多一个图标,点击之后会展开一个面板,允许你控制是否注入相关的内容。
这是如果你进入一个使用 vue 构建的网站,可以看控制台看到相关的信息,当你在页面交互时,页面应该也有相应的展示。
缺陷
就像 React Scan 的介绍中提到的,它能自动识别性能问题,单目前 Vue Scan 只是真实地反映组件的更新,并不会区分和识别此次更新是否有性能问题。
结语
通过观察网站交互时组件的更新状态,来尝试发现网站的性能问题,我觉得这是一个很好的方式。希望这个工具可以给大家带来一点乐趣和帮助。
来源:juejin.cn/post/7444449353165488168
Electron 启动白屏解决方案
对于 Web 开发者使用 Electron 构建桌面应用程序时,经常会遇到如上图所示的一个问题 —— 窗口加载过程中长时间白屏。在应用窗口创建完成到页面加载出来的这段时间里,出现了长时间的白屏,这个问题对于前端开发来说是一个老生常谈的问题,纯 Web 端可能就是异步加载、静态资源压缩、CDN 以及骨架屏等等优化方案,但是如果是开发 Electron 应用,场景又有些许不同,因此我们也不能完全按照通用的前端解决白屏的方案进行处理,本文就来探索基于 Electron 场景下启动白屏的解决方案。
问题原因分析
1. Electron 主进程加载时间过长
Electron 应用在启动时,需要先加载主进程,然后由主进程去创建浏览器窗口和加载页面。如果主进程加载时间过长,就会导致应用一直停留在空白窗口,出现白屏。
主进程加载时间长的原因可以有:
- 初始化逻辑复杂,比如加载大量数据、执行计算任务等
- 主进程依赖的模块加载时间长,例如 Native 模块编译耗时
- 主进程代码进行了大量同步 I/O 操作,阻塞了事件循环
2. Web 部分性能优化不足
浏览器窗口加载 HTML、JavaScript、CSS 等静态资源是一个渐进的过程,如果资源体积过大,加载时间过长,在加载过程中就会短暂出现白屏,这一点其实就是我们常说的前端首屏加载时间过长的问题。导致 Web 加载时间过长的原因可以是:
- 页面体积大,如加载过多图片、视频等大资源
- 没有代码拆分,一次加载全部 Bundles
- 缺乏缓存机制,资源无法命中缓存
- 主线程运算量大,频繁阻塞渲染
解决方案
1. 常规 Web 端性能优化
Web 端加载渲染过程中的白屏,可以采用常规前端的性能优化手段:
- 代码拆分,异步加载,避免大包导致的加载时间过长
- 静态资源压缩合并、CDN 加速,减少资源加载时间
- 使用骨架屏技术,先提供页面骨架,优化用户体验
- 减少主线程工作量,比如使用 Web Worker 进行复杂计算
- 避免频繁布局重排,优化 DOM 操作
以上优化可以明显减少 HTML 和资源加载渲染的时,缩短白屏现象。还是那句话,纯 Web 端的性能优化对于前端开发来说老生常谈,我这边不做详细的赘述,不提供实际代码,开发者可以参考其他大佬写的性能优化文章,本文主要针对的是 Electron 启动白屏过长的问题,因为体验下来 Electron 白屏的本质问题还是要通过 Electron 自身来解决~
2. 控制 Electron 主进程加载时机
Electron 启动长时间白屏的本质原因,前面特意强调了,解决方案还是得看 Electron 自身的加载时机,因为我这边将 Web 部分的代码打包启动,白屏时间是非常短的,与上面动图里肉眼可见的白屏时间形成了鲜明的对比。所以为了解决这个问题,我们还是要探寻 Electron 的加载时机,通过对 Electron 的启动流程分析,我们发现:
- 如果在主进程准备就绪之前就创建并显示浏览器窗口,由于此时渲染进程和页面还未开始加载,窗口内自然就是空白,因此需要确保在合适的时机创建窗口。
- 反之如果创建窗口后,又长时间不调用
window.show()
显示窗口,那么窗口会一直在后台加载页面,用户也会看不到,从而出现白屏的效果。
因此我们可以通过控制主进程的 Ready 事件时机以及 Window 窗口的加载时机来对这个问题进行优化,同样的关于加载时机我们也可以有两种方案进行优化:
- 通过监听
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()
让窗口显示就解决了白屏的问题
- 通过监听
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 都是前端的概念,通过这种方式也是可以解决启动白屏的。
相关文档:BrowserWindow、webCotnents
最后解决完成的效果如下:
总结
从上图来看最终的效果还是不错的,当窗口出现的一瞬间页面就直接加载完成了,不过细心的小伙伴应该会发现,这个方案属于偷梁换柱,给用户的感觉是窗口出现的时候页面就有内容了,但是其实窗口没出现的时间是有空档期的,大概就是下面这个意思:
从上图以及实际效果来看,其实我们的启动时间是没有发生改变的,但是因为端上应用和我们纯 Web 应用的使用场景不同,它自身就是有应用的启动时间,所以空档期如果不长,这个方案的体验还是可以的。但是如果前面的空档期过长,那么可能就是 Electron 启动的时候加载资源过多造成的了,就需要其他优化方案了。由此也可以见得其实对于用户体验来说,可能我们的产品性能并不一定有提升,只要从场景出发从用户角度去考虑问题,其实就能提升整个应用的体验。
回归本篇文章,我们从问题入手分析了 Electron 启动白屏出现的原因并提供了对应的解决方案,笔者其实对 Electron 的开发也并不深入,只是解决了一个需求一个问题用文章做记录,欢迎大家交流心得,共同进步~
来源:juejin.cn/post/7371386534179520539
用electron写个浏览器给自己玩
浏览器这种东西工程量很唬人,但是有了electron+webview我们就相当于只需要干组装的活就可以了,而且产品目标就是给自己玩,
成品的效果
😄本来想写成专业的技术博客,但是发现大家好像对那种密密麻麻,全是代码的技术博客不感兴趣,我就挑重点来写吧。
下载拦截功能
下载逻辑如果不做拦截处理的话,默认就是我们平常写web那种弹窗的方式,既然是浏览器肯定不能是那样的。
electron中可以监听BrowserWindow的页面下载事件,并把拿到的下载状态传给渲染线程,实现类似浏览器的下载器功能。
//这个global.WIN = global.WIN = new BrowserWindow({ ...})
global.WIN.webContents.session.on('will-download', (evt, item) => {
//其他逻辑
item.on('updated', (evt, state) => {
//实时的下载进度传递给渲染线程
})
})
页面搜索功能
当时做这个功能的时候我就觉得完了,这个玩意看起来太麻烦了,还要有一个的功能这不是头皮发麻啊。
查资料和文档发现这个居然是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
这里我们看dom就能清晰的看出来。
地址栏功能
地址栏支持输入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 }
}
东西太多了就先介绍这些了
安装包地址
也可以到官网后aweb123.com 如何进入微软商店下载,mac版本因为文件大于100mb没有传上去所以暂时还用不了。
来源:juejin.cn/post/7395389351641612300
大声点回答我:token应该存储在cookie还是localStorage上?
背景
前置文章:浏览器: cookie机制完全解析
在考虑token是否应该存储在cookie或localStorage中时,我们需要综合考虑安全性、便利性、两者的能力边界以及设计目的等因素。
安全性:
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
这次终于轮到前端给后端兜底了🤣
需求交代
最近我们项目组开发了个互联网采集的功能,也就是后端合理抓取了第三方的文章,数据结构大致如下:
<h1>前端人</h1>
<p>学好前端,走遍天下都不怕</p>
数据抓取到后,存储到数据库,然后前端请求接口获取到数据,直接在页面预览
<div v-html='articleContent'></div>
整个需求已经交代清楚
这个需求有点为难后端了
前天,客户说要新增一个文章的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元素存在没有完整的闭合情况
但浏览器是强大的,丝毫不影响渲染效果,原来浏览器自动帮我们补全结构了
可后端处理这件事就没那么简单了,爬取到的数据也比我举例的要复杂的多,使用第三方插件将html转pdf时会识别标签异常等问题,因此程序会抛异常
来自后端的建议
苦逼的后端折腾了很久,还是没折腾出来,终于他发现前端页面有个右键打印的功能,也就是:
于是他说:浏览器这玩意整挺好啊,前端能不能研究研究,尝试从前端实现导出
那就研究研究
我印象中,确实有个叫vue-print-nb
的前端插件,可以实现这个功能
但.......等等,这个插件仅仅是唤起打印的功能,我总不能真做成这样,让用户另存为pdf吧
于是,只能另辟蹊径,终于我找到了这么个仓库:github.com/burc-li/vue…
里面实现了dom元素导出pdf的功能
效果很不错,技术用到了jspdf
、html2canvas
这两个第三方库,代码十分简单
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>
功能正常,似乎一切都完美
问题没有想的那么简单
如果我们的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,图片占位处是个空白块
思考一下:类似案例中的图片加载方式,都是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链接必须是同源或者允许跨越的,否则就会存在图片加载异常的问题
修复图片过大的问题
部分图片的宽度会过大,导致图片加载不全的问题,这在预览的情况下也存在
因为需要加上样式限定
img {
max-width: 100%;
max-height: 100%;
vertical-align: middle;
height: auto !important;
width: auto !important;
margin: 10px 0;
}
这样就正常啦
故此需要在导出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
微信,邀你进群交流
往期精彩
来源:juejin.cn/post/7486440418139652137
React 官方推荐使用 Vite
“技术更替不是一场革命,而是一场漫长的进化过程。”
Hello,大家好,我是 三千。
React 官方已明确建议开发者逐步淘汰 Create React App (CRA) ,转而使用 Vite 等现代框架或工具来创建新项目。
那官方为什么要这样做呢?
一、CRA 被淘汰的背景与原因
- 历史局限性
CRA 诞生于 2016 年,旨在简化 React 项目的初始化配置,但其底层基于 Webpack 和 Babel 的架构在性能、扩展性和灵活性上逐渐无法满足现代开发需求。随着项目规模扩大,CRA 的启动和构建速度显著下降,且默认配置难以优化生产包体积。 - 维护停滞与兼容性问题
React 团队于 2023 年宣布停止积极维护 CRA,且 CRA 的最新版本(v5.0.1)已无法兼容 React 19 等新特性,导致其在生产环境中逐渐不适用。 - 缺乏对现代开发模式的支持
CRA 仅提供客户端渲染(CSR)的默认配置,无法满足服务端渲染(SSR)、静态生成(SSG)等需求。此外,其“零配置”理念限制了路由、状态管理等常见需求的灵活实现。
二、Vite 成为 React 官方推荐的核心优势
- 性能提升
- 开发速度:Vite 基于原生 ESM 模块和 esbuild(Go 语言编写)实现秒级启动与热更新,显著优于 CRA 的 Webpack 打包机制。
- 生产构建:通过 Rollup 优化代码体积,支持 Tree Shaking 和懒加载,减少冗余代码。
- 灵活性与生态兼容
- 配置自由:允许开发者按需定制构建流程,支持 TypeScript、CSS 预处理器、SVG 等特性,无需繁琐的
eject
操作。 - 框架无关性:虽与 React 深度集成,但也可用于 Vue、Svelte 等项目,适应多样化技术栈。
- 配置自由:允许开发者按需定制构建流程,支持 TypeScript、CSS 预处理器、SVG 等特性,无需繁琐的
- 现代化开发体验
- 原生浏览器支持:利用现代浏览器的 ESM 特性,无需打包即可直接加载模块。
- 插件生态:丰富的 Vite 插件(如
@vitejs/plugin-react
)简化了 React 项目的开发与调试。
三、迁移至 Vite 的具体步骤
- 卸载 CRA 依赖
npm uninstall react-scripts
npm install vite @vitejs/plugin-react --save-dev
- 调整项目结构
- 将
index.html
移至项目根目录,并更新脚本引用为 ESM 格式:
<script type="module" src="/src/main.jsx"></script>
- 将
.js
文件扩展名改为.jsx
(如App.js
→App.jsx
)。
- 将
- 配置 Vite
创建vite.config.js
文件:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});
- 更新环境变量
环境变量前缀需从REACT_APP_
改为VITE_
(如VITE_API_KEY=123
)。 - 运行与调试
修改package.json
脚本命令:
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
四、其他官方推荐的 React 框架
- Next.js
- 适用场景:服务端渲染(SSR)、静态生成(SSG)及全栈应用开发。
- 优势:内置路由、API 路由、图像优化等功能,适合企业级应用与 SEO 敏感项目。
- Remix
- 适用场景:嵌套路由驱动的全栈应用,注重数据加载优化与渐进增强。
- 优势:集成数据预加载机制,减少请求瀑布问题。
- 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 吗?
前言
在 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]
错误处理体系
- 基础层:80% 的异步操作使用 try/catch + 类型检查
- 中间层:15% 的通用错误使用全局拦截 + 日志上报
- 战略层:5% 的关键操作实现自动恢复机制
小结
我的观点是:不强制要求,但强烈推荐
- 不强制:如果不需要处理错误,可以不使用
try/catch
,但未捕获的 Promise 拒绝(unhandled rejection)会导致程序崩溃(在 Node.js 或现代浏览器中)。 - 推荐:90% 的场景下需要捕获错误,因此
try/catch
是最直接的错误处理方式。
所有我个人观点:使用 async/await 尽量使用 try/catch。好的错误处理不是消灭错误,而是让系统具备优雅降级的能力。
你的代码应该像优秀的飞行员——在遇到气流时,仍能保持平稳飞行。大家如有不同意见,还请评论区讨论,说出自己的见解。
来源:juejin.cn/post/7482013975077928995
告别龟速删除!前端老司机教你秒删node_modules的黑科技
引言:每个前端的痛——node_modules删除噩梦
“npm install一时爽,删包火葬场。”这几乎是所有Node.js开发者都经历过的痛。尤其是当项目依赖复杂时,动辄几百MB甚至几个G的node_modules
文件夹,手动删除时转圈圈的进度条简直让人抓狂。
如何高效解决这个问题?今天我们就来揭秘几种秒删node_modules的硬核技巧,让你从此告别龟速删除!
一、为什么手动删除node_modules这么慢?
node_modules
的目录结构复杂,层级深、文件数量庞大(比如一个中型项目可能有上万个小文件)。手动删除时,操作系统需要逐个处理这些文件,导致效率极低,尤其是Windows系统表现更差。核心原因包括:
- 文件系统限制:Windows的NTFS和macOS的HFS+对超多小文件的删除并未优化,系统需要频繁更新索引和缓存,资源占用高。
- 权限问题:某些文件可能被进程占用或权限不足,导致删除失败或卡顿。
- 递归删除效率低:系统自带的删除命令(如右键删除)是单线程操作,而
node_modules
的嵌套结构会让递归删除耗时剧增。
二、终极方案:用rimraf实现“秒删”
如果你还在手动拖拽删除,赶紧试试这个Node.js社区公认的神器——rimraf!它的原理是封装了rm -rf
命令,通过减少系统调用和优化递归逻辑,速度提升可达10倍以上。
操作步骤
- 全局安装rimraf(仅需一次):
npm install rimraf -g
- 一键删除:
进入项目根目录,执行:
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
,重新安装依赖仍会报错。此时需要彻底清理残留:
- 清除npm缓存:
npm cache clean --force
- 删除锁文件:
手动移除package-lock.json
或yarn.lock
。 - 重启IDE:确保没有进程占用文件。
五、总结:选对工具,效率翻倍
方案 | 适用场景 | 速度对比 |
---|---|---|
rimraf | 跨平台、大型项目 | ⚡⚡⚡⚡⚡ |
系统命令 | 临时快速操作 | ⚡⚡⚡ |
手动删除 | 极小项目(不推荐) | ⚡ |
推荐组合拳:日常使用rimraf
+脚本,遇到权限问题时切换系统命令。
互动话题
你遇到过最离谱的node_modules
有多大?评论区晒出你的经历!
来源:juejin.cn/post/7477926585087606820
AI 赋能 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/*,表示该文件输入框只接收图像类型的文件。
我们可以试试通过输入:
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图片识别任务。
首先我们通过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的子标签。
- 因为我们要读取整个图片文件,所以我们可以创建一个FileReader对象读取图片文件。在文件读取完成时我们可以用reader.onload实现在文件读取完毕时在id为mage-container的div标签内添加一个img标签输出图片。
- 我们用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的所有元素遍历进行渲染。
接下来我们要完成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标签中,也就是添加到图片物体区域中。
我们用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分析,再通过第三部分输出结果。
我们要有灵活的封装思想。
我们将图片识别功能分别分成三个部分实现。首先通过第一部分读取图片,通过第二部分进行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;
}
该部分不做过度解释,直接上代码。
.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;
}
效果展示
- 选择图片上传

- 选择图片后进行分析的过程
- 选择图片上传
- 选择图片后进行分析的过程
控制台输出的是e2.target.result的内容。
- 分析结果
代码
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>
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 客户端被第三方页面入侵了...
问题描述
公司有个内部项目是用 Electron
来开发的,有个功能需要像浏览器一样加载第三方站点。
本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面。
这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码。
if (window.top !== window.self) {
window.top.location = window.location;
}
翻译一下就是:如果当前窗口不是顶级窗口的话,将当前窗口设置为顶级窗口。
奇怪的是两者不是 跨域 了吗,为什么 iframe
还可以影响顶级窗口。
先说一下我当时的一些解决办法:
- 用
webview
替换iframe
- 给
iframe
添加sandbox
属性
后续内容就是一点复盘工作。
场景复现(Web端)
一开始怀疑是客户端的问题,所以我用在纯 Web 上进行了一次对比验证。
这里我们新建两个文件:1.html
和 2.html
,我们称之为 页面A 和 页面B。
然后起了两个本地服务器来模拟同源与跨域的情况。
页面A:http://127.0.0.1:5500/1.html
页面B:http://127.0.0.1:5500/2.html
和 http://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 是可以 互相访问 到对方窗口的路径。
如果这个时候在 页面B 加上文章开头提到的 代码片段,那么显然页面将会发生变化。
跨域的情况
这时候我们修改 页面A 加载 页面B 的地址,使其不符合同源策略。
理所应当的是,两个页面不能够相互访问了,这才是正常的,否则内嵌第三方页面可以互相修改,那就太不安全了。
场景复现(客户端)
既然 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
对不起,干干净净的 Electron
根本不背这个锅,在它身上的表现如同 Web端 一样,也受同源策略的限制。
那么肯定是我的项目里有什么特殊的配置,通过对比主进程的代码,答案终于揭晓。
new BrowserWindow({
...,
webPreferences: {
...,
webSecurity: false // 就是因为它
}
})
Electron 官方文档 里是这么描述 webSecurity
这个配置的。
webSecurity
boolean (可选) - 当设置为false
, 它将禁用同源策略 (通常用来测试网站), 如果此选项不是由开发者设置的,还会把allowRunningInsecureContent
设置为true
. 默认值为true
。
也就是说,Electron
本身是有一层屏障的,但当该属性设置为 false
的时候,我们的客户端将会绕过同源策略的限制,这层屏障也就消失了,因此 iframe
的行为表现得像是嵌套了同源的站点一样。
解决方案
把这个配置去掉,确实是可以解决这个问题,但考虑到可能对其他功能造成的影响,只能采取其他方案。
如文章开头提到的,用 webview
替换 iframe
。
webview
是 Electron
的一个自定义元素(标签),可用于在应用程序中嵌入第三方网页,它默认开启安全策略,直接实现了主应用与嵌入页面的隔离。
因为目前这个需求是仅作展示,不需要与嵌套页面进行交互以及复杂的通信,因此在一开始的开发过程中,并没有使用它,而是直接采用了 iframe
。
而 iframe
也能够实现类似的效果,只需要添加一个 sandbox
属性可以解决。
MDN 中提到,sandbox
控制应用于嵌入在 <iframe>
中的内容的限制。该属性的值可以为空以应用所有限制,也可以为空格分隔的标记以解除特定的限制。
如此一来,就算是同源的,两者也不会互相干扰。
总结
这不是一个复杂的问题,发现后及时修复了,并没有造成很大的影响(还好是自己人用的平台)。
写这篇文章的主要目的是为了记录这次事件,让我意识到在平时开发过程中,把注意力过多的放在了 业务
、样式
、性能
等这些看得见的问题上,可能很少关注甚至忽略了 安全
这一要素,以为前端框架能够防御像 XSS
这样的攻击就能安枕无忧。
谨记,永远不要相信第三方,距离产生美。
如有纰漏,欢迎在评论区指出。
来源:juejin.cn/post/7398418805971877914
Electron 30.0.0
作者: 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
或者从我们的发布网站下载它。继续阅读此版本的详细信息。
如果您有任何反馈,请在 Twitter 或 Mastodon 上与我们分享,或加入我们的 Discord 社区! Bug 和功能请求可以在 Electron 的问题跟踪器中报告。
重要变化
重点内容
- Windows 现在支持 ASAR 完整性检查 (#40504)
- 启用 ASAR 完整性的现有应用程序如果配置不正确,可能无法在 Windows 上工作。使用 Electron 打包工具的应用应该升级到
@electron/packager@18.3.1
或@electron/forge@7.4.0
。 - 查看我们的 ASAR Integrity 教程 以获取更多信息。
- 启用 ASAR 完整性的现有应用程序如果配置不正确,可能无法在 Windows 上工作。使用 Electron 打包工具的应用应该升级到
- 添加了
WebContentsView
和BaseWindow
主进程模块,废弃并替换BrowserView
(#35658)
BrowserView
现在是WebContentsView
的一个壳,并且旧的实现已被移除。- 查看 我们的 Web Embeds 文档 以便将新的
WebContentsView
API 和其他类似 API 进行比较。
- 实现了对 File System API 的支持 (#41827)
架构(Stack)更新
- Chromium
124.0.6367.49
- Chrome 124 和 DevTools 124 中的新功能
- Chrome 123 和 DevTools 123 中的新功能
- Node
20.11.1
- V8
12.4
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 中不再需要这种逻辑,因为自动调整大小的行为是一致的。
移除:WebContents
上 context-menu
的 params.inputFormType
属性
WebContents
的 context-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.y | 31.x.y | 32.x.y |
29.x.y | 30.x.y | 31.x.y |
28.x.y | 29.x.y | 30.x.y |
接下来
在短期内,您可以期待团队继续专注于跟上构成 Electron 的主要组件的开发,包括 Chromium、Node 和 V8。
您可以在此处找到 Electron 的公开时间表。
有关这些和未来变化的更多信息可在计划的突破性变化页面找到。
原文: Electron 30.0.0
来源:juejin.cn/post/7361426249380397068
我的 Electron 客户端也可以全量/增量更新了
前言
本文主要介绍 Electron
客户端应用的自动更新,包括全量和增量这两种方式。
全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。
增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。
本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。
如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。
前置说明:
- 由于业务场景的限制,本文介绍的更新仅支持
Windows
操作系统,其余操作系统未作兼容处理。 - 更新流程全部由前端完成,不涉及到后端,没有轮询 更新的机制。
- 发布方式限制为
generic
,线上服务需要配置nginx
确保访问到资源文件。
准备工作
脚手架搭建项目
我们通过 electron-vite 快速搭建一个基于 Vite + React + TS
的 Electron
项目。
该模板已经包括了我们需要的核心第三方库:electron-builder
,electron-updater
。
前者是用来打包客户端程序的,后者是用来实现自动更新的。
在项目根目录下,已经自动生成了两份配置文件:electron-builder.yml
和 dev-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
。
模拟服务器
我们直接运行 npm run build:win
,在默认 dist
文件夹下就出现了打包后的一些资源。
其实更新的基本思路就是对比版本号,即对比本地版本和 线上服务器 版本是否一致,若不一致则需要更新。
因此我们在开发调试的时候,需要起一个本地服务器,来模拟真实的情况。
新建一个文件夹 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. 发现新版本
2. 无需更新
增量更新
为什么要这么做
其实全量更新是一种比较简单粗暴的更新方式,我没有花费太大的篇幅去介绍,基本上就是使用 autoUpdater
封装的一些方法,开发过程中更多的注意力则是放在了渲染进程的交互上。
此外,我们的更新交互用的是一种 用户感知 的方式,即更不更新是由用户自己决定的。而 静默更新 则是用户无感知,在监测到更新后不用去通知用户,而是自行在后台下载,在某个时刻执行安装的逻辑,这种方式往往是一键安装,不会弹出让用户去选择安装路径的窗口。接下去要介绍的增量更新其实是这种 用户无感知
的形式,只不过我们更新的不是整个应用程序。
由于产品迭代比较频繁,我们的业务方经常收到更新提示,意味着他们要经常手动更新应用,以便使用到最新的功能特性。众所周知,Electron
给前端开发提供了一个比较容易上手的客户端开发解决方案,但在享受 跨平台
等特性的同时,还得忍受 臃肿的安装包 。
带宽、流量在当前不是什么大问题,下载和安装的速度也挺快,但这频繁的下载覆盖一模一样的资源文件还是挺糟心的,代码改动量小时,全量更新完全没有必要。我们希望的是,开发更新了什么代码,应用程序就替换掉这部分代码就好了,这很优雅。在 我们的场景 中,这部分代码指的是 —— main、preload、renderer
,并不包括 dll
、第三方SDK
等资源。
网上有挺多种增量更新的 解决方案,例如:
- 通过
win.loadURL(一个线上地址)
实现,相当于就套了一层客户端的壳子,与加载的Web
端同步更新。这种方法对于简单应用来说是最省心的,但是也有一定的局限性,例如不能使用node
去操作一些底层的东西。 - 设置
asar
的归档方式,替换app.asar
或app.asar.unpack
来实现。但后者在我实践过程中存在文件路径不存在的问题。 - 禁用
asar
归档,解压渲染进程压缩包后实现替换。简单尝试过,首次执行安装包的时候写入文件很慢,不知道是不是这个方式引起的。 - 欢迎补充。
本文我们采用较普遍的 替换asar 来实现。
优化 app.asar 体积
asar
是Electron
提供的一种将多个文件合并成一个文件的类tar
风格的归档格式,不仅可以缓解Windows
下路径名过长的问题, 还能够略微加快一下require
的速度, 并且可以隐藏你的源代码。(并非完全隐藏,有方法可以找出来)
Electron
应用程序启动的时候,会读取 app.asar.unpacked
目录中的内容合并到 app.asar
的目录中进行加载。在此之前我们已经打包了一份 Demo 项目,安装到本地后,我们可以根据安装路径找到 app.asar
这个文件。
例如:D:\你的安装路径\electron-update-demo\resources
在这个 Demo 项目中,脚手架生成的模板将图标剥离出来,因此出现了一个 app.asar.unpacked
文件夹。我们不难发现,app.asar
这个文件竟然有 66 MB,再回过头看我们真正的代码文件,甚至才 1 MB !那么到底是什么原因导致的这个文件这么大呢,刚才提到了,asar
其实是一种压缩格式,因此我们只要解压看看就知道了。
npm i -g asar // 全局安装
asar e app.asar folder // 解压到folder文件夹
解压后我们不难发现,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
在打包时会以该文件夹作为打包的根文件夹,即只会打包这个文件夹下的文件。
但是我原项目的主进程和预加载脚本也放在这,尝试修改后出现打包错误的情况,感兴趣的可以试试。
这是我们优化之后的结果,1.32 MB,后面只需要替换这个文件就实现了版本的更新。
校验增量更新
全量更新的校验逻辑是第三方库已经封装好了的,那么对于增量更新,就得由我们自己来实现了。
首先明确一下 校验的时机,package.json
的 version
字段表示应用程序的版本,用于全量更新的判断,那也就意味着,当这个字段保持不变的时候,会触发上文提到的 update-not-available
事件。所以我们可以在这个事件的回调函数里来进行校验。
autoUpdater.on('update-not-available', info => { console.log('不需要全量更新', info.version) })
然后就是 如何校验,我们回过头来看 electron-builder
的打包配置,在 releaseInfo
字段里描述了发行版本的一些信息,目前我们用到了 releaseNotes
来存储更新日志,查阅官网得知还有个 releaseName
好像对于我们而言是没什么用的,那么我们就可以把它作为增量更新的 热版本号。(但其实 官网 配置项还有个 vendor
字段,可是我在用的时候打包会报错,可能是版本的问题,感兴趣的可以试试。)
每次发布新版本的时候,只要不是 Electron自身版本变化
等重大更新,我们都可以通过修改 releaseInfo
的 releaseName
来发布我们的热版本号。现在我们已经有了线上的版本,那 本地热版本号 该如何获取呢。这里我们在每次热更新完成之后,就会把最新的版本号存储在本地的一个配置文件中,通常是 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
文件夹里来模拟真实的情况。有很多方式可以去下载文件,这里我用了 nodejs
的 http
模块去实现,如果是 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.asar
到 resources
目录,并重命名为 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
文件。
至此,下载这一步已经完成了,但这样还会有一个问题,因为文件名没有加上版本号,也没有重复性校验,倘若一直没有更新,那么每次启动应用需要增量更新的时候都需要再次下载。
替换 app.asar 文件
好了,新文件也有了,接下来就是直接替换掉老文件就可以了,但是 替换的时机 很重要。
在 Windows
操作系统下,直接替换 app.asar
会提示程序被占用而导致失败,所以我们应该在程序关闭的时候实现替换。
- 进入应用,下载新版本,自动关闭应用,替换资源,重启应用。
- 进入应用,下载新版本,不影响使用,用户 手动关闭 后替换,下次启动 就是新版本了。
我们有上面两种方案,最终采用了 方案2
。
在主进程监听 app.on('quit')
事件,在应用退出的时候,判断 app.asar
和 app.asar-temp
是否同时存在,若同时存在,就去替换。这里替换不能直接用 nodejs
在主进程里去写,因为主进程执行的时候,资源还是被占用的。因此我们需要额外起一个 子进程 去做。
nodejs
可以通过 spawn
、exec
等方法创建一个子进程。子进程可以执行一些自定义的命令,我们并没有使用子进程去执行 nodejs
,因为业务方的机器上不一定有这个环境,而是采用了启动 exe
可执行文件的方式。可能有人问为什么不直接运行 .bat
批处理文件,因为我调试关闭应用的时候,会有一个 命令框闪烁 的现象,这不太优雅,尽管已经设置 spawn
的 windowsHide: 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]
})
但这块有个 疑惑,为什么我的 close
、exit
以及 stdout
都没有触发,以至于不能拿到命令是否执行成功的最终结果,了解的同学可以评论区交流一下。
至此,在关闭应用之后,app.asar
就已经被替换为最新版本了,还差最后一步,更新本地配置文件里的 hotVersion
,防止下次又去下载更新包了。
child.on('spawn', () => {
log.info('子进程触发')
updateHotVersion()
})
updateHotVersion() {
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion }, null, 2))
}
增量更新日志提示
既然之前提到的全量更新有日志说明,那增量更新可以不用,也应该需要具备这个能力,不然我们神不知鬼不觉帮用户更新了,用户都不知道 新在哪里。
至于更新内容,我们可以复用 releaseInfo
的 releaseNotes
字段,把更新日志写在这里,增量更新完成后展现给用户就好了。
但是总不能每次打开都展示,这里需要用户有个交互,比如点击 知道了
按钮,或者关闭 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
即可,渲染进程效果如下:
提示增量更新日志
点击 知道了
后,再次打开应用后就不会提示日志了,因为本地配置文件已经被修改了。
当然可能也有这么个场景,开发就改了一点点无关功能的代码,无需向用户展示日志,我们只需要加一个判断 releaseNotes
是否为空的逻辑就好了,也做到了 静默更新。
小结
不足之处
本文提出的增量更新方案应该算是比较简单的,可能并不适用于所有的场景,考虑也不够全面,例如:
dll
、第三方SDK
等资源的更新。- 增量更新失败后应该通过全量更新 兜底。
- 用户在使用过程中发布新版本,得等到 第二次打开 才能用到新版本。
流程图
针对本文的解决方案,我简单画了一个 流程图。
参考文章
网上其实有不少关于 Electron
自动更新的文章,在做这个需求之前也是浏览了好多,但也有些没仔细看完,所以就躺在我的标签页没有关闭,在这罗列出来,也当作收藏了。
写这篇文章的目的也是为了记录和复盘,如果能为你提供一些思路那就再好不过了。
鸣谢:
来源:juejin.cn/post/7416311252580352034
我的 Electron 客户端也可以全量/增量更新了
前言
本文主要介绍 Electron
客户端应用的自动更新,包括全量和增量这两种方式。
全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。
增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。
本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。
如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。
前置说明:
- 由于业务场景的限制,本文介绍的更新仅支持
Windows
操作系统,其余操作系统未作兼容处理。 - 更新流程全部由前端完成,不涉及到后端,没有轮询 更新的机制。
- 发布方式限制为
generic
,线上服务需要配置nginx
确保访问到资源文件。
准备工作
脚手架搭建项目
我们通过 electron-vite 快速搭建一个基于 Vite + React + TS
的 Electron
项目。
该模板已经包括了我们需要的核心第三方库:electron-builder
,electron-updater
。
前者是用来打包客户端程序的,后者是用来实现自动更新的。
在项目根目录下,已经自动生成了两份配置文件:electron-builder.yml
和 dev-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
。
模拟服务器
我们直接运行 npm run build:win
,在默认 dist
文件夹下就出现了打包后的一些资源。
其实更新的基本思路就是对比版本号,即对比本地版本和 线上服务器 版本是否一致,若不一致则需要更新。
因此我们在开发调试的时候,需要起一个本地服务器,来模拟真实的情况。
新建一个文件夹 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. 发现新版本
2. 无需更新
增量更新
为什么要这么做
其实全量更新是一种比较简单粗暴的更新方式,我没有花费太大的篇幅去介绍,基本上就是使用 autoUpdater
封装的一些方法,开发过程中更多的注意力则是放在了渲染进程的交互上。
此外,我们的更新交互用的是一种 用户感知 的方式,即更不更新是由用户自己决定的。而 静默更新 则是用户无感知,在监测到更新后不用去通知用户,而是自行在后台下载,在某个时刻执行安装的逻辑,这种方式往往是一键安装,不会弹出让用户去选择安装路径的窗口。接下去要介绍的增量更新其实是这种 用户无感知
的形式,只不过我们更新的不是整个应用程序。
由于产品迭代比较频繁,我们的业务方经常收到更新提示,意味着他们要经常手动更新应用,以便使用到最新的功能特性。众所周知,Electron
给前端开发提供了一个比较容易上手的客户端开发解决方案,但在享受 跨平台
等特性的同时,还得忍受 臃肿的安装包 。
带宽、流量在当前不是什么大问题,下载和安装的速度也挺快,但这频繁的下载覆盖一模一样的资源文件还是挺糟心的,代码改动量小时,全量更新完全没有必要。我们希望的是,开发更新了什么代码,应用程序就替换掉这部分代码就好了,这很优雅。在 我们的场景 中,这部分代码指的是 —— main、preload、renderer
,并不包括 dll
、第三方SDK
等资源。
网上有挺多种增量更新的 解决方案,例如:
- 通过
win.loadURL(一个线上地址)
实现,相当于就套了一层客户端的壳子,与加载的Web
端同步更新。这种方法对于简单应用来说是最省心的,但是也有一定的局限性,例如不能使用node
去操作一些底层的东西。 - 设置
asar
的归档方式,替换app.asar
或app.asar.unpack
来实现。但后者在我实践过程中存在文件路径不存在的问题。 - 禁用
asar
归档,解压渲染进程压缩包后实现替换。简单尝试过,首次执行安装包的时候写入文件很慢,不知道是不是这个方式引起的。 - 欢迎补充。
本文我们采用较普遍的 替换asar 来实现。
优化 app.asar 体积
asar
是Electron
提供的一种将多个文件合并成一个文件的类tar
风格的归档格式,不仅可以缓解Windows
下路径名过长的问题, 还能够略微加快一下require
的速度, 并且可以隐藏你的源代码。(并非完全隐藏,有方法可以找出来)
Electron
应用程序启动的时候,会读取 app.asar.unpacked
目录中的内容合并到 app.asar
的目录中进行加载。在此之前我们已经打包了一份 Demo 项目,安装到本地后,我们可以根据安装路径找到 app.asar
这个文件。
例如:D:\你的安装路径\electron-update-demo\resources
在这个 Demo 项目中,脚手架生成的模板将图标剥离出来,因此出现了一个 app.asar.unpacked
文件夹。我们不难发现,app.asar
这个文件竟然有 66 MB,再回过头看我们真正的代码文件,甚至才 1 MB !那么到底是什么原因导致的这个文件这么大呢,刚才提到了,asar
其实是一种压缩格式,因此我们只要解压看看就知道了。
npm i -g asar // 全局安装
asar e app.asar folder // 解压到folder文件夹
解压后我们不难发现,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
在打包时会以该文件夹作为打包的根文件夹,即只会打包这个文件夹下的文件。
但是我原项目的主进程和预加载脚本也放在这,尝试修改后出现打包错误的情况,感兴趣的可以试试。
这是我们优化之后的结果,1.32 MB,后面只需要替换这个文件就实现了版本的更新。
校验增量更新
全量更新的校验逻辑是第三方库已经封装好了的,那么对于增量更新,就得由我们自己来实现了。
首先明确一下 校验的时机,package.json
的 version
字段表示应用程序的版本,用于全量更新的判断,那也就意味着,当这个字段保持不变的时候,会触发上文提到的 update-not-available
事件。所以我们可以在这个事件的回调函数里来进行校验。
autoUpdater.on('update-not-available', info => { console.log('不需要全量更新', info.version) })
然后就是 如何校验,我们回过头来看 electron-builder
的打包配置,在 releaseInfo
字段里描述了发行版本的一些信息,目前我们用到了 releaseNotes
来存储更新日志,查阅官网得知还有个 releaseName
好像对于我们而言是没什么用的,那么我们就可以把它作为增量更新的 热版本号。(但其实 官网 配置项还有个 vendor
字段,可是我在用的时候打包会报错,可能是版本的问题,感兴趣的可以试试。)
每次发布新版本的时候,只要不是 Electron自身版本变化
等重大更新,我们都可以通过修改 releaseInfo
的 releaseName
来发布我们的热版本号。现在我们已经有了线上的版本,那 本地热版本号 该如何获取呢。这里我们在每次热更新完成之后,就会把最新的版本号存储在本地的一个配置文件中,通常是 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
文件夹里来模拟真实的情况。有很多方式可以去下载文件,这里我用了 nodejs
的 http
模块去实现,如果是 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.asar
到 resources
目录,并重命名为 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
文件。
至此,下载这一步已经完成了,但这样还会有一个问题,因为文件名没有加上版本号,也没有重复性校验,倘若一直没有更新,那么每次启动应用需要增量更新的时候都需要再次下载。
替换 app.asar 文件
好了,新文件也有了,接下来就是直接替换掉老文件就可以了,但是 替换的时机 很重要。
在 Windows
操作系统下,直接替换 app.asar
会提示程序被占用而导致失败,所以我们应该在程序关闭的时候实现替换。
- 进入应用,下载新版本,自动关闭应用,替换资源,重启应用。
- 进入应用,下载新版本,不影响使用,用户 手动关闭 后替换,下次启动 就是新版本了。
我们有上面两种方案,最终采用了 方案2
。
在主进程监听 app.on('quit')
事件,在应用退出的时候,判断 app.asar
和 app.asar-temp
是否同时存在,若同时存在,就去替换。这里替换不能直接用 nodejs
在主进程里去写,因为主进程执行的时候,资源还是被占用的。因此我们需要额外起一个 子进程 去做。
nodejs
可以通过 spawn
、exec
等方法创建一个子进程。子进程可以执行一些自定义的命令,我们并没有使用子进程去执行 nodejs
,因为业务方的机器上不一定有这个环境,而是采用了启动 exe
可执行文件的方式。可能有人问为什么不直接运行 .bat
批处理文件,因为我调试关闭应用的时候,会有一个 命令框闪烁 的现象,这不太优雅,尽管已经设置 spawn
的 windowsHide: 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]
})
但这块有个 疑惑,为什么我的 close
、exit
以及 stdout
都没有触发,以至于不能拿到命令是否执行成功的最终结果,了解的同学可以评论区交流一下。
至此,在关闭应用之后,app.asar
就已经被替换为最新版本了,还差最后一步,更新本地配置文件里的 hotVersion
,防止下次又去下载更新包了。
child.on('spawn', () => {
log.info('子进程触发')
updateHotVersion()
})
updateHotVersion() {
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion }, null, 2))
}
增量更新日志提示
既然之前提到的全量更新有日志说明,那增量更新可以不用,也应该需要具备这个能力,不然我们神不知鬼不觉帮用户更新了,用户都不知道 新在哪里。
至于更新内容,我们可以复用 releaseInfo
的 releaseNotes
字段,把更新日志写在这里,增量更新完成后展现给用户就好了。
但是总不能每次打开都展示,这里需要用户有个交互,比如点击 知道了
按钮,或者关闭 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
即可,渲染进程效果如下:
提示增量更新日志
点击 知道了
后,再次打开应用后就不会提示日志了,因为本地配置文件已经被修改了。
当然可能也有这么个场景,开发就改了一点点无关功能的代码,无需向用户展示日志,我们只需要加一个判断 releaseNotes
是否为空的逻辑就好了,也做到了 静默更新。
小结
不足之处
本文提出的增量更新方案应该算是比较简单的,可能并不适用于所有的场景,考虑也不够全面,例如:
dll
、第三方SDK
等资源的更新。- 增量更新失败后应该通过全量更新 兜底。
- 用户在使用过程中发布新版本,得等到 第二次打开 才能用到新版本。
流程图
针对本文的解决方案,我简单画了一个 流程图。
参考文章
网上其实有不少关于 Electron
自动更新的文章,在做这个需求之前也是浏览了好多,但也有些没仔细看完,所以就躺在我的标签页没有关闭,在这罗列出来,也当作收藏了。
写这篇文章的目的也是为了记录和复盘,如果能为你提供一些思路那就再好不过了。
鸣谢:
来源:juejin.cn/post/7416311252580352034
基于英雄联盟人物的加载动画,奇怪的需求又增加了!
1、背景
前两天老板找到我说有一个需求,要求使用英雄联盟的人物动画制作一个加载中的组件,类似于下面这样:
我定眼一看:这个可以实现,但是需要UI妹子给切图。
老板:UI? 咱们啥时候招的UI !
我:老板,那不中呀,不切图弄不成呀。
老板:下个月绩效给你A。
我:那中,管管管。
2、调研
发动我聪明的秃头,实现这个需求有以下几种方案:
- 切动画帧,没有UI不中❎。
- 去lol客户端看看能不能搞到什么美术素材,3D模型啥的,可能行❓
- 问下 gpt4o,有没有哪个老表收集的有lol英雄的美术素材,如果有那就更得劲了✅。
经过我一番搜索,发现了这个网站:model-viewer,收集了很多英雄联盟的人物模型,模型里面还有各种动画,还给下载。老表,这个需求稳了50%了!
接下来有几种选择:
- 将模型动画转成动画帧,搞成雪碧图,较为麻烦,且动画不支持切换。
- 直接加载模型,将模型放在进度条上,较为简单,支持切换不同动画,而且可以自由过渡。就是模型文件有点大,初始化加载可能耗时较长。但是后续缓存一下就好了。
聪明的我肯定先选第二个方案呀,你糊弄我啊,我糊弄你。
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>
这不就完成了吗,先拿给老板看看。
老板:换个女枪的看看。
我:好嘞。
老板:弄类不赖啊小伙,换个俄洛伊的看看。
4、总结
通过本次需求,了解到了 model-viewer
组件。
老板招个UI妹子吧。
在线体验:github-pages
来源:juejin.cn/post/7377217883305279526
组长说:公司的国际化就交给你了,下个星期给我
从“跑路程序员”到“摸鱼仙人”,我用这插件把国际化的屎山代码盘活了!
tips:
使用有道翻译,朋友们,要去有道官网注册一下,有免费额度,github demo的key已经被用完了。
一、命运的齿轮开始转动
“小王啊,海外业务要上线了,国际化你搞一下,下个月验收。”组长轻描淡写的一句话,让我盯着祖传代码陷入沉思——
(脑补画面:满屏中文硬编码,夹杂着"确定"
、"取消"
、"加载中..."
)
正当我准备打开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(翻译の圣杯)
突然!页面白屏了!
别慌!这是插件在首次翻译时需要生成文件,解决方法:
- 立即执行一次
npm run build
(让插件提前生成所有翻译) - 再次
npm run dev
→ 页面加载如德芙般丝滑
四、效果爆炸:我成了全组の神
1. 不可置信の48小时
当我打开浏览器那一刻——\
我(瞳孔地震):“卧槽…真成了?!”
组长(凑近屏幕):“这…这是你一个人做的?!”(眼神逐渐迷茫)
产品经理(掏出手机拍照):“快!发朋友圈!《我司技术力碾压硅谷!》”
2. 插件の超能力
- 构建阶段:自动扫描所有中文 → 生成哈希键 → 调用API翻译
- 运行时:根据用户语言动态加载对应翻译
- 维护期:改个JSON文件就能更新所有语言版本
副作用:
- 测试妹子开始怀疑人生:“为什么一个bug都找不到?”
- 后端同事偷偷打听:“你这插件…能翻译Java注释吗?”
五、职场生存指南:如何优雅甩锅
🔨 场景1:测试妹子提着40米大刀来了!
问题:俄语翻译把“注册”译成“Регистрация”(原意是“登记处”)
传统应对:
- 熬夜改代码 → 重新打包 → 提交测试 → 被骂效率低
插件玩家:
- 打开
lang/index.json
- 把
Регистрация
改成Зарегистрироваться
(深藏功与名) - 轻描淡写:“这是有道翻译的锅,我手动修正了。”
🔨 场景2:产品经理临时加语言
需求:“老板说下周要加印地语!”
传统灾难:
- 重新配框架 → 人肉翻译 → 测试 → 加班到秃头
插件玩家:
- 配置加一行代码:
targetLangList: ['hi']
- 运行
npm run build
→ 自动生成印地语翻译 - 告诉产品经理:“这是上次预留的技术方案。”(其实只改了1行)
🔨 场景3:组长怀疑你摸鱼
质问:“小王啊,你这效率…是不是有什么黑科技?”
标准话术:
“组长,这都是因为:
- 您制定的开发规范清晰
- 公司技术栈先进(Vue3真香)
- 我参考了国际前沿方案(打开GitHub页面)”
六、高级摸鱼の奥义
🎯 秘籍1:把翻译文件变成团队武器
- 把
lang/index.json
扔给产品经理:“这是国际化核心资产!” - 对方用Excel修改后,你直接
git pull
→ 无需动代码 - 出问题直接甩锅:“翻译是市场部给的,我只负责技术!”
(脑补画面:产品经理在Excel里疯狂改翻译,程序员在刷剧)
🎯 秘籍2:动态加载の神操作
痛点:所有语言打包进主文件 → 体积爆炸!
解决方案:
// 在index.js里搞点骚操作
const loadLanguage = async (lang) => {
const data = await import(`../../lang/${lang}.json`); // 动态加载翻译文件
window.$t.locale(data, 'lang');
};
// 切换语言时调用
loadLanguage('ja'); // 瞬间切换日语,深藏功与名
🎯 秘籍3:伪装成AI大神
- 周会汇报:“我基于AST实现了自动化国际翻译中台”
- 实际:只是配了个插件
- 老板评价:“小王这技术深度,值得加薪!”(真相只有你知道)
七、终局:摸鱼の神,降临!
当组长在庆功会上宣布“国际化项目提前两周完成”时,我正用手机刷着《庆余年2》。
测试妹子:“你怎么一点都不激动?”
我(收起手机):“常规操作,要习惯。”(心想:插件干活,我躺平,这才叫真正的敏捷开发!)
立即行动(打工人自救指南):
- GitHub搜:auto-i18n-translation-plugins(点星解锁摸鱼人生)
- 复制我的配置 → 运行 → 见证魔法
- 加开发者社群:遇到问题发红包喊“大哥救命!”
终极警告:
⚠️ 过度使用此插件可能导致——
- 你的摸鱼时间超过工作时间,引发HR关注
- 产品经理产生“国际化需求可以随便加”的幻觉
- 老板误以为你是隐藏的技术大佬(谨慎处理!)
文末暴击:
“自从用了这插件,我司翻译团队的工作量从3周变成了3分钟——现在他们主要工作是帮我选中午吃啥。” —— 匿名用户の真实反馈
常见问题汇总
来源:juejin.cn/post/7480267450286800911
实现基于uni-app的项目自动检查APP更新
我们平时工作中开发APP时,及时为用户提供应用更新是提升用户体验和保证功能完整性的重要一环。本文将通过一段实际的代码示例,详细介绍如何在基于uni-app框架的项目中实现自动检查应用更新的功能,并引导用户完成升级过程。该功能主要涉及与服务器端交互获取最新版本信息、比较版本号、提示用户升级以及处理下载安装流程。
创建一个checkappupdate.js
文件
这个文件是写升级逻辑处理的文件,可以不创建,直接在App.vue中写,但是为了便于维护,还是单独放出来比较好,可以放在common或者util目录中(App.vue能引入到就行,随意放,根目录也行),App.vue中引入该文件,调用升级函数如下图所示:
js完整代码
为了防止一点点代码写,容易让人云里雾里,先放完整代码,稍后再详细解释,其实看注释也就够了。
//这是服务端请求url配置文件,如果你直接卸载下面的请求中,可以不引入
import configService from '@/common/service/config.service.js'
export default function checkappupdate(param = {}) {
// 合并默认参数
param = Object.assign({
title: "A new version has been detected!",
content: "Please upgrade the app to the latest version!",
canceltext: "No upgrade",
oktext: "Upgrade now"
}, param)
plus.runtime.getProperty(plus.runtime.appid, (widgetInfo) => {
let platform = plus.os.name.toLocaleLowerCase() //Android
let os_version = plus.os.version //13 安卓版本
let vendor = plus.device.vendor //Xiaomi
let url = configService.apiUrl
uni.request({
url: url + '/checkAppUpdate',
method: 'GET',
data: {
platform: platform,
os_version: os_version,
vendor: vendor,
cur_version: widgetInfo.version
},
success(result) {
console.log(result)
let versionCode = parseInt(widgetInfo.versionCode)
let data = result.data ? result.data : null;
// console.log(data);
let downAppUrl = data.url
//判断版本是否需要升级
if (versionCode >= data.versionCode) {
return;
}
//升级提示
uni.showModal({
title: param.title,
content: data.log ? data.log : param.content,
showCancel: data.force ? false : true,
confirmText: param.oktext,
cancelText: param.canceltext,
success: res => {
if (!res.confirm) {
console.log('Cancel the upgrade');
// plus.runtime.quit();
return
}
// if (data.shichang === 1) {
// //去应用市场更新
// plus.runtime.openURL(data.shichangurl);
// plus.runtime.restart();
// } else {
// 开始下载
// 创建下载任务
var dtask = plus.downloader.createDownload(downAppUrl, {
filename: "_downloads/"
},
function (d, status) {
// 下载完成
if (status == 200) {
plus.runtime.install(d.filename, {
force: true
}, function () {
//进行重新启动;
plus.runtime.restart();
}, (e) => {
uni.showToast({
title: 'install fail:' + JSON
.stringify(e),
icon: 'none'
})
console.log(JSON.stringify(e))
});
} else {
this.tui.toast("download fail,error code: " +
status);
}
});
let view = new plus.nativeObj.View("maskView", {
backgroundColor: "rgba(0,0,0,.6)",
left: ((plus.screen.resolutionWidth / 2) - 45) +
"px",
bottom: "80px",
width: "90px",
height: "30px"
})
view.drawText('start download...', {}, {
size: '12px',
color: '#FFFFFF'
});
view.show()
// console.log(dtask);
dtask.addEventListener("statechanged", (e) => {
if (e && e.downloadedSize > 0) {
let jindu = ((e.downloadedSize / e.totalSize) *
100).toFixed(2)
view.reset();
view.drawText('Progress:' + jindu + '%', {}, {
size: '12px',
color: '#FFFFFF'
});
}
}, false);
dtask.start();
// }
},
fail(e) {
console.log(e);
uni.showToast({
title: 'Request error'
})
}
})
}
})
});
}
函数定义:checkappupdate
定义核心函数checkappupdate
,它接受一个可选参数param
,用于自定义提示框的文案等信息。函数内部首先通过Object.assign
合并默认参数与传入参数,以确保即使未传入特定参数时也能有良好的用户体验。
获取应用信息与环境变量
利用plus.runtime.getProperty
获取当前应用的详细信息,包括但不限于应用ID、版本号(version
)和版本号代码(versionCode
),以及设备的操作系统名称、版本和厂商信息。这些数据对于后续向服务器请求更新信息至关重要。
请求服务器检查更新
构建包含平台信息、操作系统版本、设备厂商和当前应用版本号的请求参数,发送GET请求至配置好的API地址/checkAppUpdate
,查询是否有新版本可用。后端返回参数参考下面:
/**
* 检测APP升级
*/
public function checkAppUpdate()
{
$data['versionCode'] = 101;//更新的版本号
$data['url'] = 'http://xxx/app/xxx.apk';//下载地址
$data['force'] = true;//是否强制更新
return json_encode($data);//返回json格式数据到前端
}
比较版本与用户提示
一旦收到服务器响应,解析数据并比较当前应用的版本号与服务器提供的最新版本号。如果存在新版本,使用uni.showModal
弹窗提示用户,展示新版本日志(如果有)及升级选项。此步骤充分考虑了是否强制更新的需求,允许开发者灵活配置确认与取消按钮的文案。
下载与安装新版本
用户同意升级后,代码将执行下载逻辑。通过plus.downloader.createDownload
创建下载任务,并监听下载进度,实时更新进度提示。下载完成后,利用plus.runtime.install
安装新APK文件,并在安装成功后调用plus.runtime.restart
重启应用,确保新版本生效。
用户界面反馈
在下载过程中,通过创建原生覆盖层plus.nativeObj.View
展示一个半透明遮罩和下载进度信息,给予用户直观的视觉反馈,增强了交互体验,进度展示稍微有点丑,可以提自己改改哈。
总结
通过上述步骤,我们实现了一个完整的应用自动检查更新流程,不仅能够有效通知用户新版本的存在,还提供了平滑的升级体验。此功能的实现,不仅提升了用户体验,也为产品迭代和功能优化提供了有力支持。开发者可以根据具体需求调整提示文案、下载逻辑、进度样式等细节,以更好地适配自身应用的特点和用户群体。
来源:juejin.cn/post/7367555191337828361
Uniapp小程序地图轨迹绘画
轨迹绘画
简介
- 轨迹绘画常用于展示车辆历史轨迹,运动历史记录等,本次案例采用的是汽车案例同时利用腾讯地图API,来实现地图轨迹绘画功能,具体情况根据实际项目变更。
本例是汽车轨迹绘画功能

1.在页面的onReady生命周期中创建map对象
onReady() {
// 创建map对象
this.map = uni.createMapContext('map');
// 获取屏幕高度(此处获取屏幕高度是因为本示例中使用了colorui的cu-custom自定义头部,需根据系统高度来自适应)
uni.getSystemInfo({
success: res => {
this.windowHeight = res.windowHeight;
}
});
},
2.设置轨迹动画事件
页面代码:
<view class="container">
<map id='map' :latitude="latitude" :longitude="longitude" :markers="covers"
:style="{ width: '100%', height: mapHeight + 'px' }" :scale="13" :polyline="polyline">
</map>
<view class="btnBox">
<button :disabled="isDisabled" @click="start" class="cu-btn bg-blue round shadow lg">开始回放</button>
<button @click="pause" class="cu-btn bg-red round shadow lg">暂停</button>
</view>
</view>
逻辑代码:
- 1.轨迹动画的开始事件
start() {
if (this.movementInterval) {
clearInterval(this.movementInterval);
}
this.isStart = true;
this.moveMarker();
},
- 2.轨迹动画的暂停事件
pause() {
this.isStart = false;
this.isDisabled = false;
if (this.movementInterval) {
clearInterval(this.movementInterval);
this.movementInterval = null;
}
},
- 3.轨迹动画移动事件
moveMarker() {
if (!this.isStart) return;
if (this.playIndex >= this.coordinate.length) {
this.playIndex = 0;
uni.showToast({
title: "播放完成",
duration: 1400,
icon: "none",
});
this.isStart = false;
this.isDisabled = false;
return;
}
let datai = this.coordinate[this.playIndex];
this.map.translateMarker({
markerId: 1,
autoRotate: true,
destination: {
longitude: datai.longitude,
latitude: datai.latitude,
},
duration: 700,
complete: () => {
this.playIndex++;
this.moveMarker();
},
});
},
完整代码如下
<!-- 地图轨迹组件 -->
<template>
<view>
<cu-custom class="navBox" bgColor="bg-gradual-blue" :isBack="true">
<block slot="backText">返回</block>
<block slot="content">地图轨迹</block>
</cu-custom>
<view class="container">
<map id='map' :latitude="latitude" :longitude="longitude" :markers="covers"
:style="{ width: '100%', height: mapHeight + 'px' }" :scale="13" :polyline="polyline">
</map>
<view class="btnBox">
<button :disabled="isDisabled" @click="start" class="cu-btn bg-blue round shadow lg">开始回放</button>
<button @click="pause" class="cu-btn bg-red round shadow lg">暂停</button>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
map: null,
movementInterval: null, // 用于存储定时器的引用
windowHeight: 0,
mapHeight: 0,
timer: null,
isDisabled: false,
isStart: false,
playIndex: 1,
id: 0, // 使用 marker点击事件 需要填写id
title: 'map',
latitude: 34.263734,
longitude: 108.934843,
// 标记点
covers: [{
id: 1,
width: 42,
height: 47,
rotate: 270,
latitude: 34.259428,
longitude: 108.947040,
iconPath: 'http://zgonline.top/car.png',
callout: {
content: "鄂A·88888", // <img src="车牌信息" alt="" width="50%" />
display: "ALWAYS",
fontWeight: "bold",
color: "#5A7BEE", //文本颜色
fontSize: "12px",
bgColor: "#ffffff", //背景色
padding: 5, //文本边缘留白
textAlign: "center",
},
anchor: {
x: 0.5,
y: 0.5,
},
}],
// 线
polyline: [],
// 坐标数据
coordinate: [{
latitude: 34.259428,
longitude: 108.947040,
problem: false,
},
{
latitude: 34.252918,
longitude: 108.946963,
problem: false,
},
{
latitude: 34.252408,
longitude: 108.946240,
problem: false,
},
{
latitude: 34.249286,
longitude: 108.946184,
problem: false,
},
{
latitude: 34.248670,
longitude: 108.946640,
problem: false,
},
{
latitude: 34.248129,
longitude: 108.946826,
problem: false,
},
{
latitude: 34.243537,
longitude: 108.946816,
problem: true,
},
{
latitude: 34.243478,
longitude: 108.939003,
problem: true,
},
{
latitude: 34.241218,
longitude: 108.939027,
problem: true,
},
{
latitude: 34.241192,
longitude: 108.934802,
problem: true,
},
{
latitude: 34.241182,
longitude: 108.932235,
problem: true,
},
{
latitude: 34.247227,
longitude: 108.932311,
problem: true,
},
{
latitude: 34.250833,
longitude: 108.932352,
problem: true,
},
{
latitude: 34.250877,
longitude: 108.931756,
problem: true,
},
{
latitude: 34.250944,
longitude: 108.931576,
problem: true,
},
{
latitude: 34.250834,
longitude: 108.929662,
problem: true,
},
{
latitude: 34.250924,
longitude: 108.926015,
problem: true,
},
{
latitude: 34.250802,
longitude: 108.910121,
problem: true,
},
{
latitude: 34.269718,
longitude: 108.909921,
problem: true,
},
{
latitude: 34.269221,
longitude: 108.922366,
problem: false,
},
{
latitude: 34.274531,
longitude: 108.922388,
problem: false,
},
{
latitude: 34.276201,
longitude: 108.923433,
problem: false,
},
{
latitude: 34.276559,
longitude: 108.924004,
problem: false,
},
{
latitude: 34.276785,
longitude: 108.945855,
problem: false,
}
],
posi: {
id: 1,
width: 32,
height: 32,
latitude: 0,
longitude: 0,
iconPath: "http://cdn.zhoukaiwen.com/car.png",
callout: {
content: "鄂A·888888", // 车牌信息
display: "BYCLICK",
fontWeight: "bold",
color: "#5A7BEE", //文本颜色
fontSize: "12px",
bgColor: "#ffffff", //背景色
padding: 5, //文本边缘留白
textAlign: "center",
},
anchor: {
x: 0.5,
y: 0.5,
},
}
}
},
watch: {},
// 分享小程序
onShareAppMessage(res) {
return {
title: '看看这个小程序多好玩~',
};
},
onReady() {
// 创建map对象
this.map = uni.createMapContext('map');
// 获取屏幕高度
uni.getSystemInfo({
success: res => {
this.windowHeight = res.windowHeight;
}
});
},
mounted() {
this.setNavTop('.navBox')
this.polyline = [{
points: this.coordinate,
color: '#025ADD',
width: 4,
dottedLine: false,
}];
},
methods: {
setNavTop(style) {
let view = uni.createSelectorQuery().select(style);
view
.boundingClientRect((data) => {
console.log("tabInList基本信息 = " + data.height);
this.mapHeight = this.windowHeight - data.height;
console.log(this.mapHeight);
})
.exec();
},
start() {
if (this.movementInterval) {
clearInterval(this.movementInterval);
}
this.isStart = true;
this.moveMarker();
},
moveMarker() {
if (!this.isStart) return;
if (this.playIndex >= this.coordinate.length) {
this.playIndex = 0;
uni.showToast({
title: "播放完成",
duration: 1400,
icon: "none",
});
this.isStart = false;
this.isDisabled = false;
return;
}
let datai = this.coordinate[this.playIndex];
this.map.translateMarker({
markerId: 1,
autoRotate: true,
destination: {
longitude: datai.longitude,
latitude: datai.latitude,
},
duration: 700,
complete: () => {
this.playIndex++;
this.moveMarker();
},
});
},
pause() {
this.isStart = false;
this.isDisabled = false;
if (this.movementInterval) {
clearInterval(this.movementInterval);
this.movementInterval = null;
}
},
}
}
</script>
<style lang="scss" scoped>
.container {
position: relative;
}
.btnBox {
width: 750rpx;
position: absolute;
bottom: 60rpx;
z-index: 99;
display: flex;
justify-content: space-around;
}
</style>
来源:juejin.cn/post/7406173972738867227
使用 canvas 实现电子签名
一、引言
电子签名作为数字化身份认证的核心技术之一,已广泛应用于合同签署、审批流程等场景。之前做公司项目时遇到这个需求,于是研究了下,目前前端主要有两种方式实现电子签名:原生Canvason 和 使用signature_pad 依赖库。
本文将基于Vue3 + TypeScript技术栈,深入讲解原生Canvas功能实现方案,并提供完整的可落地代码。
二、原生Canvas实现方案
完整代码:GitHub - seapack-hub/seapack-template: seapack-template框架
实现的逻辑并不复杂,就是使用canvas提供一个画板,让用户通过鼠标或者移动端触屏的方式在画板上作画,最后将画板上的图案生成图片保存下来。
(一) 组件核心结构
需要同时处理 鼠标事件(PC端) 和 触摸事件(移动端),实现兼容的效果。
// PC端 鼠标事件
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', endDrawing);
// 移动端 触摸事件
canvas.addEventListener('touchstart', handleTouchStart);
canvas.addEventListener('touchmove', handleTouchMove);
canvas.addEventListener('touchend', endDrawing);
具体流程:通过状态变量控制绘制阶段:
阶段 | 触发事件 | 行为 |
---|---|---|
开始绘制 | mousedown | 记录起始坐标,标记isDrawing=true |
绘制中 | mousemove | 连续绘制路径(lineTo + stroke) |
结束绘制 | mouseup | 重置isDrawing=false` |
代码实现:
<div class="signature-container">
<canvas
ref="canvasRef"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="endDrawing"
@mouseleave="endDrawing"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="endDrawing"
></canvas>
<div class="controls">
<button @click="clearCanvas">清除</button>
<button @click="saveSignature">保存签名</button>
</div>
</div>
(二) 类型和变量
//类型定义
type RGBColor = `#${string}` | `rgb(${number},${number},${number})`
type Point = { x: number; y: number }
type CanvasContext = CanvasRenderingContext2D | null
// 配置
const exportBgColor: RGBColor = '#ffffff' // 设置为需要的背景色
//元素引用
const canvasRef = ref<HTMLCanvasElement | null>(null)
const ctx = ref<CanvasContext>()
//绘制状态
const isDrawing = ref(false)
const lastPosition = ref<Point>({ x: 0, y: 0 })
(三) 绘制逻辑实现
初始化画布
//初始化画布
onMounted(() => {
if (!canvasRef.value) return
//设置画布大小
canvasRef.value.width = 800
canvasRef.value.height = 400
//获取2d上下文
ctx.value = canvasRef.value.getContext('2d')
if (!ctx.value) return
//初始化 画笔样式
ctx.value.lineWidth = 2
ctx.value.lineCap = 'round'
ctx.value.strokeStyle = '#000' //线条颜色
// 初始填充背景
fillBackground(exportBgColor)
})
//填充背景方法
const fillBackground = (color: RGBColor) => {
if (!ctx.value || !canvasRef.value) return
ctx.value.save()
ctx.value.fillStyle = color
ctx.value.fillRect(0, 0, canvasRef.value.width, canvasRef.value.height)
ctx.value.restore()
}
获取坐标
将事件坐标转换为 Canvas 内部坐标,兼容滚动偏移
//获取坐标点,将事件坐标转换为 Canvas 内部坐标,兼容滚动偏移
const getCanvasPosition = (clientX: number, clientY: number): Point => {
if (!canvasRef.value) return { x: 0, y: 0 }
//获取元素在视口(viewport)中位置
const rect = canvasRef.value.getBoundingClientRect()
return {
x: clientX - rect.left,
y: clientY - rect.top,
}
}
// 获取事件坐标
const getEventPosition = (e: MouseEvent | TouchEvent): Point => {
//TouchEvent 是在支持触摸操作的设备(如智能手机、平板电脑)上,用于处理触摸相关交互的事件对象
if ('touches' in e) {
return getCanvasPosition(e.touches[0].clientX, e.touches[0].clientY)
}
return getCanvasPosition(e.clientX, e.clientY)
}
开始绘制
将 isDrawing 变量值设置为true,表示开始绘制,并获取当前鼠标点击或手指触摸的坐标。
//开始绘制
const startDrawing = (e: MouseEvent | TouchEvent) => {
isDrawing.value = true
const { x, y } = getEventPosition(e)
lastPosition.value = { x, y }
}
绘制中
每次移动时创建新路径,连接上一个点与当前点。
//绘制中
const draw = (e: MouseEvent | TouchEvent) => {
if (!isDrawing.value || !ctx.value) return
//获取当前所在位置
const { x, y } = getEventPosition(e)
//开始新路径
ctx.value.beginPath()
//移动画笔到上一个点
ctx.value.moveTo(lastPosition.value.x, lastPosition.value.y)
//绘制线条到当前点
ctx.value.lineTo(x, y)
//描边路径
ctx.value.stroke()
//更新最后的位置
lastPosition.value = { x, y }
}
结束绘制
将 isDrawing 变量设为false,结束绘制
//结束绘制
const endDrawing = () => {
isDrawing.value = false
}
添加清除和保存方法
//清除签名
const clearCanvas = () => {
if (!ctx.value || !canvasRef.value) return
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
}
//保存签名
const saveSignature = () => {
if (!canvasRef.value) return
const dataURL = canvasRef.value.toDataURL('image/png')
const link = document.createElement('a')
link.download = 'signature.png'
link.href = dataURL
link.click()
}
移动端适配
// 触摸事件处理
const handleTouchStart = (e: TouchEvent) => {
e.preventDefault();
startDrawing(e.touches[0]);
};
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault();
draw(e.touches[0]);
};
(四) 最终效果
来源:juejin.cn/post/7484987385665011762
微信小程序主包过大终极解决方案
随着小程序项目的不断迭代升级,避免不了体积越来越大。微信限制主包最多2M,然而我们的项目引入了直播插件直接占了1.1M,导致必须采用一些手段去优化。下面介绍一下优化思路和终极解决方案。
1.分包
我相信几乎所有人都能想到的方案,基本上这个方案就能解决问题。具体如何实现可以参照官方文档这里不做过多说明。(基础能力 / 分包加载 / 使用分包 (qq.com)),但是有时候你会发现分包之后好像主包变化不是很大,这是为什么呢?
- 痛点1:通过依赖分析,如果分包中引入了第三方依赖,那么依赖的js仍然会打包在主包中,例如echarts、wxparse、socket.io。这就导致我们即使做了分包处理,但是主包还是很大,因为相关的js都会在主包中的vendor.js
- 痛点2:插件只能在主包中无法分包,例如直播插件直接占据1M
- 痛点3:tabbar页面无法分包,只能在主包内
- 痛点4:公共组件/方法无法分包,只能在主包内
- 痛点5:图片只能在主包内
2.图片优化
图片是最好解决的,除了tabbar用到的图标,其余都放在云上就好了,例如oss和obs。而且放在云上还有个好处就是背景图片无需担心引入不成功。
3.tabbar页面优化
这部分可以采用tabbar页面都在放在一个文件夹下,比如一共有4个tab,那么一个文件夹下就只存放这4个页面。其余tabbar的子页面一律采用分包。
4.独立分包
独立分包是小程序中一种特殊类型的分包,可以独立于主包和其他分包运行。从独立分包中页面进入小程序时,不需要下载主包。当用户进入普通分包或主包内页面时,主包才会被下载。
但是使用的时候需要注意:
- 独立分包中不能依赖主包和其他分包中的内容,包括 js 文件、template、wxss、自定义组件、插件等(使用 分包异步化 时 js 文件、自定义组件、插件不受此条限制)
- 主包中的
app.wxss
对独立分包无效,应避免在独立分包页面中使用app.wxss
中的样式; App
只能在主包内定义,独立分包中不能定义App
,会造成无法预期的行为;- 独立分包中暂时不支持使用插件。
5.终极方案we-script
我们自己写的代码就算再多,其实增加的kb并不大。大部分大文件主要源于第三方依赖,那么有没有办法像webpack中的externals一样,当进入这个页面的时候再去异步加载js文件而不被打包呢(说白了就是CDN)
其实解决方案就是we-script,他允许我们使用CDN方式加载js文件。这样就不会影响打包体积了。
使用步骤
npm install --save we-script
- "packNpmRelationList": [{"packageJsonPath": "./package.json", "miniprogramNpmDistDir":"./dist/"}]
- 点击开发者工具中的菜单栏:工具 --> 构建 npm
"usingComponents": {"we-script": "we-script"}
<we-script src="url1" />
使用中存在的坑
构建后可能会出现依赖报错,解决的方式就是将编译好的文件手动拖入miniprogram_npm文件夹中,主要是三个文件夹:we-script,acorn,eval5
最后成功解决了主包文件过大的问题,只要是第三方依赖,都可以通过这个办法去加载。
感谢阅读,希望来个三连支持下,转载记得标注原文地址~
来源:juejin.cn/post/7355057488351674378
uni-app 接入微信短剧播放器
前言
作为一个 uniapp 初学者,恰巧遇到微信短剧播放器接入的需求,在网上检索许久并没有发现傻瓜式教程。于是总结 uni-app 官网文档及微信开放文档,自行实践后,总结出几个步骤,希望为大家提供些帮助。实践后发现其实确实比较简单,大佬们可能也懒得写文档,那么就由我这个小白大概总结下。本文档仅涉及剧目提审成功后的播放器接入,其余相关问题请参考微信官方文档。
小程序申请插件
参考文档:developers.weixin.qq.com/miniprogram…
首先,需要在小程序后台,申请 appid 为 wx94a6522b1d640c3b 的微信插件,可以在微信小程序管理后台进行添加,路径是 设置 - 第三方设置 - 插件管理 - 添加插件,搜索 wx94a6522b1d640c3b 后进行添加:
uni-app 项目添加微信插件
参考文档:uniapp.dcloud.net.cn/tutorial/mp…
添加插件完成后,在 manifest.json 中,点击 源码视图,找到如下位置并添加红框内的代码,此步骤意在将微信小程序插件引入项目。
/* 添加微短剧播放器插件 */
"plugins" : {
"playlet-plugin" : {
"version" : "latest",
"provider" : "wx94a6522b1d640c3b"
}
}
manifest.json 中完成添加后,需要在 pages.json 中找一个页面(我这边使用的是一个新建的空白页面)挂载组件,挂载方式如下图红框中所示,需注意,这里的组件名称需要与 manifest.json 中定义的一致:
{
"path": "newPage/newPage",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false,
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
},
"mp-weixin": {
"usingComponents": {
"playlet-plugin": "plugin://playlet-plugin/playlet-plugin"
}
}
}
}
挂载空页面是个笨办法,目前我这边尝试如果不挂载的话,会有些问题,有大神知道别的方法可以在评论区指点一下~
App.vue 配置
参考文档:developers.weixin.qq.com/miniprogram…
首先,找个地方新建一个 playerManager.js,我这边建在了 common 文件夹下。代码如下(代码参考微信官方文档给出的 demo):
var plugin = requirePlugin("playlet-plugin");
// 点击按钮触发此函数跳转到播放器页面
function navigateToPlayer(obj) {
// 下面的${dramaId}变量,需要替换成小程序管理后台的媒资管理上传的剧目的dramaId,变量${srcAppid}是提审方appid,变量${serialNo}是某一集,变量${extParam}是扩展字段,可通过
const { extParam, dramaId, srcAppid, serialNo } = obj
wx.navigateTo({
url: `plugin-private://wx94a6522b1d640c3b/pages/playlet/playlet?dramaId=${dramaId}&srcAppid=${srcAppid}&serialNo=${serialNo}&extParam=${extParam || ''}`
})
}
const proto = {
_onPlayerLoad(info) {
const pm = plugin.PlayletManager.getPageManager(info.playerId)
this.pm = pm
// encryptedData是经过开发者后台加密后(不要在前端加密)的数据,具体实现见下面的加密章节
this.getEncryptData({serialNo: info.serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
pm.setCanPlaySerialList({
data: res.encryptedData,
freeList: [{start_serial_no: 1, end_serial_no: 10}], // 1~10集是免费剧集
})
})
pm.onCheckIsCanPlay(this.onCheckIsCanPlay)
// 关于分享的处理
// 开启分享以及withShareTicket
pm.setDramaFlag({
share: true,
withShareTicket: true
})
// 获取分享参数,页面栈只有短剧播放器一个页面的时候可获取到此参数
// 例如从分享卡片进入、从投流广告直接跳转到播放器页面,从二维码直接进入播放器页面等情况
plugin.getShareParams().then(res => {
console.log('getLaunch options query res', res)
// 关于extParam的处理,需要先做decodeURIComponent之后才能得到原值
const extParam = decodeURIComponent(res.extParam)
console.log('getLaunch options extParam', extParam)
// 如果设置了withShareTicket为true,可通过文档的方法获取更多信息
// https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share.html
const enterOptions = wx.getEnterOptionsSync()
console.log('getLaunch options shareTicket', enterOptions.shareTicket)
}).catch(err => {
console.log('getLaunch options query err', err)
})
// extParam除了可以通过在path传参,还可以通过下面的接口设置
pm.setExtParam('hellotest')
// 分享部分end
},
onCheckIsCanPlay(param) {
// TODO: 碰到不可以解锁的剧集,会触发此事件,这里可以进行扣币解锁逻辑,如果用户无足够的币,可调用下面的this.isCanPlay设置
console.log('onCheckIsCanPlay param', param)
var serialNo = param.serialNo
this.getEncryptData({serialNo: serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
this.pm.isCanPlay({
data: res.encryptedData,
serialNo: serialNo,
})
})
},
getEncryptData(obj) {
const { serialNo } = obj
// TODO: 此接口请求后台,返回下面的setCanPlaySerialList接口需要的加密参数
const { srcAppid, dramaId } = this.pm.getInfo()
console.log('getEncryptData start', srcAppid, dramaId, serialNo)
return new Promise((resolve, reject) => {
resolve({
encryptedData: '' // TODO: 此参数需从后台接口获取到
})
})
},
}
function PlayerManager() {
var newProto = Object.assign({}, proto)
for (const k in newProto) {
if (typeof newProto[k] === 'function') {
this[k] = newProto[k].bind(this)
}
}
}
PlayerManager.navigateToPlayer = navigateToPlayer
module.exports = PlayerManager
新建完成后,在 App.vue 中进行组件的配置和引用。
onLaunch: function() {
// playlet-plugin必须和上面的app.json里面声明的插件名称一致
const playletPlugin = requirePlugin('playlet-plugin')
const _onPlayerLoad = (info) => {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
}
// 注册播放器页面的onLoad事件
playletPlugin.onPageLoad(_onPlayerLoad.bind(this))
},
_onPlayerLoad(info) {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
},
页面使用
参考文档:developers.weixin.qq.com/miniprogram…
以上所有步骤完成后,就可以开心的使用短剧播放器了。 我这边临时写了个图片的 click 事件测试了一下:
clk() {
// 逻辑处理...获取你的各种参数
// 打开组件中封装的播放器页面
PlayerManager.navigateToPlayer({
srcAppid: 'wx1234567890123456', // 剧目提审方 appid
dramaId: '100001', // 小程序管理后台的媒资管理上传的剧目的 dramaId
serialNo: '1', // 剧目中的某一集
extParam: encodeURIComponent('a=b&c=d'), // 扩展字段,需要encode
})
},
写在最后:
总结完了,其实整体下来不是很难,对我这种前端小白来说检索和整合的过程是比较痛苦的,所以希望下一个接入的朋友可以少检索一些文档吧。
另附一个短剧播放器接口的文档: developers.weixin.qq.com/miniprogram…
文档主要介绍了短剧播放器插件提供的几个接口,在js代码里,插件接口实例通过下面的代码获取
// 名字playlet-plugin必须和app.json里面引用的插件名一致
const playletPlugin = requirePlugin('playlet-plugin')
读书越多越发现自己的无知,Keep Fighting!
欢迎友善交流,不喜勿喷~
Hope can help~
来源:juejin.cn/post/7373473695057428506
后端出身的CTO问:"前端为什么没有数据库?",我直接无语......
😅【现场还原】
"前端为什么没有自己的数据库?把数据存前端不就解决了后端性能问题" ——当CTO抛出这个灵魂拷问时,会议室突然安静得能听见CPU风扇的嗡鸣,在座所有人都无语了。这场因后端性能瓶颈引发的技术博弈,最终以"前端分页查询+本地筛选"的妥协方案告终。
面对现在几乎所有公司的技术leader都是后端出身,有的不懂前端甚至不懂技术,作为前端开发者,我们真的只能被动接受吗?
😣【事情背景】
- 需求:前端展示所有文章的标签列表,用户可以选择标签筛选文章,支持多选,每个文章可能有多个标签,也可能没任何标签。
- 前端观点:针对这种需求,我自然想到用户选中标签后,将标签id传给后端,后端根据id筛选文章列表返回即可。
- 后端观点:后端数据分库分表,根据标签检索数据还要排序分页,有性能瓶颈会很慢,很慢就会导致天天告警。
- 上升决策:由于方案有上述分歧,我们就找来了双方leader决策,双方leader也有分歧,最终叫来了CTO。领导想让我们将数据定时备份到前端,需要筛选的时候前端自己筛选。
CTO语录:
“前端为什么没有数据库?,把数据存前端,前端筛选,数据库不就没有性能压力了”
"现在手机性能比服务器还强,让前端存全量数据怎么了?"
"IndexedDB不是数据库?localStorage不能存JSON?"
"分页?让前端自己遍历数组啊,这不就是你们说的'前端工程化'吗?"
😓【折中方案】
在方案评审会上,我们据理力争:
- 分页请求放大效应:用户等待时间=单次请求延迟×页数
- 内存占用风险:1万条数据在移动端直接OOM
- 数据一致性难题:轮询期间数据更新的同步问题
但现实往往比代码更复杂——当CTO拍板要求"先实现再优化",使用了奇葩的折中方案:
- 前端轮询获取前1000条数据做本地筛选,用户分页获取数据超过1000条后,前端再轮询获取1000条,以此类推。
- 前端每页最多获取50条数据,每次最多并发5个请求(后端要求)
只要技术监控不报错,至于用户体验?慢慢等着吧你......
🖨️【批量并发请求】
既然每页只有50条数据,那我至少得发20个请求来拿到所有数据。显然,逐个请求会让用户等待很长时间,明显不符合前端性能优化的原则。于是我选择了 p-limit 和Promise.all来实现异步并发线程池。通过并发发送多个请求,可以大大减少数据获取的总时间。
import pLimit from 'p-limit';
const limit = pLimit(5); // 限制最多5个并发请求
// 模拟接口请求
const fetchData = (page, pageSize) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`数据页 ${page}:${pageSize}条数据`);
}, 1000);
});
};
// 异步任务池
const runTasks = async () => {
const totalData = 1000; // 总数据量
const pageSize = 50; // 每页容量
const totalPages = Math.ceil(totalData / pageSize); // 计算需要多少页
const tasks = [];
// 根据总页数动态创建请求任务
for (let i = 1; i <= totalPages; i++) {
tasks.push(limit(() => fetchData(i, pageSize))); // 使用pLimit限制并发请求
}
const results = await Promise.all(tasks); // 等待所有请求完成
console.log('已完成所有任务:', results);
};
runTasks();
📑【高效本地筛选数据】
当所有数据都请求回来了,下一步就是进行本地筛选。毕竟后端已经将查询任务分配给了前端,所以我得尽可能让筛选的过程更高效,避免在本地做大量的计算导致性能问题。
1. 使用哈希进行高效查找
如果需要根据某个标签来筛选数据,最直接的做法就是遍历整个数据集,但这显然效率不高。于是我决定使用哈希表(或 Map)来组织数据。这样可以在常数时间内完成筛选操作。
const filterDataByTag = (data, tag) => {
const tagMap = new Map();
data.forEach(item => {
if (!tagMap.has(item.tag)) {
tagMap.set(item.tag, []);
}
tagMap.get(item.tag).push(item);
});
return tagMap.get(tag) || [];
};
const result = filterDataByTag(allData, 'someTag');
console.log(result);
2. 使用 Web Workers 进行数据处理
如果数据量很大,筛选过程可能会比较耗时,导致页面卡顿。为了避免这个问题,可以将数据筛选的过程交给 Web Workers 处理。Web Worker 可以在后台线程运行,避免阻塞主线程,从而让用户体验更加流畅。
const worker = new Worker('worker.js');
worker.postMessage(allData);
worker.onmessage = function(event) {
const filteredData = event.data;
console.log('筛选后的数据:', filteredData);
};
// worker.js
onmessage = function(e) {
const data = e.data;
const filteredData = data.filter(item => item.tag === 'someTag');
postMessage(filteredData);
};
📝【总结】
这场技术博弈给我们带来三点深刻启示:
- 数据民主化趋势:随着WebAssembly、WebGPU等技术的发展,前端正在获得堪比后端的计算能力
- 妥协的艺术:临时方案必须包含演进路径,我们的分页实现预留了切换GraphQL的接口
- 性能新思维:从前端到边缘计算,性能优化正在从"减少请求"转向"智能分发"
站在CTO那句"前端为什么没有数据库"的肩膀上,我们正在构建这样的未来:每个前端应用都内置轻量级数据库内核,通过差异同步策略与后端保持数据一致,利用浏览器计算资源实现真正的端智能。这不是妥协的终点,而是下一代Web应用革命的起点。
后记:三个月后,我们基于SQL.js实现了前端SQL查询引擎,配合WebWorker线程池,使得复杂筛选的耗时从秒级降至毫秒级——但这已经是另一个技术突围的故事了。
来源:juejin.cn/post/7472732247932174388
Vue3 实现最近很火的酷炫功能:卡片悬浮发光
前言
大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~
有趣的动画效果
前几天在网上看到了一个很有趣的动画效果,如下,光会跟随鼠标在卡片上进行移动,并且卡片会有视差的效果
那么在 Vue3 中应该如何去实现这个效果呢?
基本实现思路
其实实现思路很简单,无非就是分几步:
- 首先,卡片是
相对定位
,光是绝对定位
- 监听卡片的鼠标移入事件
mouseenter
,当鼠标进入时显示光 - 监听卡片的鼠标移动事件
mouseover
,鼠标移动时修改光的left、top
,让光跟随鼠标移动 - 监听卡片的鼠标移出事件
mouseleave
,鼠标移出时,隐藏光
我们先在 Index.vue
中准备一个卡片页面,光的CSS效果可以使用filter: blur()
来实现
可以看到现在的效果是这样
实现光源跟随鼠标
在实现之前我们需要注意几点:
- 1、鼠标移入时需要设置卡片
overflow: hidden
,否则光会溢出,而鼠标移出时记得还原 - 2、获取鼠标坐标时需要用
clientX/Y
而不是pageX/Y
,因为前者会把页面滚动距离也算进去,比较严谨
刚刚说到实现思路时我们说到了mouseenter、mousemove、mouseleave
,其实mouseenter、mouseleave
这二者的逻辑比较简单,重点是 mouseover
这个监听函数
而在 mouseover
这个函数中,最重要的逻辑就是:光怎么跟随鼠标移动呢?
或者也可以这么说:怎么计算光相对于卡片盒子的 left 和 top
对此我专门画了一张图,相信大家一看就懂怎么算了
- left = clientX - x - width/2
- height = clientY - y - height/2
知道了怎么计算,那么逻辑的实现也很明了了~封装一个use-light-card.ts
接着在页面中去使用
这样就能实现基本的效果啦~
卡片视差效果
卡片的视差效果需要用到样式中 transform
样式,主要是配置四个东西:
- perspective:定义元素在 3D 变换时的透视效果
- rotateX:X 轴旋转角度
- rotateY:Y 轴旋转角度
- scale3d:X/Y/Z 轴上的缩放比例
现在就有了卡片视差的效果啦~
给所有卡片添加光源
上面只是给一个卡片增加光源,接下来可以给每一个卡片都增加光源啦!!!
让光源变成可配置
上面的代码,总感觉这个 hooks 耦合度太高不太通用,所以我们可以让光源变成可配置化,这样每个卡片就可以展示不同大小、颜色的光源了~像下面一样
既然是配置化,那我们希望是这么去使用 hooks 的,我们并不需要自己在页面中去写光源的dom节点,也不需要自己去写光源的样式,而是通过配置传入 hooks 中
所以 hooks 内部要自己通过操作 DOM 的方式,去添加、删除光源,可以使用createElement、appendChild、removeChild
去做这些事~
完整源码
<!-- Index.vue -->
<template>
<div class="container">
<!-- 方块盒子 -->
<div class="item" ref="cardRef1"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef2"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef3"></div>
</div>
</template>
<script setup lang="ts">
import { useLightCard } from './use-light-card';
const { cardRef: cardRef1 } = useLightCard();
const { cardRef: cardRef2 } = useLightCard({
light: {
color: '#ffffff',
width: 100,
},
});
const { cardRef: cardRef3 } = useLightCard({
light: {
color: 'yellow',
},
});
</script>
<style scoped lang="less">
.container {
background: black;
width: 100%;
height: 100%;
padding: 200px;
display: flex;
justify-content: space-between;
.item {
position: relative;
width: 125px;
height: 125px;
background: #1c1c1f;
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
</style>
// use-light-card.ts
import { onMounted, onUnmounted, ref } from 'vue';
interface IOptions {
light?: {
width?: number; // 宽
height?: number; // 高
color?: string; // 颜色
blur?: number; // filter: blur()
};
}
export const useLightCard = (option: IOptions = {}) => {
// 获取卡片的dom节点
const cardRef = ref<HTMLDivElement | null>(null);
let cardOverflow = '';
// 光的dom节点
const lightRef = ref<HTMLDivElement>(document.createElement('div'));
// 设置光源的样式
const setLightStyle = () => {
const { width = 60, height = 60, color = '#ff4132', blur = 40 } = option.light ?? {};
const lightDom = lightRef.value;
lightDom.style.position = 'absolute';
lightDom.style.width = `${width}px`;
lightDom.style.height = `${height}px`;
lightDom.style.background = color;
lightDom.style.filter = `blur(${blur}px)`;
};
// 设置卡片的 overflow 为 hidden
const setCardOverflowHidden = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardOverflow = cardDom.style.overflow;
cardDom.style.overflow = 'hidden';
}
};
// 还原卡片的 overflow
const restoreCardOverflow = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.style.overflow = cardOverflow;
}
};
// 往卡片添加光源
const addLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.appendChild(lightRef.value);
}
};
// 删除光源
const removeLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.removeChild(lightRef.value);
}
};
// 监听卡片的鼠标移入
const onMouseEnter = () => {
// 添加光源
addLight();
setCardOverflowHidden();
};
// use-light-card.ts
// 监听卡片的鼠标移动
const onMouseMove = (e: MouseEvent) => {
// 获取鼠标的坐标
const { clientX, clientY } = e;
// 让光跟随鼠标
const cardDom = cardRef.value;
const lightDom = lightRef.value;
if (cardDom) {
// 获取卡片相对于窗口的x和y坐标
const { x, y } = cardDom.getBoundingClientRect();
// 获取光的宽高
const { width, height } = lightDom.getBoundingClientRect();
lightDom.style.left = `${clientX - x - width / 2}px`;
lightDom.style.top = `${clientY - y - height / 2}px`;
// 设置动画效果
const maxXRotation = 10; // X 轴旋转角度
const maxYRotation = 10; // Y 轴旋转角度
const rangeX = 200 / 2; // X 轴旋转的范围
const rangeY = 200 / 2; // Y 轴旋转的范围
const rotateX = ((clientX - x - rangeY) / rangeY) * maxXRotation; // 根据鼠标在 Y 轴上的位置计算绕 X 轴的旋转角度
const rotateY = -1 * ((clientY - y - rangeX) / rangeX) * maxYRotation; // 根据鼠标在 X 轴上的位置计算绕 Y 轴的旋转角度
cardDom.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; //设置 3D 透视
}
};
// 监听卡片鼠标移出
const onMouseLeave = () => {
// 鼠标离开移出光源
removeLight();
restoreCardOverflow();
};
onMounted(() => {
// 设置光源样式
setLightStyle();
// 绑定事件
cardRef.value?.addEventListener('mouseenter', onMouseEnter);
cardRef.value?.addEventListener('mousemove', onMouseMove);
cardRef.value?.addEventListener('mouseleave', onMouseLeave);
});
onUnmounted(() => {
// 解绑事件
cardRef.value?.removeEventListener('mouseenter', onMouseEnter);
cardRef.value?.removeEventListener('mousemove', onMouseMove);
cardRef.value?.removeEventListener('mouseleave', onMouseLeave);
});
return {
cardRef,
};
};
结语 & 加学习群 & 摸鱼群
我是林三心
- 一个待过小型toG型外包公司、大型外包公司、小公司、潜力型创业公司、大公司的作死型前端选手;
- 一个偏前端的全干工程师;
- 一个不正经的掘金作者;
- 一个逗比的B站up主;
- 一个不帅的小红书博主;
- 一个喜欢打铁的篮球菜鸟;
- 一个喜欢历史的乏味少年;
- 一个喜欢rap的五音不全弱鸡
如果你想一起学习前端,一起摸鱼,一起研究简历优化,一起研究面试进步,一起交流历史音乐篮球rap,可以来俺的摸鱼学习群哈哈,点这个,有7000多名前端小伙伴在等着一起学习哦 --> 摸鱼沸点
来源:juejin.cn/post/7373867360019742758
URL地址末尾加不加”/“有什么区别
URL 结尾是否带 /
主要影响的是 服务器如何解析请求 以及 相对路径的解析方式,具体区别如下:
1. 基础概念
- URL(统一资源定位符) :用于唯一标识互联网资源,如网页、图片、API等。
- 目录 vs. 资源:
- 以
/
结尾的 URL 通常表示目录,例如:
https://example.com/folder/
- 不以
/
结尾的 URL 通常指向具体的资源(如文件),例如:
https://example.com/file
- 以
2. 带 /
和不带 /
的具体区别
(1)目录 vs. 资源
https://example.com/folder/
- 服务器通常会将其解析为 目录,并尝试返回该目录下的默认文件(如
index.html
)。
- 服务器通常会将其解析为 目录,并尝试返回该目录下的默认文件(如
https://example.com/folder
- 服务器可能会将其视为 文件,如果
folder
不是文件,而是目录,服务器可能会返回 301 重定向到folder/
。
- 服务器可能会将其视为 文件,如果
📌 示例:
- 访问
https://example.com/blog/
- 服务器可能返回
https://example.com/blog/index.html
。
- 服务器可能返回
- 访问
https://example.com/blog
(如果blog
是个目录)
- 服务器可能重定向到
https://example.com/blog/
,再返回index.html
。
- 服务器可能重定向到
(2)相对路径解析
URL 末尾是否有 /
会影响相对路径的解析。
假设 HTML 页面包含以下 <img>
标签:
<img src="image.png">
📌 示例:
- 访问
https://example.com/folder/
- 访问
https://example.com/folder
- 图片路径解析为
https://example.com/image.png
- 可能导致 404 错误,因为
image.png
在folder/
里,而浏览器错误地去example.com/
下查找。
- 图片路径解析为
原因:
- 以
/
结尾的 URL,浏览器会认为它是一个目录,相对路径会基于folder/
解析。 - 不带
/
,浏览器可能认为folder
是文件,相对路径解析可能会出现错误。
(3)SEO 影响
搜索引擎对 https://example.com/folder/
和 https://example.com/folder
可能会视为两个不同的页面,导致 重复内容问题,影响 SEO 排名。因此:
- 网站通常会选择 一种形式 并用 301 重定向 规范化 URL。
- 例如:
https://example.com/folder
自动跳转 到https://example.com/folder/
。- 反之亦然。
(4)API 请求
对于 RESTful API,带 /
和不带 /
可能导致不同的行为:
https://api.example.com/users
- 可能返回所有用户数据。
https://api.example.com/users/
- 可能返回 404 或者产生不同的结果(取决于服务器实现)。
一些 API 服务器对 /
非常敏感,因此最好遵循 API 文档的规范。
3. 总结
URL 形式 | 作用 | 影响 |
---|---|---|
https://example.com/folder/ | 目录 | 通常返回 folder/ 下的默认文件,如 index.html ,相对路径解析基于 folder/ |
https://example.com/folder | 资源(或重定向) | 可能被解析为文件,或者服务器重定向到 folder/ ,相对路径解析可能错误 |
https://api.example.com/data/ | API 路径 | 可能与 https://api.example.com/data 表现不同,具体由 API 设计决定 |
如果你在开发网站,建议:
- 统一 URL 规则,例如所有目录都加
/
或者所有请求都不加/
,然后用 301 重定向 确保一致性。 - 测试 API 的行为,确认带
/
和不带/
是否影响请求结果。
来源:juejin.cn/post/7468112128928350242
用node帮老婆做excel工资表
我是天元,立志做1000个有趣的项目的前端。如果你喜欢的话,请点赞,收藏,转发。评论领取
零花钱+100
勋章
背景
我老婆从事HR的工作,公司有很多连锁店,她需要将所有的门店的工资汇总计算,然后再拆分给各门店请确认,最后再提供给财务发工资。
随着门店数量渐渐增多,渐渐的我老婆已经不堪重负,每天加班都做不完,严重影响夫妻感情生活。
最终花费了2天的时间,完成了整个node程序,她只需要传入工资表,相应的各种表格在10s内自动输出。目前已正式交付,得到了每月零花钱提高100元的重大成果
。
整体需求
- 表格的导入和识别
- 表格的计算(计算公式要代入),表格样式正确
- 最终结果按照门店拆分为工资表
需求示例(删减版)
需求为,根据传入的基本工资及补发补扣,生成总工资表,门店工资表,财务工资表发放表。
工资表中字段为门店,姓名,基本工资,补发补扣,最终工资(基本工资+补发补扣)。最后一行为总计
门店工资表按照每个门店,单独一个表格,字段同工资表。最后一行为总计
工资表
基础工资
补发补扣
技术选型
这次的主力库为exceljs
,官方文档介绍如下
读取,操作并写入电子表格数据和样式到 XLSX 和 JSON 文件。
一个 Excel 电子表格文件逆向工程项目
选择exceljs是因为它支持完整的excel的样式及公式。
安装及目录结构
优先安装exceljs
npm init
yarn add exceljs
创建input,out,src三个文件夹,src放入index.js
package.json增加start脚本
"scripts": {
"start": "node src/index.js"
},
代码相关
导入
通过new Excel.Workbook();
创建一个工作簿,通过workbook.xlsx.readFile
来导入文件, 注意这是个promise
const ExcelJS = require("exceljs");
const path = require("path");
const inputPath = path.resolve(__dirname, "../input");
const outputPath = path.resolve(__dirname, "../out");
const loadInput =async () => {
const workbook = new ExcelJS.Workbook();
const inputFile = await workbook.xlsx.readFile(inputPath + "/工资表.xlsx")
};
loadInput()
数据拆分
通过getWorksheet
Api,我们可以获取到对应的工作表的内容
const loadInput =async () => {
...
// 基本工资
const baseSalarySheet = inputFile.getWorksheet("基本工资");
// 补发补扣
const supplementSheet = inputFile.getWorksheet("补发补扣");
}
然后我们需要进一步的来进行拆分,因为第一行为每个工作表的头,这部分在我们实际数据处理中不会使用,所以通过getRows
来获取实际的内容。
const baseSalaryContent = baseSalarySheet.getRows(
2,
baseSalarySheet.rowCount
);
baseSalaryContent.map((row) => {
console.log(row.values);
});
/**
[ <1 empty item>, '2024-02', '海贼王', '路飞', 12000 ]
[ <1 empty item>, '2024-02', '海贼王', '山治', 8000 ]
[ <1 empty item>, '2024-02', '火影忍者', '鸣人', '6000' ]
[ <1 empty item>, '2024-02', '火影忍者', '佐助', 7000 ]
[ <1 empty item>, '2024-02', '火影忍者', '雏田', 5000 ]
[ <1 empty item>, '2024-02', '一拳超人', '琦玉', 4000 ]
[]
[]
**/
可以看到实际的内容已经拿到了,我们要根据这些内容拼装一下最终便于后续的调用。
我们可以通过 row.getCell
Api获取到对应某一列的内容,例如门店是在B
列,那么我们就可以使用row.getCell('B')
来获取。
因为我们需要拆分门店,所以这里的基本工资,我们以门店为单位,把数据进行拆分
const baseSalary = {};
baseSalaryContent.forEach((row) => {
const shopName = row.getCell("B").value;
if (!shopName) return; // 过滤空行
const name = row.getCell("C").value;
const salary = row.getCell("D").value;
if (!baseSalary[shopName]) {
baseSalary[shopName] = [];
}
baseSalary[shopName].push({
name,
salary,
});
});
这样我们得到了一个以门店名称为key的对象,value为该门店的员工信息数组。利用相同方法,获取补发补扣。因为每个人已经确定了门店,所以后续只需要根据姓名来做key,拆分成一个object即可
// 补发补扣
const supplement = {};
supplementSheet.getRows(2, supplementSheet.rowCount).forEach((row) => {
const name = row.getCell("C").value;
const type = row.getCell("H").value;
let count = row.getCell("D").value;
// 如果为补扣,则金额为负数
if (type === "补扣") {
count = -count;
}
if (!supplement[name]) {
supplement[name] = 0;
}
supplement[name] += count;
});
数据组合
门店工资表
因为每个门店需要独立一张表,所以需要遍历baseSalary
Object.keys(baseSalary).forEach((shopName) => {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("工资表");
// 添加表头
worksheet.addRow([
"序号",
"门店",
"姓名",
"基本工资",
"补发补扣",
"最终工资",
]);
baseSalary[shopName].forEach((employee, index) => {
worksheet.addRow([
index + 1,
shopName,
employee.name,
+employee.salary,
supplement[employee.name] || 0,
+employee.salary + (supplement[employee.name] || 0),
]);
});
});
此时你也可以快进到表格输出
来查看输出的结果,以便随时调整
这样我们就把基本工资已经写入工作表了,但是这里存在问题,最终工资使用的是一个数值,而没有公式。所以我们需要改动下
worksheet.addRow([ index + 1, shopName, employee.name, employee.salary, supplement[employee.name] || 0,
{
formula: `D${index + 2}+E${index + 2}`,
result: employee.salary + (supplement[employee.name] || 0),
},
]);
这里的formula
将对应到公式,而result
是显示的值,这个值是必须写入的,如果你写入了错误的值,会在表格中显示该值,但是双击后,公式重新计算,会替换为新的值。所以这里必须计算正确
合计
依照上方的逻辑,继续添加一行作为合计,但是之前计算的时候,需要添加一个临时变量,记录下合计的相关内容。
const count = [0, 0, 0];
baseSalary[shopName].forEach((employee, index) => {
count[0] += +employee.salary;
count[1] += supplement[employee.name] || 0;
count[2] += +employee.salary + (supplement[employee.name] || 0);
worksheet.addRow([
index + 1,
shopName,
employee.name,
+employee.salary,
supplement[employee.name] || 0,
{
formula: `D${index + 2}+E${index + 2}`,
result: +employee.salary + (supplement[employee.name] || 0),
},
]);
});
然后在尾部添加一行
worksheet.addRow([ "合计", "", "", { formula: `SUM(D2:D${baseSalary[shopName].length + 1})`,
result: count[0],
},
{
formula: `SUM(E2:E${baseSalary[shopName].length + 1})`,
result: count[1],
},
{
formula: `SUM(F2:F${baseSalary[shopName].length + 1})`,
result: count[2],
},
]);
美化
表格的合并,可以使用mergeCells
worksheet.mergeCells(
`A${baseSalary[shopName].length + 2}:C${baseSalary[shopName].length + 2}`
);
这样就合并了我们的最后一行的前三列,接下来我们要给表格添加线条。
对于批量的添加,可以直接使用addConditionalFormatting
,它将在一个符合条件的单元格范围内添加规则
worksheet.addConditionalFormatting({
ref: `A1:F${baseSalary[shopName].length + 2}`,
rules: [
{
type: "expression",
formulae: ["true"],
style: {
border: {
top: { style: "thin" },
left: { style: "thin" },
bottom: { style: "thin" },
right: { style: "thin" },
},
alignment: { vertical: "top", horizontal: "left", wrapText: true },
},
},
],
});
表格输出
现在门店工资表已经拆分完成,我们可以直接保存了,使用xlsx.writeFile
Api来保存文件
Object.keys(baseSalary).forEach((shopName) => {
...
workbook.xlsx.writeFile(outputPath + `/${shopName}工资表.xlsx`);
})
最终效果
相关代码地址
来源:juejin.cn/post/7346421986607087635
蓝牙耳机丢了,我花几分钟写了一个小程序,找到了!
你是否曾经经历过蓝牙耳机不知道丢到哪里去的困扰?特别是忙碌的早晨,准备出门时才发现耳机不见了,整个心情都被影响。幸运的是,随着技术的进步,我们可以利用一些简单的小程序和蓝牙技术轻松找到丢失的耳机。今天,我要分享的是我如何通过一个自制的小程序,利用蓝牙发现功能,成功定位自己的耳机。这不仅是一次有趣的技术尝试,更是对日常生活中类似问题的一个智能化解决方案。
1. 蓝牙耳机丢失的困扰
现代生活中,蓝牙耳机几乎是每个人的必备品。然而,耳机的体积小、颜色常常与周围环境融为一体,导致丢失的情况时有发生。传统的寻找方式依赖于我们对耳机放置地点的记忆,但往往不尽人意。这时候,如果耳机还保持在开机状态,我们就可以借助蓝牙技术进行定位。然而,市场上大部分设备并没有自带这类功能,而我们完全可以通过编写小程序实现。
2. 蓝牙发现功能的原理
蓝牙发现功能是通过设备之间的信号传输进行连接和识别的。当一个蓝牙设备处于开机状态时,它会周期性地广播自己的信号,周围的蓝牙设备可以接收到这些信号并进行配对。这个过程的背后其实是信号的强度和距离的关系。当我们在手机或其他设备上扫描时,能够检测到耳机的存在,但并不能直接告诉我们耳机的具体位置。此时,我们可以通过信号强弱来推测耳机的大概位置。
3. 实现步骤:从构想到实践
有了这个想法后,我决定动手实践。首先,我使用微信小程序作为开发平台,利用其内置的蓝牙接口实现设备扫描功能。具体步骤如下:
- • 环境搭建:选择微信小程序作为平台主要因为其开发简便且自带蓝牙接口支持。
- • 蓝牙接口调用:调用
wx.openBluetoothAdapter
初始化蓝牙模块,确保设备的蓝牙功能开启。 - • 设备扫描:通过
wx.startBluetoothDevicesDiscovery
函数启动设备扫描,并使用wx.onBluetoothDeviceFound
监听扫描结果。 - • 信号强度分析:通过读取蓝牙信号强度(RSSI),结合多次扫描的数据变化,推测设备的距离,最终帮助定位耳机。
在代码的实现过程中,信号强度的变化尤为重要。根据RSSI值的波动,我们可以判断耳机是在靠近还是远离,并通过走动测试信号的变化,逐渐缩小搜索范围。
下面是我使用 Taro 实现的全部代码:
import React, { useState, useEffect } from "react";
import Taro, { useReady } from "@tarojs/taro";
import { View, Text } from "@tarojs/components";
import { AtButton, AtIcon, AtProgress, AtList, AtListItem } from "taro-ui";
import "./index.scss";
const BluetoothEarphoneFinder = () => {
const [isSearching, setIsSearching] = useState(false);
const [devices, setDevices] = useState([]);
const [nearestDevice, setNearestDevice] = useState(null);
const [isBluetoothAvailable, setIsBluetoothAvailable] = useState(false);
const [trackedDevice, setTrackedDevice] = useState(null);
useEffect(() => {
if (isSearching) {
startSearch();
} else {
stopSearch();
}
}, [isSearching]);
useEffect(() => {
if (devices.length > 0) {
const nearest = trackedDevice
? devices.find((d) => d.deviceId === trackedDevice.deviceId)
: devices[0];
setNearestDevice(nearest || null);
} else {
setNearestDevice(null);
}
}, [devices, trackedDevice]);
const startSearch = () => {
const startDiscovery = () => {
setIsBluetoothAvailable(true);
Taro.startBluetoothDevicesDiscovery({
success: () => {
Taro.onBluetoothDeviceFound((res) => {
const newDevices = res.devices.map((device) => ({
name: device.name || "未知设备",
deviceId: device.deviceId,
rssi: device.RSSI,
}));
setDevices((prevDevices) => {
const updatedDevices = [...prevDevices];
newDevices.forEach((newDevice) => {
const index = updatedDevices.findIndex(
(d) => d.deviceId === newDevice.deviceId
);
if (index !== -1) {
updatedDevices[index] = newDevice;
} else {
updatedDevices.push(newDevice);
}
});
return updatedDevices.sort((a, b) => b.rssi - a.rssi);
});
});
},
fail: (error) => {
console.error("启动蓝牙设备搜索失败:", error);
Taro.showToast({
title: "搜索失败,请重试",
icon: "none",
});
setIsSearching(false);
},
});
};
Taro.openBluetoothAdapter({
success: startDiscovery,
fail: (error) => {
if (error.errMsg.includes("already opened")) {
startDiscovery();
} else {
console.error("初始化蓝牙适配器失败:", error);
Taro.showToast({
title: "蓝牙初始化失败,请检查蓝牙是否开启",
icon: "none",
});
setIsSearching(false);
setIsBluetoothAvailable(false);
}
},
});
};
const stopSearch = () => {
if (isBluetoothAvailable) {
Taro.stopBluetoothDevicesDiscovery({
complete: () => {
Taro.closeBluetoothAdapter({
complete: () => {
setIsBluetoothAvailable(false);
},
});
},
});
}
};
const getSignalStrength = (rssi) => {
if (rssi >= -50) return 100;
if (rssi <= -100) return 0;
return Math.round(((rssi + 100) / 50) * 100);
};
const getDirectionGuide = (rssi) => {
if (rssi >= -50) return "非常接近!你已经找到了!";
if (rssi >= -70) return "很近了,继续朝这个方向移动!";
if (rssi >= -90) return "正确方向,但还需要继续寻找。";
return "信号较弱,尝试改变方向。";
};
const handleDeviceSelect = (device) => {
setTrackedDevice(device);
Taro.showToast({
title: `正在跟踪: ${device.name}`,
icon: "success",
duration: 2000,
});
};
return (
<View className="bluetooth-finder">
{isSearching && (
<View className="loading-indicator">
<AtIcon value="loading-3" size="30" color="#6190E8" />
<Text className="loading-text">搜索中...Text>
View>
)}
{nearestDevice && (
<View className="nearest-device">
<Text className="device-name">{nearestDevice.name}Text>
<AtProgress
percent={getSignalStrength(nearestDevice.rssi)}
status="progress"
isHidePercent
/>
<Text className="direction-guide">
{getDirectionGuide(nearestDevice.rssi)}
Text>
View>
)}
<View className="device-list">
<AtList>
{devices.map((device) => (
<AtListItem
key={device.deviceId}
title={device.name}
note={`${device.rssi} dBm`}
extraText={
trackedDevice && trackedDevice.deviceId === device.deviceId
? "跟踪中"
: ""
}
arrow="right"
onClick={() => handleDeviceSelect(device)}
/>
))}
AtList>
View>
<View className="action-button">
<AtButton
type="primary"
circle
onClick={() => setIsSearching(!isSearching)}
>
{isSearching ? "停止搜索" : "开始搜索"}
AtButton>
View>
View>
);
};
export default BluetoothEarphoneFinder;
嘿嘿,功夫不负苦心人,我最终通过自己的小程序找到了我的蓝牙耳机。
我将我的小程序发布到了微信小程序上,目前已经通过审核,可以直接使用了。搜索老码宝箱 即可体验。
顺带还加了非常多的小工具,而且里面还有非常多日常可能会用到的工具,有些还非常有意思。
比如
绘制函数图
每日一言
汇率转换(实时)
BMI 计算
简易钢琴
算一卦
这还不是最重要的
最重要的是,这里的工具是会不断增加的,而且,更牛皮的是,你还可以给作者提需求,增加你想要的小工具,作者是非常欢迎一起讨论的。有朝一日,你也希望你的工具也出现在这个小程序上,被千万人使用吧。
4. 实际应用与优化空间
这个小程序的实际效果超出了我的预期。我能够通过它快速找到丢失的耳机,整个过程不到几分钟时间。然而,值得注意的是,由于蓝牙信号会受到环境干扰,例如墙体、金属物等,导致信号强度并不总是精确。在后续的优化中,我计划加入更多的信号处理算法,例如利用三角定位技术,结合多个信号源来提高定位精度。此外,还可以考虑在小程序中加入可视化的信号强度图,帮助用户更直观地了解耳机的大致方位。
一些思考:
蓝牙耳机定位这个小程序的开发,展示了技术在日常生活中的强大应用潜力。虽然这个项目看似简单,但背后的原理和实现过程非常具有教育意义。通过这次尝试,我们可以看到,借助开源技术和简单的编程能力,我们能够解决许多日常生活中的实际问题。
参考资料:
- 微信小程序官方文档:developers.weixin.qq.com
- 蓝牙信号强度(RSSI)与距离关系的研究:http://www.bluetooth.com
- 个人开发者经验分享: 利用蓝牙发现功能定位设备
来源:juejin.cn/post/7423610485180727332
前端可玩性UP项目:大屏布局和封装
前言
autofit.js 发布马上要一年了,也收获了一批力挺用户,截至目前它在github上有1k 的 star,npm 上有超过 13k 的下载量。
这篇文章主要讲从设计稿到落地开发大屏应用,大道至简,这篇文章能帮助各位潇洒自如的开发大屏。
分析设计稿
分析设计稿之前先吐槽一下大屏这种展现形式,这简直就是自欺欺人、面子工程的最直接的诠释,是吊用没有,只为了好看,如果设计的再不好看啊,这就纯纯是屎。在我的理解中,这就像把PPT放到了web端,仅此而已。
但是王哥告诉我:"你看似没有用的东西,其实都有用,很多想真正做出有用的产品的企业,没钱,就要先把面子工程做好,告诉别人他们要做一件什么事,这样投资人才会看到,后面才有机会发展。"
布局方案
上图展示了一个传统意义上且比较普遍的大屏形态,分为四个部分,分别是
头部
头部经常放标题、功能菜单、时间、天气
左右面板
左右面板承载了各种数字和报表,还有视频、轮播图等等
中间
中间部分一般放地图,这其中又分假地图(一张图片)、图表地图(如echarts)、地图引擎(如:leaflet、mapbox、高德、百度)。或者有的还会放3D场景,一般有专门的同事去做3D场景,然后导入到web端。
大屏的设计通常的分辨率是 1920*1080 的,这也是迄今为止应用最广泛的显示器配置,当然也有基于客户屏幕做的异形分辨率,这就五花八门了。
但是万变不离其宗,分辨率的变化不会影响它的基本结构,根据上面的图,我们可以快速构建结构代码
<div class='Box'>
<div class="header"></div>
<div class="body">
<div class="leftPanel"></div>
<div class="mainMap"></div>
<div class="rightPanel"></div>
</div>
</div>
上面的代码实现了最简单的上下(Box)+左右(body)的布局结构,完全不需要任何定位策略。
要实现上图的效果,只需最简单的CSS即可完成布局。
组件方案
大屏虽然是屎,但是是一种可玩性很强的项目,想的越复杂,做起来就越复杂,想的越简单,做起来就越简单。
可以疯狂封装炫技,因为大屏里面的可玩组件简直太多了,且涵盖的太全了,想怎么玩都可以,包括但不限于 各类图表库的封装(echarts、highCharts、vChart)、轮播图(swiper)、地图引擎、视频库(包括直播流)等等。
如果想简单,甚至可以不用封装,可以看到结构甚至简单到不用CSS几行就可以搭建出基本框架,只把header、leaftPanel、rightPanel、map封装一下就可以了。
这里还有一个误区,就是大家都喜欢把 大型的组件库 拉到大屏里来用,结果做完了发现好像只用了一个 toast 和一个下拉组件,项目打包后却增大了几十倍的体积,其实像这种简单的组件,完全可以手写,或者找小的独立包来用,一方面会减小体积,不至于让项目臃肿,另一方面可以锻炼自己的手写能力,这才是有必要的封装。
适配
目前主流的适配方案,依然是 rem 方案,其原理就是根据根元素的 font-size 自动计算大小,但是此方法需要手动计算 rem 值,或者使用第三方插件如postcss等,但是此方案还有一个弊端,就是无法向下兼容,因为浏览器中最小的文字大小是12px。
vh/vw方案就不再赘述了,原理基本和 rem/em 相似,都涉及到单位的转换。
autofit.js
主要讲一下使用 autofit.js 如何快速实现适配。
不支持的场景
首先 autofit.js 不支持 elementUI(plus)、ant-design等组件库,具体是不支持包含popper.js的组件,popper.js 在计算弹出层位置时,不会考虑 scale 后的元素的视觉大小,所以会造成弹出元素的位置偏移。
其次,不支持 百度地图,百度地图对窗口缩放事件没有任何处理,有同学反馈说,即使使用了resize属性,百度地图在和autofit.js共同使用时,也会有事件热区偏移的问题。而且百度地图使用 bd-09 坐标系,和其他图商不通用,引擎的性能方面也差点意思,个人不推荐在开发中使用百度地图。
然后一些拖拽库,如甘特图插件,可能也不支持,他们在计算鼠标位置时同样没有考虑 scale 后的元素的视觉大小。
用什么单位
不支持的单位:vh、vw、rem、em
让我诧异的是,老有人问我该用什么单位,主要徘徊在 px 和 % 之间,加群的同学多数是因为用了相对单位,导致留白了。不过人各有所长,跟着各位大佬我也学到了很多。
看下图
假如有两个宽度为1000的元素,他们内部都有一个子元素,第一个用百分比设置为 width:50%;left:1%
, 第二个设置为 wdith:500px;left:10px
。此时,只要外部的1000px的容器宽度不变,这两个内部元素在视觉上是一模一样的,且在实际数值上也是一模一样的,他们宽度都为500px,距离左侧10px。
但是如果外部容器变大了,来看一下效果:
在样式不变的情况下,仅改变外部容器大小,差异就出来了,由上图可知,50%的元素依然占父元素的一半,实际宽度变成了 1000px,距离左侧的实际距离变成了 20px。
这当然不难理解,百分比单位是根据 最近的、有确定大小的父级元素计算的。
所以,应该用什么单位其实取决于想做什么,举个例子:在1920*1080
基础上开发,中间的地图写成了宽度为 500px ,这在正常情况下,看起来没有任何问题,它大概占屏幕的 26%,当屏幕分辨率达到4096*2160
时,它在屏幕上只占 12%,看起来就是缩在一角。而当你设置宽度为26%时,无论显示器如何变化,它始终占屏幕26%。
autofit.js 所干的事,就是把1000px 变成了 2000px或者把2000px变成了1000px,并给它设置了一个合适的缩放大小。
图表、图片拉伸
背景或各种图片按需设置 object-fit: cover;
即可
图表如echarts一般推荐使用百分比,且监听窗口变化事件做resize()
结语
再次感慨,大道至简,事情往往没有那么复杂,祝各位前程似锦。
来源:juejin.cn/post/7344625554530779176