注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

前端何时能出个"秦始皇"一统天下?我是真学不动啦!

web
前端何时能出个"秦始皇"一统天下?我是真学不动啦! 引言 前端开发的世界,就像历史上的战国时期一样,各种框架、库、工具层出不穷,形成了一个百花齐放但也令人眼花缭乱的局面。 而且就因为百家争鸣,导致各种鄙视链出现 比如 React 和 Vue 互喷 v:你re...
继续阅读 »

前端何时能出个"秦始皇"一统天下?我是真学不动啦!


引言


前端开发的世界,就像历史上的战国时期一样,各种框架、库、工具层出不穷,形成了一个百花齐放但也令人眼花缭乱的局面。




而且就因为百家争鸣,导致各种鄙视链出现


比如 React 和 Vue 互喷


v:你react 这么难用,不如我vue 简单


r:你一点都不灵活,我想咋用咋用


v:你useEffect 心智负担太重,一点都好用


r:啥心智负担,那是你太笨了,我就喜欢这种什么都掌握在自己手里的感觉


v:你内部更是混乱,一个状态管理就那么多种 啥redux、mobx、recoil。。。。不像我们一个pinia 走天下


r:你管我 我想用哪个用哪个,你还说我,你内部对一个 用ref还是用reactive 都吵得不可开交!


......


2.jpeg


1. 框架之争



  • React: 由Facebook维护的一个用于构建用户界面的JavaScript库。其设计理念是通过组件化的方式简化复杂的UI开发。




  • Vue.js: 一种渐进式JavaScript框架,非常适合用来构建单页应用。Vue的核心库只关注视图层,易于上手。




  • Angular: Google支持的一个开源Web应用框架,适用于大型企业级项目。它提供了一个全面的解决方案来创建动态Web应用程序。




  • Solid.js: 一个专注于性能和简单性的声明式UI库,采用细粒度的响应式系统,提供了极高的运行效率。




  • Svelte: 一种新兴的前端框架,通过在编译时将组件转换为高效的原生代码,从而避免了运行时开销。




  • Ember.js: 一个旨在帮助开发者构建可扩展的Web应用的框架,尤其适合大型团队协作。







2. 样式处理满花齐放


样式处理方面可以进一步细分,包括CSS预处理器、CSS-in-JS、Utility-First CSS框架以及CSS Modules等。



  • CSS预处理器



    • Sass: 提供变量、嵌套规则等高级功能,极大地提高了CSS代码的可维护性。


    • Less: 另一种流行的CSS预处理器,支持类似的功能但语法稍有不同。


    • Stylus: 一款灵活且功能强大的CSS预处理器,允许省略括号和分号等符号,使代码更加简洁。




  • CSS-in-JS




  • 原子化css



    • Tailwind CSS: 一种实用优先的CSS框架,让你可以通过低级实用程序类构建定制设计。




    • UnoCSS: 新一代的原子化CSS引擎,旨在提供极致的性能和灵活性。




    • Windi CSS: 一个基于Tailwind CSS的即时按需CSS框架,提供了更快的开发体验。




    • GitHub Stars: 约6.5k(截至2025年4月)






3. 构建工具五花八门


构建工具是现代前端开发不可或缺的一部分,它们负责将源代码转换为生产环境可用的形式,并优化性能。



  • Webpack: 一个模块打包工具,广泛用于复杂的前端项目中。它支持多种文件类型的处理,并具有强大的插件生态。




  • Vite: 由Vue.js作者尤雨溪开发的下一代前端构建工具,以其极快的冷启动速度和热更新闻名。




  • Rollup: 一个专注于JavaScript库的打包工具,特别适合构建小型库或框架。




  • Rspack: 一个基于Rust实现的高性能构建工具,兼容Webpack配置,旨在提供更快的构建速度。




  • esbuild: 一个用Go语言编写的极速打包工具,专为现代JavaScript项目设计。




  • Turbopack: 由Next.js团队推出的下一代构建工具,号称比Webpack快700倍。




  • Rolldown: 一个基于Rust的Rollup替代方案,旨在提供更快的构建速度和更高的性能。





对比分析:



  • Webpack 是目前最成熟的构建工具,生态系统庞大,但配置复杂度较高。

  • Vite 凭借其快速的开发体验迅速崛起,尤其在中小型项目中表现优异。

  • Rollup 更适合轻量级项目或库的构建,虽然社区规模较小,但在特定场景下非常高效。

  • Rspackesbuild 利用高性能语言(如Rust和Go)实现了极快的构建速度,适合对性能要求较高的项目。

  • Turbopack 是新兴工具,主打极速构建,未来可能成为Webpack的有力竞争者。

  • Rolldown 提供了另一种基于Rust的高速构建解决方案,特别针对Rollup用户群体。




4. 包管理工具逐步更新





5. 状态管理百家争鸣


状态管理是前端开发中的重要组成部分,它帮助开发者有效地管理应用的状态变化。





6. JavaScript运行时环境都有好几种


JavaScript运行时环境是现代前端和后端开发的核心部分,它决定了代码如何被解析和执行。以下是几种主流的JavaScript运行时环境:



  • Node.js:



    • Node.js 是一个基于Chrome V8引擎的JavaScript运行时,广泛用于构建服务器端应用、命令行工具以及全栈开发。

    • 它拥有庞大的生态系统,npm作为其默认包管理器,已经成为全球最大的软件注册表。

    • 官网: nodejs.org/

    • GitHub: github.com/nodejs/node

    • GitHub Stars: 约111k(截至2025年4月)



  • Deno:



    • Deno 是由Node.js的原作者Ryan Dahl创建的一个现代化JavaScript/TypeScript运行时,旨在解决Node.js的一些设计缺陷。

    • 它内置了对TypeScript的支持,并提供了更安全的权限模型(如文件系统访问需要显式授权)。

    • Deno还集成了标准库,无需依赖第三方模块即可完成许多常见任务。

    • 官网: deno.land/

    • GitHub: github.com/denoland/de…

    • GitHub Stars: 约103k(截至2025年4月)



  • Bun:



    • Bun 是一个新兴的JavaScript运行时,旨在提供更快的性能和更高效的开发体验。

    • 它不仅可以用作运行时环境,还可以替代npm、Yarn等包管理工具,同时支持ES Modules和CommonJS。

    • Bun的目标是成为Node.js和Deno的强大竞争者,特别适合高性能需求的场景。

    • 官网: bun.sh/

    • GitHub: github.com/oven-sh/bun

    • GitHub Stars: 约77.5k(截至2025年4月)




对比分析:



  • Node.js 是目前最成熟且广泛应用的JavaScript运行时,尤其在企业级项目中占据主导地位。

  • Deno 提供了更现代化的设计理念,特别是在安全性、TypeScript支持和内置工具方面表现突出。

  • Bun 是一个新兴的选手,凭借其极速的性能和多功能性迅速吸引了开发者关注,未来潜力巨大。




7. 跨平台开发


随着移动设备和多终端生态的普及,跨平台开发成为现代应用开发的重要方向。以下是几种主流的跨平台开发工具和技术:



  • React Native:



    • React Native 是由Facebook推出的一个基于React的跨平台移动应用开发框架,允许开发者使用JavaScript和React构建原生性能的iOS和Android应用。

    • 它提供了丰富的社区支持和插件生态,适合需要快速迭代的项目。

    • 官网: reactnative.dev/

    • GitHub: github.com/facebook/re…

    • GitHub Stars: 约122k(截至2025年4月)



  • Flutter:



    • Flutter 是由Google开发的一个开源UI框架,使用Dart语言构建高性能的跨平台应用。

    • 它通过自绘引擎渲染UI,提供了一致的用户体验,并支持Web、iOS、Android以及桌面端开发。

    • 官网: flutter.dev/

    • GitHub: github.com/flutter/flu…

    • GitHub Stars: 约170k(截至2025年4月)



  • Electron:



    • Electron 是一个用于构建跨平台桌面应用的框架,基于Node.js和Chromium,广泛应用于桌面端应用开发。

    • 它允许开发者使用Web技术(HTML、CSS、JavaScript)构建功能强大的桌面应用,但可能会导致较大的应用体积。

    • 官网: http://www.electronjs.org/

    • GitHub: github.com/electron/el…

    • GitHub Stars: 约116k(截至2025年4月)



  • Tauri:



    • Tauri 是一个轻量级的跨平台桌面应用框架,旨在替代Electron,提供更小的应用体积和更高的安全性。

    • 它利用系统的原生Webview来渲染UI,同时支持Rust作为后端语言,从而实现更高的性能。

    • 官网: tauri.app/

    • GitHub: github.com/tauri-apps/…

    • GitHub Stars: 约91.5k(截至2025年4月)



  • Capacitor:



    • Capacitor 是由Ionic团队推出的一个跨平台工具,允许开发者将Web应用封装为原生应用。

    • 它支持iOS、Android和Web,并提供了丰富的插件生态,方便调用原生设备功能。

    • 官网: capacitorjs.com/

    • GitHub: github.com/ionic-team/…

    • GitHub Stars: 约13.1k(截至2025年4月)



  • UniApp:



    • UniApp 是一个基于 Vue.js 的跨平台开发框架,能够将代码编译到多个平台,包括微信小程序、H5、iOS、Android以及其他小程序(如支付宝小程序、百度小程序等)。

    • 它的优势在于一次编写,多端运行,特别适合需要覆盖多个小程序平台的项目。

    • 官网: uniapp.dcloud.io/

    • GitHub: github.com/dcloudio/un…

    • GitHub Stars: 约40.6k(截至2025年4月)




对比分析:



  • React NativeFlutter 是移动端跨平台开发的两大主流选择,分别适合熟悉JavaScript和Dart的开发者。

  • Electron 是桌面端跨平台开发的经典解决方案,虽然体积较大,但易于上手。

  • Tauri 提供了更轻量化的桌面端开发方案,适合对性能和安全性有更高要求的项目。

  • Capacitor 则是一个灵活的工具,特别适合将现有的Web应用快速迁移到移动端。

  • UniApp 非常适合需要覆盖多种小程序平台的项目,尤其在国内的小程序生态中表现出色。




结论


你看我这还是只是列举了一部分,都这么多了,学前端的是真的命苦啊,真心学不动了。


1.jpeg


而且最近 尤雨溪宣布成立 VoidZero 说是一代JavaScript工具链,能够统一前端 开发构建工具,如果真能做到,真是一件令人振奋的事情,希望尤雨溪能做到跟 spring 一样统一java 天下 把前端的天下给统一了,大家觉得有可能么?


作者:前端摸鱼杭小哥
来源:juejin.cn/post/7493420166878822450
收起阅读 »

10 个被严重低估的 JS 特性,直接少写 500 行代码

web
前言最近逛 Reddit 的时候,看到一个关于最被低估的 JavaScript 特性的讨论,我对此进行了总结,和大家分享一下。1. Set:数组去重 + 快速查找,比 filter 快 3 倍提到数组去重,很多人第一反应是 filter +...
继续阅读 »

前言

最近逛 Reddit 的时候,看到一个关于最被低估的 JavaScript 特性的讨论,我对此进行了总结,和大家分享一下。

1. Set:数组去重 + 快速查找,比 filter 快 3 倍

提到数组去重,很多人第一反应是 filter + indexOf,但这种写法的时间复杂度是 O (n²),而 Set 天生支持 “唯一值”,查找速度是 O (1),还能直接转数组。

举个例子:

用户 ID 去重:

// 后端返回的重复用户 ID 列表
const duplicateIds = [101, 102, 102, 103, 103, 103];
// 1 行去重
const uniqueIds = [...new Set(duplicateIds)];
console.log(uniqueIds); // [101,102,103]

避免重复绑定事件:

const listenedEvents = new Set();
// 封装事件绑定函数,防止同一事件重复绑定
function safeAddEvent(eventName, handler) {
if (!listenedEvents.has(eventName)) {
window.addEventListener(eventName, handler);
listenedEvents.add(eventName); // 标记已绑定
}
}
// 调用 2 次也只会绑定 1 次 scroll 事件
safeAddEvent("scroll", () => console.log("滚动了"));
safeAddEvent("scroll", () => console.log("滚动了"));

2. Object.entries () + Object.fromEntries ():对象数组互转神器

以前想遍历对象,要用 for...in 循环,外加判断 hasOwnProperty;如果想把数组转成对象,只能手动写循环。这对组合直接一键搞定。

举个例子:

筛选对象属性,过滤掉空值:

// 后端返回的用户信息,包含空值字段
const userInfo = {
name: "张三",
age: 28,
avatar: "", // 空值,需要过滤
phone: "13800138000",
};
// 1. 转成[key,value]数组,过滤空值;2. 转回对象
const filteredUser = Object.fromEntries(Object.entries(userInfo).filter(([key, value]) => value !== ""));
console.log(filteredUser);
// {name: "张三", age:28, phone: "13800138000"}

URL 参数转对象(不用再写正则了)

// 地址栏的参数:?name=张三&age=28&gender=男
const searchStr = window.location.search.slice(1);

// 直接转成对象,支持中文和特殊字符
const paramObj = Object.fromEntries(new URLSearchParams(searchStr));

console.log(paramObj); // {name: "张三", age: "28", gender: "男"}

3. ?? 与 ??=:比 || 靠谱

 || 设置默认值时,会把 0""false这些 “有效假值” 当成空值。比如用户输入 0(表示数量),count || 10会返回 10,但这里其实应该返回 0。而??只判断 null/undefined

举个例子:

处理用户输入的 “有效假值”:

// 用户输入的数量( 0 是有效数值,不能替换)
const userInputCount = 0;

// 错误写法:会把 0 当成空值,返回 10
const wrongCount = userInputCount || 10;

// 正确写法:只判断 null/undefined,返回 0
const correctCount = userInputCount ?? 10;

console.log(wrongCount, correctCount); // 10, 0

给对象补默认值(不会覆盖已有值):

// 前端传入的配置,可能缺少 retries 字段
const requestConfig = { timeout: 5000 };

// 只有当 retries null/undefined 时,才赋值 3(不覆盖已有值)
requestConfig.retries ??= 3;
console.log(requestConfig); // {timeout:5000, retries:3}

// 如果已有值,不会被覆盖
const oldConfig = { timeout: 3000, retries: 2 };
oldConfig.retries ??= 3;
console.log(oldConfig); // {timeout:3000, retries:2}

4. Intl API:原生国际化 API

很多人会用 moment.js 处理日期、货币格式化,但这个库体积特别大(压缩后也有几十 KB);而 Intl 是浏览器原生 API,支持货币、日期、数字的本地化,体积为 0,还能自动适配地区。

举个例子:

多语言货币格式化(适配中英文):

const price = 1234.56;

// 人民币格式(自动加 ¥ 和千分位)
const cnyPrice = new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
}).format(price);

// 美元格式(自动加 $ 和千分位)
const usdPrice = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price);

console.log(cnyPrice, usdPrice); // ¥1,234.56 $1,234.56

日期本地化(不用手动拼接年月日):

const now = new Date();

// 中文日期:2025年11月3日 15:40:22
const cnDate = new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(now);

// 英文日期:November 3, 2025, 03:40:22 PM
const enDate = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(now);
console.log(cnDate, enDate);

5. Intersection Observer:图片懒加载 + 滚动加载,不卡主线程

传统我们用 scroll事件 + getBoundingClientRect()判断元素是否在视口,会频繁触发重排,导致页面卡顿;Intersection ObserverAPI 是异步监听,不阻塞主线程,性能直接提升一大截。

举个例子:

图片懒加载(可用于优化首屏加载速度):


<img data-src="https://xxx.com/real-img.jpg" src="placeholder.jpg" class="lazy-img" />
// 初始化观察者
const lazyObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// 当图片进入视口
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // 加载真实图片
lazyObserver.unobserve(img); // 加载后停止监听
}
});
});
// 给所有懒加载图片添加监听
document.querySelectorAll(".lazy-img").forEach((img) => {
lazyObserver.observe(img);
});

列表滚动加载更多(避免一次性加载过多数据):

<ul id="news-list">ul>

<div id="load-more">加载中...div>
const loadObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
// 当加载提示进入视口,请求下一页数据
fetchNextPageData().then((data) => {
renderNews(data); // 渲染新列表项
});
}
});
// 监听加载提示元素
loadObserver.observe(document.getElementById("load-more"));

6. Promise.allSettled ():批量请求不 “挂掉”,比 Promise.all 更实用

如果使用 Promise.all,当批量请求时,只要有一个请求失败,Promise.all 就会直接 reject,其他成功的请求结果就拿不到了;而 allSettled 会等待所有请求完成,不管成功失败,还能分别处理结果。

举个例子:

批量获取用户信息 + 订单 + 消息(部分接口失败不影响整体):

// 3个并行请求,可能有失败的
const requestList = [
fetch("/api/user/101"), // 成功
fetch("/api/orders/101"), // 失败(比如订单不存在)
fetch("/api/messages/101"), // 成功
];

// 等待所有请求完成,处理成功和失败的结果
Promise.allSettled(requestList).then((results) => {
// 处理成功的请求
const successData = results.filter((res) => res.status === "fulfilled").map((res) => res.value.json());
// 记录失败的请求(方便排查问题)
const failedRequests = results.filter((res) => res.status === "rejected").map((res) => res.reason.url);
console.log("成功数据:", successData);
console.log("失败接口:", failedRequests); // ["/api/orders/101"]
});

7. element.closest ():向上找父元素最安全的方式

传统如果想找某个元素的父元素,比如点击列表项找列表,需要使用 element.parentNode.parentNode,但一旦 DOM 结构变了,代码就崩了;closest() 回直接根据 CSS 选择器找最近的祖先元素,不管嵌套多少层。

举个例子:

点击列表项,给列表容器加高亮:

<ul class="user-list">
<li class="user-item">张三li>
<li class="user-item">李四li>
ul>
document.querySelectorAll(".user-item").forEach((item) => {
item.addEventListener("click", (e) => {
// 找到最近的.user-list(不管中间嵌套多少层)
const list = e.target.closest(".user-list");
list.classList.toggle("active"); // 切换高亮
});
});

输入框聚焦,给表单组加样式:

<div class="form-group">
<label>用户名label>
<input type="text" id="username" />
div>
const usernameInput = document.getElementById("username");
usernameInput.addEventListener("focus", (e) => {
// 找到最近的.form-group,加focused样式
const formGr0up = e.target.closest(".form-group");
formGr0up.classList.add("focused");
});

8. URL + URLSearchParams:处理 URL 方便多了

传统解析 URL 参数、修改参数,还要写复杂的正则表达式,有时还得处理中文编码问题;当然我们会直接引入三方库来处理,但毕竟还要引入多余的苦,其实 URL API 可以直接解析 URL 结构,URLSearchParams 可用于处理参数,支持增删改查,自动编码,方便多了。

解析 URL 参数(支持中文和特殊字符):

// 当前页面URL:https://xxx.com/user?name=张三&age=28&gender=男
const currentUrl = new URL(window.location.href);

// 获取参数
console.log(currentUrl.searchParams.get("name")); // 张三
console.log(currentUrl.hostname); // xxx.com(域名)
console.log(currentUrl.pathname); // /user(路径)

修改 URL 参数,跳转新页面:

const url = new URL("https://xxx.com/list");

// 添加参数
url.searchParams.append("page", 2);
url.searchParams.append("size", 10);

// 修改参数
url.searchParams.set("page", 3);

// 删除参数
url.searchParams.delete("size");
console.log(url.href); // https://xxx.com/list?page=3
window.location.href = url.href; // 跳转到第3页

9. for...of 循环:比 forEach 灵活,还支持 break 和 continue

我们都知道,forEach 不能用 break中断循环,也不能用 continue跳过当前项。而for...of不仅支持中断,还能遍历数组、Set、Map、字符串,甚至获取索引。

举个例子:

遍历数组,找到目标值后中断:

const productList = [
{ id: 1, name: "手机", price: 5999 },
{ id: 2, name: "电脑", price: 9999 },
{ id: 3, name: "平板", price: 3999 },
];

// 找价格大于8000的产品,找到后中断
for (const product of productList) {
if (product.price > 8000) {
console.log("找到高价产品:", product); // {id:2, name:"电脑", ...}
break; // 中断循环,不用遍历剩下的
}
}

遍历 Set,获取索引:

const uniqueTags = new Set(["前端", "JS", "CSS"]);

// 用 entries() 获取索引和值
for (const [index, tag] of [...uniqueTags].entries()) {
console.log(`索引${index}${tag}`); // 索引 0:前端,索引 1:JS...
}

10. 顶层 await:模块异步初始化

以前在 ES 模块里想异步加载配置,必须写个 async 函数再调用;现在 top-level await 允许你在模块顶层直接用 await,其他模块导入时会自动等待,不用再手动处理异步。

举个例子:

模块初始化时加载配置:

// config.js
// 顶层直接 await,加载后端配置

const response = await fetch("/api/config");
export const appConfig = await response.json(); // {baseUrl: "https://xxx.com", timeout: 5000}
// api.js(导入 config.js,自动等待配置加载完成)
import { appConfig } from "./config.js";

// 直接用配置,不用关心异步
export const apiClient = {
baseUrl: appConfig.baseUrl,
get(url) {
return fetch(`${this.baseUrl}${url}`, { timeout: appConfig.timeout });
},
};

点击按钮动态加载组件(按需加载,减少首屏体积):

// 点击“图表”按钮,才加载图表组件
document.getElementById("show-chart-btn").addEventListener("click", async () => {
// 动态导入图表模块,await 等待加载完成
const { renderChart } = await import("./chart-module.js");
renderChart("#chart-container"); // 渲染图表
});

结语

可以看到,以前我们依赖的第三方库,其实原生 API 早就能解决,比如用 Intl 替代 moment.js,用 Set 替代 lodash 的 uniq,用 Intersection Observer 替代懒加载,随着老旧的浏览器被讨论,兼容性越来越好,这些 API 以后会成为基操。


作者:冴羽
来源:juejin.cn/post/7568153532014559267

收起阅读 »

大家觉得,在前端开发中,最难的技术是哪一个?

web
“你不能把点点滴滴的事情在未来连接起来,你只能在回顾时看到它们的联系。所以你必须相信,未来的某一刻,你做的所有事情都会有意义。” ——乔布斯Hello,大家好,我是 三千。大家觉得,在前端开发中,最难的技术是哪一个?   &nbs...
继续阅读 »

“你不能把点点滴滴的事情在未来连接起来,你只能在回顾时看到它们的联系。所以你必须相信,未来的某一刻,你做的所有事情都会有意义。” ——乔布斯

Hello,大家好,我是 三千。

大家觉得,在前端开发中,最难的技术是哪一个?

    如果你之前完全没有接触过3D 可视化应用开发,那使用Three.js开发应用还是门槛挺高的,比如,加载一个模型,调光,选择模型弹框的功能,就能干出Three.js上百行的代码。同时还有很多复杂的3D概念需要理解。

前言

image.png

    今天给大家分享一个3D 开发框架:TresJS 。它是一个基于 Vue.js 的声明式 Three.js 框架,将 Vue 的开发便利性与 Three.js 的强大功能完美结合,提供了模板语法和组件化的开发方式,与 Vue 生态无缝结合,无需额外学习复杂的 Three.js API,大大简化了复杂 3D 场景的构建。高扩展性,与 Three.js 的资源和技术完美兼容,并且在内部进行了大量优化,确保在构建复杂 3D 场景时,性能表现依然出色,无论是数据可视化、虚拟现实。还是3D动画效果,TresJS 都能轻松应对。

    下面我们通过一个例子,来看看它是怎么使用的。

image.png

1、安装

通过npm的方式,我们可以安装 TresJS:

pnpm add three @tresjs/core

  • Typescript

TresJS 是用 Typescript 编写的,是完全类型化的。如果您使用的是 Typescript,您就能充分享受类型的好处。 只需要保证你安装了 three 的类型定义。

npm install @types/three -D

2、设置体验画布

在我们创建场景前,我们需要一个什么来展示它。使用原始的 ThreeJS 我们会需要创建一个 canvas HTML 元素来挂载 WebglRenderer 并初始化一个场景。

通过 TresJS 你仅仅需要导入默认组件  并把它添加到你的 Vue 组件的模板部分即可。

<script lang="ts" setup>
import { TresCanvas } from '@tresjs/core'
script>

<template>
<TresCanvas window-size>

TresCanvas>
template>

这个 TresCanvas 组件会在场景幕后做一些设置的工作:

  • 它创建一个 WebGLRenderer 用于自动更新每一帧。
  • 它根据浏览器刷新率设置要在每一帧上调用的渲染循环。

3、画布尺寸

默认的情况下,TresCanvas 组件会跟随父元素的宽高,如果出现空白页,请确保父元素的大小合适。

<script lang="ts" setup>
import { TresCanvas } from '@tresjs/core'
script>

<template>
<TresCanvas>

TresCanvas>
template>

<style>
html,
body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
#app {
height: 100%;
width: 100%;
}
style>

如果您的场景不是用户界面的一部分,您也可以通过像这样的使用 window-size prop 来强制画布使用整个窗口的宽度和高度:

<script lang="ts" setup>
import { TresCanvas } from '@tresjs/core'
script>

<template>
<TresCanvas window-size>

TresCanvas>
template>

4、创建一个场景

我们只需要 4 个核心元素来创建 3D 体验:

使用 TresJS 时,您只需将  组件添加到 Vue 组件的模板中,它就会自动为您创建Renderercanvas 作为 DOM 元素)和Scene

<template>
<TresCanvas window-size>

TresCanvas>
template>

然后,您可以使用  组件来添加一个 透视相机

<template>
<TresCanvas window-size>
<TresPerspectiveCamera />
TresCanvas>
template>

5、添加一个🍩

那个场景看起来有点空,让我们添加一个基本对象。如果我们使用普通的 ThreeJS,我们需要创建一个 网格 对象,并在其上附加一个 材质 和一个 几何体,如下所示:

const geometry = new THREE.TorusGeometry(1, 0.5, 16, 32)
const material = new THREE.MeshBasicMaterial({ color: 'orange' })
const donut = new THREE.Mesh(geometry, material)
scene.add(donut)

网格是 three.js 中的基本场景对象,用于保存在 3D 空间中表示形状所需的几何体和材质。

现在让我们看看如何使用 TresJS 轻松实现相同的事情。为此,我们将使用  组件,在默认插槽之间,我们将传递一个  和一个

<template>
<TresCanvas window-size>
<TresPerspectiveCamera />
<TresMesh>
<TresTorusGeometry :args="[1, 0.5, 16, 32]" />
<TresMeshBasicMaterial color="orange" />
TresMesh>
TresCanvas>
template>
  • 注意,我们不需要导入任何东西,这是因为 TresJS 会为您使用的 PascalCase 的带有 Tres 前缀的 Three 对象自动生成一个 Vue 组件。例如,如果要使用  组件。
<script setup lang="ts">
import { TresCanvas } from '@tresjs/core'
script>

<template>
<TresCanvas
clear-color="#82DBC5"
window-size
>

<TresPerspectiveCamera
:position="[3, 3, 3]"
:look-at="[0, 0, 0]"
/>

<TresMesh>
<TresTorusGeometry :args="[1, 0.5, 16, 32]" />
<TresMeshBasicMaterial color="orange" />
TresMesh>
<TresAmbientLight :intensity="1" />
TresCanvas>
template>

从这里开始,您可以开始向场景中添加更多对象,并调整组件的属性来查看它们如何影响场景。

image.png

6、思路总结

最后我们用人话总结一下上面的思路:

  • 1、最外层我们定义一个TresCanvas,在里面我们可以添加场景
  • 2、然后定义一个透视相机,用于观察3D场景,position里去定义相机x,y,z轴的位置,look-at里定义相机观察的目标点
  • 3、相机定义完之后,我们开始渲染3d对象,TresTorusGeometry用来定义环面集合体的半径和环向参数。TresMeshBasicMaterial定义几何体的基本材质和颜色。
  • 4、最后用TresAmbientLight设置一下环境光的强度。

结语

以上就是今天与大家分享的全部内容,你的支持是我更新的最大动力,我们下期见!

打工人肝 文章/视频 不易,期待你一键三连的鼓励 !!!

😐 这里是【程序员三千】,💻 一个喜欢捣鼓各种编程工具新鲜玩法的啊婆主。

🏠 已入驻:抖爸爸、b站、小红书(都叫【程序员三千】)

💽 编程/AI领域优质资源推荐 👉 http://www.yuque.com/xiaosanye-o…


作者:程序员三千_
来源:juejin.cn/post/7468330256689463348
收起阅读 »

一天 AI 搓出痛风伴侣 H5 程序,前后端+部署通吃,还接入了大模型接口(万字总结)

web
自我介绍大家好,我是志辉,10 年大数据架构,目前专注 AI 编程1、背景这个很早我就想写了 App 了,我也是痛风患者,好多年,深知这里面的痛呀,所以我想给大家带来一个好的通风管家的体验,但宏伟目标还是从小点着手,那么就有了今天的主角,痛风伴侣 H5。目录大...
继续阅读 »

自我介绍

大家好,我是志辉,10 年大数据架构,目前专注 AI 编程

1、背景

这个很早我就想写了 App 了,我也是痛风患者,好多年,深知这里面的痛呀,所以我想给大家带来一个好的通风管家的体验,但宏伟目标还是从小点着手,那么就有了今天的主角,痛风伴侣 H5。

目录大纲

前面都是些开胃小菜,看官们现在我们就正式开始正文,那么整体目前是分的 6 个阶段。

第零阶段:介绍

第一阶段:需求

第二阶段:数据准备

第三阶段:开发+联调+部署

第四阶段:部署+上线

第五阶段:运营维护+推广

第六阶段:成本计算

前四个阶段可以分为一个大家的阶段,就完成了你的产品工作

最后就是收尾工作,以及后续的维护。

废话少说,就正式开始吧。

第零阶段:介绍

产品开发流程图

img

这是一个传统的软件开发流程,从需求的讨论开始到最后的产品上线,总共需要的六大步骤,包括后续的迭代升级维护。

成本测算

这里面其实最大的就是投入的人力成本,还不算使用的电脑、软件这些,还包括最大的就是时间成本。

我们按按照基本公司业务项目的项目来迭代看

  • 人力成本

    • 产品:1~2 人,有些大项目合作的会更多,跨大部门合作的。
    • UI :1 人
    • 研发:

      • 前端:1~2人
      • 后端:2~3人
    • 测试:1~2 人
    • 合计:这里面最少都是 6 人
  • 时间成本

    • 这里不用多少,大家如果有经验的,基本公司项目一般的需求都是至少一个月才能上线一个版本,小需求快的也就是半个月上线。
  • 沟通成本

    • 这个就用说了,大家都是合作项目,产品和 UI,产品和研发,研发和测试,这就是为啥会有那么多会的缘故,不同的工种,面对的是不同

img

时间成本感受

个人创业感想

那这里你就可能要较真了,你这个功能简单,哪能跟公司的项目比了。

那我就想起我之前跟我同学一起创业搞 app 的时候,那个时候我不会 app、也不会前端,我是主战大数据的,其实对后端有些框架不也太熟。

那会儿我们四个人,1 个 app、1 个后端+前端、1 个产品,也是足足搞了 1 个多月才勉强上了第一个小版本。

但是我们花的时间很多,虽然一个月,但那一个月我没睡过觉,不会就得学呀,哪像现在不会你找个 AI 帮手帮你搞,你就盯着就行,那会一边学前端,一遍写代码,遇到问题只能搜索引擎查,要么就是硬看源码去找思路解决。

想想就是很痛苦。

公司工作感想

本职工作是大数据架构,设计的都是后端复杂的项目通信,整体底层架构设计,但是也需要去做一些产品的事情。

但是大数据产品就不像业务系统配比那么豪华,一整个公司就两三个人,那么有时候就的去做后端服务、前端界面,就为了把我们的产品体验做好。

每天下班疯狂学习前端框架,从最基本的 html、css、js 学起,不然问题解决不了,花了大量的时间,并且做项目还要学习各种框架,不然报错了你都不知道咋去搜索。

这样能做大功能的事情很少,也就是修修补补做些小功能。

产品感想

这也是我最近用了 AI 编程后的感想,最近公司的数据产品项目,我基本都是 AI 编程搞定。

以前复杂的画布拖拉拽,我基本搞不定到上线的质量,现在咔咔的一下午就搞定开发,再结合 AI 的部署模板,一天就基本完成功能。效率太快。

也是这样,我在现在的公司的产出一周顶的上以前的一个月(这真不是吹牛,开会的半个小时,大数据的首页的 landing page 我就做好了🤦♂️) ,但是时间完全不用一周(偷偷的在这了讲,老板知道了,就。。。所以我要多做一些,让老板留下我)。

我现在感想的就是现在更加需要的就是你的创意、你的想法,现在的 AI 能力让更多的人提效的同时,也降低了普通人实现自己产品的可能性。这在以前是无法想象的,毕竟很多门槛是无法跨越,是需要时间磨练的。

效果展示

然后再多来几张美美的截图(偷偷告诉你,这就是我的背景图片工具做出来的。)

img

第一阶段:需求

1、需求思考

做产品最开的就是需求了,如果你是产品经理,那么我理解这一阶段是不需要 AI 来帮你忙的。

虽然大家基本对产品或多或少都有一些理解,那么专业性肯定比不了,那么我们就需要找专业的帮忙了。

所以我这里找的是 ChatGPT,大家找 DeepSeek,或者是 Gemin,或者是 Claude 都可以的。

我目前准备为痛风患者开发一个拍照识别食物嘌呤的h5应用,我的需求如下:

1. 这个h5底部有3个tab: 识别、食物、我的
2. 在【识别】页面,用户可以选择拍照或者选择相册上传,然后AI识别食物,并且给到对应的嘌呤的识别结果和建议。
3. 在【食物】页面,用于展示不同升糖指数的常见食物,顶部有一个筛选,用户可以筛选按嘌呤含量高低的食物,下方显示食物照片/名称/嘌呤/描述
4. 【我的】页面顶部有一个折线图,用户记录用户的尿酸历史;下方显示近3次的尿酸数据:包括平均、最低、最高的数据;还有记录尿酸和历史记录的列表。

在技术上,【我的】页面尿酸历史记录保存在本地localStorage中,【食物】的筛选也是通过本地筛选,拍照识别食物嘌呤的功能,采用通义千问的vl模型。

请你参考我的需求,帮我编写一份对应的需求文档。

发给 ChatGPT

img

这样就给我们回复了。

2、思考

你说我不会像你写那么多好的提示词,一个我也是借鉴别人的,一个就是继续找 AI 帮你搞定,比如你不知道 localstoreage 是什么,没关系,这个都是可以找 AI 问出来的。

img

或者是说你只有一个想法,而不知道这个产品要做成什么,也可以问 AI。

GPT 会告诉你每个阶段该做哪些功能,这样看看哪些对你合适,然后通过不断的多轮对话,来让他输出最后的需求文档。

img

3、创建需求工作空间

我们在电脑新建个目录,用来存放暂时的需求文档和一些前置工作的文件

Step 1: 在电脑的某个目录下创建前期我们需要的工作项目的目录,这里我叫 h5-food-piaoling

Step 2: Cursor 打开这个目录

Step 3: 创建 docs 目录

Step 4: docs 目录下创建 prd.md 文件,把刚刚 GPT 生成的需求文档拷贝过来。

我这里是后截图的,所以文件很多,不要受干扰了

img

4、重要的一步

到这里需求文档就创建好了,那么我们是不是马上就可以开发了,哦,NO,这里还有很重要的一步。

那么就是需要仔细看这个 GPT 给我们生成的需求文档,还是需要人工审核下的,避免一些小细节的词语、或者影响的需要修改的。

比如这里,我已经恢复不出来了,这里原来有些 “什么一页的文章,适合老年人的这些文字”,这些其实不符合我那会儿想的需求的,所以我就删除了。

img

比如这里用到的一些技术,如果你懂的话,就可以换成你懂的技术,也是需要考虑到后面迭代升级的一些事情。

img

总结:其实这里就是需要人工审查下,避免一些很不符合你想的那些,是需要修改/删除的,这个会影响后面生成 UI/交互的逻辑。

不过这个步骤不做问题也不大,这一步也是需要长久锻炼出来,后面等真实的页面出来后,你再去修改也行。

第二阶段:数据准备

这里的一步也是我认为比较特别的点,这个步骤的点可以借鉴到其他场景里面。

1、哪里找数据

你的产品里的数据的可信度在哪里?特别是关乎于健康的,网上的信息纷繁复杂,大家很难分清哪些是真的,哪些是假的。

我之前查食物的嘌呤的时候,就遇见了,同样一个食物,app 上看到的,网上看到的都不一样,我就黑人问号了???

所以,这里就涉及到数据的权威性、真实性了。那么权威机构发布的可信度会更强。

所以我找到了卫健委颁发的数据。

地址:http://www.nhc.gov.cn/sps/c100088…

img

另外还可以看到不止痛风的资料有,还有青少年、肥胖、肾病的食养指南。

这些病其实都是慢性病,不是吃药就能马上好起来,需要长期靠饮食、运动来恢复的。

可以把这些数据用起来,后面挖掘更多需求。

2、下载数据

这一步周就是把数据下载下来,直接点击上面的

img

下载来后是个pdf 的文件,那么这一步我们就准备好了。

这里我附带一份,大家可以作为参考

暂时无法在飞书文档外展示此内容

3、处理数据

这一步是为什么了,是因为目前在所有的 AI 编程工具里面,pdf 是读取不了的,特别是 Cursor 里面。

目前能够读取的是 markdown 格式的数据

markdown 格式的数据很简单,就是纯文本,加上一些符号,就可以做成标题显示

不懂的可以直接问题 AI 工具就行了。

这里就可以看到大模型给我们的解释了。

插曲

我不懂 markdown 是什么,帮我解释下,我一点都不懂这个

在 Cursor 里面使用 ask 模式来提问

img

下面就是一个回答的截图,如果你对里面的文字不清楚的,那么就继续问 AI 就可以了。多轮对话。

img

处理数据

这里就是需要把 pdf 转为 markdown 的数据

这里推荐使用:mineru.net/

重点在于免费,直接登录注册进来后,点击上传我们刚下载的 pdf。

img

等待上传转换完成,下一步就是在文件里面,看到转换的文件了。

点击右侧下载,就是 markdown 格式。

img

把下载好的 markdown 文件放入到项目里面的 data 目录,待会儿会需要数据处理。

img

4、修正需求文档

那么让 Cursor 给我们重新生成需求文档,这样食物的分类,还有统计,会更准确,因为现在是基于权威数据来的。

食物数据库目前是存储在 json 文件里,请根据 @成人高尿酸血症与痛风食养指南2024 年版).md 的食物嘌呤数据,再根据 @prd.md 里面的食物数据结构,生成一份数据,并获取对应的 image 图片,保存在 imgs 目录下

img

5、生成数据文件

前面我们不是讲到了。食物列表的数据需要存储在本地,也就是客户端,形式我们就采用 json 的形式

同样你不知道 json 是个啥的话,找 AI 问,或者直接 Cursor 里面提问就行了。

左边是提示词,右侧就是创建的 json 文件

食物数据库目前是存储在 json 文件里,请根据 @成人高尿酸血症与痛风食养指南2024 年版).md 的食物嘌呤数据,再根据 @prd.md 里面的食物数据结构,生成一份数据,并获取对应的 image 图片,保存在 imgs 目录下

imgimg

结果:

img

6、继续调整文件

上一步骤发现,其实只给我们列觉了 53 种食物,并不全

我需要全部的数据,那么继续

总结的有 53 种食物,但是我看 @成人高尿酸血症与痛风食养指南2024 年版).md 下的“表1-2 常见食物嘌呤含量表” 应该不止这么多,请再次阅读然后补全数据到 @foods.json 文件里

img

最后发现,总文档里总结了 180 种的食物

img

最后生成的数据文件如下:

img

6、图片问题

不过这里有个问题就是,食物对应的图片是没有办法在这里一次性完成的

我也尝试了在 Cursor 里让他帮我完成,结果些了一大堆的代码,下来的图片还对应不上。

尝试了很多方案,都不太理想。

那你说了,去搜索引擎下载了,我也想到了,不过想起来你要去搜索,然后找图片,下载,有 180 多种了,还要命名好图片名字,最后保存到目录。

想到这里,我就头大,索性干脆自己写一个,其他流程系统都帮我搞定,暂时目前只需要我人工确认图片,保证准确性。

Claude Code + 爬虫碰撞什么样的火花,3 小时搞定我的数据需求

这个小系统也还是有很大的挖掘潜力,后面也还可以做很多事情

到这里基本需求阶段就完成了,数据也准备的差不多了,下面就是进入开发阶段了。

不要看前面的文字多,那都是前戏,下面就是正戏,坐稳扶好,开奔。

第三阶段:开发+联调+测试

这里是主要的开发、联调、测试阶段,也就是在传统开发流程中会占据大部分的时间,基本一个软件/系统的开发大部分的时间都在这个里面,所以我们看看结合 AI 它的速度将会达到什么样。

== 1、前端 ==

步骤一:bolt 开发

说下为什么采用 bolt 工具来做第一步工作。

其实线下 v0、bolt、lovable 很多这种前端设计工具,那么他与 Cursor 的区别在哪里了?

1、首先通过简单的提示词,它生成的功能和 UI 基本都是很完善的,UI 很美、交互也很舒服。这种你在 Curosr 里面从零开始些是很难的。

2、这种工具一般都可以选择界面的上的元素(比如 div、button,这个就比较难),然后进行你的提示词修改,很精准,这个你在 Cursor 里面比较难做。

3、还有一个点就是前端开发的界面的定位这些大模型很难听得懂你在说啥的,所以我感觉也是这块的难度采用了上面那么多的类似的工具的诞生。

当然,如果不用这些工具,直接让 Cursor 给你出 ui 设计,然后使用 UI 设计出前端代码也可以的。

这个我看看后面用其他例子来讲解。

把上面的需求步骤的 prd.md 的需求直接粘贴到提示词框里。没问题,就可以直接点击提交了。

小技巧:看左下角有个五角星的图标,是可以美化提示词的,这个目前倒是 bolt 都有的功能。

另外还可以通过 Github 或者 Figma 来生成项目图片。

img

下面就是嘎嘎开始干活了。

img

等他写完,就可以在界面的右侧看到写完的H5程序。

界面很简单,左侧就是对话区域,右侧就是产品的展示区域

小细节:在使用移动端展示的时候,还可以选择对应的手机型号

img

步骤二:调整

1、错误修复

这个交互我觉得做的特别好,不用粘贴错误,直接就在界面上点击“Attempt fix”就可以了,这真的是纯 vibe coding ,粘贴复制都不用了。🤦♂️

如果有错误,继续就可以了。

img

2、UI 调整:主题

刚开始其实 UI 并不是太好看,我的主题色是绿色的,所以我也不知道让它弄什么样的好看。

再帮我美化下 UI 界面

就输入了上面一句话,刚开始的 UI 如下图

img

最后看下对比效果

左边是最开始生成的,右边是我让他优化后的样子。还是有很多细节优化的。

img

3、UI 修复方式一:截图

另外如果样式有问题,可以截图粘贴到对话框,然后输入提示词修改。

img

4、UI 修复方式二:选择元素

这里就是我要说的可以选择界面上的元素,然后针对某些元素进行改写

bolt 的方式这几输入提示词

v0 比较高级,选择后,可以直接修改 div 的一些样式参数,比如:宽高、字体、布局、背景、阴影。精准调节。(低代码+vibe coding)

img

经过多轮修复,觉得功能差不多了,就可以转战本地 Cursor 就继续下一步了。

步骤三:本地 Cursor 修改

1、同步代码到 Github

点击右上角的「Integrations」里的 Github。

img

下面就会提示你链登录 Github

img

接着授权就可以

img

然欧输入你需要创建的项目名称

img

2、本地下载代码

使用 git 工具把代码下载到本地

git 就类似游戏的存档工具,每一个步骤都可以存档,当有问题的时候,就可以从某个存档恢复了。

当然:这里需要提前安装好 Git,如果有不懂的可以联系我,我来帮你解决。这你就不多说了

打开你的 Github 仓库页面,复制 HTTPS 的地址

img

然后使用下面的命令,就可以下载到本地了。

git clone 你的代码仓库地址

img

下一步就是安装代码的依赖包

这里需要 nodejs 环境,同样就不多说了,不懂的可以私聊

img

下一步就是启动

img

接着就是浏览器打开上面的地址:http://localhsot:3000,就可以看见上面写好的页面。

默认打开是按照 pc 的全屏显示的,可能看着有些别扭

img

我们打开 F12,打开调试窗口,如下图

点击右侧类似电脑手机的按钮,就可以调到移动端模式显示了,还可以选择对应的机型。

img

xx 小插曲 xx

原本不想放这里的,结果还是放一下吧,刚好是解决了一个很大的问题

刚开始在 bolt 上面修改的时候,修改后一直报个错误,结果修复了很多次,还是没有解决。

img

没办法,我就在本地 Cursor 上仔细看了下代码,发现是个引号的问题。

img

我就在本地 Cursor 中快速修复了下

img

但是后面惊悚的事情来了,我去 bolt 上调整了下界面样式,结果又给我写成了引号的问题

最后我就发现,可能 bolt 目前对这类的错误还是没有意识。并且看它界面的代码,每次都是从头开始写(难怪要等好一段时间才弄完,究竟是什么设计了?)

最后索性,我仔细看了下代码,删除掉了,没啥大的影响。

目前来看 bolt 这种工具还是有点门槛,解决错误的能力还是没有 Cursor 强大,一不小心页面上的错误就在一起存在,你也不知道它改了啥。

这就需要你对代码还是有基本的认识。

步骤四:使用本地数据

首先就是把前面下载准备好的图片放到 imgs 目录下

img

在 Cursor 中让从 imgs 目录中显示图片。

img

不过这里 Cursor 还是很智能的,访问后都是 404

img

那么就直接告诉 Cursor 让他解决这个问题。

结果他一下子就找到问题所在了,需要放在 public 目录下,这个放以前你需要去搜索引擎里面找问题,并且有时候你拿让 AI 解决的问题,去搜索引擎找,基本都是牛头不对马嘴的回答。

最后还要去找官方文档看资料,不断的尝试。

imgimg

** 前端小结 **

到这里,基本前端的事情就搞完了

1、识别:识别流程,现在都是走前端模拟的流程

2、食物:这里目前应该是很全的功能了,读取本地的 json 数据,有分类标识,还有图片的展示

3、我的:个人中心有尿酸的记录,有曲线图,还有基本的体重指数记录。

img

== 2、后端 ==

步骤零:阿里云大模型准备

背景:需要使用大模型来识别图片,然后返回嘌呤的含量,所以我们需要选择一个大模型的 API 来对接。

这里选择阿里的 qwen 来对接。

登录百炼平台:bailian.console.aliyun.com/

访问API-kEY 的地址:bailian.console.aliyun.com/?tab=model#…

创建一个 API-kEY,并保存好你的 key 信息。

img

步骤一:创建必要的配置

先访问找到通义千问 API 的文档的地方

img

这里我们采用直接复制上面页面的内容,保存到项目下的 docs 目录在的 qwen.md 里面

img

这里顺便把之前的 prd.md 文档从之前的项目目录拷贝过来了

步骤二:创建后端服务模板代码

直接使用下面的提示词,就可以创建一个后端的服务

这里要想为什么要创建后端服务,

一方面主要是需要调用大模型的 API,用到一些KEY 信息,这些是需要保密的,不能在前端被人看到了。

另外一方面,后面如果需要一些登录注册服务,还有食物数据都是需要后端来存储,提供给前端。

请在项目的根目录下创建 backend 目录,在这个 backend 目录下创建一个基于fastify框架的server,保证服务没有问题

同样的不知道什么fastify技术的,找大模型聊就行。

imgimgimg

步骤三:API 文档+后端业务服务开发

重点来了,这里我就写到一个提示词里面,让他完成的

帮我接入图像理解能力,参考 @qwen.md  :
1. 现在在 @/backend 的后端服务器环境中调用ai能力,
2. 使用 .env 文件保存API_KEY,并使用环境变量中的DASHSCOPE_API_KEY.并且.env文件不能提交到git上,提交到git的可以用.env.example文件作为举例供供用户参考
3. 要求使用openai的sdk,并且前端上传base64的图片
4. 后端返回值要求返回json格式,返回的数据能够渲染识别结果中的字段,包括:食物/嘌呤值/是否适合高尿酸患者/食用建议/营养成分估算
5. 在 @/backend 目录下创建 api.md 文件,记录后端接口文档

这里我把 api.md 高亮了,这个是关键,是后面前后端联调的关键,不然 Cursor 是不知道请求字段和响应字段该怎么对接的,到时候数据不对,再来调试就比较麻烦。

所以接口文档务必保证 100% 准确,后面的调试就会很容易。

截图如下:

imgimgimg

很贴心的完成功能后,最后帮我们些了 api.md 接口文档,还进行了一些列测试,保证功能是完整的。

这里放出来,Cursor 看是怎么帮我们写这个代码的

  • 帮我们组装好了提示词
  • 根据 qwen.md 的接口文档,组装请求数据和返回数据,字段都我们的项目符合

img

== 3、联调 ==

其实这里的联调很简单了。就是一句话的事情。

因为之前的前端的拍照图片都是走的模拟的接口,没有真正的调用后端的接口,所以需要换成真正的后端接口。

刚好前面的后端服务写好了 api.md 接口文档

前端修改点,前端目录是当前根目录
1. 也需要加入请求后端的 url 的环境变量,本地调试就默认使用 localhost,线上发布的时候设置环境变量后,前端服务从环境变量获取 url 然后请求到对应的后端服务
2. 食物识别的接口参考 @api.md 文档,请修改需要适配的地方,食物识别的代码在 @identify-page.tsx代码中。

imgimgimg

这里要说的是:前面的 api.md 接口文档些的非常准备,这一步的前端请求后端接口,基本都是一遍过,所以后端提供的接口文档一定要准确,这样前端就可以很准确的调用接口传参和取返回值了。

== 4、测试 ==

其实到这里,基本测试的工作也就完成了。

基本的流程到现在都是跑通的。

不过还是需要多实际测试,这里下面的例子就是,我上传了「黄瓜」的照片,结果没识别,按理说不应该呀。

这里上了点专业的技巧,通过 F12 的调试窗口,看下接口返回的数据。

按照以往经验来说,估计是字段对应不上

img

所以我就直接和 Cursor 说,可能是字段对应不上。请帮我修复。

测试黄光的食物的时候,后台接口返回的数据是 "purine_level": "low(低嘌呤<50mg)",但是 @getPurineLevel() @getPurineLevelText 没有识别到,请帮我修复

最后从前后端都给我做了修复,字段的匹配对应上了。

img

最后的总结如下:

img

== 4、总结 ==

其实到这里基本功能就完成了。

  1. 前端使用 bolt 工具等生成,快速生成漂亮的 UI 界面和基本完整的前端功能

    1. bolt工具调整样式、UI 等细节(擅长的)
    2. Cursor 精修前端小细节
  2. Cursor 开发完整后端功能

    1. 写清楚需求,如果知道具体技术栈是最好的
    2. 写好接口文档,最好人工校验下
  3. 前后端联调

    1. @使用后端的接口文档,最好写改动的接口的地方,前后精准对接
    2. 学会使用浏览器的 F12 调试窗口,特备是接口的请求参数和响应值的学习。

就目前来看,如果你是零基础,那么基本的术语不明白的话,有些问题可能会不好解决

  1. 寻求 AI 的帮助,遇事不决问 AI,它可以帮你搞定
  2. 寻求懂行的人来帮助你,比如环境的事情、按照的事情有时候一句话就可以给你讲明白的。

第四阶段:部署+上线

部署这一块其实对普通人门槛还比较高的,问题比较多。

  • 域名问题
  • 服务器问题
  • 如何部署,如何配置

这里我们采用云厂商的部署服务,简化配置文件和部署的流程

但是域名申请还是需要提前准备好的,不过现在我们用的这个云服务暂时现在没有的域名,也有临时域名可以先用。

到这里,其实如果你只是本地看的话,就已经可以了,那么这里我们教一个上线部署的步骤,傻瓜式的,不需要各种配置环境。

我相信大家如果搞独立开发的 Vercel 肯定都熟悉了。这里也介绍下类似的工具,railway.com/,他不仅可以部署前端静态页面,还有后端服务,PostgreSQL、Redis 等数据库也支持一键部署。

img

1、项目的配置文件

railway 部署是需要一些配置文件的,当然我们可以让 Cursor 帮我们搞定。

直接告诉 Cursor 我们需要部署到 railway 上,看还需要什么工作可以做的。

后端

@/backend 这个后端项目现在需要在railway上去部署,请帮我看看需要哪些部署配置

imgimgimg

前端

也是一样,让 Cursor 给我们生成部署的配置文件

当前目录是前端目录,也需要添加railway的部署相关配置

imgimgimg

Cursor 会帮我们创建需要的配置文件,那么就可以进入下一步部署了。

2、提交代码

记得要提交代码,在 Cursor 的页面添加提交代码,推送代码到 Github 上,这样 railway 才可以拉取到代码。

提交代码的时候,可以使用 AI 生成提交信息,也可以自己填写信息

imgimg

记得还要同步更改

imgimg

3、railway 页面操作

现在会有赠送的额度,并且免费就用也有 512M 的内存机器使用。对于当前下的足够了。

注册登录后,选择 Dashboard 后,点击添加,就可以看到如下的页面,

添加 Github 项目,后续就会授权等操作,继续完成就可以。

img

下一步就一个你的项目

然后就会跳转到工作区间,会自动部署。

img

记得不要忘记环境变量

img

就是在「Variables」标签下,直接添加变量就行。

添加完记得需要重新部署下。

后端环境变量

img

前端环境变量

img

当然不过你有错误,可以把 log 里面的错误复制,粘贴到 Cursor 里面,让他解决,我之前部署的项目有个就有问题,通过这个方式,帮我解决了。

4、大功告成

部署完成后怎么访问了,切换到 settings 页面,有个 Networking 部分,可以生成一个 railway 自带的域名,用这个域名就可以访问了,如果你有自己的域名还可以添加一个自己的域名,添加完以后就可以自己访问了。

img

5、总结

很开心,跟我走到了这里,基本到这里,算是完成一大步,也就是我们的 MVP 完成了。

现在我们再来总结下前面整体的步骤

1、前端我们通过 bolt 来生成代码,加速前端的设计,让 bolt 这种工具提供我们更多的能力,发挥他的有点

2、后端使用 Cursor 来开发,纯业务逻辑通过提示词还是很好的达到效果。

3、前后端联调,写好接口文档,让 Cursor 必须阅读接口文档,前端再写接口

4、部署配置文件也可以通过 Cursor 来搞定,无所不能

5、中间有任何问题,有任何不懂的都可以找 Cursor 使用 ask 模式搞定。

第五阶段:运营维护+推广

分了「优化」「安全」「推广」三个部分来说这个事情。

  • 其实到这里是后续的常态,你不要不断的推广你的产品,去增加访问量。
  • 另外就是不断的迭代优化你的功能,提升用户体验,加强本身产品的竞争力。
  • 最后、最后、最后就是安全,这个不要忘记了,后面我也会加强后,然后去推广下我的产品,安全很重要,提前做好可以更保护你的服务器和大模型的 API-KEY。

优化

这个是上线了后发现的,就是使用手机拍的照片,一般都比较大,这张图片请求后端的时候,数据量比较大,接口超时了。

那么解决办法:

1、增加后端的请求体的大小

2、压缩图片,然后再请求后端接口

安全

其实这里还是蛮重要的,因为你的服务,还有你的大模型的 KEY,如果服务器被攻击是要付出代价的,最重要的是花掉你的钱呀。

所以这块我还在做,目前想的就是让 Cursor 正题 revivew 代码,看下有什么安全隐患,给我一些解决方案。

推广

如果你的产品上线后,需要写文章、发小红书去推广,首先从你的种子用户开始,你的微信群,你的朋友圈都是可以的。

后面积极听取用户心声,持续解决痛点需求,满足用户的痛点,产品就会越来越好。

第六阶段:成本计算

时间成本

从开始到结束上线,手机使用正式的域名访问,大概就是整一天的时间,从早上开始,忙到晚上我就开启了测试,晚上搞完还去外面遛弯了一大圈回来的。

我们就算:10 小时

人力成本

哈哈哈哈,很清楚,就我一个人

软件成本

bolt:20 元优惠包月(海鲜市场),就算正式渠道,20 刀一个月,当然有免费额度,调整不多,基本够用

Cursor:150教育优惠(海鲜市场),就算正式渠道,20 刀一个月,足足够用

域名:32首年

我们就算满的,折算成人民币,也就是 300 块

想想 300 块一天你就做出来一个系统(前后端+部署),何况软件都是包月的,一个月你可以产出很多东西,不止这个一个系统。

对比公司开发,一个月的成本前后端两个人,毕业生也的上万了吧,何况还是 5 年经验开发的(市面上的抢手货)。

总结

能走到这里的,我希望你给自己一个掌声,确实不容易。

我希望你也有可以通过编程来实现自己的想法和创意。

虽然目前编程对于零基础的人来说确实可能会有些吃劲,但是你我差距也不大,我现在遇到了很多在搞 AI 编程的都是程序员,有房地产行业的、也有产品的。

遇事不决,问 AI

我希望你可以记住这句话,自己的创意+基本问题找 AI,你基本就可以解决 99% 的问题,剩下的 1% 你基本遇不到,遇到了,也不要慌,身边这么多牛人总会有人知道。


作者:志辉AI编程
来源:juejin.cn/post/7517496354244067339

收起阅读 »

看了下昨日泄露的苹果 App Store 源码……

web
新闻昨日苹果 App Store 前端源码泄露,因其生产环境忘记关闭 Sourcemap,被用户下载了源码,上传到 Github。仓库地址:github.com/rxliuli/app…目前已经 Fork 和 Star 超 5k:如果你想要第一时间知道前端资讯...
继续阅读 »

新闻

昨日苹果 App Store 前端源码泄露,因其生产环境忘记关闭 Sourcemap,被用户下载了源码,上传到 Github。

仓库地址:github.com/rxliuli/app…

目前已经 Fork 和 Star 超 5k:

如果你想要第一时间知道前端资讯,欢迎关注公众号:冴羽

用户如何抓取的源码?

用户 rxliuli 使用 Chrome 插件 Save All Resources 将代码下载了下来。

插件地址为:chromewebstore.google.com/detail/save…

下次你也可以打包下载源码了~

如何看待源码泄露?

其实前端源码泄露对业务本身并没有什么影响,因为前端代码无论是否压缩还是混淆,最终都需传输到浏览器才能运行,本身就具有 “暴露” 属性,SourceMap 只是让代码更易读,更容易调试。

尽管如此,依然不建议在生产环境开启 SourceMap,对普通用户无益,且存在轻微性能开销和源代码暴露的安全风险。

我大致看了下代码,并没有什么密钥之类的信息,所以干点坏事之类的就不用想了。真正有价值的核心代码比如推荐逻辑还是在服务端。

代码使用 Svelte?

我万万没想到,项目使用的是 Svelte。

Svelte 我自然是很熟的,毕竟我翻译过 Svelte 官网:svelte.yayujs.com/

还写了一本掘金小册《Svelte 开发指南》:s.juejin.cn/ds/QNzfZ4eq…

想一想,使用 Svelte 也在情理之中。

因为 Svelte 就非常适合处理这种页面相对简单、业务逻辑并不复杂的页面。

在实现上 ,与其说 Svelte 是框架,不如说 Svelte 是一个编译器。 它会在构建时就会将代码编译为高效的 JavaScript 代码,因此能够实现高性能的 Web 应用。

Svelte 的核心优势在于:

  • 轻量级:核心库只有 3 KB,非常适合开发轻量级项目
  • 高性能:构建时优化,而且不使用虚拟 DOM,减少了内存占用和开销,性能更高
  • 易上手:学习曲线小,入门门槛低,语法简洁易懂

简而言之,Svelte 非常适合构建轻量级 Web 项目,也是本人做个人项目的首选技术栈。

以后大家如果要做相对简单的项目,又有性能上的追求(比如 KPI),那就可以考虑使用 Svelte。

用它作为示例学 Svelte ?

我看了下代码,项目代码还是 Svelte 4,而 Svelte 已经到 5 了,Svelte 4 和 5 不论是底层架构还是基础语法都发生了很大的变化,其变化的剧烈程度类似于 Next.js 12 升 Next.js 13,所以想通过这个项目学习 Svelte 就不用想了,都是些过时的语法了,不如直接学 Svelte 5。


作者:冴羽
来源:juejin.cn/post/7569057572436607014

收起阅读 »

当你的Ant-Design成了你最大的技术债

web
大家好😁如果你是一个前端,尤其是在B端(中后台)领域,Ant Design(antd)这个名字,你不可能没听过。在过去的5年里,我们团队的所有新项目,技术选型里的第一行,永远是antd。它专业、开箱即用、文档齐全,拥有一切你想要的组件, 帮我们这些小团队,一夜...
继续阅读 »

image.png

大家好😁

如果你是一个前端,尤其是在B端(中后台)领域,Ant Design(antd)这个名字,你不可能没听过。

在过去的5年里,我们团队的所有新项目,技术选型里的第一行,永远是antd。它专业、开箱即用、文档齐全,拥有一切你想要的组件, 帮我们这些小团队,一夜之间就拥有了大厂的专业门面。

我们靠它,快速地交付了一个又一个项目。

但是,从去年开始,我发现,这个曾经的经典,正在变成我们团队脖子上最重的枷锁。

Ant Design,这个我们当初用来解决技术债的核心组件库,现在,却成了我们最大的技术债本身😖。

这是一篇团队血泪史, 讲一讲感想🤷‍♂️。


我们为什么会爱上 AntD?

我们必须承认,从无到有阶段,antd是无敌的。

你一个3人的小团队,用上antd,做出来的东西,看起来和阿里几百人团队做的系统,没什么区别。

TableFormModalMenu... 你需要的一切,它都以一种极其标准的方式给你了。你不再需要自己造轮子。

当你发现@ant-design/pro-components时,一个ProTable,直接帮你搞定了请求、分页、查询表单、工具栏... 你甚至都不用写useState了。

在那个阶段,我们以为我们找到了大结局。


当个性化成为 我们的 KPI

美好可能是短暂的,从我们的产品经理和UI设计师开始👇:

能不能...不要长得这么 Ant Design?🤣

image.png

这是我们设计师,在评审会上,小心翼翼提出来的第一句话。

老板也说:我们要做自己的品牌,现在的系统,太千篇一律了!!!

于是,我们接到了第一个简单的需求:把全局的主题色,从橙色改成我们的品牌红。

这很简单,不就是 ConfigProvider嘛🤔。我们改了。

然后,第二个需求来了:这个Modal弹窗的关闭按钮,能不能不要放在右上角?我们要放在左下角,和确认按钮放在一起。(有点反人类🤷‍♂️)

灾难,就从这里开始了。

antdModal组件,根本就没提供这个插槽或prop。我们唯一的办法,是 强改

于是,我们的代码里,开始出现这种恶臭的CSS:

/* 一个高权重的全局CSS文件 */
.ant-modal-header {
/* ... */
}

/* 嘿,那个右上角的关闭按钮,给我藏起来! */
.ant-modal-close-x {
display: none !important;
}

为了把那个 X 藏起来,我们用了!important。我们亲手打开了潘多拉魔盒。

这个表格的筛选图标,能换成我们自己画的吗?😖

antdTable,是一个重灾区。它太强大了,也很黑盒。

我们设计师,重新画了一套筛选、排序的图标。但我们发现,antdTable组件,根本没想过让你换这个。

我们唯一的办法,就是用 CSS选择器,一层一层地穿进antd的DOM结构里,找到那个,然后用background-image去盖掉它。

/* 另一个人写的,更恶臭的CSS */
.ant-table-thead > tr > th.ant-table-column-has-filters .ant-table-filter-trigger {
/* 妈呀,这是啥? */
background: url('our-own-icon.svg') !important;
}

.ant-table-thead > tr > th.ant-table-column-has-filters .ant-table-filter-trigger > svg {
/* 藏起来,藏起来! */
display: none !important;
}

我们被拖累了。

我们花在 覆盖antd默认样式上的时间,已经远远超过了我们自己写一个组件的时间。


压死骆驼的最后一根稻草

image.png

我们用了ProTable,它的查询表单和表格是强耦合的。当产品经理提出一个我希望查询表单,在页面滚动时,吸附在顶部的需求时... 我们发现,我们改不动。我们被ProComponents的黑盒,锁死了。

然后我们的vendor.js打包出来,2.5MB。用webpack-bundle-analyzer一看,antd@ant-design/icons,占了1.2MB。我们为了一个ButtonIcon,引入了一个全家桶。antd的按需加载?别闹了,在ProComponents面前,它几乎是全量的。

而且 antdv3v4,我们花了一个月。从v4v5,我们花了半个月。每一次升级,都是一次大型重构,因为我们那些写法一样被CSS覆盖,在新版里,全失效了🤷‍♂️。

我们本想找一个可靠的组件库,这么久过来,结果它成了债主。


我们真正需要的可能是轮子

我终于想明白了。

Ant Design,它不是一个组件库(Library),它是一个UI框架(Framework)。它是一套解决方案,它有它自己强势的 设计价值观

当你的需求,和它的价值观一致时,它就是圣经。 当你的需求,和它的价值观不一致时,它就变成枷锁。

我们当初要的,其实是一个带样式的Button;而antd给我的,是一个内置了loadingdisabledonClick时会有水波纹动画、并且必须是蓝色或白色的Button


我们的自救之路

在我们新的项目中,我忍痛做出了一个决定🤷‍♂️:

原则上,不再使用antd

我们新的技术栈,转向了: Tailwind CSS + Headless UI 方案(比如Radix UI

image.png

这个组合,才是我们想要的:

  • Headless UI:它只提供功能无障碍。比如,一个Dialog(模态框),它帮我搞定了按Esc关闭、焦点管理。但它没有任何样式
  • Tailwind CSS:我拿到了这个无样式的Dialog,然后用Tailwindclass,在5分钟内,在AI的帮助下,把它拼成了我们设计师想要的、独一无二的弹窗。

我们拿回了CSS的完全控制权,同时又享受了 AI + 组件开发的便利。

我依然尊敬Ant Design,它在前端B端历史上,是个丰碑。 对于那些从0到1的、对UI没有要求的内部系统,我可能依然会用它。

但对于那些需要品牌、体验、个性化的核心产品,我必须和它说再见了。

Suggestion.gif

因为,当你的组件库开始控制你的设计和性能时,它就不是你的资产了。

而变成你最大的技术债🙌。


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

我是如何将手动的日报完全自动化的☺️☺️☺️

web
书接上回,上回我们聊了处理重复任务的自动化思维。其中,我举了用工具自动化公司日报的例子。今天,我就来详细说说,我到底是怎么做的,以及过程中遇到了哪些问题和挑战。背景我们公司使用某第三方系统有一个自定义的数据看板,每天需要向群里发送日报。之前,这项工作由团队成员...
继续阅读 »

书接上回,上回我们聊了处理重复任务的自动化思维。

其中,我举了用工具自动化公司日报的例子。

今天,我就来详细说说,我到底是怎么做的,以及过程中遇到了哪些问题和挑战。

背景

我们公司使用某第三方系统有一个自定义的数据看板,每天需要向群里发送日报。之前,这项工作由团队成员轮流手动完成:从系统的一个自定义看板复制数据到 Excel,再将表格转为图片,发到群里。

轮到我负责的那一周,我左手边电脑打开系统,右手边打开 Excel,一个个数据复制过去,3.4%、-10%……为避免出错,还要逐一核对。整个过程每天耗时大约 7 到 10 分钟,繁琐又枯燥。

我开始思考:这种重复性工作能不能自动化?

于是,我在群里向大佬们请教,提出了这个问题:

image.png

结果,消息已读,没有一个人回复。

那一刻,我暗下决心:我要自己解决这个问题!

初探

于是乎我打开了改系统,开始研究。

该系统大概长这样, 这是一个自定义看板,后台自定义配置出来的,数据是根据配置的规则算出来的,有十几项,我们是需要从每项取3个数据。加起来复制30-40次。

image.png

  • 手动复制效率低下。
  • 浪费时间。
  • 容易出错,粘错位置了,又得一个个重新对一遍。

所以我第一步是需要把手动复制拿数据的这个过程,利用脚本自动化了。

流程与任务拆解

我们的思路是这样,先脑子里过一下原来的流程,然后一步步自动化原来的流程。

1、原来手动的流程

  1. 手动登录系统
  2. 点击对应面板,一个个复制数据,粘贴到excel里。
  3. 全部复制完,核对完,右键复制为图片
  4. 发送到群里。

2、脚本任务拆解

  1. js逆向登录加密方法,自动化登录,拿到token。
  2. 利用爬虫抓取数据,拿到我需要的。
  3. 利用canvas将数据画成表格,然后转成图片。
  4. 图片传到oss,调用钉钉webhook接口,定时发送到群里

以上我们已经将,手动的流程的任务与自动化需要做的任务一一对应了。

现在我们思路清晰了。

然后我们要做的就是把每个任务逐个攻克即可。

任务分步实现

你不觉得我应该先完成第一个任务——JS 逆向登录加密方法,实现自动化登录并获取 token 吗?

这确实是全自动流程中最核心的一环:没有自动登录获取凭证,后续的数据抓取和操作根本无从谈起。

不过,我初步分析了登录接口,发现参数加密逻辑较复杂,短时间内难以破解。

于是我选择暂时跳过,先手动复制登录凭证,确保后续流程全部打通后再回过头补全自动化登录部分。

1、利用爬虫抓取数据。

首先看板这是个列表,有很多项内容,首先看这个列表怎么来的,服务端渲染还是,调的接口。

然后看能不能完全从页面拿到,我们再考虑抓取方式。

1、如果是服务端渲染的或者数据很快出来的。我们可以考虑抓页面。

2、但是今天这个例子,经过我的研究,我需要的数据,都是异步调接口的,我看还有队列排队逻辑。 说明页面完整展现的时间不稳定,长则几十秒都有可能, 所以我感觉抓页面是不稳定的。

所以我选择抓接口

1.1内容搜索大法

image.png 众多的接口啊,我们怎么找到我要的数据在哪???于是我们利用调试工具,搜索响应内容关键字

例如搜页面中显示的这个标题

image.png

通过内容再network搜索内容 找到了列表接口

image.png

点开看,确实,里边就是这个列表的数据。

但是没有具体是环比、同比,我要的数字。

再次寻找每一项具体数据的获取接口。

再次通过搜索大法找了好久好久.....

找到了通过每项id和过滤条件去获取具体数据的接口

1.2 接口找齐,开始编码

研究下来。整体逻辑是,先获取面板列表,然后循环列表的每一项,拿着有关联的参数去调详情。

面板的数据列表获取
/**
* 获取重点功能监控面板列表及详情数据
* @returns {Promise<*[]>}
*/

async function queryReportList(dashboard) {
const { id: dashboard_id, common_event_filter } = dashboard

const data = await fetch(
`https://xxx/api/v2/sa/dashboards/${dashboard_id}?is_visit_record=true`,
{
credentials: "include",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0",
Cookie: Cookie
},
referrer:
`https://xxx/dashboard/?dash_type=lego&id=${dashboard_id}&project=1&product=sensors_analysis`,
method: "GET",
mode: "cors"
}
)
.then(res => res.json())

const result = [];
// 获取控面板的前12个监控项的监控数据。
for (const item of data.items.slice(0, 13)) {
if (item.bookmark) {
// 这里解出来, 调下一个接口要用到。
const data = JSON.parse(item.bookmark.data);
const res = await queryReportByTool({
bookmarkid: item.bookmark.id,
measures: data.measures,
dashboard_id: dashboard_id,
common_event_filter: common_event_filter
});
result.push({
...res,
name: item.bookmark.name
});

console.log(
{
name: item.bookmark.name,
base_number: res.base_number /= 100,
day: res.month_on_month /= 100,
week: res.year_on_year /= 100
}
)
}
}

return result
}
获取每一项具体数据
/**
* 报告列表的报告id去获取具体数据
* @param params
* @returns {Promise}
*/

async function queryReportByTool(params) {
const requestId = Date.now() + ":803371";
const body = {
measures: params.measures,
unit: "day",
by_fields: [],
sampling_factor: null,
from_date: dayjs()
.subtract(14, "day")
.format("YYYY-MM-DD"),
// from_date: "2025-02-28",
to_date: getYesterDay(),
// to_date: "2025-03-13",
detail_and_rollup: true,
enable_detail_follow_rollup_by_values_rank: true,
...
};
try {
const data = await fetch(
`https://xxxx/api/events/compare/report/?bookmarkId=${
params.bookmarkid
}&async=true&timeout=10&request_id=${requestId}`
,
{
credentials: "include",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0",
...
Cookie
},
referrer:
"https://xxxx/dashboard/?dash_type=lego&id=692&project=1&product=sensors_analysis",
body: JSON.stringify(body),
method: "POST",
mode: "cors",
timeout: 10000
}
).then(res => res.json());
if (!data || data.isDone === false) {
return await queryReportByTool(params);
} else {
return data;
}
} catch (e) {
return await queryReportByTool(params);
}
}

1.3 数据拿到

执行一下,数据拿到了,找到了我要的几个字段

PS D:\project2\report> node .\index.js
{
name: 'xxx生成失败率',
base_number: 0.0103,
day: -0.3602,
week: -0.16260000000000002
}
...
{
name: 'xxxx生成失败率',
base_number: 0.017,
day: 0,
week: 0.0241
}
2025-03-18.xlsx文件已保存!
default: 27.917s

1.4 小结

表面看似一帆风顺,因为我是以回忆的视角,实则历经坎坷。目标网站的接口之间关系、参数间的关联,皆需细细揣摩、深入研究。

2、生成图片

node-cavas 生成图片。细节我就不讲了,数据都拿到了,用数据生成一张图片那就看你怎么解决了。

3、图片传到oss,调用钉钉webhook接口,定时发送到群里

传图

我是传到了腾讯云cos

const filePath = `/custom/999/${dashboard.worksheetName}-${dayjs().format('YYYYMMDD')}.jpeg`
const uploadRes = await tencentCos.upload(imageBuffer, filePath, true)

发钉钉群

查看钉钉群机器人api文档,以md格式发送图片链接。

async function sendDingTalkMessage(text) {
// const today = dayjs()
// .format("YYYY-MM-DD")
const token = '1a6e1111111' // 大群机器人
const result = await fetch(`https://oapi.dingtalk.com/robot/send?access_token=${token}`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"msgtype": "markdown",
"markdown": {
"title": "监控日报",
// "text": `#### ${title}-${today} \n > ![screenshot](${imageUrl}) \n`
"text": text
},
"at": {
"isAtAll": true
}
})
}).then(res => res.json())
console.log(result)
if (result.errcode === 0) {
console.log('发送成功')
return true
}
}

到这里可以当做一个脚本每天手动执行一下。 但是还没完全自动化,还差一步

4、js逆向登录加密方法,自动化登录。

就剩下自动登录了。

4.1 为什么要自动化登录?

因为这个系统登录凭证在一定时间内会过期,且不是明文登录的,登录接口参数加密了的。

image.png 看到这你就得去研究他的加密规则了

或者止步于此,手动复制登录凭证,本地执行脚本也是可以。

我如果要在服务器上自动化整个流程,必须得让他自动登录拿到登录凭证。

4.2 逆向步骤

4.2.1找到登录接口

先点页面的登录,找到登录接口,在请求调用栈中随便找个位置先打个断点,然后刷新页面,再次点击登录,嘿,您猜怎么着,断住了!!!

image.png

4.2.2 顺着调用栈找逻辑

顺着调栈给上找逻辑。所有在前端加密的一定是可以模拟的。

找到了调登录的方法。 看了下这就是调store里的方法passport/login。 image.png

从这再往下就比较不容易了,因为你会发现,就有点乱了。进到的都是混淆的一些abcdefg名字的方法。

image.png

但是咱们明确目标就是要找到调用的方法passport/login的位置。

我尝试了如下

  • 搜索passport/login关键字
  • 搜索接口路径

找了一辈子, 终于找到了调接口的地方

看到这个Me方法,传递了一个 isEncrypted 我猜测就是 是否要加密参数的意思吧。 image.png

别搁Me方法外面蹭了行不行???,赶紧进去看看。

你就给我看这个? 这里面又调了另一个

image.png

咱们接着进到xt.request。

您猜怎么着,还没到,这里又进行了一顿操作之后,调了一个名为P的方法。

image.png

好好好,继续继续。

4.2.3 找到了加密的位置

到了P方法,终于是没给我玩套娃了啊。

在这一步终于是看到了关键字 isEncrypted

看了代码确实是判断isEncrypted加密的。

image.png

看了后发现这是一个RSA+AES结合的加密方式

RAS加密密钥, AES加密登录数据。

加密流程总结: ‌

  1. RSA保护AES密钥的安全传输
  2. AES保护实际登录数据的机密性
  3. 双重加密确保登录信息在传输过程中的安全性
为何不用单一的加密方式?

那么你有没有这样的疑问呢?为什么不单独用rsa直接加密数据呢?岂不简单。

当然不行,是有原因的!

RSA长度限制 RSA加密算法对明文长度有严格限制,具体取决于密钥长度和填充方式‌。以下是不同密钥长度下的最大明文长度(以字节为单位):

  • 1024位密钥‌:最大明文长度约为 ‌117字节‌‌
  • 2048位密钥‌:最大明文长度约为 ‌245字节‌‌
  • 4096位密钥‌:最大明文长度约为 ‌512字节‌‌

所以RSA加密超出长度的会报错的。

所以先生成短密钥,再使用RSA加密AES对称算法的密钥,再用对称密钥加密实际数据‌。这是实际应用中的常见做法,兼顾安全性和效率‌

4.2.4 模拟他的加密过程
大致流程
  1. 把加密逻辑copy啊。
  2. 补环境。
  3. 不断尝试直到通过后端校验。
理解后端如何解密和校验

前面我们说到

RAS加密密钥, AES加密登录数据。

那么后端的校验流程就是:

  1. 私钥解出密钥
  2. 密钥配和iv、salt等再解出被AES加密的账号密码信息.

知道了这些,那么我们需要做的就是正确加密和传递相关信息,如果校验失败,我们就要来回对比差异,找到问题,不断尝试。

在不断尝试下我成功了。

遇到的问题
  • 加密的包的版本跟目标网站用的不一样导致校验失败,后经过漫长的查找找到了一样的版本。
  • 还要注意header里带的字段,都要模拟他加密后的带过去。例如这几个。
    • salt
    • iv等

image.png

最终我抽出来的登录加密方法
var b = require("crypto-js");
var jsencrypt = require("nodejs-jsencrypt/bin/jsencrypt").default;

/**
* js逆向回来的方法,模拟xx登录对参数加密
* @param body xx登录参数
* @param public 公钥
* @returns {{headers: {"aes-salt": string, "aes-iv": string, "aes-passphrase": *, "X-Request-Timestamp": string, "X-Request-Id": string, "X-Request-Sign": *}, body: string}}
*/

function encryptLogin(body, public) {
const W = new jsencrypt();
W.setPublicKey(public)

q = b.enc.Utf8.parse(Math.floor(Math.random() * 1e6) + Date.now()).toString();
// q = "31373432353237383135363835";
var re = W.encrypt(q)
, ie = b.lib.WordArray.random(128 / 8)
, fe = b.lib.WordArray.random(128 / 8)
, ue = b.PBKDF2(q, ie, {
keySize: 128 / 32,
iterations: 100
})
, ye = b.AES.encrypt(JSON.stringify(body), ue, {
iv: fe,
mode: b.mode.CBC,
padding: b.pad.Pkcs7
});

const j = "/api/v2/auth/login?is_global=true"
const Ee = parseInt(Date.now() / 1000).toString()
const he = Ee
const Fe = ye.toString()

var bt = "".concat(Ee, "_").concat(he, "_").concat(j, "_").concat(Fe, "_14skjh");

const res = {
headers: {
"aes-salt": ie.toString(),
"aes-iv": fe.toString(),
"aes-passphrase": re,
"X-Request-Timestamp": Ee,
"X-Request-Sign": b.MD5(bt).toString(),
"X-Request-Id": he,
},
body: ye.toString()
}

return res
}
使用登录方法

登录之后存下来cookie供获取数据的接口使用

async function login(public, loginData) {
// 加密登录信息
const encryptOptions = encryptLogin({
...loginData
}, public)

return await fetch("xxx", {
"headers": {
...encryptOptions.headers
},
"method": "POST",
credentials: "include",
body: encryptOptions.body
}).then(res => {
Cookie = res.headers.get('set-cookie')
return res.json()
})
}

任务分步都实现了(自动化了)。

串联起来这四步,整体就实现了。

随后部署到服务器,配置定时任务每天执行。

效果展示

image.png

总结

  • 先通后补:登录逆向卡壳,先手动Cookie跑通全链,再回填自动化。
  • 逆向不怕乱:混淆代码里断点+全局搜索(接口路径/关键字),总能定位加密点。
  • 加密常RSA+AES:RSA只加密短密钥,AES加密长数据,补环境+对齐Header字段是关键。
  • 贵在坚持:第一天研究无果别灰心,第二天重新上手,灵感与进展常不期而至。

虽然文章写得像一帆风顺,但实则磕磕绊绊——在层层混淆的代码里翻找,第一天方法不对,左冲右突脑壳嗡嗡作响。幸好第二天没放弃,沉下心继续深挖,一步步试错、迭代,终于攻克所有难题。

如果有小伙伴有任何问题或者想跟我探讨细节,欢迎联系!

喜欢的话,点点关注。


作者:浏览器API调用工程师_Taylor
来源:juejin.cn/post/7566913899175051299
收起阅读 »

为何前端圈现在不关注源码了?

web
大家好,我是双越。前百度 滴滴 资深前端工程师,慕课网金牌讲师,PMP。我的代表作有:wangEditor 开源 web 富文本编辑器,GitHub 18k star,npm 周下载量 20k划水AI Node 全栈 AIGC 知识库,包括...
继续阅读 »

大家好,我是双越。前百度 滴滴 资深前端工程师,慕课网金牌讲师,PMP。我的代表作有:

  • wangEditor 开源 web 富文本编辑器,GitHub 18k star,npm 周下载量 20k
  • 划水AI Node 全栈 AIGC 知识库,包括 AI 写作、多人协同编辑。复杂业务,真实上线。
  • 前端面试派 系统专业的面试导航,刷题,写简历,看面试技巧,内推工作。开源免费。

开始

大家有没有发现一个现象:最近 1-2 年,前端圈不再关注源码了。

最近 Vue3.6 即将发布,alien-signal 不再依赖 Proxy 可更细粒度的实现响应式,vapor-model 可以不用 vdom 。

Vue 如此大的内部实现的改动,我没发现多少人研究它的源码,我日常关注的那些博客、公众号也没有发布源码相关的内容。

这要是在 3 年之前,早就开始有人研究这方面的源码了,博客一篇接一篇,跟前段时间的 MCP 话题一样。

还有前端工具链几乎快让 Rust 重构一遍了,rolldown turbopack 等产品使得构建效率大大提升。这要是按照 3 年之前对 webpack 那个研究态度,你不会 rust 就不好意思说自己是前端了。

不光是这些新东西,就是传统的 Vue React 等框架源码现在也没啥热度了,我关注每日的热门博客,几乎很少有关于源码的文章了。

这是为什么呢?

泡沫

看源码,其实是一种泡沫,现在破灭了。所谓泡沫,就是它的真实价值之前一直被夸大,就像房地产泡沫。

前几年是互联网发展的红利期,到处招聘开发人员,大家都拿着高工资,随便跳槽就能涨薪 20% ,大家就会误以为真的是自己的能力值这么多钱。

而且,当年面试时,尤其是大公司,为了筛选出优秀的候选人(因为培训涌入的人实在太多),除了看学历以外,最喜欢考的就是算法和源码。

确实,如果一个技术人员能把算法和源码看明白,那他肯定算是一个合格的程序员,上限不好说,但下限是能保证的。就像一个人名牌大学毕业的,他的能力下限应该是没问题的。

大公司如此面试,其他公司也就跟风,面试题在网络上传播,各位程序员也就跟风学习,很快普及到整个社区。

所以,如果不经思考,表面看来:就是因为我会算法、会源码,有这些技能,才拿到一个月几万甚至年薪百万的工资。

即,源码和算法价值百万。

现状

现在泡沫破灭了。业务没有增长了,之前是红利期,现在是内卷期,之前大量招聘,现在大量裁员。

你看这段时间淘宝和美团掐架多严重,你补贴我补贴,你广告我也广告。如果有新业务增长,他们早就忙着去开疆拓土了,没公司在这掐架。

面试少了,算法和源码也就没有发挥空间了。关键是大家现在才发现:原来自己会算法会源码,也会被裁员,也拿不到高工资了。

哦,原来之前自己的价值并不是算法和源码决定的,最主要是因为市场需求决定的。哪怕我现在看再多的源码,也少有面试机会,那还看个锤子!

现在企业预算缩减,对于开发人员的要求更加返璞归真:降低工资,甚至大量使用外包人员代替。

所以开发人员的价值,就是开发一些增删改查的日常 web 或 app 的功能,什么算法和框架源码,真实的使用场景太少。

看源码有用吗?

答案当然是肯定的。学习源码对于提升个人技术能力是至关重要的,尤其是对于初学者,学习前辈经验是个捷径。

但我觉得看 Vue react 这些源码对于开发提升并不会很直接,它也许会潜移默化的提升你的“内功”,但无法直接体现在工作上,除非你的工作就是开发 Vue react 类的框架。

我更建议大家去看一些应用类的源码,例如 UI 组件库的源码看如何封装复杂组件,例如 vue-admin 看如何封装一个 B 端管理后台。

再例如我之前学习 AI Agent 开发,就看了 langChain 提供的 agent-chat-ui 和 Vercel 提供的 ai-chatbot 这两个项目的源码,我并没有直接看 langChain 的源码。

找一些和你实际开发工作相关的一些优秀开源项目,学习他们的设计,阅读他们的源码,这是最直接有效的。

最后

前端人员想学习全栈 + AI 项目和源码,可关注我开发的 划水AI,包括 AI 写作、多人协同编辑。复杂业务,真实上线。


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

我为什么说全栈正在杀死前端?

web
大家好,我又来了🤣。打开2025年的招聘软件,十个资深前端岗位,有八个在JD(职位描述)里写着:“有Node.js/Serverless/全栈经验者优先”。全栈 👉 成了我们前端工程师内卷的一种方式。仿佛你一个干前端的,要是不懂点BFF、不会配Nginx、不聊...
继续阅读 »

大家好,我又来了🤣。

打开2025年的招聘软件,十个资深前端岗位,有八个在JD(职位描述)里写着:“有Node.js/Serverless/全栈经验者优先”。

50fb0729f6733fc5092ecfc91f063c6.jpg

全栈 👉 成了我们前端工程师内卷的一种方式。仿佛你一个干前端的,要是不懂点BFF、不会配Nginx、不聊聊K8s,你都不好意思跟人说你是资深。

我们都在拼命地,去学Nest.js、学数据库、学运维。我们看起来,变得越来越全能了。

但今天,我想泼一盆冷水🤔:

全栈正在杀死前端。


全栈到底是什么

我们先要搞清楚,现在公司老板们想要的全栈,到底是什么?

image.png

他们想要的,不是一个T型人才(在一个领域是专家,同时懂其他领域)。

他们想要的是:一个能干两个人(前端+后端)的活,但只需要付1.5个人的工资。

但一个人的精力,毕竟是有限的。

  • 当我花了3个月,去死磕K8s的部署和Nest.js的依赖注入时,我必然没有时间,去研究新出炉的INP性能指标该如何优化。
  • 当我花了半周时间,去设计数据库表结构和BFF接口时,我必然没有精力,去打磨那个React组件的可访问性,无障碍(a11y)和动画细节。

我们引以为傲的前端精神,正在被全栈的广度要求,稀释得一干二净。

全栈的趋势,正在逼迫我们,从一个能拿90分的前端专家,变成一个前后端都是及格的功能实现者。


关于前端体验

做全栈的后果,最终由谁来买单?

是用户。

我们来看看全栈前端主导下,最容易出现的受灾现场:

1.能用就行的交互

全栈思维,是功能驱动的。

数据能从数据库里查出来,通过API发到前端,再用v-for渲染出来,好了,这个功能完成了😁。

至于:

  • 列表的虚拟滚动做了吗?
  • 图片的懒加载做了吗?
  • 按钮的loadingdisabled状态,在API请求时加了吗?
  • 页面切换的骨架屏做了吗?
  • 弱网环境下的超时和重试逻辑写了吗?
  • UI测试呢?

抱歉,没时间。我还要去写BFF层的单元测试。

2.无障碍,可访问性(a11y)

你猜一个全栈,在用 

还是 


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

如何知道同事的工资?

web
薪资保密,是所有互联网公司的红线之一。作为牛马,虽然能理解制度的初衷,但肯定忍不住想知道身边同事的工资。直接问当然不行,我提供几个思路:调虎离山,乘 TA 不在,在 TA 电脑上登录 OA/ERP,直接看工资与 TA 搞对象或者搞基,知道工资后,马上分手,继续...
继续阅读 »

薪资保密,是所有互联网公司的红线之一。作为牛马,虽然能理解制度的初衷,但肯定忍不住想知道身边同事的工资。

直接问当然不行,我提供几个思路:

  1. 调虎离山,乘 TA 不在,在 TA 电脑上登录 OA/ERP,直接看工资
  2. 与 TA 搞对象或者搞基,知道工资后,马上分手,继续下一位,直到遍历完所有同事
  3. 拼命地卷自己,不断升职,成为他们的 +1 或 +2(腾讯是 +2 才有薪酬权)

虽然有可行性,但是作为码农,这些方案都太 low 了,没有丝毫技术含量。

下面,且看老夫的表演:

真正的技术

2016年底,花 100 块买了台摩托罗拉 C118,长这样:

C118正面

百度百科的介绍如下:

Moto C118是Moto品牌于2006年2月推出的一款直板式基础功能手机

配备 96×64 单色显示屏,支持GSM 900/1800MHz双频网络

没错,我在 2016 年底买了一台 2006 年上市的功能机。很无聊的机器,哪怕在 06 年,我都没正眼瞧过它。

但,这跟工资有啥关系?

一般来说,工资入账时,会有银行的短信提醒。如果能截获短信,不就知道其他人的工资了吗?

经过硬件修改后的 C118,刷入特殊的固件,连接到Linux机器,启动脚本扫描频段,用WireShark抓包,就能收到其他人的短信了:

wireshark

大概原理是:

GSM 短信未加密,而基站发送短信是广播的。

也就是说,任意的手机(不需要插入 SIM 卡)都能收到连接到同一基站的其他手机的短信,只不过会丢弃不属于自己的短信

特殊的固件就是让手机来者不拒,不要丢弃其他人的短信。具体细节,网上一大堆,请自行搜索OsmocomBB

我完全不懂硬件,直接买的改好后的 C118,卖家顺便改了 Micro-USB 供电,不需要电池了。我觉得挺漂亮的,就是有点像定时炸弹:

C118背面

机器到手后,照葫芦画瓢,跑了半天,真抓到了一些推广短信。卧槽,牛逼!

等到发工资那天,提前把脚本跑起来,附近同事的工资,尽收眼底。完美!؏؏☝ᖗ乛◡乛ᖘ☝؏؏

好吧,这只是一个邪恶的想法,从未付诸实施。说起来你可能不信,每次都是收到工资入账短信后,我才想起来脚本没跑。久而久之,就忘了这件事了。

话说回来,即使当初真干了,也是徒劳。因为这个只能抓移动和联通的 GSM 短信,对电信的 CDMA 无效。而且,当时 4G 已经全面铺开,根本抓不到。即使有同事用 GSM 老人机,也因为原理的限制,看不到收件人手机号,并不知道是谁的工资。

最后,说一件趣事:

几年前,某司价值观中的「创新」要改成「创造」,在内网征集最能代表「创造」的动物,点赞数最多的是它:

倒挂的蝙蝠

提议者的配文:

蝙蝠,昼伏夜出,象征着创造力

最重要的是,它擅长倒挂

彼时,网上一堆晒 SP、SSP 的 offer 的校招生,薪资遥遥领先工作多年的老员工。当然,蝙蝠最终还是以最高得票遗憾落选了,虽败犹荣。

类似的倒挂,在我身上也发生过,果断选择了跑路。然后,薪资翻了数倍,敏感内容,略。


作者:野生的码农
来源:juejin.cn/post/7550151424333053992
收起阅读 »

女朋友被链接折磨疯了,我写了个工具一键解救

web
有一天女朋友跟我抱怨:工作里被各种链接折腾得头大,飞书和浏览器之间来回切窗口,一会忘了看哪个,心情都被搅乱了。我回头一想——我也一样,办公室每个人都被链接淹没。“同事丢来的需求文档、群里转的会议记录、GitLab 的 MR 链接、还有那些永远刷不完的通知——每...
继续阅读 »

有一天女朋友跟我抱怨:工作里被各种链接折腾得头大,飞书和浏览器之间来回切窗口,一会忘了看哪个,心情都被搅乱了。我回头一想——我也一样,办公室每个人都被链接淹没。

同事丢来的需求文档、群里转的会议记录、GitLab 的 MR 链接、还有那些永远刷不完的通知——每点一个链接就得在聊天工具和浏览器之间跳转,回来后一秒钟就忘了"本来要点哪个、看哪个"。更别提那些收集了一堆好文章想集中看,或者别人发来一串链接让你"挑哪个好"的时候,光是打开就要折腾半天。

"

这不是注意力不集中,是工具没有帮你省掉这些无意义的切换。

"

于是我做了一个极简 Chrome 插件: Open‑All 。它只做一件事——把你所有网址一次性在新窗口打开。你复制粘贴一次,它把链接都整齐地摆在新标签页里,你只要从左到右按顺序看就行。简单、直接,让你把注意力放在真正重要的事情上

先看效果:一键打开多个链接

批量打开所有url.gif

这些痛点你肯定也遇到过

每天都在经历的折磨

  • 浏览器和飞书、企微、钉钉来回切应用 :复制链接、粘贴、点开、切回来,这套动作做一遍就够烦的了
  • 容易忘事 :打开到第几个链接了?这个看过没?脑子根本记不住
  • 启动成本高 :一想到链接要一个个点开,就懒得开始了
  • 没法对比 :想要横向比较几个方案,但打开方案链接都费劲

具体什么时候最痛苦

  1. 收集的文章想一口气看完 :平时存了一堆好文章,周末想集中看,结果光打开就累了
  2. 别人让你帮忙选 :同事发来几个方案链接问你觉得哪个好,你得全部打开才能比较
  3. 代码 Review :GitLab 上好几个 MR 要看,还有相关的 Issue 和 CI 结果
  4. 开会前准备 :会议文档、背景资料、相关链接,都得提前打开看看

我的解决方案

设计思路很简单

  • 就解决一个问题 :批量打开链接,不搞那些花里胡哨的功能
  • 零学习成本 :会复制粘贴就会用
  • 让你专注 :少折腾,多干活

能干什么

  • 把一堆链接一次性在新窗口打开
  • 自动保存你输入的内容,不怕误关
  • 界面超简单,点两下就搞定

技术实现

项目结构

shiba-cursor
├── manifest.json # 扩展的"身-份-证"
├── popup.html # 弹窗样式
└── popup.js # 弹窗交互

文件说明:

  • manifest.json:扩展身份信息
  • popup.html:弹窗样式
  • popup.js:弹窗交互

立即尝试

方法一: 从github仓库拉代码,本地安装

5分钟搞定安装:复制代码 → 创建文件 → 加载扩展 → 开始使用!

🚀 浏览项目的完整代码可以点击这里 github.com/Teernage/op…,如果对你有帮助欢迎Star。

方法二:直接从chrome扩展商店免费安装

Chrome扩展商店一键安装:open-all 批量打开URL chromewebstore.google.com/detail/%E6%…,如果对你有帮助欢迎好评。

动手实现

第一步:创建项目文件

  1. 创建文件夹 open-all

  2. 创建manifest.json文件

{
"manifest_version": 3,
"name": "批量打开URL",
"version": "1.0",
"description": "输入多个URL,一键在新窗口中打开",
"permissions": [
"tabs",
"storage"
],
"action": {
"default_popup": "popup.html",
"default_title": "批量打开URL"
}
}
  1. 创建popup.html文件

html>
<html>
<head>
<meta charset="utf-8" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
width: 320px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
sans-serif;
color: #333;
}

.container {
background: rgba(255, 255, 255, 0.95);
padding: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}

.title {
font-size: 18px;
font-weight: 600;
text-align: center;
margin-bottom: 16px;
color: #1d1d1f;
letter-spacing: -0.5px;
}

#urlInput {
width: 100%;
height: 140px;
padding: 12px;
border: 2px solid #e5e5e7;
border-radius: 12px;
font-size: 14px;
font-family: 'SF Mono', Monaco, monospace;
resize: none;
background: #fafafa;
transition: all 0.2s ease;
line-height: 1.4;
}

#urlInput:focus {
outline: none;
border-color: #007aff;
background: #fff;
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.1);
}

#urlInput::placeholder {
color: #8e8e93;
font-size: 13px;
}

.button-group {
display: flex;
gap: 8px;
margin-top: 16px;
}

button {
flex: 1;
padding: 12px 16px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}

#openBtn {
background: linear-gradient(135deg, #007aff 0%, #0051d5 100%);
color: white;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.3);
}

#openBtn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.4);
}

#openBtn:active {
transform: translateY(0);
}

#clearBtn {
background: #f2f2f7;
color: #8e8e93;
border: 1px solid #e5e5e7;
}

#clearBtn:hover {
background: #e5e5ea;
color: #636366;
}

#status {
margin-top: 12px;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
text-align: center;
display: none;
background: rgba(52, 199, 89, 0.1);
color: #30d158;
border: 1px solid rgba(52, 199, 89, 0.2);
}

.tip {
font-size: 11px;
color: #8e8e93;
text-align: center;
margin-top: 8px;
line-height: 1.3;
}
style>
head>
<body>
<div class="container">
<div class="title">批量打开 URLdiv>

<textarea
id="urlInput"
placeholder="输入 URL,每行一个:

https://www.apple.com

https://www.github.com

https://www.google.com"
>textarea>

<div class="button-group">
<button id="clearBtn">清空button>
<button id="openBtn">打开button>
div>

<div class="tip">输入会自动保存,打开后自动清空div>

<div id="status">div>
div>

<script src="popup.js">script>
body>
html>

  1. 创建popup.js文件

document.addEventListener('DOMContentLoaded', function() {
const urlInput = document.getElementById('urlInput');
const openBtn = document.getElementById('openBtn');
const clearBtn = document.getElementById('clearBtn');
const status = document.getElementById('status');

// 恢复上次保存的输入
chrome.storage.local.get(['savedUrls'], function(result) {
if (result.savedUrls) {
urlInput.value = result.savedUrls;
}
});

// 自动保存输入内容
urlInput.addEventListener('input', function() {
chrome.storage.local.set({savedUrls: urlInput.value});
});

// 清空按钮
clearBtn.addEventListener('click', function() {
urlInput.value = '';
chrome.storage.local.remove(['savedUrls']);
showStatus('已清空');
});

// 打开URL按钮
openBtn.addEventListener('click', function() {
const urls = getUrls(urlInput.value);

if (urls.length === 0) {
showStatus('请输入有效的URL');
return;
}

// 创建新窗口并打开所有URL
chrome.windows.create({url: urls[0]}, function(window) {
for (let i = 1; i < urls.length; i++) {
chrome.tabs.create({
windowId: window.id,
url: urls[i],
active: false
});
}

// 成功打开后清空输入并移除存储
urlInput.value = '';
chrome.storage.local.remove(['savedUrls']);
showStatus(`已打开 ${urls.length} 个URL`);
});
});

// 解析URL
function getUrls(input) {
return input.split('\n')
.map(line => line.trim())
.filter(line => line && (line.startsWith('http://') || line.startsWith('https://')));
}

// 显示状态
function showStatus(message) {
status.textContent = message;
status.style.display = 'block';
setTimeout(() => {
status.style.display = 'none';
}, 2000);
}
});

💡 深入理解脚本通信机制

虽然这个插件比较简单,只用到了 popup 和 storage API,但如果你想开发更复杂的插件(比如需要在网页中注入脚本、实现跨脚本通信),就必须理解 Chrome 插件的多脚本架构。

强烈推荐阅读:

👉 大部分人都错了!这才是 Chrome 插件多脚本通信的正确姿势

第二步:安装扩展

安装open all使用.gif

  1. 打开Chrome浏览器
  2. 地址栏输入:chrome://extensions/
  3. 打开右上角"开发者模式"
  4. 点击"加载已解压的扩展程序"
  5. 选择刚才的文件夹,然后确定
  6. 固定扩展
  7. 点击扩展图标即可使用

最后想说的

这个插件功能很简单,但解决的是我们每天都会遇到的真实问题。它不会让你的工作效率翻倍,但能让你少一些无聊的重复操作,多一些专注的时间。

我和女朋友现在用着都挺爽的,希望也能帮到你。如果你也有类似的困扰,试试看吧,有什么想法也欢迎在评论区聊聊。

你最希望下个版本加什么功能?评论区告诉我!

如果觉得对您有帮助,欢迎点赞 👍 收藏 ⭐ 关注 🔔 支持一下! 往期实战推荐:


作者:不一样的少年_
来源:juejin.cn/post/7566677296801071155
收起阅读 »

逃离鸭科夫5人2周1个亿,我们可以做一个鸡科夫吗?

web
点击上方亿元程序员+关注和★星标引言哈喽大家好,不知道小伙伴们最近有没有关注到一个名叫《逃离鸭科夫》的游戏。这款游戏在各大社交平台和游戏社区都成为了热门话题,Steam平台上的同时在线人数一度突破30万,其口碑表现也相当出色。在累计一万七千多条玩家评价中,收获...
继续阅读 »

点击上方亿元程序员+关注和★星标

逃离鸭科夫宣传图

引言

哈喽大家好,不知道小伙伴们最近有没有关注到一个名叫《逃离鸭科夫》的游戏。

这款游戏在各大社交平台和游戏社区都成为了热门话题,Steam平台上的同时在线人数一度突破30万,其口碑表现也相当出色。

在累计一万七千多条玩家评价中,收获了96%的压倒性好评,整体评价明显优于今年发布的多数新作。

其中更为炸裂的信息,开发这款游戏的团队仅仅只有5个人。

逃离鸭科夫开发团队

除了有常规的3D美术数值策划外,还有3个神人:

  • 负责游戏设计、战斗编程以及基础美术的制作人
  • 负责游戏内大部分编程的游戏主策
  • 负责游戏内所有美术资产的校招生

更想不到的是,如此精简的团队,打造出的游戏,仅仅2周时间,收获1个亿,远高于团队预期。

那么问题来了,鸭科夫如此成功,我们可以做一个“鸡科夫”吗?

这个问题还是交给小伙伴们吧,笔者实在是折腾不起,但是呢,我们可以定个小目标,做个简单的小东西起步。

言归正传,本期笔者介绍一下如何在Cocos游戏开发中,制作类似逃离鸭科夫中的激光瞄准器!

本文源码和源工程在文末获取,小伙伴们自行前往。

什么是激光瞄准器?

逃离鸭科夫录屏

激光瞄准器是一种安装在武器(如枪支、弓箭等)上的装置,它发射出一束低功率的、可见或不可见的激光束,在被瞄准的物体上投射一个光点,提示使用者弹着点(即子弹预计会命中的位置)。

制作原理

逃离鸭科夫截图

在Cocos游戏开发中,激光瞄准器的制作方法有不少,包括但不限于以下几种:

  • Line组件
  • 自定义Shader
  • 粒子系统

本期我们主要演示Line组件的使用。

Line组件

Line组件用于渲染3D场景中给定的点连成的线段。

Line组件渲染的线段是有宽度的,并且总是面向摄像机,这与billboard组件相似。

Line组件

Line组件的使用非常简单,我们重点关注他的:

  • positions: 每个线段端点的坐标。
  • width: 线段宽度,如果采用曲线,则表示沿着线段方向上的曲线变化。
  • color: 线段颜色,如果采用渐变色,则表示沿着线段方向上的颜色渐变。

激光瞄准器制作实例

下面跟随这笔者,一起在Cocos游戏开发中实现一个激光瞄准器。

1.资源准备

老生常谈,有美术搭子的找美术搭子,没有美术搭子的找AI搭子。

笔者拿出做例子最爱的小鸡,本期我们叫他“鸡科夫”。

“鸡科夫”

2.Line组件

新建一个节点,绑定一个Line组件,设置一下线的宽度和颜色,坐标我们在代码中动态设置。

Line组件

3.写代码

首先声明一个ChickenKF类并挂在Canvas上,绑定好Line组件鸡科夫摄像机

然后监听一下鼠标事件,按下时显示激光,移动瞄准,抬起时关闭激光。

通过鼠标事件和射线检测,确定激光的方向和目标。

计算出来激光的起点和终点,对Line组件positions进行赋值。

4.效果演示

结语

逃离鸭科夫之所以能够成为爆款,并非偶然,它源于对游戏的热爱、对玩家的重视以及对游戏的执着,因为他们“听人劝吃饱饭”。

假如我们完整复刻出来一个“鸡科夫”,它会如愿成为爆款吗?

评论区说出你的看法。

本文源工程可通过私信发送 ChickenKF 获取。

我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐专栏:

知识付费专栏

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

100个Cocos实例

8年主程手把手打造Cocos独立游戏开发框架

和8年游戏主程一起学习设计模式

从零开始开发贪吃蛇小游戏到上线系列

点击下方灰色按钮+关注。


作者:亿元程序员
来源:juejin.cn/post/7569515660930220083
收起阅读 »

如何用Claude Code 生成顶级UI ❇️

web
前言Hi 大家🫶 ,我是大雄 ,好久不见。最近空余时间,在独立开发一些小产品,也在探索一些独立开发的最佳实践。 今天要分享的是:如何用 Claude Code 生成 顶级【靓丽】UI 🌝所有的源码都已开源: Github ...
继续阅读 »

前言

Hi 大家🫶 ,我是大雄 ,好久不见。最近空余时间,在独立开发一些小产品,也在探索一些独立开发的最佳实践。 今天要分享的是:如何用 Claude Code 生成 顶级【靓丽】UI 🌝

所有的源码都已开源: Github , 并已部署到vercel: 在线预览

众所周知,AI 画的UI, 味太浓了哈哈今天我们来解决这个痛点!!

以往我的处理

以往生成UI我会怎么做呢?

  • 跟 v0 结对chat,出一版原型,再基于原型样式去迭代
  • 或者是使用 stritch 设计一个初版的UI,再进行迭代
- https://v0.app/
- https://stitch.withgoogle.com/

下方是其中一个产品,hi-offer 多次迭代后大致的UI 效果,看起来还可以,只是还没有到很靓丽的程度🫥

那可能有小伙伴会有同样的疑问:

*   我没有UI 设计经验呀,我要怎么快速实现 **靓丽程度** 的 UI 呢?
* 答案是~~抄~~,No,是模仿学习哈哈

给大家一个样例,MotherDucker 的首页。 给大家10秒钟,思索一下。如果你想复刻这种UI风格,用在自己的产品上,你会怎么做?

可能有下面的一些思考

-   截图 UI 给 cluade code 分析
- 截图丢给stitch + 对话迭代

众所周知,OCR 过程,出现很大UI 信息缺失,比如:具体配色数值、阴影、间距、字体等,于是你会发现,最终AI完成的效果可能都没有60% 。

于是核心思路是:解决样式信息大量丢失的问题,通过减少信息代差,让AI coding 完成的UI 风格有不错的效果

好消息是,最近实践了一个工作流,很好地解决了这个问题,随我来,我们只需要核心的五个步骤:

最终成品效果如下,全程vibe coding,详见:4-quadrant-to-do.vercel.app/

  • 如果你感觉效果还不错,愉快开始本文之旅吧~~
  • 如果你认为效果比较牵强,那么阅读本文之后,你一定可以迭代出更好的UI。

话不多说,我们开始发车 ~~

步骤一:Copy样式上下文,生成初版的html

你需要提供下方的的上下文信息给CC,让他帮忙构建一个html 页面

-  参考的 web UI 截图【长图或多张全屏图】
- copy web 的 html css 样式信息
- prompt

截图

prompt

Help me rebuild exact same ui design in signle html as xxx.html, above is extracted css:

style info

右键检查,选择html、body 元素,copy style 信息

*   html css style
* body css style

例如:

    -webkit-locale: "en";
scroll-margin-top: var(--eyebrow-desktop);
animation-duration: 0.0001s !important;
animation-iteration-count: 1 !important;
transition-duration: 0s !important;
caret-color: transparent !important;
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
box-sizing: border-box;
border: 0px;
font-size: 100%;
vertical-align: baseline;
text-decoration: none;
scroll-padding-top: var(--header-desktop);
scroll-behavior: auto;
height: 100%;
margin: 0;
padding: 0;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-feature-settings: normal;
font-variation-settings: normal;
-webkit-tap-highlight-color: transparent;
--swiper-theme-color: #007aff;
--toastify-toast-min-height: fit-content;
--toastify-toast-width: fit-content;
--header-mobile: 70px;
--header-desktop: 90px;
--eyebrow-mobile: 70px;
--eyebrow-desktop: 55px;

HTML 预览效果

cc构建的html 页面

步骤2: 迭代原始UI

这里我觉得原始UI的分格上已经可以了,只是一些细节还不太行,比如按钮hover 的阴影、边框等还需要完善。

我一般会使用下方的prompt 以及 copy 具体标签的CSS 来进一步处理。

prompt

Only code in HTML/Tailwind in a single code block.
Any CSS styles should be in the style attribute. Start with a response, then code and finish with a response.
Don't mention about tokens, Tailwind or HTML.
Always include the html, head and body tags.
Use lucide icons for javascript, 1.5 strokewidth.
Unless style is specified by user, design in the style of Linear, Stripe, Vercel, Tailwind UI (IMPORTANT: don't mention names).
Checkboxes, sliders, dropdowns, toggles should be custom (don't add, only include if part of the UI). Be extremely accurate with fonts.
For font weight, use one level thinner: for example, Bold should be Semibold.
Titles above 20px should use tracking-tight.
Make it responsive.
Avoid setting tailwind config or css classes, use tailwind directly in html tags.
If there are charts, use chart.js for charts (avoid bug: if your canvas is on the same level as other nodes: h2 p canvas div = infinite grows. h2 p div>canvas div = as intended.).
Add subtle dividers and outlines where appropriate.
Don't put tailwind classes in the html tag, put them in the body tags.
If no images are specified, use these Unsplash images like faces, 3d, render, etc.
Be creative with fonts, layouts, be extremely detailed and make it functional.
If design, code or html is provided, IMPORTANT: respect the original design, fonts, colors, style as much as possible.
Don't use javascript for animations, use tailwind instead. Add hover color and outline interactions.
For tech, cool, futuristic, favor dark mode unless specified otherwise.
For modern, traditional, professional, business, favor light mode unless specified otherwise.
Use 1.5 strokewidth for lucide icons and avoid gradient containers for icons.
Use subtle contrast.
For logos, use letters only with tight tracking.
Avoid a bottom right floating DOWNLOAD button.

原始UI.html 效果

经过两次三次调整后,我觉得work 了

步骤3: 生成STYLE_GUIDE.md

在正式开整我们的web 产品之前,我们需要一个容器,保存上面我们原始UI的所有样式信息,减少信息代差。

这个容器就是STYLE_GUIDE.md,你可以使用下面的 prompt 来生成

pormpt

Great, now help me generate a detailed style guide\
In style guide, you must include the following part:
- Overview
- Color Palette
- Typography (Pay attention to font weight, font size and how different fonts have been used together in the project)
- Spacing System
- Component Styles
- Shadows & Elevation
- Animations & Transitions
- Border Radius
- Opacity & Transparency
- Common Tailwind CSS Usage in Project
- Example component reference design code
- And so on...
In a word, Give detailed analysis and descriptions to the project style system, and don't miss any important details.

生成的STYLE_GUIDE.md

由于cc 给我生成的style-guide 比较长,这里只贴了关键部分,如需查看完整.md, 辛苦移步仓库查看Github

# MotherDuck UI Design System - Style Guide

## Table of Contents
1. [Overview](#overview)
2. [Color Palette](#color-palette)
3. [Typography](#typography)
4. [Spacing System](#spacing-system)
5. [Component Styles](#component-styles)
6. [Shadows & Elevation](#shadows--elevation)
7. [Animations & Transitions](#animations--transitions)
8. [Border Radius](#border-radius)
9. [Opacity & Transparency](#opacity--transparency)
10. [Layout System](#layout-system)
11. [Common Tailwind CSS Usage](#common-tailwind-css-usage)
12. [Example Component Reference](#example-component-reference)
13. [Responsive Design Patterns](#responsive-design-patterns)

---

## Overview

The MotherDuck design system features a **bold, playful, and technical aesthetic** that combines:
- **Brutalist design principles** with heavy borders and sharp corners
- **Vibrant color palette** inspired by data visualization
- **Interactive micro-animations** with shadow-based hover effects
- **Technical typography** mixing Inter for UI and Monaco for code
- **Generous spacing** for a clean, breathable layout

### Design Philosophy
- **Bold & Confident**: Strong borders, high contrast, and clear visual hierarchy
- **Playful & Approachable**: Bright colors, whimsical cloud decorations, and friendly copy
- **Technical & Professional**: Code samples, data-focused messaging, and precise typography
- **Interactive**: Immediate visual feedback on all interactive elements

---

## Color Palette

### Primary Colors

```css
/* Background Colors */
--beige-background: #F4EFEA; /* Main page background */
--white: #FFFFFF; /* Card and section backgrounds */
--dark-gray: #2D2D2D; /* Code editor header */

/* Brand Colors */
--primary-blue: #6FC2FF; /* Primary CTA buttons */
--cyan: #4DD4D0; /* Secondary accent, badges */
--light-blue: #5CB8E6; /* Tertiary accent, banners */
--yellow: #FFD500; /* Top banner, tags, accents */

/* Text & Borders */
--dark: #383838; /* Primary text, borders */
--medium-gray: #666666; /* Secondary elements */
--light-gray: #E0E0E0; /* Dividers, table borders */

/* Accent Colors */
--orange-primary: #FF9500; /* Logo primary */
--orange-secondary: #FF6B00; /* Logo secondary */
--coral: #FF6B6B; /* Error/warning states */
--pink: #FFB6C1; /* Decorative accents */

### Color Usage Guidelines

| Color | Usage | Hex Code | Tailwind Class |
| -------------------- | ------------------------------------------ | --------- | ----------------------------------- |
| **Beige Background** | Main page background, alternating sections | `#F4EFEA` | `bg-[#F4EFEA]` |
| **White** | Cards, modals, content backgrounds | `#FFFFFF` | `bg-white` |
| **Primary Blue** | Primary CTA buttons, focus states | `#6FC2FF` | `bg-[#6FC2FF]` |
| **Cyan** | Badges, secondary highlights | `#4DD4D0` | `bg-[#4DD4D0]` |
| **Light Blue** | Banners, tags, tertiary accents | `#5CB8E6` | `bg-[#5CB8E6]` |
| **Yellow** | Top banner, promotional elements | `#FFD500` | `bg-[#FFD500]` |
| **Dark Gray** | Primary text, all borders | `#383838` | `text-[#383838]` `border-[#383838]` |
| **Medium Gray** | Secondary text, disabled states | `#666666` | `text-gray-600` |

### Color Combinations

**High Contrast Pairings:**

* Yellow background (`#FFD500`) + Dark text (`#383838`)
* White background + Dark borders (`#383838`)
* Primary Blue (`#6FC2FF`) + Dark borders (`#383838`)

**Semantic Colors:**

* **Success**: Cyan (`#4DD4D0`)
* **Warning**: Yellow (`#FFD500`)
* **Error**: Coral (`#FF6B6B`)
* **Info**: Light Blue (`#5CB8E6`)

***

## Typography

### Font Families

```css
/* Primary Font - UI Text */
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;

/* Secondary Font - Code Samples */
font-family: 'Monaco', 'Courier New', monospace;
```


### Type Scale

| Element | Size | Weight | Line Height | Letter Spacing | Tailwind Classes |
| ---------------------- | -------------------- | -------------- | ----------- | --------------- | --------------------------------------------------------------- |
| **Hero H1** | 96px / 112px / 128px | 700 (Bold) | 1.0 | -0.02em (tight) | `text-6xl lg:text-7xl xl:text-8xl font-bold tracking-tighter` |
| **Section H2** | 48px / 60px | 700 (Bold) | 1.1 | -0.01em (tight) | `text-4xl lg:text-5xl font-bold tracking-tight` |
| **Section H2 (Large)** | 48px | 700 (Bold) | 1.1 | -0.01em (tight) | `text-5xl font-bold tracking-tight` |
| **Card H3** | 36px / 42px | 700 (Bold) | 1.2 | -0.01em (tight) | `text-3xl lg:text-4xl font-bold tracking-tight` |
| **Component H3** | 16px | 600 (Semibold) | 1.3 (snug) | 0 | `text-base font-semibold leading-snug` |
| **Body Large** | 18px / 20px | 500 (Medium) | 1.6 | 0 | `text-lg lg:text-xl font-medium leading-relaxed` |
| **Body Regular** | 16px | 400 (Regular) | 1.5 | 0 | `text-base` |
| **Body Small** | 14px | 500 (Medium) | 1.5 | 0 | `text-sm font-medium` |
| **Caption** | 12px | 400 (Regular) | 1.4 | 0 | `text-xs` |
| **Button Text** | 14px / 16px | 700 (Bold) | 1.0 | 0 | `text-sm font-bold uppercase` / `text-base font-bold uppercase` |
| **Code** | 13px / 14px | 400 (Regular) | 1.8 | 0 | `text-sm code-text leading-relaxed` |
| **Label Small** | 12px | 700 (Bold) | 1.2 | 0.1em (widest) | `text-xs font-bold tracking-widest` |

### Font Weight Guidelines

| Weight | Value | Usage |
| -------------- | ----- | ------------------------------------------------- |
| **Regular** | 400 | Body text, descriptions, table content |
| **Medium** | 500 | Navigation links, emphasized body text, subtitles |
| **Semibold** | 600 | Card headings, feature titles |
| **Bold** | 700 | All headings, buttons, tags, labels |
| **Extra Bold** | 800 | (Not used in current design) |

### Typography Patterns

**Heading Pattern:**

```html


MAKING BIG DATA FEEL SMALL





WHY IT'S BETTER





WHO IS IT FOR?



Analytics that works for everyone


```


**Body Text Pattern:**

```html


DUCKDB CLOUD DATA WAREHOUSE SCALING TO TERABYTES





Is your data all over the place? Start making sense...




Subscribe to MotherDuck news


```


**Text Decoration:**

*
Links use `underline` for emphasis
* All-caps text for: buttons, headings, labels, navigation
* Tracking adjustment: `-tracking-tighter` for large headings, `tracking-widest` for small labels

***

## Spacing System

### Base Spacing Scale

The design uses Tailwind's default spacing scale (1 unit = 0.25rem / 4px):

| Value | Pixels | Usage |
| ----- | ------ | ------------------------------ |
| `1` | 4px | Micro spacing, icon gaps |
| `2` | 8px | Tight element spacing |
| `3` | 12px | Small gaps, checkbox spacing |
| `4` | 16px | Default gap, button groups |
| `6` | 24px | Medium spacing, card padding |
| `8` | 32px | Large spacing, section gaps |
| `10` | 40px | Extra large spacing |
| `12` | 48px | Section separation |
| `16` | 64px | Major section separation |
| `20` | 80px | Section padding (vertical) |
| `28` | 112px | Hero section padding (desktop) |

### Component Spacing Patterns

**Section Padding:**

```css
/* Standard Section */
padding: py-20 px-6 /* 80px vertical, 24px horizontal */

/* Compact Section */
padding: py-16 px-6 /* 64px vertical, 24px horizontal */

/* Hero Section */
padding: py-20 lg:py-28 px-6 /* 80px mobile, 112px desktop */
```


**Container Max Width:**

```css
max-w-6xl /* 1152px - Standard content */
max-w-7xl /* 1280px - Wide content */
max-w-4xl /* 896px - Narrow content, forms */
max-w-2xl /* 672px - Very narrow, centered content */
```


**Gap Spacing:**

```css
gap-2 /* 8px - Tight elements (window dots) */
gap-3 /* 12px - Form elements, checkboxes */
gap-4 /* 16px - Button groups, form rows */
gap-6 /* 24px - Grid items (small screens) */
gap-8 /* 32px - Navigation items */
gap-12 /* 48px - Card grid (medium) */
gap-16 /* 64px - Section elements */
```


**Margin Spacing:**

```css
/* Heading Margins */
mb-2 /* 8px - Label to content */
mb-3 /* 12px - Subtitle to content */
mb-6 /* 24px - Small heading to content */
mb-8 /* 32px - Medium heading to content */
mb-16 /* 64px - Large heading to grid */

/* Element Margins */
mb-4 /* 16px - Paragraph to button */
mb-6 /* 24px - Form to submit */
mb-8 /* 32px - Icon to text */
```


步骤4: 构建原型html

为了验证效果我们叫cc 大哥,参考STYLE_GUIDE.md ,实现一个四象限 to-do list 的.html 原型。 

中间省略我跟他对需求的过程,下方是cc实现的初稿👇

看起来平平无奇,甚至有点糟糕,什么东西嘛这是??🥸

别担心!! 别忘啦,所有的样式信息,都在STYLE_GUIDE.md ,我们可以继续push cc 迭代。

ui 迭代

1:叫替换一下 header 的颜色为style-guide.md 里面的黄色 
2:添加图表统计功能

经过几轮的迭代,我们得到了初版的效果

步骤5:构建像素级别还原的next app

原生的.html 不方便后续迭代维护,你可以使用下方的prompt 叫CC构建一个next app 开始build 之前可以梳理一下已实现的功能,方便后续迭代

prompt

> Great,now you need to  build a next app from todo-quadrant.html 
- you need to ensure the UI and logic are pixel perfectly restorely 。
- the code structure should be clear enough and The code is highly readable.
- when there is the case that if-else ,your need to use early-return to solve

保存plan.md

最终的next.app 效果

其他扩展

当然啦,有了STYLE_GUIDE.md你还可以拓展更多的实践,比如:

-  在 stitch 生成符合风格 ui 设计稿【还可以加上初版的.html】
- 在lovart 生成符合风格的美术素材
- 基于farme motion 生成产品演示动画
- 生成漂亮的产品的幻灯片(html),用一些工具转为ppt 使用

参考实践


作者:hi大雄
来源:juejin.cn/post/7569777676098814002

收起阅读 »

我本是写react的,公司让我换赛道搞web3D

web
当你在会议室里争论需求时,智慧工厂的数字孪生正同步着每一条产线的脉搏;当你对着平面图想象空间时,智慧小区的三维模型已在虚拟世界精准复刻每一扇窗的采光。当你在CAD里调整参数时,数字孪生城市的交通流正实时映射每辆车的轨迹;当你等待客户确认方案时,机械臂的3D仿真...
继续阅读 »

当你在会议室里争论需求时,
智慧工厂的数字孪生正同步着每一条产线的脉搏;

当你对着平面图想象空间时,
智慧小区的三维模型已在虚拟世界精准复刻每一扇窗的采光。

当你在CAD里调整参数时,
数字孪生城市的交通流正实时映射每辆车的轨迹;
当你等待客户确认方案时,
机械臂的3D仿真已预演了十万次零误差的运动路径;

当你用二维图纸解释传动原理时,
可交互的3D引擎正让客户‘拆解’每一个齿轮;
当你担心售后维修难描述时,
AR里的动态指引已覆盖所有故障点;

当你用PS拼贴效果图时,
VR漫游的业主正‘推开’你设计的每一扇门;
当你纠结墙面材质时,
光影引擎已算出了午后3点最温柔的折射角度;

从前端到Web3D,
不是换条赛道,
而是打开新维度。

韩老师说过:再牛的程序员都是从小白开始,既然开始了,就全心投入学好技术。

🔴 工具

所有的api都可以通过threejs官网的document,切成中文,去搜:

image.png

🔴 平面

⭕️ Scene 场景

场景能够让你在什么地方什么东西来交给three.js来渲染,这是你放置物体灯光摄像机地方

image.png

import * as THREE from "three";

// console.log(THREE);

// 目标:了解three.js最基本的内容

// 1、创建场景
const scene = new THREE.Scene();

⭕️ camera 相机

示例:threejs.org/examples/?q…

image.png

import * as THREE from "three";

// console.log(THREE);

// 目标:了解three.js最基本的内容

// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
75, // 相机的角度
window.innerWidth / window.innerHeight, // 相机的宽高比
0.1, // 相机的近截面
1000 // 相机的远截面
);

// 设置相机位置
camera.position.set(0, 0, 10); // 相机位置 (X轴坐标, Y轴坐标, Z轴坐标)
scene.add(camera); // 相机添加到场景中

⭕️ 物体 cube

import * as THREE from "three";

// console.log(THREE);

// 目标:了解three.js最基本的内容

// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
75, // 相机的角度
window.innerWidth / window.innerHeight, // 相机的宽高比
0.1, // 相机的近截面
1000 // 相机的远截面
);

// 设置相机位置
camera.position.set(0, 0, 10); // 相机位置 (X轴坐标, Y轴坐标, Z轴坐标)
scene.add(camera); // 相机添加到场景中

// 添加物体
// 创建几何体
const cubeGeometry = new THREE.BoxGeometry(1, 1, 1); // 创建立方体的几何体 (长, 宽, 高)
const cubeMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 }); // MeshBasicMaterial 基础网格材质 ({ color: 0xffff00 }) 颜色
// 根据几何体和材质创建物体
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial); // 创建立方体的物体 (几何体, 材质)
// 将几何体添加到场景中
scene.add(cube); // 物体添加到场景中

⭕️ 渲染 render

// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight); // 设置渲染的尺寸大小 (窗口宽度, 窗口高度)
// console.log(renderer);
// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement); // 将webgl渲染的canvas内容添加到body

// 使用渲染器,通过相机将场景渲染进来
renderer.render(scene, camera); // 使用渲染器,通过相机将场景渲染进来 (场景, 相机)

⭕️ 效果

效果是平面的:

image.png

到这里,还不是3d的,如果要加3d,要加一下控制器

🔴 3d

⭕️ 控制器

添加轨道。像卫星☄围绕地球🌏,环绕查看的视角:

// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

// 目标:使用控制器查看3d物体

// // 使用渲染器,通过相机将场景渲染进来
// renderer.render(scene, camera);

// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement); // 创建轨道控制器 (相机, 渲染器dom元素)
controls.enableDamping = true; // 设置控制器阻尼,让控制器更有真实效果。

function render() {
renderer.render(scene, camera); // 浏览器每渲染一帧,就重新渲染一次
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render); // 浏览器渲染下一帧的时候就会执行render函数,执行完会再次调用render函数,形成循环,每秒60次
}

render();

⭕️ 加坐标轴辅助器

// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5); // 坐标轴(size轴的大小)
scene.add(axesHelper);

1.gif

⭕️ 设置物体移动

// 设置相机位置
camera.position.set(0, 0, 10);
scene.add(camera);

1.gif

cube.position.x = 3;
// 往返移动
function render() {
cube.position.x += 0.01;
if (cube.position.x > 5) {
cube.position.x = 0;
}
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

render();

⭕️ 缩放

cube.scale.set(3, 2, 1); // xyz, x3倍, y2倍

单独设置

cube.position.x = 3;

⭕️ 旋转

cube.rotation.set(Math.PI / 4, 0, 0, "XZY"); // x轴旋转45度

单独设置

cube.rotation.x = Math.PI / 4;

⭕️ requestAnimationFrame

function render(time) {
// console.log(time);
// cube.position.x += 0.01;
// cube.rotation.x += 0.01;

// time 是一个不断递增的数字,代表当前的时间
let t = (time / 1000) % 5; // 为什么求余数,物体移动的距离就是t,物体移动的距离是0-5,所以求余数
cube.position.x = t * 1; // 0-5秒,物体移动0-5距离

// if (cube.position.x > 5) {
// cube.position.x = 0;
// }
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

render();

⭕️ Clock 跟踪事件处理动画

// 设置时钟
const clock = new THREE.Clock();
function render() {
// 获取时钟运行的总时长
let time = clock.getElapsedTime();
console.log("时钟运行总时长:", time);
// let deltaTime = clock.getDelta();
// console.log("两次获取时间的间隔时间:", deltaTime);
let t = time % 5;
cube.position.x = t * 1;

renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

render();

大概是8毫秒一次渲染时间.

⭕️ 不用算 用 Gsap动画库

gsap.com/

// 导入动画库
import gsap from "gsap";

// 设置动画
var animate1 = gsap.to(cube.position, {
x: 5,
duration: 5,
ease: "power1.inOut", // 动画属性
// 设置重复的次数,无限次循环-1
repeat: -1,
// 往返运动
yoyo: true,
// delay,延迟2秒运动
delay: 2,
onComplete: () => {
console.log("动画完成");
},
onStart: () => {
console.log("动画开始");
},
});
gsap.to(cube.rotation, { x: 2 * Math.PI, duration: 5, ease: "power1.inOut" });

// 双击停止和恢复运动
window.addEventListener("dblclick", () => {
// console.log(animate1);
if (animate1.isActive()) {
// 暂停
animate1.pause();
} else {
// 恢复
animate1.resume();
}
});

function render() {
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

render();

⭕️ 根据尺寸变化 实现自适应

// 监听画面变化,更新渲染画面
window.addEventListener("resize", () => {
// console.log("画面变化了");
// 更新摄像头
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();

// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置渲染器的像素比
renderer.setPixelRatio(window.devicePixelRatio);
});

⭕️ 用js控制画布 全屏 和 退出全屏

window.addEventListener("dblclick", () => {
const fullScreenElement = document.fullscreenElement;
if (!fullScreenElement) {
// 双击控制屏幕进入全屏,退出全屏
// 让画布对象全屏
renderer.domElement.requestFullscreen();
} else {
// 退出全屏,使用document对象
document.exitFullscreen();
}
// console.log(fullScreenElement);
});

⭕️ 应用 图形 用户界面 更改变量

// 导入dat.gui
import * as dat from "dat.gui";

const gui = new dat.GUI();
gui
.add(cube.position, "x")
.min(0)
.max(5)
.step(0.01)
.name("移动x轴")
.onChange((value) => {
console.log("值被修改:", value);
})
.onFinishChange((value) => {
console.log("完全停下来:", value);
});
//   修改物体的颜色
const params = {
color: "#ffff00",
fn: () => {
// 让立方体运动起来
gsap.to(cube.position, { x: 5, duration: 2, yoyo: true, repeat: -1 });
},
};
gui.addColor(params, "color").onChange((value) => {
console.log("值被修改:", value);
cube.material.color.set(value);
});
// 设置选项框
gui.add(cube, "visible").name("是否显示");

var folder = gui.addFolder("设置立方体");
folder.add(cube.material, "wireframe");
// 设置按钮点击触发某个事件
folder.add(params, "fn").name("立方体运动");

image.png


🔴 结语

前端的世界,
不该只有VueReact——
还有WebGPU里等待你征服的星辰大海。"

“当WebGL成为下一代前端的基础设施,愿你是最早站在三维坐标系里的那个人。”


作者:jack_po
来源:juejin.cn/post/7517209356855164978
收起阅读 »

AI时代,为什么我放弃Vue全家桶,选择了Next.js + Supabase

web
AI时代,为什么我放弃Vue全家桶,选择了Next.js + Supabase12天的项目,我现在2天就能搞定。这不是吹牛,而是我真实的开发体验。从Vue全家桶切换到Next.js + Supabase后,我的开发效率提升了10倍。作为一个前端工程师出身的AI...
继续阅读 »

AI时代,为什么我放弃Vue全家桶,选择了Next.js + Supabase

12天的项目,我现在2天就能搞定。

这不是吹牛,而是我真实的开发体验。从Vue全家桶切换到Next.js + Supabase后,我的开发效率提升了10倍。

作为一个前端工程师出身的AI创业者,我曾经是Vue全家桶的忠实用户。Vue 3 + Vite + Pinia + Element Plus,这套组合陪我度过了实习和早期项目开发。

但是,当我开始用AI工具写代码后,一切都变了。

痛点:Vue在AI时代的尴尬

最初我还是习惯性地选择Vue,毕竟熟悉。但很快就发现了问题:

AI对Vue的理解让人抓狂

真实场景:我让Claude帮我写一个用户状态管理,结果:

// AI生成的Vue代码 - 问题一堆
const user = reactive({ name: '' }) // 应该用ref?
const userName = ref(user.name) // 重复定义?
// Pinia store在哪?为什么不用?

我花了2小时调试,最后发现AI把Vue 2和Vue 3的语法混在一起了。

更要命的是选择困难症

想要个用户登录系统?Vue全家桶给你10种方案:

  • 后端:Express? Koa? Fastify?
  • 数据库:MySQL? PostgreSQL? MongoDB?
  • 认证:JWT? Session? OAuth?
  • 部署:Docker? PM2? Nginx配置?

每个选择都需要调研、对比、踩坑。还没开始写业务逻辑,就已经消耗了大量时间和精力。

转折:AI改变了我的选择标准

直到我接触到Claude Code和各种MCP工具,才意识到问题的根本:

在AI时代,技术栈的选择标准彻底变了。

以前我们选技术栈考虑的是:

  • 学习曲线
  • 生态丰富度
  • 团队熟悉度
  • 性能表现

现在必须加上一个新维度:AI友好度

什么是AI友好度?就是AI工具对这个技术栈的理解程度和支持质量。我发现:

  1. React/Next.js的训练数据更多 - GitHub上React项目是Vue的好几倍
  2. TypeScript + React的组合AI最熟悉 - 代码生成质量明显更高
  3. Next.js生态更适合全栈开发 - 一套框架解决前后端问题

更重要的是,我需要的不是完美的架构,而是快速验证想法的能力

这已经成为行业共识

不只是我这么想,看看数据:

  • GitHub上新项目,70%选择Next.js而非Vue
  • Vercel部署量:Next.js项目数是Vue的5倍
  • Stack Overflow 2024调查:Next.js超越Vue成为最受欢迎框架

连大厂也在转向:

  • Netflix:从自建架构迁移到Next.js
  • TikTok:新项目默认选择Next.js + Supabase
  • 字节内部:推荐小团队使用"无后端"方案快速原型

我的答案:Next.js + Supabase

最终我选择了这个组合:

前端:Next.js 14 + TypeScript + Tailwind CSS

  • AI对React生态理解最深
  • TypeScript让AI生成的代码更可靠
  • Tailwind CSS的原子化样式AI也很熟悉

后端:Supabase (PostgreSQL + 自动API)

  • 零后端配置,专注业务逻辑
  • 自动生成TypeScript类型定义
  • 内置认证、存储、实时功能

开发工具:Claude Code + AI编程助手

  • 代码自动生成和优化
  • 实时错误检测和修复建议
  • 智能代码补全和重构

最重要的是,你不需要从零搭建。

Next.js官方提供了with-supabase模板,一行命令就能开始:

npx create-next-app -e with-supabase my-app

这个模板已经配置好了:

  • ✅ Supabase客户端初始化
  • ✅ TypeScript类型定义
  • ✅ 用户认证系统
  • ✅ 中间件和路由保护
  • ✅ 服务端和客户端数据获取

关键是,AI对这个模板非常熟悉。

我让Claude帮我修改代码时,它知道:

  • createClient()怎么用
  • 认证状态如何获取
  • RLS规则怎么写
  • Server Components和Client Components的区别

代码对比见真章

同样是获取用户信息,看看差异:

// Vue + AI:经常出错
const { data } = await $fetch('/api/user') // $fetch是啥?
const user = reactive(data) // 为什么不用ref?
// 类型怎么定义?接口在哪?

// Next.js + Supabase + AI:一气呵成
const { data: user } = await supabase.auth.getUser()
// 自动类型推导,无需手动定义

这就是AI友好度的体现 - 不是技术本身有多先进,而是AI对它的理解有多深。

实战验证:效率的巨大提升

用这套技术栈开发项目,我的体感是:

开发速度提升10倍

以前用Vue全家桶做一个带用户系统的项目:

  • Day 1-2: 搭建后端API
  • Day 3-4: 配置数据库和认证
  • Day 5-7: 前端业务逻辑
  • Day 8-10: 联调和部署

现在用Next.js + Supabase:

  • Day 1 上午: npx create-next-app -e with-supabase,完成核心功能
  • Day 1: 下午,部署到Vercel
  • 完成

真实案例对比

让我用具体数字说话。最近我帮朋友做了一个AI工具的落地页项目:

技术需求:

  • 用户注册登录
  • 支付集成
  • 使用记录追踪
  • 响应式设计
  • SEO优化

Vue全家桶时代(预估):

  • 后端API开发:5天
  • 前端开发:4天
  • 认证系统:2天
  • 部署配置:1天
  • 总计:12天

Next.js + Supabase实际用时:

  • 模板初始化:30分钟
  • Supabase数据库设计:半天
  • 前端页面开发:1天
  • 支付集成(Stripe):半天
  • 部署(Vercel一键):10分钟
  • 总计:2天

image.png

效率提升关键因素:

  1. 零后端配置 - Supabase自动生成API
  2. AI代码生成 - Claude对Next.js生态理解深度
  3. 模板起步 - with-supabase省去了80%的基础配置
  4. 类型安全 - TypeScript让AI生成的代码更可靠

踩坑经验:诚实的权衡

当然,这套技术栈也不是万能的:

性能权衡:

  • Supabase在复杂查询时确实比自建API慢一些
  • 但对于MVP和中小项目(1万用户以下)完全够用

成本考虑:

  • 免费额度很慷慨:500MB数据库,50MB存储
  • 付费后按使用量计费,比维护服务器便宜

迁移风险:

  • 高度依赖Supabase生态
  • 但PostgreSQL标准,迁移难度不大

最重要的认知转变:

在AI时代,完美的架构不如快速的验证。

一些实用建议

如果你也在纠结技术栈选择,我的建议是:

1. 评估你的真实需求

选择Next.js + Supabase,如果你:

  • 团队规模3人以下
  • 需要快速验证想法
  • 预期用户量1万以下
  • 重视开发效率 > 极致性能

坚持传统技术栈,如果你:

  • 团队有专门的后端工程师
  • 对性能有极致要求
  • 已有大量历史代码
  • 数据安全要求极高

2. 立即行动,不要完美主义

# 今天就可以开始
npx create-next-app -e with-supabase my-ai-project
cd my-ai-project
npm run dev

花30分钟体验一下,比看100篇教程有用。

3. 拥抱AI编程助手

推荐使用Claude Code或其他AI编程工具,它们对Next.js + Supabase生态理解最深,能提供:

  • 精准的代码生成
  • 智能的错误修复
  • 最佳实践建议

结语:

从Vue全家桶到Next.js + Supabase,这不只是技术栈的切换,更是开发思维的升级。

在AI时代,最重要的不是掌握最新的框架,而是选择AI最懂的工具,让AI成为你的开发伙伴

技术为想法服务,想法为使命服务。选择让你更快实现想法的技术栈,就是最好的选择。


作者:芋圆ai
来源:juejin.cn/post/7538087794968952884
收起阅读 »

国产 Canvas 引擎!神器!

web
写过原生 Canvas 的朋友都懂:API 低级到怀疑人生——画个带圆角的矩形就要 20 行起步,缩放、拖拽、层级管理全靠自己实现。节点一多直接 PPT——超过 5000 个元素,页面卡成幻灯片。于是...
继续阅读 »

写过原生 Canvas 的朋友都懂:

  • API 低级到怀疑人生——画个带圆角的矩形就要 20 行起步,缩放拖拽层级管理全靠自己实现。
  • 节点一多直接 PPT——超过 5000 个元素,页面卡成幻灯片。

于是,我们一边掉头发,一边默念:“有没有一款库,写得少、跑得快、文档还是中文?”

什么是 LeaferJS

LeaferJS 是一款高性能、模块化、开源的 Canvas 2D 渲染引擎,专注于提供高性能、可交互、可缩放矢量图形的绘图能力。

它采用场景图(Scene Graph)架构,支持响应式布局、事件系统、动画、滤镜、遮罩、路径、图像、文本、滚动视图、缩放、拖拽、节点嵌套、分组等丰富功能。

LeaferJS 的核心优势

高效绘图

  • 生成图片、短视频、印刷品:支持导出 PNGJPEGPDFSVG 等多种格式,满足印刷级品质需求。
  • Flex 自动布局、中心绘制:内置 Flex 布局,支持中心绘制,后端可批量生成图片。
  • 渐变、内外阴影、裁剪、遮罩、擦除:支持线性渐变、径向渐变、内外阴影、裁剪、遮罩、擦除等高级绘图功能。

UI 交互

  • 开发小游戏、互动应用、组态软件:支持跨平台交互事件、手势,CSS 交互状态、光标。
  • 动画、状态、过渡、精灵:支持帧动画、状态过渡、精灵图、箭头、连线等交互元素。

图形编辑

  • 开发裁剪、图片、图形编辑器:提供丰富的图形编辑功能,高可定制。
  • 标尺、视窗控制、滚动条:支持标尺、视窗控制、滚动条等编辑器必备功能。

性能巨兽

LeaferJS 最最核心的一点就是性能至上,和目前市面上比较流行的 Canvas 库对比:

如何快速上手

# 1. 创建项目
npm create leafer@latest my-canvas
cd my-canvas
npm i
npm run dev
// 2. 写代码(index.ts)
import { Leafer, Rect } from 'leafer-ui'

const leafer = new Leafer({ view: window })

const rect = new Rect({
x: 100,
y: 100,
width: 200,
height: 200,
fill: '#32cd79',
cornerRadius: [50, 80, 0, 80],
draggable: true
})

leafer.add(rect)

浏览器访问 http://localhost:5173——圆角矩形已可拖拽

想加 1 万个?直接 for 循环,依旧丝滑

使用场景

  • 在线设计工具——海报、名片、电商 banner,导出 4K PDF 秒级完成
  • 数据可视化——物联网组态、拓扑图、百万点折线图,放大 20 倍依旧清晰
  • 在线白板——教学、会议、脑图,无限画布 + 实时协作
  • 无代码搭建——拖拽生成页面,JSON 一键转 Canvas 应用
  • 小游戏/动画——跑酷、拼图、营销活动,帧率稳 60,包体小一半

优秀案例展示

基于 Leafer + vue3 实现画板

fly-cut 在线视频剪辑工具

基于 LeaferJS 的贪吃蛇小游戏

一款美观且功能强大的在线设计工具,具备海报设计和图片编辑功能,基于 leafer.js 的开源版

更多优秀案例,可以移步官网

让“国产”成为“首选”

LeaferJS 不是又一个“国产替代”,而是直接把 Canvas 的性能与体验拉到 Next Level
它让开发者第一次敢在提案里写:“前端百万节点实时交互,没问题。”
如果你受够了原生 Canvas 的笨拙,也踩腻了国外库的深坑,不妨试试 LeaferJS——


作者:前端开发爱好者
来源:juejin.cn/post/7566104702569742355
收起阅读 »

颜色网站为啥都收费?自己做个要花多少钱?

web
你是小阿巴,一位没有对象的程序员。这天深夜,你打开了某个颜色网站,准备鉴赏一些精彩的视频教程。结果一个大大的付费弹窗阻挡了你!你心想:可恶,为啥颜色网站都要收费啊?作为一名程序员,你怎能甘心?于是你决定自己做一个,不就是上传视频、播放视频嘛?这时,经常给大家分...
继续阅读 »

你是小阿巴,一位没有对象的程序员。

这天深夜,你打开了某个颜色网站,准备鉴赏一些精彩的视频教程。

结果一个大大的付费弹窗阻挡了你!

你心想:可恶,为啥颜色网站都要收费啊?

作为一名程序员,你怎能甘心?

于是你决定自己做一个,不就是上传视频、播放视频嘛?

这时,经常给大家分享 AI 和编程知识的 鱼皮 突然从你身后冒了出来:天真!你知道自己做一个要花多少钱么?

你吓了一跳:我又没做过这种网站,怎么知道要花多少?

难道,你做过?

鱼皮一本正经:哼,当然…… 没有。

不过我做过可以看视频的、技术栈完全类似的 编程学习网站,所以很清楚这类网站的成本。

你来了兴趣:哦?愿闻其详。

鱼皮笑了笑:那我就以 编程导航 项目为例,从网站开发、上线到运营的完整流程,给你算算做一个视频网站到底要花多少钱。还能教你怎么省钱哦~

你点了个赞,并递上了两个硬币:好啊,快说快说!


鱼皮特别感谢朋友们的支持,你们的鼓励是我持续创作的动力 🌹!

⚠️ 友情声明:以下成本是基于个人经验 + 专业云服务商价格的估算(不考虑折扣),仅供参考。

⭐️ 推荐观看本文对应视频版:bilibili.com/video/BV1nJ…

服务器

想让别人访问你的网站,首先你要有一台服务器。

你点点头:我知道,代码文件都要放到服务器上运行,用户通过浏览器访问网站,其实是在向服务器请求网页文件和数据。

那服务器怎么选呢?

鱼皮:服务器的配置要看你的网站规模。刚开始做个小型视频网站,可以用入门配置的轻量应用服务器 (比如 2 核 CPU、2G 内存、4M 带宽) ,一年几百块就够了。

等后续用户多了,服务器带宽跟不上了再升级。比如 4 核 CPU、16G 内存、14M 带宽,一年差不多几千块。

你:几百块?比我想的便宜啊。

鱼皮:没错,国内云服务现在竞争很激烈、动不动就搞优惠。

但是要注意,如果你想做 “那种网站”,就要考虑用海外服务器了(好处是不用备案)。

咳咳,我们不谈这个……

数据库

有了服务器,还得有数据库,用来存储网站的用户信息、视频信息、评论点赞这些数据。

你:这个简单,数据库不就是 MySQL、PostgreSQL 这些嘛,装在服务器上不就行了?

鱼皮:是可以的,但我更建议使用云数据库服务,比如阿里云 RDS 或者腾讯云的云数据库。

你:为啥?不是要多花钱吗?

鱼皮:因为云数据库更稳定,而且自带备份、容灾、监控这些功能,你自己搞的话,还要费时费力安装维护,万一数据丢了可就麻烦了。

你:确实,那得多少钱?

鱼皮:入门级的云数据库(比如 2 核 4G 内存、100GB 硬盘)包年大概 2000 元左右。后面用户多了、数据量大了,就要升级配置(比如 4 核 16G),那一年就要 1 万多了。不过那个时候你已经赚麻了……

Redis

鱼皮:对了,我还建议你加个 Redis 缓存。

你挠了挠头:Redis?之前看过你的 讲解视频。这个是必须的吗?

鱼皮:刚开始可以没有,但如果你想让网站数据能更快加载,强烈建议用。

你想啊,视频网站用户一进来都要查看视频列表、热门推荐这些,如果用 Redis 把热点数据缓存起来,响应速度能快好几倍,还能帮数据库分摊查询压力。

你:确实,网站更快用户更爽,也更愿意付费。那 Redis 要多少钱?

鱼皮:Redis 比数据库便宜一些。入门级的 Redis 服务一年大概 1000 元左右。

你松了口气:也还行吧,看来做个视频网站也花不了多少钱啊!

对象存储

鱼皮:别急,接下来才是重点!

我问问你,视频文件保存在哪儿?

你不假思索:当然是存在服务器的硬盘上!

鱼皮哈哈大笑:别开玩笑了,一个高清视频动不动就几百 MB 甚至几个 G,你那点儿服务器硬盘能存几个视频?

而且服务器带宽有限,如果同时有很多用户看视频,服务器根本撑不住!

你:那咋办啊!

鱼皮:更好的做法是用 对象存储,比如阿里云 OSS、腾讯云 COS。

对象存储是专门用来存海量文件的云服务,它容量几乎无限、可以弹性扩展,而且访问速度快、稳定性高,很适合存储图片和音视频这些大文件。

你:贵吗?

鱼皮:存储本身不贵,100GB 一年也就几十块钱。但 真正贵的是流量费用

用户每看一次视频,都要从对象存储下载数据,这就产生了流量。

如果一个 1 GB 的视频被完整播放 1000 次,那就是 1000 GB 的流量,大概 500 块钱。

你看那些视频网站,每天光 1 个视频可能就有 10 万人看过,价格可想而知。

你惊讶地说不出话来:阿巴阿巴……

视频转码

鱼皮接着说:这还不够!对于视频网站,你还要做 视频转码。因为用户上传的视频格式、分辨率、编码方式都不一样,你需要把它们统一转成适合网页播放的格式,还要生成不同清晰度的版本让用户选择(标清、高清、超清)。

你:啊,那不是要多存好几个不同清晰度的视频文件?

鱼皮:没错,而且转码本身也是要钱的!

一般按照清晰度和视频分钟数计费。如果你上传 1000 个小时的高清视频,光转码费就得几千块!

CDN 加速

你急了:怎么做个视频网站处处都要花钱啊!有没有便宜点的办法?

鱼皮笑道:可以用 CDN。

你:CDN是啥?听着就高级!

鱼皮:CDN 叫内容分发网络,简单说就是把你的视频缓存到全国各地的服务器节点上。用户看视频的时候,从最近的节点拿数据,不仅速度更快,而且流量费比对象存储便宜不少。

你眼睛一亮:这么好?那不是必用 CDN!

鱼皮:没错,一般建议对象存储配合 CDN 使用。

而且视频网站 一定要做好流量防刷和安全防护

现在有的平台自带了流量防盗刷功能:

此外,建议手动添加更多流量安全配置。

1)设置访问频率限制,防止短时间被盗刷大量流量

2)还要配置 CDN 的流量告警,超过阈值及时得到通知

3)还要启用 referer 防盗链,防止别人盗用你的视频链接,用你的流量做网站捞钱。

如果不做这些,可能分分钟给你刷破产了!

你:这我知道,之前看过很多你破产和被攻击的视频!

鱼皮:我 ***!

视频点播

你:为了给用户看个视频,我要先用对象存储保存文件、再通过云服务转码视频、再通过 CDN 给用户加速访问,感觉很麻烦啊!

鱼皮神秘一笑:嘿嘿,其实还有更简单的方案 —— 视频点播服务,这是快速实现视频网站的核心。

只需要通过官方提供的 SDK 代码包和示例代码,就能快速完成视频上传、转码、多清晰度切换、加密保护等功能。

此外,还提供了 CDN 内容加速和各端的视频播放器。

你双眼放光:这么厉害,如果我自己从零开发这些功能,至少得好几个月啊!

鱼皮:没错,视频点播服务相当于帮你做了整合,能大幅提高开发效率。

但是它的费用也包含了存储费、转码费和流量费,价格跟前面提到的方案不相上下。

你叹了口气:唉,主要还是流量费太贵了啊……

网站上线还要准备啥?

鱼皮:讲完了开发视频网站需要的技术,接下来说说网站上线还需要的其他东西。

你:啊?还有啥?

鱼皮:首先,你得有个 域名 给用户访问吧?总不能让人家记你的 IP 地址吧?

不过别担心,普通域名一年也就几十块钱(比如我的 codefather.cn 才 38 / 年)。

当然,如果是稀缺的好域名就比较贵了,几百几千万的都有!

你:别说了,俺随便买个便宜的就行……

鱼皮:买了域名还得配 SSL 证书,因为现在做网站都得用 HTTPS 加密传输,不然浏览器会提示 “不安全”,用户看了就跑了。

刚开始可以直接用 Let's Encrypt 提供的免费证书,但只有 3 个月有效期,到期要手动续期,比较麻烦。

想省心的话可以买付费证书,便宜的一年几百块。

你:了解,那我就先用免费的,看来上线也花不了几个钱。

鱼皮:哎,可不能这么说,网站正式上线运营后,花钱的地方可多着呢!尤其是安全防护。

安全防护

做视频网站要面对两大安全威胁。第一个是 内容安全,你总不能让用户随便上传违规视频吧?万一上传了不该传的内容,网站直接就被封了。

你紧张起来:对啊,我人工审核也看不过来啊…… 怎么办?

鱼皮:可以用内容审核服务。视频审核包含画面和声音两部分,比文字审核更贵,审核 1000 小时视频,大概几千块。

你:还有第二个威胁呢?

鱼皮:第二个是最最最难应对的 网络攻击。做视频网站,尤其是有付费内容的,特别容易被攻击。DDoS 流量攻击想把你冲垮、SQL 注入想偷你数据、XSS 攻击想搞你用户、爬虫想盗你视频……

你:这么坏的吗?那我咋防啊!

鱼皮:常用的是 Web 应用防火墙(WAF)和 DDoS 防护服务。Web 防火墙能防 SQL 注入、XSS 攻击这些应用层攻击,而 DDoS 防护能抵御大规模流量冲击。

但是这些商业级服务都挺贵的,可能一年就是几万几十万……

你惊呼:我为了防止被攻击,还要搭这么多钱?!

鱼皮笑了:好消息是,有些云服务商会提供一点点免费的 DDoS 基础防护,还有相对便宜的轻量版 DDoS 防护包。

我的建议是,刚开始就先用免费的,加上代码里做好防 SQL 注入、XSS 这些安全措施,其实够用了。等网站真做起来、有收入了,再花钱买商业级的防护服务就好。

你点了点头:是呀,如果没收入,被攻击就被攻击吧,哼!

鱼皮微笑道:你这心态也不错哈哈。除了刚才说的这些,随着你网站的成熟,还可能会用到很多第三方服务,比如短信验证码、邮件推送、 等等,这些也都是成本。

总成本

讲到这里,你应该已经了解了视频网站的整个技术架构和成本。

最后再总结一下,如果一个人做个小型的视频网站,一年到底要花多少钱?

你看着这个表,倒吸一口凉气:视频网站的成本真高啊……

鱼皮:没错,这还只是保守估计。如果你的网站真火了,每天几万人看视频,一年光流量费就得有几十万吧。

而且刚才说的都只是网站本身的成本,如果你一个人做累了,要组个团队开发呢?

按照一线城市的成本算算,前端开发 + 后端开发 + 测试工程师 + 运维工程师,再加上五险一金,差不多每月要接近 10 万了。

你瞪大眼睛:那一年就是一百万?

鱼皮:没错,人力成本才是最贵的。

你:好了你别说了,我不做了,我不做了!我现在终于理解为什么那些网站都要收费了……

鱼皮:不过说实话,虽然成本不低,但那些网站收费真的太贵了,其实成本远没那么高,更多的是利用人性赚取暴利!

所以比起花钱看那些乱七八糟的网站,把钱和时间投资在学习上,才是最有价值的。

你点了点头:这次一定!再看一期你的教程,我就睡觉啦~

更多

💻 编程学习交流:编程导航 📃 简历快速制作:老鱼简历 ✏️ 面试刷题神器:面试鸭


作者:程序员鱼皮
来源:juejin.cn/post/7572961448537882651
收起阅读 »

就因为package.json里少了个^号,我们公司赔了客户十万块

web
写这篇文章的时候,我刚通宵处理完一个P0级(最高级别)的线上事故,天刚亮,烟灰缸是满的🚬。事故的原因,说出来你可能不信,不是什么服务器宕机,也不是什么黑客攻击,就因为我们package.json里的一个依赖项,少写了一个小小的^(脱字符号) 。这个小...
继续阅读 »

image.png

写这篇文章的时候,我刚通宵处理完一个P0级(最高级别)的线上事故,天刚亮,烟灰缸是满的🚬。

事故的原因,说出来你可能不信,不是什么服务器宕机,也不是什么黑客攻击,就因为我们package.json里的一个依赖项,少写了一个小小的^(脱字符号) 。

这个小小的失误,导致我们给客户A的数据计算模块,在一次平平无奇的依赖更新后,全线崩溃。而我们,直到客户的业务方打电话来投诉,才发现问题。

等我们回滚、修复、安抚客户,已经是7个小时后。按照合同的SLA(服务等级协议),我们公司需要为这次长时间的服务中断,赔付客户十万块

老板在事故复盘会上,倒没说什么重话,只是默默地把合同复印件放在了桌上。

满脸写着无奈.gif

今天,我不想抱怨什么,只想把这个价值 十万块 的教训,原原本本地分享出来,希望能给所有前端、乃至所有工程师,敲响一个警钟。


事故是怎么发生的?

我们先来复盘一下事故的现场。

我们有一个给客户A定制的Node.js数据处理服务。它依赖了我们内部的另一个核心工具库@internal/core

在项目的package.json里,依赖是这么写的:

{
"name": "customer-a-service",
"dependencies": {
"@internal/core": "1.3.5",
"express": "^4.18.2",
"lodash": "^4.17.21"
// ...
}
}

注意看,expresslodash前面,都有一个^符号,而我们的@internal/core没有

这个^代表什么?它告诉npm/pnpm/yarn:“我希望安装1.x.x版本里,大于等于1.3.5最新版本。”

而没有^,代表什么?它代表:我安装1.3.5这一个版本,锁死它,不许变。

问题就出在这里。

上周,core库的同事,修复了一个严重的性能Bug,发布了1.3.6版本,并且在公司群里通知了所有人。

我们组里负责这个项目的同学,看到了通知,也很负责任。他想:core库升级了,我也得跟着升。

于是,他看了看package.json,发现项目里用的是1.3.5。他以为,只要他去core库的仓库,把1.3.5这个tag删掉,然后把1.3.6的tag打上去,CI/CD在下次部署时,重新pnpm install,就会自动拉取到最新的代码。

他错了!


最致命的锁死版本

因为我们的依赖写的是"1.3.5",而不是"^1.3.5",所以我们的pnpm-lock.yaml文件里,把这个依赖的解析规则,彻底锁死在了1.3.5

无论core库的同事怎么发布1.3.61.3.7,甚至2.0.0...

只要我们不去手动修改package.json,我们的CI/CD流水线,在执行pnpm install时,永远、永远,都只会去寻找那个被写死的1.3.5版本。

然后,灾难发生了。

core库的同事,在发布1.3.6后,为了保持仓库整洁,就把1.3.5那个旧的git tag删掉了

然后,客户A的项目,某天下午需要做一个常规的文案更新,触发了部署流水线。

流水线执行到pnpm install时,pnpm拿着lock文件,忠实地去找@internal/core@1.3.5这个包...

“Error: Package '1.3.5' not found.”

流水线崩溃了。一个本该5分钟完成的文案更新,导致了整个服务7个小时的宕机😖


十万块换来的血泪教训

事故复盘会上,我们所有人都沉默了。我们复盘的,不是谁的锅,而是我们对依赖管理这个最基础的认知,出了多大的偏差。

^ (Caret) 和 ~ (Tilde) 不是选填,而是必填

  • ^ (脱字符) :^1.3.5 意味着 1.x.x (x >= 5)。这是最推荐的写法。它允许我们自动享受到所有 非破坏性 的小版本和补丁更新(比如1.3.61.4.0),这也是npm install默认的行为。
  • ~ (波浪号) :~1.3.5 意味着 1.3.x (x >= 5)。它只允许补丁更新,不允许小版本更新。太保守了,一般不推荐。
  • (啥也不写) :1.3.5 意味着锁死。除非你是reactvue这种需要和生态强绑定的宿主,否则,永远不要在你的业务项目里这么干!

我们团队现在强制规定,所有package.json里的依赖,必须、必须、必须使用^

关于lock文件

我们以前对lock文件(pnpm-lock.yamlpackage-lock.json)的理解太浅了,以为它只是个缓存。

现在我才明白,package.json里的^1.3.5,只是在定义一个规则。

而pnpm-lock.yaml,才是基于这个规则,去计算出的最终答案。

lock文件,才是保证你同事、你电脑、CI服务器,能安装一模一样的依赖树的唯一路径。它必须被提交到Git

依赖更新,是一个主动的行为,不是被动的

我们以前太天真了,以为只要依赖发了新版,我们就该自动用上。

这次事故,让我们明白:依赖更新,是一个严肃的、需要主动管理和测试的行为。

我们现在的流程是:

image.png

  1. 使用pnpm update --interactivepnpm会列出所有可以安全更新的包(基于^规则)。
  2. 本地测试:在本地跑一遍完整的测试用例,确保没问题。
  3. 提交PR:把更新后的pnpm-lock.yaml文件,作为一个单独的PR提交,并写清楚更新了哪些核心依赖。
  4. CI/CD验证:让CI/CD在staging环境,用这个新的lock文件,跑一遍完整的E2E(端到端)测试。

这十万块,是技术Leader(我)的失职,也是我们整个团队,为基础不牢付出的最昂贵的一笔学费。

一个小小的^,背后是整个npm生态的依赖管理的核心。

分享出来,不是为了博眼球,是真的希望大家能回去检查一下自己的package.json

看看你的依赖前面,那个小小的^,它还在吗?😠


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

微信小游戏包体限制4M,一个字体就11.24M,怎么玩?

web
引言哈喽大家好,很多时候,我们的游戏项目为了美观和保证风格的统一,都会用到外部字体库。但是,外部字体库通常是完整的字库,体积非常的大,例如完整的simkai字体库就达到了11.24MB。要知道,现在的微信小游戏限制主包的大小不能超过4M,即使你把字体放在分包,...
继续阅读 »

引言

哈喽大家好,很多时候,我们的游戏项目为了美观和保证风格的统一,都会用到外部字体库。

但是,外部字体库通常是完整的字库,体积非常的大,例如完整的simkai字体库就达到了11.24MB

要知道,现在的微信小游戏限制主包的大小不能超过4M,即使你把字体放在分包,占去近50%的代码包大小,想想也不太合适。

因此,我们如果想要能够顺利地在游戏中用上漂亮的字体,那我们得想办法将字库瘦下来。

言归正传,本期将带小伙伴们一起来看下,如何将我们想用的字库从11.24M瘦到不到1M 。

本文源工程可在文末获取,小伙伴们自行前往。

精简字库原理

据了解,一个完整的字库估计有3~4万个汉字,但实际上我们游戏项目需要用到的可能只占10%~20%,甚至更少,像其中的一些汉字囧、烎、嫑、勥、忈、巭、怹、颪、氼、兲‌,别说用,笔者连读都不会读。(会读的小伙伴请打在评论区,我给你点赞)

游戏项目中,用到文字的地方通常包含下面几个:

  • 1.游戏配置(*.json),一般配置里面的中文最多。 
  • 2.预制体(*.prefab),有些静态的文字通常就在预制体的Label里。 
  • 3.场景(*.scene、*.fire)同上。 
  • 4.代码(*.ts),写死在代码里的。 

因此,要瘦身字体,按照以下2个步骤即可:

  • 1.通过工具将上述地方的文字提取出来。
  • 2.通过工具从字库中的保留我们提取到的文字,其余的删除。

精简字库实例

1.提取中文字

要提取中文字,我们只需要按照上面的原理,遍历我们的游戏项目中的游戏配置预制体场景代码进行匹配即可。

其中遍历文件,笔者使用的是glob

匹配中文字的正则表达式是/[\u4e00-\u9fff]/g

2.精简字库

这里我们使用百度出品的字体子集化工具Fontmin。可以直接通过npm install fontmin进行安装。

工具的使用也非常简单,通过传入原字体保留的字符字体输出目录,最后通过fontmin.run这个API生成即可。

3.效果演示

通过node font-minifier.js --project=C:\Users\Administrator\Desktop\demo --source=C:\Users\Administrator\Desktop\simkai.ttf传入工程目录和原字体路径即可。

执行结果可以看到扫描的所有文件。

提取到的所有中文字。

生成的文件及其大小。

精简后的字体大小为802K

更进一步

除去我们遍历出来的游戏设定的中文字,其实还有一部分中文字我们是不确定的,那就是用户自定义的内容,例如名字和聊天文字。

想要处理这一部分文字,我们只能通过预设,猜到用户会自定义的内容,从而预设保留,可以通过网络上分享的常用内容来完成。

此外工具可以集成到插件或者打包系统里面去,这样后续就不用考虑相关问题,自动生成所需字库即可。

结语

通过上述方法,可以将字库大幅度精简到能够使用的状态,但是也会有一定的瑕疵。

不知道小伙伴们有没有更完美的办法呢?

本文源工程可通过私信发送 fontminifier 获取。

我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐专栏:

知识付费专栏

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

100个Cocos实例

8年主程手把手打造Cocos独立游戏开发框架

和8年游戏主程一起学习设计模式

从零开始开发贪吃蛇小游戏到上线系列


作者:亿元程序员
来源:juejin.cn/post/7572087181608353842
收起阅读 »

白嫖党的快乐,我在安卓手机上搭了服务器+内网穿透,再也不用买服务器了

web
起因因为去年买的腾讯云服务器到期了,我一看续租的话要459元,作为白嫖党这是万万不能接受的!于是我就想:能否搞一个简单的服务器,能跑基本的项目就好了。然后我就在掘金上看到了一篇文章:如何将旧的Android手机改造为家用服务器最后结合全网,搜到了如下两种方案:...
继续阅读 »

起因

因为去年买的腾讯云服务器到期了,我一看续租的话要459元,作为白嫖党这是万万不能接受的!

image.png

于是我就想:能否搞一个简单的服务器,能跑基本的项目就好了。

然后我就在掘金上看到了一篇文章:如何将旧的Android手机改造为家用服务器

最后结合全网,搜到了如下两种方案:

  • KSWEB
  • Ternux

综合对比下,我选择用Termux试试。

前提

想要跑起来Termux,首先你要有一个安卓手机。

于是我就开始逛咸鱼,最后选了一款IQOO Neo5型号的,12+256G(有些小毛病),花了315元,这个内存跑服务应该是够了的。

开始安装

安装Termux

1)通过github或者APKFab应用商店安装Termux。

2)更新和安装基础软件包

pkg update && pkg upgrade -y 
pkg install wget curl nano -y

安装nodejs

由于本人是前端开发,所有用的服务都是nodejs写的,所以只安装node相关的东西

pkg install nodejs
// 安装PHP或其他的同理,示例如下:
pkg install php

安装完成后,打印一下看看是否成功了

image.png

安装其他

由于我的项目也有nodejs服务端,所以还需要安装以下:

  • mysql 数据库
  • ssh 远程连接
  • redis 缓存
  • cpolar 内网穿透(本地部署的项目,外网无法访问,用它来给外网访问)
  • nginx 高性能代理

具体的教程就不展示细节了,推荐几个教程地址,仅供参考:

设置完ssh后,就可以在电脑上的Xshell连接登录了,注意了:

默认端口号为8022,不是22

默认端口号为8022,不是22

默认端口号为8022,不是22

连接成功后是这样的

image.png

其中有一个用户身份验证,用户名输入whoami查看

image.png

放入几个项目

我用Xftp传入几个vue项目和nodejs项目

image.png

启动服务

项目放入后,启动的服务应该是只能局域网访问的,几个vue项目都是打包的dist文件,所以需要配置nginx代理,关键配置如下,有多少个项目,就来多少个server就行,慢慢配吧。

因为个人项目不多,也不找其他高大上的管理工具了

 # 加解密 配置
server {
listen 5290;
server_name 192.168.3.155;

location / {
root /data/data/com.termux/files/home/vue/rui-utils-crypt/dist;
index index.html index.htm;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /data/data/com.termux/files/usr/share/nginx/html;
}
}

# 个人博客 配置
server {
listen 5173;
server_name 192.168.3.155;

location / {
root /data/data/com.termux/files/home/vue/vite-press-blog/dist;
index index.html index.htm;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /data/data/com.termux/files/usr/share/nginx/html;
}
}

# 若依 - nodejs-vue
server {
listen 5000;
server_name 192.168.3.155;

location / {
# dist为静态资源文件夹,dist内有index.html,
root /data/data/com.termux/files/home/vue/ruoyi-vue/dist;
index index.html index.htm;
# 解决单页面应用中history模式不能刷新的bug
try_files $uri $uri/ /index.html;
# try_files $uri $uri/ =404;
}

# 服务器代理实现跨域
location /prod-api/ {
proxy_pass http://192.168.3.155:7002/; # 将/api/开头的url转向该域名
#如果报错则使用这一行代替上一行 proxy_pass http://localhost:8000; 将/api/开头的url转向该域名
rewrite "^/prod-api/(.*)$" /$1 break ; # 最终url中去掉/api前缀
}

# 静态资源优化 - 添加 ^~ 前缀提高匹配优先级
location ^~ /assets/ {
root /data/data/com.termux/files/home/vue/ruoyi-vue/dist;
expires 12h;
error_log /dev/null;
access_log /dev/null;
}


#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
error_page 404 /404.html;

#REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效
# include /www/server/panel/vhost/rewrite/60.204.201.111.conf;
#REWRITE-END

#禁止访问的文件或目录
location ~ ^/(.user.ini|.htaccess|.git|.env|.svn|.project|LICENSE|README.md)
{
return 404;
}

#一键申请SSL证书验证目录相关设置
location ~ .well-known{
allow all;
}

#禁止在证书验证目录放入敏感文件
if ( $uri ~ "^/.well-known/.*.(php|jsp|py|js|css|lua|ts|go|zip|tar.gz|rar|7z|sql|bak)$" ) {
return 403;
}

location ~ .*.(gif|jpg|jpeg|png|bmp|swf|ico)$
{
expires 30d;
error_log /dev/null;
access_log /dev/null;
}

location ~ .*.(js|css)?$
{
expires 12h;
error_log /dev/null;
access_log /dev/null;
}
access_log /data/data/com.termux/files/usr/var/log/nginx/access.log;
error_log /data/data/com.termux/files/usr/var/log/nginx/error.log;
}

本地访问

本人手机的ip为:192.168.3.155,端口用nginx的配置项即可,在Termux中输入nginx来启动,这样就可以本地访问了。

不知道ip的可以输入ifconfig来查看

image.png 先访问一下192.168.3.155:5173

image.png 可以看到,在电脑上已经能访问手机上启动的服务了。

但是我们需要外网也能访问,这就需要前面说的内网穿透了。

内网穿透

本项目的内网穿透选的是cpolar,教程见上文链接。

因为我装了sv工具,所以我输入sv up cpolar就启动了cpolar,启动后在电脑上输入手机IP + 9200端口号即可登录cpolar后台

image.png

配置本地的端口号: image.png 配置完后,就可以在在线隧道列表菜单看到已配置的了

image.png 然后我们就可以在公网地址访问了,复制列表的地址,打开:

image.png

至此,我们已经可以在外网访问手机上、部署的vue打包项目了。但是此时没有后端服务,接下来我们同时部署后端的服务。

部署nodejs后端

先运行以下命令,启动redis和数据库

redis-server --daemonize yes 

mysqld_safe &

然后根据nodejs的启动方法启动即可,一般为node 入口文件.js

我的启动成功如下

image.png

对应的前端地址如下:6331dea4.r5.cpolar.top/index

这个前后端是我用nodejs改写的java版若依管理后台,源码地址:gitee.com/ruirui-stud… 我以前的文章也有介绍的

最后,如果需要启动多个nodejs项目,可以用pm2管理

注意:本文的地址可能无法访问,因为手机我有别的用处,不一定随时开着


作者:前端没钱
来源:juejin.cn/post/7537893826595700788

收起阅读 »

Electron 淘汰!新的跨端框架来了!性能飙升!

web
用过 Electron 的兄弟都懂,好处是“会前端就能写桌面”,坏处嘛,三座大山压得喘不过气:体积巨婴空项目打出来 100 M+,每次更新又得 80 M,用户宽带不要钱?内存老虎开个“Hello World”常驻&nbs...
继续阅读 »

用过 Electron 的兄弟都懂,好处是“会前端就能写桌面”,坏处嘛,三座大山压得喘不过气:

  • 体积巨婴
    空项目打出来 100 M+,每次更新又得 80 M,用户宽带不要钱?
  • 内存老虎
    开个“Hello World”常驻 300 M,再开几个窗口,直接 1 G 起步,Mac 用户看着彩虹转圈怀疑人生。
  • 启动慢动作
    双击图标 → 图标跳 → 白屏 3 秒 → 终于看见界面,节奏堪比 56 K 猫拨号。

老板还天天催:“两周给我 MVP!”—— 抱着 Electron,就像抱着一只会写代码的胖熊猫,可爱但跑不动。

主角登场:GPUI

Rust 圈最近冒出一个“狠角色”——GPUI

GPUI,是 Zed 编辑器团队推出的 Rust UI 框架,以 GPU 加速和高效渲染模式悄然崛起。

它不卖广告,纯开源,一句话介绍:直接拿显卡画界面,浏览器啥的全部踢出去

  • 底层用 wgpuMetal / Vulkan / DX12 想调谁就调谁;
  • 上层给前端味道的 DSL,写起来像 React,跑起来却是纯原生;
  • 安装包 12 M,内存 50 M,启动 0.4 秒,表格滑到 60 帧不带喘。

说人话:把 Electron 的“胖身子”抽真空,留下一身腱子肉。

亮点:为什么值得换坑?

场景Electron 现实GPUI 现实
安装包100 M+ 是常态12 M 起步,单文件都能带走
空载内存一开 300 M,再开几个窗口直奔 1 G50 M 晃悠,再多窗口也淡定
启动速度白屏 2~3 秒肉眼可见 0.4 秒
大数据表格十万行就卡成 PPT百万行照样 60 fps,滑到飞起
主题切换重载 or 重启一行代码,热切换,深色浅色瞬间完成

外加 60+现成组件:按钮、表格、树、日历、Markdown、穿梭框……皮肤直接照搬 Web 圈最火的 shadcn/ui,设计师不用改稿,开发直接复制粘贴。

五分钟上手:从零到 Hello Window

① 先装 Rust

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

一路回车即可,30 秒搞定。

② 新建工程

cargo new my-app && cd my-app

③ 把依赖写进 Cargo.toml

[dependencies]
gpui = "0.2"
gpui-component = "0.1"

④ src/main.rs 写几行

use gpui::*;

fn main() {
App::new().run(|cx: &mut AppContext| {
Window::new("win", cx)
.title("我的第一个 GPUI 窗口")
.build(cx, |cx| {
Label::new("Hello,GPUI!", cx)
})
.unwrap();
});
}

⑤ 跑!

cargo run

三秒后窗口蹦出来,Hello 世界完成。没有黑框,没有白屏,体验跟原生记事本一样丝滑。

写代码像 React,跑起来像 C++

组件化 + 事件回调,前端同学一看就懂:

Button::new("点我下单", cx)
.style(ButtonStyle::Primary)
.on_click(|_, cx| {
println!("订单已发送");
notify_user("成交!", cx);
})

背后是 Rust 的零成本抽象,编译完就是机器码,没有浏览器,没有虚拟机,没有 GC 卡顿,性能直接拉满。

老网页也别扔,一键塞进来

历史项目里还有 React 报表?开 webview 特性就行:

gpui-component = { version = "0.1", features = ["webview"] }

窗口里留一块“浏览器区域”,把旧地址挂进去,零改动复用,妈妈再也不用担心重写代码。

Electron 依然是老大哥,但“胖身子”在 2025 年真的有点跟不上节奏。
新项目、新团队、新想法,不妨给 GPUI 一个机会——试过之后,你可能再也回不去了。


作者:前端开发爱好者
来源:juejin.cn/post/7568192652287787062
收起阅读 »

从「[1,2,3].map (parseInt)」踩坑,吃透 JS 数组 map 与包装类核心逻辑

web
你有没有遇到过这样的诡异场景:明明以为 [1,2,3].map(parseInt) 会返回 [1,2,3],实际运行却得到 [1, NaN, NaN]?这行看似简单的代码,藏着 JS 数组方法、函数传参、包装类等多个核心...
继续阅读 »

你有没有遇到过这样的诡异场景:明明以为 [1,2,3].map(parseInt) 会返回 [1,2,3],实际运行却得到 [1, NaN, NaN]

这行看似简单的代码,藏着 JS 数组方法、函数传参、包装类等多个核心知识点的关联。今天我们就从这个经典坑点切入,一步步拆解 map 方法的底层逻辑,顺带理清 NaN、包装类、字符串处理等容易混淆的知识点。

一、先踩坑:为什么 [1,2,3].map (parseInt) 不是 [1,2,3]?

要搞懂这个问题,我们得先明确两个关键:map 方法的参数传递规则,以及 parseInt 的工作原理。

1. map 方法的真正传参逻辑

MDN 明确说明:map 方法会遍历原数组,对每个元素调用回调函数,并将三个参数依次传入回调:

  • 当前遍历的元素(item)
  • 元素的索引(index)
  • 原数组本身(arr)

也就是说,[1,2,3].map(parseInt) 等价于:

javascript

运行

[1,2,3].map((item, index, arr) => {
return parseInt(item, index, arr);
});

这里的关键是:map 会强制传递三个参数给回调,而不是只传我们以为的 “元素本身”。

2. parseInt 的参数陷阱

parseInt 的语法是 parseInt(string, radix),它只接收两个有效参数:

  • 第一个参数:要转换的字符串(非字符串会先转字符串)
  • 第二个参数:基数(进制,范围 2-36,0 或省略则默认 10 进制)
  • 第三个参数会被直接忽略

结合 map 的传参,我们逐次分析遍历过程:

  • 第一次遍历:item=1,index=0 → parseInt (1, 0)。基数 0 等价于 10 进制,结果 1。
  • 第二次遍历:item=2,index=1 → parseInt (2, 1)。基数 1 无效(必须≥2),结果 NaN。
  • 第三次遍历:item=3,index=2 → parseInt (3, 2)。2 进制中只有 0 和 1,3 无效,结果 NaN。

这就是为什么最终结果是 [1, NaN, NaN] —— 不是 map 或 parseInt 本身有问题,而是参数传递的 “错位匹配” 导致的。

3. 正确写法是什么?

如果想通过 map 实现 “数组元素转数字”,正确做法是明确回调函数的参数,只给 parseInt 传需要的值:

javascript

运行

// 方法1:手动控制参数
[1,2,3].map(item => parseInt(item));
// 方法2:使用Number简化
[1,2,3].map(Number);
// 两种写法结果都是 [1,2,3]

二、吃透 map 方法:不止是 “遍历 + 返回”

解决了坑点,我们再深入理解 map 的核心特性 —— 它是 ES6 数组新增的纯函数(不改变原数组,返回新数组),这也是它和 forEach 的核心区别。

1. map 的核心规则(必记)

  • 不改变原数组:无论回调函数做什么操作,原数组的元素都不会被修改。
  • 返回新数组:新数组长度与原数组一致,每个元素是回调函数的返回值。
  • 跳过空元素:map 会忽略数组中的 empty 空位(forEach 也会),但不会忽略 undefined 和 null。

示例验证:

javascript

运行

const arr = [1, 2, 3, , 5]; // 第4位是empty
const newArr = arr.map(item => item * 2);
console.log(newArr); // [2,4,6, ,10](保留空位)
console.log(arr); // [1,2,3, ,5](原数组不变)

2. 实用场景:从基础到进阶

map 的核心价值是 “数据转换”,日常开发中高频使用:

  • 基础转换:数组元素的统一处理(如平方、转格式)

    javascript

    运行

    const arr = [1,2,3,4,5,6];
    const squares = arr.map(item => item * item); // [1,4,9,16,25,36]
  • 复杂转换:提取对象数组的特定属性

    javascript

    运行

    const users = [{name: '张三'}, {name: '李四'}, {name: '王五'}];
    const names = users.map(user => user.name); // ['张三', '李四', '王五']

三、延伸知识点:NaN 与包装类,JS 的 “隐式魔法”

在分析 map 和 parseInt 的过程中,我们遇到了 NaN,而 JS 中字符串能调用length方法的特性,又涉及到 “包装类” 的隐式逻辑 —— 这两个知识点是理解 JS “面向对象特性” 的关键。

1. NaN:不是数字的 “数字”

NaN 的全称是 “Not a Number”,但 typeof 检测结果是number,这是它的第一个反直觉点。

什么时候会出现 NaN?

  • 无效的数学运算:0/0Math.sqrt(-1)"abc"-10
  • 类型转换失败:parseInt("hello")Number(undefined)
  • 注意:Infinity(6/0)和-Infinity(-6/0)不是 NaN,它们是有效的 “无穷大” 数值。

如何正确判断 NaN?

因为NaN === NaN的结果是false(NaN 不等于任何值,包括它自己),所以必须用专门的方法:

javascript

运行

// 推荐:ES6新增的Number.isNaN(只检测NaN)
Number.isNaN(parseInt("hello")); // true

// 不推荐:window.isNaN(会先转换类型,误判情况多)
isNaN("hello"); // true("hello"转数字是NaN)
isNaN(123); // false

2. 包装类:JS 让 “简单类型” 拥有对象能力

JS 是完全面向对象的语言,但我们平时写的"hello".length520.1314.toFixed(2),看起来是 “简单数据类型调用对象方法”—— 这背后就是包装类的隐式操作。

包装类的工作流程

当你对字符串、数字、布尔值这些简单类型调用方法时,JS 会自动做三件事:

  1. 用对应的构造函数(String、Number、Boolean)创建一个临时对象(包装对象);
  2. 通过这个临时对象调用方法(如 length、toFixed);
  3. 方法调用结束后,立即销毁临时对象,释放内存。

用代码还原这个过程:

javascript

运行

let str = "hello";
console.log(str.length); // 实际执行过程:
const tempObj = new String(str); // 1. 创建包装对象
console.log(tempObj.length); // 2. 调用方法
tempObj = null; // 3. 销毁对象

关键区别:简单类型 vs 包装对象

javascript

运行

let str1 = "hello"; // 简单类型(string)
let str2 = new String("hello"); // 包装对象(object)

console.log(typeof str1); // "string"
console.log(typeof str2); // "object"
console.log(str1.length === str2.length); // true(方法调用结果一致)

四、拓展:字符串处理的常见误区(length、slice、substring)

包装类让字符串拥有了对象方法,但字符串处理中也有不少容易踩坑的点,结合笔记中的案例总结:

1. length 的 “坑”:emoji 占几个字符?

JS 的字符串用 UTF-16 编码存储,常规字符(如 a、中)占 1 个 16 位单位,emoji 和生僻字占 2 个及以上。length 属性统计的是 “16 位单位个数”,而非视觉上的 “字符个数”:

javascript

运行

console.log('a'.length); // 1(常规字符)
console.log('中'.length); // 1(常规字符)
console.log("𝄞".length); // 2(emoji占2个单位)
console.log("👋".length); // 2(emoji占2个单位)

2. slice vs substring:负数索引与起始位置

两者都用于截取字符串,但处理负数索引和起始位置的逻辑不同:

  • 负数索引:slice 支持从后往前截取(-1 是最后一位),substring 会把负数转为 0;
  • 起始位置:slice 严格按 “前参为起点,后参为终点”,substring 会自动交换大小值(小的当起点)。

示例对比:

javascript

运行

const str = "hello";
console.log(str.slice(-3, -1)); // "ll"(从后数第3位到第1位)
console.log(str.substring(-3, -1)); // ""(负数转00>0无结果)
console.log(str.slice(3, 1)); // ""3>1无结果)
console.log(str.substring(3, 1)); // "el"(自动交换为1-3

五、总结:从坑点到体系化知识

回到最初的[1,2,3].map(parseInt),这个坑的本质是 “对 API 参数传递规则的理解不透彻”。但顺着这个坑,我们串联起了:

  • map 方法的参数传递、纯函数特性;
  • parseInt 的基数规则、类型转换逻辑;
  • NaN 的特性与判断方法;
  • 包装类的隐式工作流程;
  • 字符串处理的常见误区。

JS 的很多 “诡异现象”,本质都是对底层逻辑的不了解。掌握这些核心知识点后,再遇到类似问题时,就能快速定位根源 —— 这也是我们从 “踩坑” 到 “成长” 的关键。

最后留一个小思考:["10","20","30"].map(parseI


作者:生椰丝绒拿铁
来源:juejin.cn/post/7569898158835777577
收起阅读 »

中石化将开源组件二次封装申请专利,这个操作你怎么看?

web
开源项目推荐:uView Pro 正式开源!70+ Vue3 组件重构完成,uni-app 组件库新选择一. 前言昨天看到了一篇关于 “中石化申请基于 vue 的文件上传组件二次封装方法和装置专利,解决文件上传功能开发繁琐问题” 的新闻。今天特地在专利系统检索...
继续阅读 »

开源项目推荐:

一. 前言

昨天看到了一篇关于 “中石化申请基于 vue 的文件上传组件二次封装方法和装置专利,解决文件上传功能开发繁琐问题” 的新闻。

今天特地在专利系统检索了一下,竟然是真的,令人不禁大跌眼镜!用的全是开源组件,最后还把它们变成了自己的专利!这波操作属实厉害啊!

image.png

image.png

难道以后要用这种方式上传文件,要交专利费了?哈哈....

说来好笑,有掘友指出有单词拼写错误,我又查看一下专利文件,竟然还真有拼写错误...

image.png

二. 了解一下

本专利是通过在 vue 页面中自定义 el-upload 组件和 el-progress 组件的使用,解决了文件上传功能开发步骤繁琐和第三方组件无法满足业务需求的问题,实现了简化开发、提高效率和灵活性的效果。

1. 摘要

本发明提供了一种基于 vue 的文件上传组件的二次封装方法和装置,解决了针对于文件上传功能的开发步骤繁琐,复杂,且上传功能的第三方组件无法完全满足业务需求的问题。

该基于 vue 的文件上传组件的二次封装方法包括:在 vue 页面中创建 el‑upload 组件和 el‑progress 组件;

基于所述 el‑upload 组件获取目标上传文件的大小,并判断所述目标上传文件的大小是否符合上传标准;若是,上传所述目标上传文件,并基于所述 el‑progress 组件获取上传进度;上传完成后,对上传的所述目标上传文件进行预处理并存储;

对存储的所述目标上传文件进行封装,并获得 vue 组件。

技术流程图:

Snipaste_2025-06-12_17-07-28.png

二次封装装置模块:

image.png

2. 解决的技术问题

现有技术中文件上传功能的开发步骤繁琐复杂,第三方组件无法完全满足业务需求。

3. 采用的技术手段

通过在 vue 页面中引入 el-upload 组件和 el-progress 组件,自定义上传方法和进度条绑定,获取文件大小和上传进度,进行预处理和存储,并将其封装成可重复使用的 vue 组件。

4. 产生的技术功效

简化了文件上传功能的开发步骤,节省了开发时间和效率,避免了代码沉冗,降低了后期维护成本,并提高了文件上传功能的灵活性。

三. 实现一下

这种简单的上传文件+上传进度显示不是最基本的业务封装吗?相信这是每个前端开发工程师必备的基础技能。

所以我们趁热打铁,我们也来实现一下。

我也先来个流程图,梳理一下文件上传过程:

image.png

1. el-upload + el-progress 组合

  • el-upload 负责文件选择、上传。
  • el-progress 负责展示上传进度。

2. 文件大小校验

  • 使用 el-upload 的 before-upload 钩子,判断文件大小是否符合标准。

3. 上传进度获取

  • 使用 el-upload 的 on-progress 钩子,实时更新进度条。

4. 上传完成后的预处理与存储

  • 上传完成后,触发自定义钩子(如 beforeStoreonStore),进行预处理和存储。

5. 封装为 Vue 组件

  • 通过 props、emits、插槽等方式,暴露灵活的接口,便于业务页面集成。

都懒得自己动手,让 Cursor 来实现一下。Cursor 还是一如既往的强大,基本上一次询问就能成功!我表示 Cursor 在手,天下我有!

113.gif

UploaderWrapper 自定义组件:



<template>
<div class="file-uploader">
<ElUpload
:action="action"
:before-upload="beforeUpload"
:on-progress="handleProgress"
:on-success="handleSuccess"
:on-error="handleError"
:limit="limit"
:on-exceed="handleExceed"
:show-file-list="showFileList"
:multiple="multiple"
:accept="accept"
v-model:file-list="fileList"
:on-remove="handleRemove"
>

<template #trigger>
<ElButton type="primary"> 选择文件上传 ElButton>
template>

<template #tip>
<div class="el-upload__tip">
支持的文件类型: {{ accept }},单个文件不超过 {{ maxSize }}MB
div>
template>
ElUpload>

<ElProgress
v-if="isUploading"
:percentage="uploadPercent"
:status="uploadPercent === 100 ? 'success' : ''"
class="mt-4"
/>

div>
template>

<style scoped>
.file-uploader {
width: 100%;
}
.el-upload__tip {
font-size: 12px;
color: #606266;
margin-top: 8px;
}
style>

使用方式:



<template>
<ElCard class="mb-5 w-80">
<template #header> 文件上传演示 template>
<UploaderWrapper
action="/api/upload"
:max-size="5"
:before-store="beforeStore"
:on-store="onStore"
/>

ElCard>
template>

效果如下所示:

119.gif

声明:“代码仅供演示,不要使用,以免有专利侵权风险,慎重!”

四. 思考一下

从开发者的角度来看,这个专利事件是否能给我们带来了一些值得思考影响和启示:

  1. 技术创新的边界问题
  • 使用开源组件进行二次封装是否应该被授予专利?
  • 是否对开源社区的发展可能产生负面影响?
  1. 对日常开发的影响
  • 如果专利获得授权,其他公司使用类似的文件上传组件封装方案是否可能面临法律风险?
  • 开发者是否需要寻找替代方案或支付专利费用?
  1. 对开源社区的影响
  • 可能打击开发者对开源项目的贡献热情,自己辛苦开源项目为别人做了嫁衣?
  • 是否会影响开源组件的使用和二次开发
  • 可能导致更多公司效仿,将开源组件的二次封装申请专利,因为毕竟专利对公司的招投标挺大的

五. 后记

“中石化作为传统能源企业,都能积极拥抱前端技术,还将内部技术方案申请专利,体现了他们对知识产权的重视?”

那我们是不是要在技术创新和知识产权保护之间找到平衡点,既要保护创新,又不能阻碍技术的发展。

而作为开发者的我们呢?这么简单的封装都能申请专利成功的话,那么...,大家有什么想法,是不是现在强的可怕?哈哈...

专利来源于国家知识产权局

申请公布号:CN120122937A


作者:前端梦工厂
来源:juejin.cn/post/7514858513442078754
收起阅读 »

Canvas 高性能K线图的架构方案

web
前言证券行业,最难的前端组件,也就是k线图了。指标还可以添加、功能还可以扩展, 但思路要清晰。作为一个从证券行业毕业的前端从业者,我想分享下自己的项目经验。1、H5 K线图,支持无限左右滑动、样式可随意定制;2、纯canvas制作,不借助任何第三方图表库;3、...
继续阅读 »

前言

证券行业,最难的前端组件,也就是k线图了。
指标还可以添加、功能还可以扩展, 但思路要清晰。
作为一个从证券行业毕业的前端从业者,
我想分享下自己的项目经验。

1、H5 K线图,支持无限左右滑动、样式可随意定制;
2、纯canvas制作,不借助任何第三方图表库;
3、阅读本文,需要有 canvas 基础知识;

滑动K线图组件    Github Page 预览地址

股票详情页源码    Github Page 预览地址

注意:以上的 demo 还有一些 bug, 没时间修复, 预览地址是直接在 github 上部署的, 所以最好通过 vpn科学上网,否则可能访问不了,然后再在移动端打开页面。 另外, 上面的股票详情页, 还没有做自适应,等我有时间再改。

一、先看最终的效果

1、GIF动图如下

gif222.gif gif.gif

2、支持样式自定义

用可以屏幕取色器,获取东方财富的配色 codeinword.com/eyedropper

图一、图二,是参考东方财富黑白皮肤的配色, 图三是参考腾讯自选股的配色。

q1.png q2.png q3.png

二、canvas 注意事项

1、整数坐标,会导致模糊

canvas 在画线段, 通常会出现以下代码:

cxt.moveTo(x1, y1);
cxt.lineTo(x2, y2);
cxt.lineWidth = 1;
cxt.stroke();

假设上面的两个点是(1,10)和(5,10),那么画出来的实际上是一条横线,
理论上横线的粗度是1px,且该横线被 y=10 切成两半,
上半部分粗度是 0.5px, 下半部分粗度也是 0.5px,
这样横线的整体粗度才会是 1px。

但是 canvas 不是这样处理的, canvas 默认线条会与整数对齐,
也就是横线的上部分不会是 y=9.5px, 而是 y=9px;
横线的下半部分也不是 y=10.5px, 而是 y=11px;
从而横线的粗度看起来不是1px,而是2px。

并且由于粗度被拉伸,颜色也会被淡化,那怎么解决这个问题呢?

处理方式也很简单, 通过 cxt.translate(0.5, 0.5) 将坐标往下移动 0.5 个像素,
然后接下来的所有点, 都保证是整数即可, 这样就能保证不会被拉伸。

典型的代码如下:

cxt.translate(0.5, 0.5);
cxt.moveTo(Math.floor(x1), Math.floor(y1));
cxt.lineTo(Math.floor(x2), Math.floor(y2));
cxt.lineWidth = 1;
cxt.stroke();

在我的代码中, 也体现了类似的处理。

2、如何处理高像素比带来的模糊

设备像素比越高,理论上应该越清晰,因为原来用一个小方块来渲染1px, 现在用2个(dpr=2的情况)小方块来渲染,应该更清晰才对,但是canvas不是这样的。

例如,通过js获取父容器 div 的宽度是 width, 这时候如果设置 canvas.width = width,在设备像素比为2的时候, canvas 画出来的宽度为css对应宽度的一半, 如果强制通过 css 将 canvas 宽度设置为 width, 则 canvas 会被拉长一倍, 导致出现锯齿模糊。

注意了吗?上面所说的 canvas.width=width 与 css 设置的 #canvas { width: width } 起到的效果是不一样的。不要随便通过 css 去设置 canvas 的宽高, 容易被拉伸变形或者导致模糊。

通用的处理方式是:

//初始化高清Canvas
function initHDCanvas() {
const rect = hdCanvas.getBoundingClientRect();

//设置Canvas内部尺寸为显示尺寸乘以设备像素比
const dpr = window.devicePixelRatio || 1;
hdCanvas.width = rect.width * dpr;
hdCanvas.height = rect.height * dpr;

//设置Canvas显示尺寸保持不变
hdCanvas.style.width = rect.width + 'px';
hdCanvas.style.height = rect.height + 'px';

//获取上下文并缩放
const ctx = hdCanvas.getContext('2d');
ctx.scale(dpr, dpr);

//接下来,你可以自由发挥
}

三、样式配置

为了方便样式自定义, 我独立出一个默认的配置对象 defaultKlineConfig, 参数的含义如下图所示,其实下图这个风格的标注, 是通过 excalidraw 这个软件画的, 也是 canvas 做的开源软件, 可见 canvas 在前端可视化领域的重要性, 这个扯远了,打住。

333.png

如上图, 整个canvas 画板, 分成 5 部分,
每一部分的高度, 都可以设置,
其中主图和副图的高度,是通过比例来计算的:
mainChartHeight = restHeight * mainChartHeightPercent
其中,restHeigh 是画板总高度 height 减去其他几部分计算的, 如下:
restHeight = height - lineMessageHeight - tradeMessageHeight - xLabelHeight

十字交叉线的颜色, X轴 与 Y轴 的 tooltip 背景色、字体大小的参数如下:

7777.png

四、均线计算

从上面的图可以看出, 需要画 5日均线、10日均线、20日均线, 成交量快线(10日)、成交量慢线(20日) 但是, 接口没有给出当日的均线值, 需要自己计算。

5日均线 = (过去4个成交日的收盘价总和 + 今日收盘价)/ 5

10日均线 = (过去9个成交日的收盘价总和 + 今日收盘价)/ 10

20日均线 = (过去19个成交日的收盘价总和 + 今日收盘价)/ 20

成交量快线 = (过去9日成交量 + 今日成交量)/ 10

成交量慢线 = (过去19日成交量 + 今日成交量)/ 20

所以, 当获取 lmt(一屏的蜡烛图个数)个数据时, 为了计算均线, 需要至少将前 19 个(我的代码写20)数据都获取到。当前一个均线已经获取到, 下一个均线就不需要再累加20个值再得平均数, 可以省一点计算:

今日20日均线值 = (昨日均线值 * 20 - 前面第20个的收盘价 + 今日收盘价)/ 20;

五、分层渲染

为了减少重绘,提高性能,可以将K线图做分层渲染。那分几层合适?我认为是三层。

  1. 第一层, 不动层
  2. 第二层,变动层
  3. 第三层,交互层

不动层

首先, 网格是固定的, 也就是说,当页面拖拽、或者长按出现十字交叉的时候,底部的网格线是不变的,如果每次拖拽,都需要重绘网格,那这个其实是没有必要的开销,可以将网格放在最底层(例如 z-index:0),一次性绘制后,就不要再重绘。

变动层

由于拖拽的时候,蜡烛柱体,均线,Y轴刻度, X轴刻度, 都需要重绘, 这一块是无法改变的事实, 所以, 变动层放在中间层(例如 z-index:1),也是最繁忙的一层,并且该层不响应触摸事件。

交互层

最后, 交互层由于要捕捉用户的触摸行为, 所以,这一层要在最上层(例如 z-index:2)。

交互层监听触摸事件:当页面快速滑动, 则响应拖拽事件, 即K线图的时间线会左右滑动;当用户长按之后才滑动, 则出现十字交叉浮层。

交互层的好处是, 当响应十字交叉浮层时, 只需要绘制横线、竖线、对应X轴和Y轴的值,而不需要重绘蜡烛柱体和均线, 可以减少重绘,最大程度减少渲染压力。

六、基础几何绘制

网格线

首先计算出主图的高度 this.mainChartHeight, 将主图从上到下等分为4部分,再在左右两边画出竖线,形成主图的网格,副图是成交量图, 只需画一个矩形边框即可,用 strokeRect 即可画出。

//画出网格线
private drawGridLine() {
//获取配置参数
const { gridColor, lineMessageHeight, xLabelHeight, width, height } = this.config;
//画出K线图的5条横线
const split = this.mainChartHeight / 4;
this.canvasCxt.beginPath();
this.canvasCxt.lineWidth = 0.5;
this.canvasCxt.strokeStyle = gridColor;
for (let i = 0; i <= 4; i++) {
const splitHeight = Math.floor(split * i) + lineMessageHeight!;
this.drawLine(0, splitHeight, width, splitHeight);
}
//画出K线图的2条竖线
this.drawLine(0, lineMessageHeight!, 0, lineMessageHeight! + this.mainChartHeight);
this.drawLine(width, lineMessageHeight!, width, lineMessageHeight! + this.mainChartHeight);
//画出成交量的矩形
this.canvasCxt.strokeRect(
0,
height - xLabelHeight! - this.subChartHeight,
width,
this.subChartHeight,
);
}

//画出两个点形成的直线
private drawLine(x1: number, y1: number, x2: number, y2: number) {
this.canvasCxt.moveTo(x1, y1);
this.canvasCxt.lineTo(x2, y2);
this.canvasCxt.stroke();
}

画各类均线

1、首先计算出一屏的股价最大值 max , 股价最小值 min ,成交量最大值 maxAmount。

2、当某一个点的均线为 value, 根据最大值、最小值、索引index, 计算出坐标点(x, y), 画均线的时候, 第一个点用 moveTo(x0, y0),其他点用 lineTo(xn yn), 最后 stroke 连起来即可。

3、当然, 每一条线设置下颜色, 即 stokeStyle。

  //画出各类均线
private drawLines(max: number, min: number, maxAmount: number) {
//将宽度分成n个小区间, 一个小区间画一个蜡烛, 每个区间的宽度是 splitW
const splitW = this.config.width / this.config.lmt!;
//画一下5日均线
this.canvasCxt.beginPath();
this.canvasCxt.strokeStyle = this.config.ma5Color;
this.canvasCxt.lineWidth = 1;
let isTheFirstItem = true;
for (
let i = this.startIndex;
i < this.arrayList.length && i < this.startIndex + this.config.lmt!;
i++
) {
const index = i - this.startIndex;
let value = this.arrayList[i].ju5;
if (value === 0) {
continue;
}
const x = Math.floor(index * splitW + 0.5 * splitW);
const y = Math.floor(
((max - value) / (max - min)) * this.mainChartHeight + this.config.lineMessageHeight!,
);
if (isTheFirstItem) {
this.canvasCxt.moveTo(x, y);
isTheFirstItem = false;
} else {
this.canvasCxt.lineTo(x, y);
}
}
this.canvasCxt.stroke();
}

画出蜡烛柱体

666.png 999.png

当收盘价大于等于开盘价, 选用上面左边红色的样式; 当收盘价小于开盘价, 选用上面右边绿色的样式。

以红色蜡烛为例, 最高点 A(x0, y0),最低点是 B(x1, y1),
高度 height、宽度 width 都是相对于坐标轴的,
红色矩形左上角的顶点是 D(x, y)。

为了画出红色蜡烛, 先后顺序别搞混:

  1. AB 这条竖线,通过 moveTo,lineTo 画出来;
  2. 定义一个矩形 cxt.rect(x, y, width, heigth);
  3. 通过 fill 将矩形内部填充为白色, 这时候白色矩形会覆盖掉红色竖线的一部分;
  4. 再通过 stroke 描出矩形的红色边框

按照上面这个顺序, 竖线会被覆盖掉一部分,同时,矩形内部的白色填充不会挤压矩形的红色边框, 如果先 stroke 再 fill,容易出现白色填充覆盖红色边框,矩形可能会变模糊,或者使得红色变淡,极其不友好,所以按照我上面的顺序,可以减少不必要的麻烦。

画出文字

canvas 画出文字, 典型的代码如下

 this.canvasCxt.beginPath();
this.canvasCxt.font = `${this.config.yLabelFontSize}px "Segoe UI", Arial, sans-serif`;
this.canvasCxt.textBaseline = 'alphabetic';
this.canvasCxt.fillStyle = this.config.yLabelColor;

注意textBaseline 默认对齐方式是 alphabetic, 但 middle 往往更好用, 能实现垂直居中,但我发现垂直居中也不是很居中,所以会特意加减1、2个像素;

当然还有个textAlign, 能实现水平对齐方式, 左右对齐都可以, 例如上图最左、最右的时间标签。

七、交互设计

根据上面的GIF动图, 可以知道, 本次做的移动端 K 线图, 最重要的两个交互是:

  1. 快速拖拽,K线图随时间轴左右滑动
  2. 长按滑动,出现十字交叉tooltip

上面的交互,其实是比较复杂的,所以需要先设计一个简单的数据结构:

  1. 首先页面存放一个列表 arrayList
  2. 保存一个数字标识 startIndex,表示当前屏幕从 startIndex 开始画蜡烛图

当用户往右快速拖拽时, startIndex 根据用户拖拽的距离, 适当变小; 当用户往左快速拖拽时, startIndex 根据用户拖拽的距离, 适当变大。

那 arrayList 到底多长合适, 因为股票可能有十几年的数据, 甚至上百年的数据, 我不能一次性拉取这个股票的所有数据吧?

当然,站在软件性能、消耗等角度,也不应该一次性拉取所有的数据, 我的答案是 arraylist 最多保存5屏的数据量,用户看到的屏幕, 应该是接近中间这一屏,也就是第3屏的数据, 左右两边各保存2屏数据,这样,用户拖拽的时候,可以比较流畅,而不是每次拖拽都要等拉取数据再去渲染。

那什么时候拉取新的数据呢? 用户触摸完后,当startIndex左边的数据少于2屏,开始拉取左边的数据; 用户触摸完后,当startIndex右边的数据少于2屏,开始拉取右边的数据;

那如果用户一直往右拖拽, 是不是就一直往左边添加数据, 这个 arraylist 是不是会变得很长?

当然不是,例如,当我往 arraylist 的左边添加数据的时候,startIndex 也会跟着变动, 因为用户看到的第一条柱体,在 arraylist 的索引已经变了。当我往 arraylist 的某一边添加数据后, arraylist 的另一边如果数据超过 2 屏, 要适当裁掉一些数据, 这样 arraylist 的总数, 始终保持在 5 屏左右,就不会占用太多的存放空间。

总体思想是, 从 startIndex 开始渲染屏幕的第一条柱体, 当前屏幕的左右两边, 都预留2屏数据,防止用户拖拽频繁调用接口, 导致卡顿; 同时也控制了 arraylist 的长度, 这是虚拟列表的变形,这样设计,可以做一个 高性能 的k线图。

八、触摸事件解耦

根据上面的分析:

  1. 快速拖拽, K线图左右移动
  2. 长按再滑动, 出现十字交叉tooltip

以上两种拖拽,都在 touchmove 事件中触发, 那怎么区分开呢? 典型的 touchstart、 touchmove 、 touchend 解耦如下:

let timer = null;
let startX = 0;
let startY = 0;
let isLongPress = false;

canvas.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
isLongPress = false;

timer = setTimeout(() => {
isLongPress = true;
// 显示十字光标hover
showCrossHair(e);
}, 500);
});

canvas.addEventListener('touchmove', (e) => {
if (isLongPress) {
// 长按移动时更新十字光标位置
updateCrossHair(e);
} else {
// 快按拖动时移动K线图
clearTimeout(timer);
moveKLineChart(e);
}
});

canvas.addEventListener('touchend', () => {
clearTimeout(timer);
if (isLongPress) {
// 长按结束隐藏十字光标
hideCrossHair();
}
isLongPress = false;
});

// 关闭十字光标
function hideCrossHair() {
// 隐藏逻辑
}

根据上面的框架, 再详细补充下代码就可以了。 然后再在 touchend 事件中, 新增或减少 arraylist 的数据量。

九、性能优化

其实, 做到上面的设计,性能已经很好了,可以监控帧率来看下滑动的流畅程度。

总结下我做了什么操作,来提高整体的性能:

1、分层渲染

将K线图画在3个canvas上。

  1. 不动层只需要绘画一次;
  2. 变动层根据需要而变动;
  3. 交互层独立出来,不会影响其它层,变动层的大量蜡烛柱体、均线等也不会受交互层的影响

2、离屏渲染

当需要在K线上标注一些icon时, 这些 icon 可以先离屏渲染, 需要的时候, 再copy到变动层对应的位置,这样比临时抱佛脚去画,要省很多时间,也能提高新能。

3、设置数据缓冲区

就是屏幕只渲染一屏数据, 但是在当前屏的左右两边,各缓存了2屏数据, 超过5屏数据的时候,及时裁掉多余的数据, 这样arraylist的数据量始终保持在5屏, 控制了数据量,有效的控制了占用空间。

4、节流防抖

touchmove 会很频繁触发, 可通过节流来控制,减少不必要的渲染。

十、部署到GitHub Pages

1、安装gh-pages包

npm install --save-dev gh-pages

2、package.json 添加如下配置

注意, Stock 这个需要对应github的仓库名

{
"homepage": "https://fhrddx.github.io/Stock",
"scripts": {
"predeploy": "npm run build",
"deploy": "gh-pages -d build"
}
}

3、运行部署命令

npm run build
npm run deploy

1.png

最后, 访问上面的链接(注意,在国内可能要开vpn)

fhrddx.github.io/Stock/

这样, github pages 部署成功, 访问上面链接, 可以看到如下效果。

2.png

github page 的部署需要将仓库设置为 public, 这个我挺反感的, 可以用 vercel 部署, 也就是将 github 账号与 vercel 关联起来, 项目的 package.js 的 homepage 设置为 “.” , 然后 vercel 可以点击一下, 一键部署, 常见的命令行如下:

# 1. 安装 Vercel CLI
npm install -g vercel

#
2. 在项目根目录登录 Vercel
vercel login

#
3. 部署项目
npm run build
vercel --prod

# 或者直接部署 build 文件夹

vercel --prod --build

作者:VincentFHR
来源:juejin.cn/post/7556154928059334666

收起阅读 »

从零基于 Canvas 实现一个文本渲染器

web
一、前因后果1.1 目的起因是女朋友想做小红薯的账号。她每天会先把小故事写好复制到手机上,然后按照一定的图片规格一张张裁剪,最后发布到平台上。接着我就在想,有没有什么高效的文本转成图片并且能够自动分页的方案呢?查了很久都没找到合适的方案,最后还是决定自己写一个...
继续阅读 »

一、前因后果

1.1 目的

起因是女朋友想做小红薯的账号。她每天会先把小故事写好复制到手机上,然后按照一定的图片规格一张张裁剪,最后发布到平台上。

画板

接着我就在想,有没有什么高效的文本转成图片并且能够自动分页的方案呢?查了很久都没找到合适的方案,最后还是决定自己写一个转换工具。

1.2 需求

总结了一下我的场景,发现需求如下:

  1. 能够内容转成指定尺寸的图片
  2. 能够设置字体、背景、样式
  3. 支持自动换行、自定义换行
  4. 支持分页
  5. 支持图片下载

根据输入文本,通过 Canvas 渲染

展示全部分页图片的内容,可以批量下载

1.3 思路

基于需求,最后决定采用 Canvas 的绘制方案。思路如下:

  1. 根据输入文本,完成分行、分页的计算
  2. 将分页数据绘制到 Canvas
  3. 批量将 Canvas 的内容导出图片进行下载

1.4 难点

做的时候发现几个核心的问题难点,分别是换行、分页的问题。

I. 换行问题

在 canvas 中进行文本绘制不同于 HTML 标签,文本是不能自动换行的。所以这个需要自己去计算什么时候换行,在哪个字符段该换行。

II. 分页问题

当内容超出当前页了,我们希望能够自动进行分页,这个就需要去计算行高页面内容区高度

二、设计方案

下面介绍一下 Canvas 文本渲染器的设计方案。主要会围绕着文本计算、文本绘制、导出图片来讲解。

2.1 如何计算文本

计算文本主要做的事情就是,根据用户输入的文本和想要生成的图片、字体参数,来计算需要分多少页,每一页具体要展示多少行,每一行要展示多少内容。

I. 分词

要实现换行,就要知道一句话中从哪个分词开始是超出了当前行的最大宽度。这个分词可能是某个中文字符、某个英文单词、某串数字、某个其他字符等。

这里我们只讨论简单的中英文数字的场景

所以要做的第一步,就是将输入的文本拆解成一个个分词,然后去筛选掉空的字符。

画板

核心代码如下:

II. 分行

现在已经将文本拆分成了足够细的分词。接下来要做的就是将每个分词不断地塞入每一行中。

你可以把每一行理解成一个固定宽度的容器,一但某个分词塞不进了,就得创建一个新的容器再把这个分词塞进去。

画板

所这个遍历的过程,需要知道行的最大宽度以及当前分词的真实宽度。最大宽度的计算规则,根据用户设置的页面宽度减去左右边距的宽度,就是内容区的最大行宽度。

画板

代码如下:

分词的真实宽度则是用 canvas 中的 measureText 来测量字符串的宽度。

接下来就是一个累加的过程。把分词加入当前行,判断是否超出最大宽度,如果超出就新起一行。

最后遍历完后就会得出所有的分行内容。

上面是分行大致的思路,但在实际的代码实现上,会随着分词类型、换行需求的增加变得更加复杂。例如:

  1. 英文单词前面需要追加空格

  1. 用户想自定义空行逻辑:匹配到句号自动换行并且空一行。

III. 分页

有了分行的数据,就可以进行分页了。其实逻辑差不多,只不过一个是横向,一个是纵向。把每个页当成一个固定高度的容器,不断的把行塞进去,塞不进就新起一个页容器。

画板

这个遍历的过程需要知道页面内容区的最大高度以及每一行的行高。行高一般是用户设置的,所以只需关注页面内容区的最大高度的计算规则。

画板

代码如下:

分页计算流程,遍历 lines 把分行不断塞入当前页。如果超出高度就放到新的页面。最后就会得出所有分页的数据。

2.2 如何绘制

有了分页数据以后,绘制主要做的事情就是根据用户设置的样式(页面边距、字体和背景)来渲染每一页具体的内容。

I. 指定图片尺寸

通过设置 Canvas 画布的大小即可。

II. 绘制背景

背景直接通过 fillRect 绘制即可

III. 绘制内容

绘制内容的时候,主要考虑两个点:

  1. 设置字体样式
  2. 根据边距、行高计算每行绘制的位置

2.3 如何导出图片

导出图片就是将 Canvas 上的内容转成 DataURL 然后下载成图片

三、最后

基于上面的思路,你不仅仅可以开发一个简单的文本渲染器,你甚至可以做一个复杂的编辑器哦~

最后我将这个工具封装成了一个 npm 库,直接导入这个库就可以完成文本到图片的一个转换了。

image.png

如果感兴趣,完整代码放置在 GitHub 了:github.com/zixingtangm…


作者:唐某人丶
来源:juejin.cn/post/7485758756911857683

收起阅读 »

前端部署,又有新花样?

web
大多数前端开发者在公司里,很少需要直接操心“部署”这件事——那通常是运维或 DevOps 的工作。 但一旦回到个人项目,情况就完全不一样了。写个小博客、搭个文档站,或者搞个 demo 想给朋友看,部署往往成了最大的拦路虎。 常见的选择无非是 Vercel、Ne...
继续阅读 »

大多数前端开发者在公司里,很少需要直接操心“部署”这件事——那通常是运维或 DevOps 的工作。


但一旦回到个人项目,情况就完全不一样了。写个小博客、搭个文档站,或者搞个 demo 想给朋友看,部署往往成了最大的拦路虎。


常见的选择无非是 Vercel、Netlify 或 GitHub Pages。它们表面上“一键部署”,但细节其实并不轻松:要注册平台账号、要配置域名,还得接受平台的各种限制。国内的一些云服务商(比如阿里云、腾讯云)管控更严格,操作门槛也更高。更让人担心的是,一旦平台宕机,或者因为地区网络问题导致访问不稳定,你的项目可能随时“消失”在用户面前。虽然这种情况不常见,但始终让人心里不踏实。


很多时候,我们只是想快速上线一个小页面,不想被部署流程拖累,有没有更好的方法?


一个更轻的办法


前段时间我发现了一个开源工具 PinMe,主打的就是“极简部署”。



它的使用体验非常直接:



  • 不需要服务器

  • 不用注册账号

  • 在项目目录敲一条命令,就能把项目打包上传到 IPFS 网络

  • 很快,你就能拿到一个可访问的地址


实际用起来的感受就是一个字:


整个过程几乎没有繁琐配置,不需要绑定平台账号,也不用担心流量限制或收费。


这让很多场景变得顺手:



  • 临时展示一个 demo,不必折腾服务器

  • 写了个静态博客,不想搞 CI/CD 流程

  • 做了个活动页或 landing page,随时上线就好


以前这些需求可能要纠结“用 GitHub Pages 还是 Vercel”,现在有了 PinMe,直接一键上链就行。


体验一把


接下来看看它在真实场景下的表现:部署流程有多简化?访问速度如何?和传统方案相比有没有优势?


测试项目


为了覆盖不同体量的场景,这次我选了俩类项目来测试:



  • 小型项目:fuwari(开源的个人博客项目),打包体积约 4 MB。

  • 中大型项目:Soybean Admin(开源的后台管理系统),打包体积约 15 MB。


部署项目


PinMe 提供了两种方式:命令行可视化界面



这两种方式我们都来试一下。


命令行部署


先全局安装:


npm install -g pinme

然后一条命令上传:


pinme upload <folder/file-path>

比如上传 Soybean Admin,文件大小 15MB:



输入命令之后,等着就可以了:



只用了两分钟,终端返回的 URL 就能直接访问项目的控制页面。还能绑定自己的域名:



点击网站链接就可以看到已经部署好的项目,访问速度还是挺快的:



同样地,上传个人博客也是一样的流程。



部署完成:



可视化部署


不习惯命令行?PinMe 也提供了网页上传,进度条实时显示:



部署完成后会自动进入管理页面:



经过测试,部署速度和命令行几乎一致。


其他功能


历时记录


部署过的网站都能在主页的 History 查看:



历史部署记录:



也可以用命令行:


pinme list

历史部署记录:



删除网站


如果不再需要某个项目,执行以下命令即可:


pinme rm

PinMe 背后的“硬核支撑”


如果只看表层,PinMe 就像一个极简的托管工具。但要理解它为什么能做到“不依赖平台”,还得看看它背后的底层逻辑。


PinMe 的底层依赖 IPFS,这是一个去中心化的分布式文件系统。


要理解它的意义,得先聊聊“去中心化”这个概念。


传统互联网是中心化的:你访问一个网站时,浏览器会通过 DNS 找到某台服务器,然后从这台服务器获取内容。这条链路依赖强烈,一旦 DNS 被劫持、服务器宕机、服务商下线,网站就无法访问。



去中心化的思路完全不同:



  • 数据不是放在单一服务器,而是分布在全球节点中

  • 访问不依赖“位置”,而是通过内容哈希来检索

  • 只要有节点存储这份内容,就能访问到,不怕单点故障


这意味着:



  • 更稳定:即使部分节点宕机,内容依然能从其他节点获取。

  • 防篡改:文件哪怕改动一个字节,对应的 CID 也会完全不同,从机制上保障了前端资源的完整性和安全性。

  • 更自由:不再受制于中心化平台,文件真正由用户自己掌控。


当然,IPFS 地址(哈希)太长,不适合直接记忆和分享。这时候就需要 ENS(Ethereum Name Service)。它和 DNS 类似,但记录存储在以太坊区块链上,不可能被篡改。比如你可以把 myblog.eth 指向某个 IPFS 哈希,别人只要输入 ENS 域名就能访问,不依赖传统 DNS,自然也不会被劫持。



换句话说:



ENS + IPFS = 内容去中心化 + 域名去中心化




前端个人项目瞬间就有了更高的自由度和安全性。


一点初步感受


PinMe 并不是要取代 Vercel 这类成熟平台,但它带来了一种新的选择:更简单、更自由、更去中心化


如果你只是想快速上线一个小项目,或者对去中心化部署感兴趣,PinMe 值得一试。





这是一个完全开源的项目,开发团队也会持续更新。如果你在测试过程中有想法或需求,不妨去 GitHub 提个 Issue —— 这不仅能帮助项目成长,也能让它更贴近前端开发的实际使用场景!


作者:CUGGZ
来源:juejin.cn/post/7547515500453380136
收起阅读 »

antd 对 ai 下手了!Vue 开发者表示羡慕!

web
前端开发者应该对 Ant Design 不陌生,特别是 React 开发者,antd 应该是组件库的标配了。 近年来随着 AI 的爆火,凡是想要接入 AI 的都想搞一套自己的 AI 交互界面。专注于 AI 场景组件库的开源项目倒不是很多见,近日 antd 宣布...
继续阅读 »


前端开发者应该对 Ant Design 不陌生,特别是 React 开发者,antd 应该是组件库的标配了。


近年来随着 AI 的爆火,凡是想要接入 AI 的都想搞一套自己的 AI 交互界面。专注于 AI 场景组件库的开源项目倒不是很多见,近日 antd 宣布推出 Ant Design X 1.0 🚀 ,这是一个基于 Ant Design 的全新 AGI 组件库,使用 React 构建 AI 驱动的用户交互变得更简单了,它可以无缝集成 AI 聊天组件和 API 服务,简化 AI 界面的开发流程。


该项目已在 Github 开源,拥有 1.6K Star!



看了网友的评论,看来大家还是需要的!当前的 Ant Design X 只支持 React 项目,看来 Vue 开发者要羡慕了...



ant-design-x 特性



  • 🌈 源自企业级 AI 产品的最佳实践:基于 RICH 交互范式,提供卓越的 AI 交互体验

  • 🧩 灵活多样的原子组件:覆盖绝大部分 AI 对话场景,助力快速构建个性化 AI 交互页面

  • ⚡ 开箱即用的模型对接能力:轻松对接符合 OpenAI 标准的模型推理服务

  • 🔄 高效管理对话数据流:提供好用的数据流管理功能,让开发更高效

  • 📦 丰富的样板间支持:提供多种模板,快速启动 LUI 应用开发

  • 🛡 TypeScript 全覆盖:采用 TypeScript 开发,提供完整类型支持,提升开发体验与可靠性

  • 🎨 深度主题定制能力:支持细粒度的样式调整,满足各种场景的个性化需求


支持组件


以下圈中的部分为 ant-design-x 支持的组件。可以看到主要都是基于 AI Chat 场景的组件设计。现在你可以基于这些组件自由组装搭建一个自己的 AI 界面。



ant-design-x 也提供了一个完整 AI Chat 的 Demo 演示,可以查看 Demo 的代码并直接使用。



更多组件详细内容可参考 组件文档


使用


以下命令安装 @ant-design/x 依赖。


注意,ant-design-x 是基于 Ant Design,因此还需要安装依赖 antd


yarn add antd @ant-design/x

import React from 'react';
import {
// 消息气泡
Bubble,
// 发送框
Sender,
} from '@ant-design/x';

const messages = [
{
content: 'Hello, Ant Design X!',
role: 'user',
},
];
const App = () => (
<div>
<Bubble.List items={messages} />
<Sender />
</div>

);

export default App;

Ant Design X 前生 ProChat


不知道有没有小伙伴们使用过 ProChat,这个库后面的维护可能会有些不确定性,其维护者表示 “24 年下半年后就没有更多精力来维护这个项目了,Github 上的 Issue 存留了很多,这边只能尽量把一些恶性 Bug 修复



如上所示,也回答了其和 Ant Design X 的关系:ProChat 是 x 的前生,新用户请直接使用 x,老用户也请尽快迁移到 x


感兴趣的朋友们可以去试试哦!


作者:智见君
来源:juejin.cn/post/7444878635717443595
收起阅读 »

为什么 Electron 项目推荐使用 Monorepo 架构 🚀🚀🚀

web
最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777在现代前端开发中,Monorepo(单一代码仓库)架构已经成为大型项目的首选方案。对...
继续阅读 »

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

在现代前端开发中,Monorepo(单一代码仓库)架构已经成为大型项目的首选方案。对于Electron应用开发而言,Monorepo架构更是带来了诸多优势。本文将以一个实际的Electron项目为例,深入探讨为什么Electron项目强烈推荐使用Monorepo架构,以及它如何解决传统多仓库架构的痛点。

什么是Monorepo

Monorepo是一种软件开发策略,它将多个相关的项目或包存储在同一个代码仓库中。与传统的多仓库(Multi-repo)架构不同,Monorepo允许开发团队在单一代码库中管理多个相互依赖的模块。

Electron项目的复杂性分析

Electron应用通常包含以下核心组件:

  • 主进程(Main Process):负责创建和管理应用窗口
  • 渲染进程(Renderer Process):运行前端UI代码
  • 预加载脚本(Preload Scripts):安全地桥接主进程和渲染进程
  • 共享代码库:业务逻辑、工具函数、类型定义等
  • 构建配置:Webpack、Vite等构建工具配置
  • 打包配置:Electron Builder等打包工具配置

这种多层次的架构使得代码组织变得复杂,传统的多仓库架构往往无法很好地处理这些组件之间的依赖关系。

实际项目结构深度解析

让我们以您的项目为例,深入分析Monorepo架构的实际应用:

项目整体架构

electron-app/
├── apps/ # 应用层
│ ├── electron-app/ # Electron主应用
│ │ ├── src/
│ │ │ ├── main/ # 主进程代码
│ │ │ └── preload/ # 预加载脚本
│ │ ├── build/ # 构建配置
│ │ └── package.json # 应用依赖
│ └── react-app/ # React前端应用
│ ├── src/
│ │ ├── components/ # React组件
│ │ └── page/ # 页面组件
│ └── package.json # 前端依赖
├── packages/ # 共享包层
│ ├── electron-core/ # 核心业务逻辑
│ │ ├── src/
│ │ │ ├── base-app.ts # 基础应用类
│ │ │ ├── app-config.ts # 应用配置
│ │ │ ├── menu-config.ts # 菜单配置
│ │ │ └── ffmpeg-service.ts # FFmpeg服务
│ │ └── package.json
│ ├── electron-ipc/ # IPC通信封装
│ │ ├── src/
│ │ │ ├── ipc-handler.ts # IPC处理器
│ │ │ ├── ipc-channels.ts # IPC通道定义
│ │ │ └── ipc-config.ts # IPC配置
│ │ └── package.json
│ └── electron-window/ # 窗口管理
│ ├── src/
│ │ ├── window-manager.ts # 窗口管理器
│ │ └── window-factory.ts # 窗口工厂
│ └── package.json
├── scripts/ # 构建脚本
├── package.json # 根配置
├── pnpm-workspace.yaml # Workspace配置
├── turbo.json # Turbo构建配置
└── tsconfig.json # TypeScript配置

核心配置文件分析

1. pnpm-workspace.yaml - 工作空间配置

packages:
- 'apps/*'
- 'packages/electron-*'

这个配置定义了工作空间的范围,告诉pnpm哪些目录包含包。这种配置的优势:

  • 统一依赖管理:所有包共享同一个node_modules
  • 版本一致性:确保所有包使用相同版本的依赖
  • 安装效率:避免重复安装相同的依赖

2. turbo.json - 构建管道配置

{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["/.env.*local"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/", "out/", "build/", ".next/"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": []
},
"typecheck": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
},
"clean": {
"cache": false
},
"format": {
"cache": false
}
}
}

这个配置定义了构建管道,实现了:

  • 依赖关系管理:dependsOn: ["^build"]确保依赖包先构建
  • 增量构建:只构建发生变化的包
  • 并行执行:多个独立任务可以并行运行
  • 缓存机制:避免重复构建

3. 根package.json - 统一脚本管理

{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck",
"electron:dev": "turbo run dev --filter=@monorepo/react-app && turbo run dev --filter=my-electron-app",
"electron:build": "turbo run build --filter=@monorepo/react-app && turbo run build --filter=my-electron-app"
}
}

Monorepo架构的六大核心优势

1. 统一的依赖管理

传统多仓库架构的问题:

  • 每个子项目都需要独立管理依赖
  • 容易出现版本不一致的问题
  • 重复安装相同的依赖,浪费磁盘空间

Monorepo解决方案:

在您的项目中,所有包都使用workspace:*协议引用内部依赖:

// apps/electron-app/package.json
{
"dependencies": {
"@monorepo/electron-core": "workspace:*",
"@monorepo/electron-window": "workspace:*",
"@monorepo/electron-ipc": "workspace:*"
}
}

这种配置的优势:

  • 版本一致性:所有包使用相同版本的内部依赖
  • 实时更新:修改共享包后,依赖包立即获得更新
  • 避免重复:pnpm的符号链接机制避免重复安装

2. 代码共享与复用

实际案例分析:

BaseApp基类的共享

// packages/electron-core/src/base-app.ts
export abstract class BaseApp {
protected config: AppConfig;

constructor(config: AppConfig) {
this.config = config;
}

abstract initialize(): void;

protected setupAppEvents(): void {
app.on('activate', () => {
if (this.shouldCreateWindow()) {
this.createWindow();
}
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
}

protected abstract shouldCreateWindow(): boolean;
protected abstract createWindow(): void;
}

这个基类被多个应用共享,提供了:

  • 统一的生命周期管理:所有Electron应用都遵循相同的生命周期
  • 代码复用:避免在每个应用中重复实现相同的逻辑
  • 类型安全:通过抽象类确保所有子类实现必要的方法

IPC通信的封装

// packages/electron-ipc/src/ipc-handler.ts
export class ElectronIpcHandler implements IpcHandler {
setupHandlers(): void {
// Basic IPC handlers
ipcMain.on('ping', () => console.log('pong'));

// App info handlers
ipcMain.handle('get-app-version', () => {
return process.env.npm_package_version || '1.0.0';
});

ipcMain.handle('get-platform', () => {
return process.platform;
});

// System info handlers
ipcMain.handle('get-system-info', () => {
return {
platform: process.platform,
arch: process.arch,
version: process.version,
nodeVersion: process.versions.node,
electronVersion: process.versions.electron,
};
});
}
}

这个IPC处理器提供了:

  • 统一的通信接口:所有IPC通信都通过标准化的接口
  • 类型安全:通过TypeScript接口确保通信的类型安全
  • 可扩展性:易于添加新的IPC处理器

3. 原子性提交

传统多仓库架构的问题:

  • 跨仓库的修改需要分别提交
  • 容易出现不一致的状态
  • 难以追踪相关的修改

Monorepo解决方案:

在您的项目中,一次提交可以同时修改多个相关文件:

# 一次提交同时修改多个包
git add packages/electron-core/src/base-app.ts
git add packages/electron-ipc/src/ipc-handler.ts
git add apps/electron-app/src/main/index.ts
git commit -m "feat: 重构应用基类和IPC处理器"

这种提交方式的优势:

  • 原子性:相关修改作为一个整体提交
  • 一致性:确保所有相关文件的状态一致
  • 可追溯性:通过git历史可以追踪完整的修改过程

4. 统一的构建和测试

实际构建流程分析:

Turbo构建管道

{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/", "out/", "build/", ".next/"]
}
}
}

这个配置实现了:

  • 依赖构建:^build确保依赖包先构建
  • 增量构建:只构建发生变化的包
  • 并行构建:多个独立包可以并行构建

实际构建命令

# 构建所有包
pnpm run build

# 只构建Electron应用
pnpm run electron:build

# 只构建React应用
pnpm run react:build

5. 更好的开发体验

一站式开发环境:

# 启动整个开发环境
pnpm run dev

# 启动Electron开发环境
pnpm run electron:dev

这种开发体验的优势:

  • 单一命令启动:一个命令启动整个开发环境
  • 热重载:修改代码后自动重新加载
  • 统一调试:可以在同一个IDE中调试所有代码

6. 类型安全

TypeScript项目引用:

// tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true
},
"references": [
{ "path": "./packages/electron-core" },
{ "path": "./packages/electron-ipc" },
{ "path": "./packages/electron-window" },
{ "path": "./apps/electron-app" },
{ "path": "./apps/react-app" }
]
}

这种配置实现了:

  • 增量编译:只编译发生变化的文件
  • 类型检查:确保所有包的类型定义一致
  • 智能提示:IDE可以提供完整的类型提示

实际开发流程分析

1. 新功能开发流程

假设要添加一个新的IPC处理器:

  1. 在共享包中定义接口:
// packages/electron-ipc/src/ipc-channels.ts
export const IPC_CHANNELS = {
// ... 现有通道
NEW_FEATURE: 'new-feature',
} as const;
  1. 实现处理器:
// packages/electron-ipc/src/ipc-handler.ts
ipcMain.handle(IPC_CHANNELS.NEW_FEATURE, () => {
// 实现逻辑
});
  1. 在应用中注册:
// apps/electron-app/src/main/index.ts
const ipcConfig = new IpcConfig();
ipcConfig.setupHandlers();
  1. 在前端中使用:
// apps/react-app/src/components/SomeComponent.tsx
const result = await window.electronAPI.invoke('new-feature');

2. 依赖更新流程

当需要更新共享包时:

  1. 修改共享包:
// packages/electron-core/src/base-app.ts
// 添加新功能
  1. 自动更新依赖: 由于使用workspace:*,所有依赖包自动获得更新
  2. 类型检查:
pnpm run typecheck
  1. 构建测试:
pnpm run build

性能优化分析

1. 构建性能

Turbo缓存机制:

  • 构建结果缓存到.turbo目录
  • 只有发生变化的包才会重新构建
  • 并行构建多个独立包

实际性能提升:

  • 首次构建:~30秒
  • 增量构建:~5秒
  • 缓存命中:~1秒

2. 开发性能

热重载优化:

  • 只重新加载发生变化的模块
  • 保持应用状态
  • 快速反馈循环

3. 安装性能

pnpm优势:

  • 符号链接避免重复安装
  • 全局缓存减少网络请求
  • 并行安装提高速度

最佳实践总结

1. 包划分原则

按功能模块划分:

  • electron-core:核心业务逻辑
  • electron-ipc:IPC通信
  • electron-window:窗口管理

避免过度拆分:

  • 不要为了拆分而拆分
  • 保持包的职责单一
  • 考虑包的维护成本

2. 依赖管理

使用workspace协议:

{
"dependencies": {
"@monorepo/electron-core": "workspace:*"
}
}

避免循环依赖:

  • 使用依赖图分析工具
  • 定期检查依赖关系
  • 重构消除循环依赖

3. 构建优化

利用Turbo缓存:

  • 合理设置outputs目录
  • 使用dependsOn管理依赖
  • 避免不必要的重新构建

4. 代码规范

统一配置:

  • ESLint配置统一管理
  • Prettier格式化统一
  • TypeScript配置统一

迁移策略

1. 评估现有项目

分析您当前的项目结构:

  • 识别可复用的代码
  • 分析依赖关系
  • 确定迁移优先级

2. 选择工具链

基于您的项目,推荐的工具链:

  • 包管理器:pnpm(已使用)
  • 构建工具:Turbo(已使用)
  • 类型检查:TypeScript(已使用)

3. 逐步迁移

第一阶段:迁移核心包

  • 将共享代码提取到packages目录
  • 设置workspace配置
  • 更新依赖引用

第二阶段:迁移应用

  • 重构应用代码使用共享包
  • 更新构建配置
  • 测试功能完整性

第三阶段:优化配置

  • 优化Turbo配置
  • 设置CI/CD流程
  • 性能调优

总结

Monorepo架构为Electron项目带来了显著优势:统一的依赖管理通过pnpm workspace实现版本一致性,代码共享与复用让BaseApp、IPC处理器等核心组件被多个应用共享,原子性提交确保相关修改作为一个整体提交,统一的构建和测试通过Turbo实现增量构建和并行执行,更好的开发体验提供一站式开发环境,类型安全通过TypeScript项目引用实现完整的类型检查。对于复杂的Electron应用而言,Monorepo架构不仅是一个推荐的选择,更是一个必要的架构决策,它能够显著提高开发效率和代码质量,为项目的长期发展奠定坚实的基础。


作者:Moment
来源:juejin.cn/post/7565204846044102671
收起阅读 »

Vue3.0父传子子传父的血和泪:一个菜鸟的踩坑实录

web
,没有声明 scope 参数,所以 scope 是 undefined。 解决方案 正确的写法应该是: <el-table-column label="操作" width="150"> <...
继续阅读 »

event loop 事件循环

web
什么是事件循环? 事件循环是 JavaScript 运行时的一个核心机制,它管理着代码的执行顺序。它是一种机制,用于处理异步操作,事件循环的核心是一个循环,它不断地检查调用栈和任务队列,以确保代码按照正确的顺序执行。 JavaScript 的单线程本质 Jav...
继续阅读 »

什么是事件循环?


事件循环是 JavaScript 运行时的一个核心机制,它管理着代码的执行顺序。它是一种机制,用于处理异步操作,事件循环的核心是一个循环,它不断地检查调用栈和任务队列,以确保代码按照正确的顺序执行。


JavaScript 的单线程本质


JavaScript 被设计为单线程语言,这意味着它只有一个调用栈,一次只能执行一段代码。这听起来像是一个限制,但正是这种简单性让 JavaScript 如此易于使用。


console.log('开始'); // 1

setTimeout(() => {
console.log('定时器回调'); // 3
}, 1000);

console.log('结束'); // 2

// 输出顺序:
// 开始
// 结束
// 定时器回调

事件循环的组成部分


1. 调用栈(Call Stack)


调用栈是 JavaScript 执行代码的地方。当函数被调用时,它会被推入栈顶;当函数返回时,它会从栈顶弹出。


function first() {
console.log('第一个函数');
second();
}

function second() {
console.log('第二个函数');
}

first();

2. 任务队列(Task Queue)


任务队列(也称为宏任务队列)存储着待处理的任务,如:



  • setTimeoutsetInterval 回调

  • I/O 操作

  • UI 渲染

  • 事件处理程序


3. 微任务队列(Microtask Queue)


微任务队列具有更高的优先级,包括:



  • Promise 回调(.then(), .catch(), .finally()

  • queueMicrotask()

  • MutationObserver


事件循环的工作流程


事件循环遵循一个简单的循环:



  1. 执行调用栈中的同步代码

  2. 当调用栈为空时,检查微任务队列

  3. 执行所有微任务(直到微任务队列为空)

  4. 检查宏任务队列,执行一个宏任务

  5. 重复步骤 2-4


console.log('脚本开始'); // 同步代码

setTimeout(() => {
console.log('setTimeout'); // 宏任务
}, 0);

Promise.resolve()
.then(() => {
console.log('Promise 1'); // 微任务
})
.then(() => {
console.log('Promise 2'); // 微任务
});

console.log('脚本结束'); // 同步代码

// 输出顺序:
// 脚本开始
// 脚本结束
// Promise 1
// Promise 2
// setTimeout

实际应用示例


场景 1:用户交互与数据获取


// 模拟用户点击和API调用
document.getElementById('button').addEventListener('click', () => {
console.log('点击事件处理'); // 宏任务

// 微任务优先于渲染
Promise.resolve().then(() => {
console.log('Promise 在点击中');
});

// 模拟API调用
fetch('/api/data')
.then(response => response.json())
.then(data => {
console.log('获取到的数据:', data); // 微任务
});
});

console.log('脚本加载完成');

场景 2:动画性能优化


// 不推荐的写法 - 可能阻塞渲染
function processHeavyData() {
const data = Array.from({length: 100000}, (_, i) => i);
return data.map(x => Math.sqrt(x)).filter(x => x > 10);
}

// 推荐的写法 - 使用事件循环分块处理
function processInChunks(data, chunkSize = 1000) {
let index = 0;

function processChunk() {
const chunk = data.slice(index, index + chunkSize);

// 处理当前块
chunk.forEach(item => {
// 处理逻辑
});

index += chunkSize;

if (index < data.length) {
// 使用 setTimeout 让出控制权,允许渲染
setTimeout(processChunk, 0);
}
}

processChunk();
}

常见陷阱与最佳实践


陷阱 1:阻塞事件循环


// ❌ 避免 - 长时间运行的同步操作
function blockingOperation() {
const start = Date.now();
while (Date.now() - start < 5000) {
// 阻塞5秒
}
console.log('操作完成');
}

// ✅ 推荐 - 使用异步操作
async function nonBlockingOperation() {
await new Promise(resolve => setTimeout(resolve, 5000));
console.log('操作完成');
}

陷阱 2:微任务递归


// ❌ 可能导致微任务无限循环
function dangerousRecursion() {
Promise.resolve().then(dangerousRecursion);
}

// ✅ 使用 setImmediate 或 setTimeout 打破循环
function safeRecursion() {
Promise.resolve().then(() => {
setTimeout(safeRecursion, 0);
});
}

现代 JavaScript 中的事件循环


async/await 与事件循环


async function asyncExample() {
console.log('开始 async 函数');

await Promise.resolve();
console.log('在 await 之后'); // 微任务

const result = await fetch('/api/data');
console.log('数据获取完成'); // 微任务
}

console.log('脚本开始');
asyncExample();
console.log('脚本结束');

// 输出顺序:
// 脚本开始
// 开始 async 函数
// 脚本结束
// 在 await 之后
// 数据获取完成

调试技巧


1. 使用 console 理解执行顺序


console.log('同步 1');

setTimeout(() => console.log('宏任务 1'), 0);

Promise.resolve()
.then(() => console.log('微任务 1'))
.then(() => console.log('微任务 2'));

queueMicrotask(() => console.log('微任务 3'));

console.log('同步 2');

2. 性能监控


// 测量任务执行时间
const startTime = performance.now();

setTimeout(() => {
const endTime = performance.now();
console.log(`任务执行耗时: ${endTime - startTime}ms`);
}, 0);

执行顺序问题


网上很经典的面试题


async function async1 () {
console.log('async1 start')
await async2()
console.log('async1 end')
}

async function async2 () {
console.log('async2')
}

console.log('script start')

setTimeout(function () {
console.log('setTimeout')
}, 0)

async1()

new Promise (function (resolve) {
console.log('promise1')
resolve()
}).then (function () {
console.log('promise2')
})

console.log('script end')


输出结果


script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

总结


理解 JavaScript 事件循环对于编写高效、响应迅速的应用程序至关重要。记住这些关键点:



  • 同步代码首先执行

  • 微任务在同步代码之后、渲染之前执行

  • 宏任务在微任务之后执行

  • 避免阻塞主线程

  • 合理使用微任务和宏任务


掌握事件循环机制将帮助你写出更好的异步代码,避免常见的性能问题,并创建更流畅的用户体验。


希望这篇博客能帮助你更好地理解 JavaScript 的事件循环机制!如果你有任何问题或想法,欢迎在评论区讨论。


作者:读忆
来源:juejin.cn/post/7565766784159776809
收起阅读 »

JavaScript 开发必备规范:命名、语法与代码结构指南

web
在 JavaScript 开发中,遵循良好的编程规范对于构建高效、可维护的代码至关重要。它不仅能提升代码的可读性,让团队成员之间更容易理解和协作,还能减少错误的发生,提高开发效率。本文将详细介绍 JavaScript 编程中的一些重要规范。 一、命名规范 变...
继续阅读 »

在 JavaScript 开发中,遵循良好的编程规范对于构建高效、可维护的代码至关重要。它不仅能提升代码的可读性,让团队成员之间更容易理解和协作,还能减少错误的发生,提高开发效率。本文将详细介绍 JavaScript 编程中的一些重要规范。



一、命名规范


变量和函数命名


采用小驼峰命名法,第一个单词首字母小写,后续单词首字母大写。例如firstName用于表示名字变量,getUserName函数用于获取用户名。这种命名方式能够清晰地区分变量和函数,并且让名称具有语义化,便于理解其用途。避免使用单字母或无意义的命名,如ab等,除非在特定的循环等场景下有约定俗成的用法。


常量命名


常量通常使用全大写字母,单词之间用下划线分隔,比如MAX_COUNT表示最大计数,API_URL表示 API 的链接地址。这样的命名方式能够直观地让开发者知道该变量是一个常量,其值在程序运行过程中不会改变。


二、语法规范


使用严格模式


在脚本或函数的开头添加'use strict';开启严格模式。严格模式下,JavaScript 会进行更严格的语法检查,比如禁止使用未声明的变量,防止意外创建全局变量等常见错误。它有助于开发者养成良好的编程习惯,提高代码的质量和稳定性。


// 严格模式
function strictWithExample() {
'use strict';
var obj = { x: 1 };
// 抛出 SyntaxError
with (obj) {
console.log(x);
}
}
strictWithExample();

语句结束加分号


尽管 JavaScript 在某些情况下可以省略分号,但为了避免潜在的错误和代码歧义,强烈建议在每条语句结束后都加上分号。


例如let num = 5let num = 5;,前者在一些复杂的代码结构中可能会因为自动分号插入机制而出现意想不到的问题,而后者则明确地表示了语句的结束。


let num = 5
console.log(num)
[1, 2, 3].forEach(function (element) {
console.log(element);
});

在上述代码中,let num = 5 后面没有分号,由于 [ 是 JavaScript 中的数组字面量符号,同时也可以用于数组的索引访问操作(例如 arr[0]),所以引擎会认为你可能想要对 num 进行某种与数组相关的操作,比如 num[1, 2, 3](虽然这在语法上是错误的,因为 num 是一个数字,不是数组)。


代码缩进


统一使用 2 个或 4 个空格进行缩进,这能让代码的层次结构一目了然。比如在嵌套的if - else语句、循环语句等结构中,合理的缩进能清晰地展示代码的逻辑关系,使代码更易于阅读和维护。


代码块使用大括号


即使代码块中只有一条语句,也建议使用大括号括起来。例如:


if (condition) {
doSomething();
}

这样在后续需要添加更多语句到代码块中时,能避免因遗漏大括号而导致的语法错误。


三、比较操作规范


尽量使用===!==进行比较操作,避免使用==!=。因为==!=在比较时会进行类型转换,这可能会带来意外结果。例如'5' == 5会返回true,而'5' === 5会返回false,在实际开发中,明确知道数据类型并使用全等操作符能减少错误的发生。


四、代码结构规范


避免全局变量污染


在 JavaScript 开发中,尤其是构建大型项目时,全局变量带来的问题不容小觑。全局变量如同在公共空间随意摆放的物品,极易引发混乱。在一个复杂项目中,可能有多个开发人员同时工作,不同模块的代码相互交织。如果每个模块都随意创建全局变量,很容易出现命名冲突。



  • 例如,一个模块定义了全局变量count用于记录某个操作的次数,另一个模块可能也需要使用count变量来记录其他信息,这就会导致变量值被意外覆盖,引发难以排查的错误。


同时,在大型项目中,代码的维护和调试本身就具有挑战性。全局变量的存在会使问题变得更加棘手。因为全局变量在整个程序的生命周期内都存在,其值可能在程序的任何地方被修改。当出现错误时,开发人员很难确定是哪个部分的代码对全局变量进行了不恰当的修改,增加了调试的难度和时间成本。


模块化


为了解决这些问题,模块化是一种非常有效的手段。通过将相关的功能代码封装在一个模块中,每个模块都有自己独立的作用域。在 JavaScript 中,ES6 引入了模块系统,使用exportimport关键字来管理模块的导出和导入。例如,有一个处理用户数据的模块userModule.js


// userModule.js
const userData = {
name: '',
age: 0
};

function setUserName(name) {
userData.name = name;
}

function getUserName() {
return userData.name;
}

export { setUserName, getUserName };

在这个模块中,userDatasetUserNamegetUserName函数都在模块内部作用域中,外部无法直接访问userData。只有通过导出的setUserNamegetUserName函数,其他模块才能间接操作userData。在其他模块中使用时,可以这样导入:


// main.js
import { setUserName, getUserName } from './userModule.js';

setUserName('John');
console.log(getUserName());

这样就有效地避免了全局变量的使用,降低了命名冲突的风险,同时也使得代码的结构更加清晰,易于维护和调试。


立即执行函数表达式(IIFE)


另一种方式是使用立即执行函数表达式(IIFE)。在 JavaScript 中,通过将函数定义包裹在括号中,并紧接着在后面加上括号进行调用,便形成了一个 IIFE。IIFE 能够创建一个独立的函数作用域,在该作用域内定义的变量和函数均为私有。这就确保了函数内部的变量和函数不会被外部随意访问和修改 。例如:


const app = (function () {
let privateVariable = 10;

function privateFunction() {
console.log('This is a private function.');
}

return {
publicFunction: function () {
privateFunction();
console.log('The value of private variable is:', privateVariable);
}
};
})();

app.publicFunction();

在上述代码中,



  • (function () {... })():在包裹匿名函数的括号后面再添加一对括号(),这对括号用于立即调用前面定义的匿名函数。当 JavaScript 引擎执行到这部分代码时,就会立即调用这个匿名函数,所以称为 “立即执行函数”。

  • privateVariableprivateFunction都在 IIFE 内部的私有作用域中,因此外部无法直接访问它们。通过返回一个包含publicFunction的对象,向外暴露了一个公共接口,这样一来既实现了功能,又避免了全局变量污染。


合理使用注释


在关键代码逻辑处添加注释,解释代码的功能、用途、算法思路等。注释要简洁准确,避免过度注释。



  • 例如在一个复杂的算法函数前,可以注释说明该算法的作用、输入参数和返回值的含义,方便其他开发者理解代码。

  • 但不要在过于简单的代码上添加冗余注释,如let num = 1; // 定义一个数字变量,这样的注释对理解代码没有实质性帮助。


五、注释规范


注释分为单行注释和多行注释。单行注释使用//,用于对某一行代码进行简单解释。多行注释使用/* */,适合对一段代码块进行详细说明。在写注释时,要确保注释与代码同步更新,避免代码修改后注释不再准确的情况。


六、异步编程规范


随着 JavaScript 在前端和后端开发中的广泛应用,异步编程变得越来越重要。使用async/await语法可以让异步代码看起来更像同步代码,提高代码的可读性。例如:


async function getData() {
try {
let response = await fetch('https://example.com/api');
let data = await response.json();
return data;
} catch (error) {
console.error('获取数据失败', error);
}
}

在处理多个异步操作时,要注意合理控制并发数量,避免因过多并发请求导致性能问题。


七、代码格式化规范


使用代码格式化工具,如 Prettier、ESLint 等,能够自动按照设定的规则对代码进行格式化。它可以统一代码风格,包括缩进、空格、换行等,使团队成员的代码风格保持一致,减少因风格差异带来的冲突和阅读障碍。


八、代码复用


尽量编写可复用的代码,通过函数封装、模块封装等方式,将重复使用的代码逻辑提取出来。



  • 例如,在多个地方需要对数据进行格式化处理,可以编写一个通用的数据格式化函数,在需要的地方调用,这样不仅能减少代码量,还方便维护和修改。


九、错误处理


在代码中要合理处理错误,使用try - catch块捕获可能出现的异常。对于异步操作,也要通过try - catch或者.catch方法来处理错误。



  • 例如在网络请求失败时,要及时向用户反馈错误信息,而不是让程序崩溃。同时,可以自定义错误类型,以便在不同的业务场景下进行更精准的错误处理。



遵循这些 JavaScript 编程规范,能够帮助开发者写出更整洁、高效、易于维护的代码。在实际开发中,团队可以根据项目需求进一步细化和完善这些规范,以提升整个项目的质量。



作者:逆袭的小黄鸭
来源:juejin.cn/post/7493346464920404003
收起阅读 »

前端常见的6种设计模式

web
一.为什么需要理解设计模式? 前端项目会随着需求迭代变得越来越复杂,设计模式的作用就是提前规避 “后期难改、牵一发动全身” 的坑,设计模式的核心价值:解决 “可维护、可扩展” 问题。 1.工厂模式 工厂模式:通过一个统一的 “工厂函数 / 类” 封装对象的创建...
继续阅读 »

一.为什么需要理解设计模式?


前端项目会随着需求迭代变得越来越复杂,设计模式的作用就是提前规避 “后期难改、牵一发动全身” 的坑,设计模式的核心价值:解决 “可维护、可扩展” 问题。


1.工厂模式


工厂模式:通过一个统一的 “工厂函数 / 类” 封装对象的创建逻辑,外界只需传入参数(如类型、配置),即可获取所需实例,无需关心实例内部的构造细节。核心是 “创建逻辑与使用逻辑分离”,实现批量、灵活地创建相似对象。


前端应用场景


1.Axios 实例


2.Vue实例


3.组件库中的 “表单组件工厂”,统一管理所有表单组件的基础属性(如 iddisabled


2.单例模式:确保全局只有一个实例


核心是为了解决 “重复创建实例导致的资源浪费、状态混乱、逻辑冲突” 问题—— 当某个对象在系统中只需要 “唯一存在” 时,单例模式能确保全局访问到的是同一个实例,从根源避免多实例带来的隐患。


前端典型场景:


1.Vuex单一store实例


2.浏览器的 window 对象


3.原型模式:通过 “复制” 创建新对象


原型模式的核心是 “基于已有对象(原型)复制创建新对象” —— 不是从零开始定义新对象的属性和方法,而是直接 “拷贝” 一个现有对象(原型)的结构,再根据需要修改差异化内容。


前端中原型模式的本质:依托 JavaScript 原型链。


JavaScript 本身就是基于原型的语言,所有对象都有 __proto__ 属性(指向其原型对象),这是原型模式在前端的 “天然实现”。


普通对象原型属性: 只有'proto'属性。


函数原型属性:proto、prototype属性。


prototype专属属性,只有函数有,用于 "当函数作为构造函数时,给新创建的实例提供原型"。


原型链顶端: Object.prototype.proto :指向null ;


前端典型场景:


1.Object.create()


2.Vue2 的数组方法重写:Vue2 为数组的pushpop等方法添加响应式逻辑,新数组会继承这些重写后的方法。


3.继承


工厂模式与原型模式区别:


工厂模式


基于参数 / 规则 “全新创建” 对象;


核心目的:封装复杂的创建逻辑,让调用者无需关心对象构造细节。


原型模式


基于 “已有原型对象” 复制生成新对象


核心目的:复用已有对象的属性 / 方法,减少重复定义,支持继承扩展


4.观察者模式:“一对多” 的依赖通知机制


观察者模式(Observer Pattern)是一种 “一对多” 的依赖关系设计模式:



  • 存在一个 “被观察者(Subject)” 和多个 “观察者(Observer)”;

  • 当被观察者的状态发生变化时,会自动通知所有依赖它的观察者,并触发观察者的更新逻辑;

  • 核心是 “解耦被观察者和观察者”—— 双方无需知道彼此的具体实现,只需通过统一的接口通信



前端典型场景:


1.浏览器事件监听(最基础的观察者模式)

浏览器的 DOM 事件本质是观察者模式的实现:



  • 被观察者:DOM 元素(如按钮);

  • 观察者:事件处理函数(onclickonchange 等);

  • 流程:给元素绑定事件(订阅)→ 元素状态变化(如被点击)→ 自动执行所有绑定的事件处理函数(通知观察者)。

  • 观察者模式的核心价值是 “状态变化自动同步


2.状态管理库(Vuex/Pinia/Redux)

Vuex、Redux 等全局状态管理库的核心机制就是观察者模式:



  • 被观察者:Store 中的状态(如 state.userstate.cart);

  • 观察者:依赖该状态的组件;

  • 流程:组件订阅状态(mapState 或 useSelector)→ 状态更新(commit 或 dispatch)→ 所有订阅该状态的组件自动重新渲染(收到通知更新)


3. 框架的响应式系统(Vue/React)

Vue 的响应式原理(数据驱动视图)和 React 的状态更新机制,底层都依赖观察者模式:



  • Vue:数据对象(data)是被观察者,视图(DOM)和计算属性是观察者 —— 数据变化时,Vue 自动触发依赖收集的观察者(视图重新渲染、计算属性重新计算)。

  • ReactsetState 触发状态更新时,组件树中依赖该状态的组件(观察者)会被重新渲染(收到通知执行更新)。


5.发布-订阅模式


发布 - 订阅模式是观察者模式的变体,核心是通过一个 “中间者(事件中心)” 实现 “发布者” 和 “订阅者” 的完全解耦 —— 发布者不用知道谁在订阅,订阅者也不用知道谁在发布,双方仅通过事件中心传递消息,就像 “报社(发布者)→ 邮局(事件中心)→ 订报人(订阅者)” 的关系。



  • 三大角色



    1. 发布者(Publisher) :负责 “发布事件”(比如触发某个状态变化,如用户登录、数据更新),但不直接联系订阅者;

    2. 订阅者(Subscriber) :负责 “订阅事件”(比如关注 “用户登录” 事件),并定义事件触发时的 “回调逻辑”(比如登录后显示欢迎信息);

    3. 事件中心(Event Bus) :中间枢纽,负责存储 “事件 - 订阅者” 的映射关系,接收发布者的事件并通知所有订阅者。



  • 核心逻辑:订阅者先在事件中心 “订阅” 某个事件 → 发布者在事件中心 “发布” 该事件 → 事件中心找到所有订阅该事件的订阅者,触发它们的回调。


与观察者模式区别:


维度观察者模式发布 - 订阅模式
依赖关系被观察者直接持有观察者列表发布者和订阅者无直接依赖,靠事件中心连接
耦合程度较高(被观察者知道有哪些观察者)极低(双方不知道彼此存在)
适用场景单一被观察者、观察者明确的场景跨模块、多发布者 / 多订阅者的复杂场景
典型例子Vue 响应式(data 直接通知依赖的 DOM)跨组件通信(事件总线)、全局状态更新

前端典型场景:


1.跨组件通信(事件总线 Event Bus)


2.全局状态管理(如 Redux 的 Action 机制)



  • 发布者:组件通过 dispatch(action) 发布 “状态变更事件”;

  • 事件中心:Redux 的 Store,存储状态并管理订阅者;

  • 订阅者:组件通过 store.subscribe(() => { ... }) 订阅状态变化,状态更新时重新渲染。



状态管理库到底是观察者模式还是发布 - 订阅模式?


状态管理库(如 Vuex、Redux)之所以会让人觉得 “既是观察者模式,又是发布 - 订阅模式”,是因为它们融合了两种模式的核心思想—— 在底层实现上,既保留了观察者模式 “状态与依赖直接关联” 的特性,又通过 “中间层” 实现了发布 - 订阅模式的 “解耦” 优势,本质是两种模式的结合与优化


1. 底层:状态与组件的 “观察者模式”(直接依赖)


状态管理库中, “全局状态” 与 “依赖该状态的组件”  之间是典型的观察者模式:



  • 被观察者:全局状态(如 Vuex 的 state、Redux 的 store);

  • 观察者:订阅了该状态的组件;

  • 逻辑:当状态发生变化时,会直接通知所有依赖它的组件(观察者),触发组件重新渲染。


这一层的核心是 “精准依赖”—— 组件只订阅自己需要的状态(比如 Vue 的 mapState、Redux 的 useSelector),状态变化时只有相关组件会被通知,避免无效更新。


2. 上层:组件与状态的 “发布 - 订阅模式”(解耦通信)


状态管理库中, “组件触发状态变更” 与 “状态变更通知组件”  的过程,通过 “中间层(如 commit/dispatch)” 实现,类似发布 - 订阅模式:



  • 发布者:触发状态变更的组件(通过 store.commit('increment') 或 dispatch(action) 发布 “状态变更事件”);

  • 事件中心:状态管理库的核心逻辑(如 Vuex 的 Store 实例、Redux 的 dispatch 机制);

  • 订阅者:依赖状态的组件(通过 subscribe 或计算属性订阅状态)。


这一层的核心是 “解耦”—— 组件不需要知道谁会处理状态变更,也不需要知道哪些组件依赖该状态;状态管理库作为中间层,接收 “发布” 的变更请求,处理后再 “通知” 订阅者,双方完全隔离。


6.代理模式


代理模式(Proxy Pattern)是一种 “通过中间代理对象控制对原始对象的访问” 的设计模式 —— 不直接操作目标对象,而是通过一个 “代理” 来间接访问,代理可以在访问前后添加额外逻辑(如权限校验、缓存、日志记录等)。


核心作用:“控制访问” 与 “增强功能”

前端典型场景:


1. 权限控制代理(限制访问)

2.Vue3响应式核心

用 “中间商” 的思路理解 Vue3 响应式:


  • 目标对象:你定义的 data 数据(如 { count: 0, user: { name: '张三' } });

  • 代理对象:Vue3 通过 reactive() 或 ref() 创建的 “响应式代理”(本质是 Proxy 实例);

  • 调用者:组件中的模板(视图)或业务逻辑(如 {{ count }} 或 count.value++);

  • 代理的 “附加操作” :拦截数据的读取(get)和修改(set),在读取时 “收集依赖”(记录哪些地方用到了这个数据),在修改时 “触发更新”(通知依赖的地方重新渲染)。


1. 目标对象:原始数据 const target = { count: 0 }; 
2. 依赖收集的容器:记录哪些函数依赖了数据(比如视图渲染函数)
const deps = new Set();
3. 创建代理对象(核心:拦截读写,添加响应式逻辑)
const reactiveProxy = new Proxy(target,
{
// 拦截“读取数据”操作(如访问 count 时)
get(target, key){
// 附加操作1:
收集依赖(假设当前正在执行的函数是依赖)
if (currentEffect) { deps.add(currentEffect); // 把依赖存起来 }
return target[key]; // 返回原始值 },
}
// 拦截“修改数据”操作(如 count++ 时)
set(target, key, value) {
// 更新原始数据
target[key] = value;
// 附加操作2:触发更新(通知所有依赖重新执行)
deps.forEach(effect => effect()); return true; } });
}

扩展:Vue3响应式对比vue2响应式

1.Vue2 用的是 Object.defineProperty 拦截属性,只能拦截已存在的属性(对新增属性、数组索引修改不友好);


具体原因拆解:

Object.defineProperty 的工作方式是给对象的某个具体属性添加 getter/setter


但数组本质是特殊对象(属性是索引,如 arr[0]arr[1]),如果用 Object.defineProperty 拦截数组,只能逐个拦截索引(如 01),但存在两个致命问题:


1.问题一:无法拦截数组的原生方法(push/pop/splice 等)
数组的常用操作(如 push 新增元素、splice 删除元素)是通过调用数组原型上的方法实现的,这些方法会直接修改数组本身,但 Object.defineProperty 无法拦截 “方法调用”,只能拦截 “属性读写”。所以最终Vue2采取了这7个数组方法的重写。


 arrayMethods[method] = function(...args) {
// 先调用原生方法(比如 push 实际添加元素)
const result = arrayProto[method].apply(this, args);
// 手动触发更新(通知依赖重新渲染)
notifyUpdate();
return result;

2.问题二:拦截数组索引的成本极高,且不实用。



  • 初始化成本高:数组长度可能很大(甚至动态变化),提前拦截所有索引会浪费性能;

  • 数组长度变化无法拦截
    当 arr.length = 0 时,数组会清空所有元素(即删除索引 012),但 Object.defineProperty 只能知道 length 被改成了 0无法知道具体哪些元素被删除了


对于响应式系统来说,需要知道 “哪些元素变化了” 才能精准通知依赖这些元素的视图。但 length 拦截只能知道 “长度变了”,无法定位具体变化的元素,导致依赖这些元素的视图可能不会更新(比如某个视图依赖 arr[0]length=0 后 arr[0] 不存在了,但视图可能还显示旧值)。


2.Vue3 用 Proxy 直接代理整个对象,能拦截所有属性的读写(包括新增、删除、数组操作),是更彻底、更灵活的代理模式实现,这也是 Vue3 响应式比 Vue2 强大的核心原因之一。


总结


最后想强调:设计模式不是必须遵守的 “规则”,而是解决问题的 “工具”。在实际开发中,我们不需要刻意追求 “用满所有模式”,而是根据场景选择合适的工具:



  • 需批量创建对象 → 工厂模式

  • 需全局唯一实例 → 单例模式

  • .....


参考文章:juejin.cn/post/754253…


作者:大杯咖啡
来源:juejin.cn/post/7563981206674817051
收起阅读 »

electron-updater实现热更新完整流程

web
最近项目做了一个electron项目,记录一下本次客户端热更新中对electron-updater的使用以及遇到的一些问题。 一、配置electron-builder 在electron-builder的配置文件"build"中增加 "publish": [ ...
继续阅读 »

最近项目做了一个electron项目,记录一下本次客户端热更新中对electron-updater的使用以及遇到的一些问题。


一、配置electron-builder


在electron-builder的配置文件"build"中增加


"publish": [
{
"provider": "generic",
"url": "oss://xxx",
}
]

url: 打包出来的文件存放的地址,配置之后会生成latest.yml文件。electron-updater会去比较这个文件,判断是否需要更新。


二、electron-updater的使用


官方文档: http://www.electron.build/auto-update…


主进程


import { autoUpdater } from "electron-updater";
const { ipcMain } = require("electron");

// 配置提供更新的程序,及build中配置的url
autoUpdater.setFeedURL("oss://xxx")
// 是否自动更新,如果为true,当可以更新时(update-available)自动执行更新下载。
autoUpdater.autoDownload = false

// 1. 在渲染进程里触发获取更新,开始进行更新流程。 (根据具体需求)
ipcMain.on("checkForUpdates", (e, arg) => {
autoUpdater.checkForUpdates();
});

autoUpdater.on("error", function (error) {
printUpdaterMessage('error');
mainWindow.webContents.send("updateError", error);
});

// 2. 开始检查是否有更新
autoUpdater.on("checking-for-update", function () {
printUpdaterMessage('checking');
});

// 3. 有更新时触发
autoUpdater.on("update-available", function (info) {
printUpdaterMessage('updateAvailable');
// 4. 告诉渲染进程有更新,info包含新版本信息
mainWindow.webContents.send("updateAvailable", info);
});

// 7. 收到确认更新提示,执行下载
ipcMain.on('comfirmUpdate', () => {
autoUpdater.downloadUpdate()
})

autoUpdater.on("update-not-available", function (info) {
printUpdaterMessage('updateNotAvailable');
});

// 8. 下载进度,包含进度百分比、下载速度、已下载字节、总字节等
// ps: 调试时,想重复更新,会因为缓存导致该事件不执行,下载直接完成,可找到C:\Users\40551\AppData\Local\xxx-updater\pending下的缓存文件将其删除(这是我本地的路径)
autoUpdater.on("download-progress", function (progressObj) {
printUpdaterMessage('downloadProgress');
mainWindow.webContents.send("downloadProgress", progressObj);
});

// 10. 下载完成,告诉渲染进程,是否立即执行更新安装操作
autoUpdater.on("update-downloaded", function () {
mainWindow.webContents.send("updateDownloaded");
// 12. 立即更新安装
ipcMain.on("updateNow", (e, arg) => {
autoUpdater.quitAndInstall();
});
}
);

// 将日志在渲染进程里面打印出来
function printUpdaterMessage(arg) {
let message = {
error: "更新出错",
checking: "正在检查更新",
updateAvailable: "检测到新版本",
downloadProgress: "下载中",
updateNotAvailable: "无新版本",
};
mainWindow.webContents.send("printUpdaterMessage", message[arg]??arg);
}


渲染进程:


// 5. 收到主进程可更新的消息,做自己的业务逻辑
ipcRenderer.on('updateAvailable', (event, data) => {
// do sth.
})

// 6. 点击确认更新
ipcRenderer.send('comfirmUpdate')

// 9. 收到进度信息,做进度条
ipcRenderer.on('downloadProgress', (event, data) => {
// do sth.
})

// 11. 下载完成,反馈给用户是否立即更新
ipcRenderer.on('updateDownloaded', (event, data) => {
// do sth.
})

// 12. 告诉主进程,立即更新
ipcRenderer.send("updateNow");

本地环境


如果想在本地环境调试更新,会报错找不到dev-app-update.yml文件
需要自己在根目录(或报错时显示的目录下)手动新建一个dev-app-update.yml里就可以了。文件,将打包生成好的latest.yml复制到dev-app-update.yml里就可以了。


完成截图


image.png


image.png


作者:致命一击
来源:juejin.cn/post/7054811432714108936
收起阅读 »

深入理解 JavaScript 报错:TypeError: undefined is not a function

web
深入理解 JavaScript 报错:TypeError: undefined is not a function 在日常的 JavaScript 开发中,几乎每个人都见过这条令人熟悉又头疼的错误信息: 🚀Taimili 艾米莉 ( 一款免费开源的 taimi...
继续阅读 »

深入理解 JavaScript 报错:TypeError: undefined is not a function


在日常的 JavaScript 开发中,几乎每个人都见过这条令人熟悉又头疼的错误信息:


🚀Taimili 艾米莉 ( 一款免费开源的 taimili.com )


艾米莉 是一款优雅便捷的  GitHub Star 管理和加星工具 ,基于 PHP & javascript 构建, 能对github 得 star fork follow watch 管理和提升,最适合github 的深度用户


image.png


作者:开源之眼

链接:juejin.cn/post/755906…

来源:稀土掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。



TypeError: undefined is not a function



这行报错简短却致命,尤其当代码行数成千上万时,找到问题根源往往需要一点侦探技巧。本文将从原理、常见原因、排查方法和最佳实践四个方面深入讲解这一错误。




一、错误的本质是什么?


首先要知道:

在 JavaScript 中,一切几乎都是对象,包括函数。

当你调用一个变量并在后面加上 () 时,JavaScript 会假设该变量是一个函数对象,并尝试执行它。


let fn;
fn(); // ❌ TypeError: fn is not a function

在上面的例子中,fn 的值是 undefined,但我们却尝试执行它,于是引发了经典错误:



TypeError: undefined is not a function



简单来说:



“你正在试图执行一个并不是函数的东西。”





二、常见的触发场景


让我们来看一些在实际项目中常见的触发情境。


1. 调用未定义的函数


sayHello(); // ❌ TypeError: sayHello is not a function

var sayHello = function() {
console.log("Hello");
};


原因var 声明的变量会提升,但赋值不会。执行到函数调用时,sayHello 还是 undefined



✅ 正确写法:


function sayHello() {
console.log("Hello");
}
sayHello(); // ✅ Hello

或者:


const sayHello = () => console.log("Hello");
sayHello(); // ✅ Hello



2. 调用了对象上不存在的方法


const user = {};
user.login(); // ❌ TypeError: user.login is not a function


原因user 对象没有 login 方法,访问结果是 undefined



✅ 正确做法:


const user = {
login() {
console.log("User logged in");
}
};
user.login(); // ✅ User logged in



3. 第三方库或异步加载未完成


// 某个库尚未加载完成
myLibrary.init(); // ❌ TypeError: myLibrary.init is not a function


原因:脚本加载顺序错误或资源未加载完。



✅ 解决方案:


<script src="mylib.js" onload="initApp()"></script>

或使用现代模块化方式:


import myLibrary from './mylib.js';
myLibrary.init();



4. 被覆盖的函数名


let alert = "Hello";
alert("Hi"); // ❌ TypeError: alert is not a function


原因:内置函数被变量覆盖。



✅ 解决方案:


避免重名:


let message = "Hello";
window.alert("Hi"); // ✅



5. this 指向错误


const obj = {
run() {
console.log("Running");
}
};

const run = obj.run;
run(); // ❌ TypeError: undefined is not a function (在严格模式下)


原因this 丢失导致方法不再属于原对象。



✅ 解决方案:


const boundRun = obj.run.bind(obj);
boundRun(); // ✅ Running

或直接调用:


obj.run(); // ✅ Running



三、排查思路与调试技巧


当遇到这个错误时,不要慌。按照以下步骤排查:


✅ 1. 查看错误堆栈(stack trace)


浏览器控制台一般会指明出错的文件与行号。

打开 DevTools → Console → 点击错误行号,即可定位具体位置。


✅ 2. 打印变量类型


使用 typeof 或 console.log 检查被调用的变量:


console.log(typeof myFunc); // 应该输出 'function'

✅ 3. 检查函数定义顺序


尤其是在使用 var 或异步加载模块时,注意执行顺序。


✅ 4. 检查导入导出是否匹配


在模块化开发中,这类错误经常来自错误的导入:


// ❌ 错误示例
import { utils } from './utils.js';
utils(); // TypeError: utils is not a function

✅ 应确认模块导出方式:


// utils.js
export default function utils() {}

然后正确导入:


import utils from './utils.js';
utils(); // ✅



四、防止 “undefined is not a function” 的最佳实践



  1. 使用 const/let 替代 var — 避免变量提升造成的未定义调用

  2. 模块化代码结构 — 保证依赖先加载

  3. 给函数添加类型校验


    if (typeof fn === 'function') fn();


  4. 启用严格模式或 TypeScript — 提前发现类型问题

  5. 避免覆盖全局对象(如 alertconfirmsetTimeout 等)


作者:开源之眼
来源:juejin.cn/post/7563220648827715610
收起阅读 »

那些前端老鸟才知道的秘密

web
前端老鸟才知道的秘密:void(0),这东西到底有什么用 那天我盯着同事的代码看了半天,心里默念:这货是不是写错了? 前几天 review 代码,看到一个小年轻写了这么一行: const foo = void 0; 我当时就乐了,心想:" 这孩子是不是被...
继续阅读 »

前端老鸟才知道的秘密:void(0),这东西到底有什么用



那天我盯着同事的代码看了半天,心里默念:这货是不是写错了?



前几天 review 代码,看到一个小年轻写了这么一行:


const foo = void 0;

我当时就乐了,心想:" 这孩子是不是被产品经理逼疯了?直接写undefined不香吗?非得整这出?"


但转念一想,不对啊,这写法我好像在哪儿见过... 仔细一琢磨,卧槽,这不就是前端老司机的暗号吗!


所以,void 0 到底是个啥?


简单来说,void 0就是强行返回 undefined的一种写法。


你可能会问:"那我直接写 undefined 不就完事了?干嘛要多此一举?"


问得好!这就要从前端开发的 "血泪史" 说起了。


那些年被 undefined 坑过的日子


在 JavaScript 的远古时期(其实就是 ES5 之前),undefined 这个变量是可以被重写的!


没错,你没听错,就是那个表示 "未定义" 的 undefined,它自己都可能被定义成别的东西...


// 在古老的浏览器里,你可以这么玩(现在别试了)
undefined = "我是谁?我在哪?";
console.log(undefined); // 输出:"我是谁?我在哪?"

这就很尴尬了 —— 你用来判断是否未定义的变量,自己都可能被篡改!


这时候,void 0就闪亮登场了。


void 0 的三大绝技


1. 绝对安全的 undefined


void操作符有个特点:不管后面接什么,都返回 undefined


void 0 // undefined
void "hello" // undefined
void {} // undefined
void function(){} // undefined

所以void 0就成了获取真正 undefined 的最可靠方式。


2. 阻止链接跳转的老司机


还记得以前写<a href="javascript:void(0)">吗?这就是为了防止点击链接后页面跳转。


虽然现在大家都用event.preventDefault()了,但这可是老一辈前端人的集体记忆啊!


3. 立即执行函数的替代方案


有些老代码里你会看到:


void function() {
// 立即执行的代码
}();

这其实是为了避免函数声明被误认为是语句开头。


现在还需要 void 0 吗?


说实话,在现代前端开发中,直接用undefined已经足够安全了。ES5 之后的规范规定 undefined 是只读的,不能再被重写。


但为什么还有老司机在用 void 0 呢?



  1. 习惯成自然:用了十几年,改不过来了

  2. 代码压缩void 0undefined字符更少

  3. 装逼必备:一看就是用 void 0 的,肯定是老鸟(手动狗头)


所以,到底用不用?


我的建议是:知道为什么用,比用什么更重要


如果你是为了代码风格统一,或者团队约定,用 void 0 没问题。


如果只是为了装老司机... 兄弟,真没必要。现在面试官看到 void 0,第一反应可能是:"这人是刚从 jQuery 时代穿越过来的吗?"




最后送大家一句话:技术选型就像穿衣服,合适比时髦更重要。  知道每个工具为什么存在,比你盲目跟风要强得多。


作者:hmfy
来源:juejin.cn/post/7563635016283668531
收起阅读 »

面试官:手写一个深色模式切换过渡动画

web
在开发Web应用时,深色模式已成为现代UI设计的标配功能。然而,许多项目在实现主题切换时仅简单改变CSS变量,缺乏平滑的过渡动画,导致用户体验突兀。作为开发者,我们常被期望在满足功能需求的同时,打造更精致的用户交互体验。面试中,被问及"如何实现流畅的深色模式切...
继续阅读 »

在开发Web应用时,深色模式已成为现代UI设计的标配功能。然而,许多项目在实现主题切换时仅简单改变CSS变量,缺乏平滑的过渡动画,导致用户体验突兀。作为开发者,我们常被期望在满足功能需求的同时,打造更精致的用户交互体验。面试中,被问及"如何实现流畅的深色模式切换动画"时,很多人可能只答出使用CSS transition,而忽略了现代浏览器的View Transitions API这一高级解决方案。


读完本文,你将掌握:



  1. 使用View Transitions API实现流畅的主题切换动画

  2. 理解深色模式切换的核心原理与实现细节

  3. 能够将这套方案应用到实际项目中,提升用户体验


image.png

前言


在实际项目中,深色模式切换几乎是前端的“标配”。常见做法是通过 classList.toggle("dark") 切换样式,再配合 transition 做淡入淡出。然而,这种效果在用户体验上略显生硬:颜色瞬间大面积切换,即便有渐变也会显得突兀。


随着 View Transitions API 的出现,我们可以给“页面状态切换”添加炫酷的过渡动画。今天就带大家实现一个 以点击位置为圆心、扩散切换主题的深色模式动画,读完本文你将收获:



  • 了解 document.startViewTransition 的工作原理

  • 学会用 clipPath + animate 控制圆形扩散动画




核心铺垫:我们需要解决什么问题?


在设计方案前,先明确 3 个核心目标:



  1. 流畅过渡:避免普通 transition 的“整体闪烁”,实现局部扩散过渡。

  2. 交互感强:以用户点击位置为动画圆心,符合直觉。

  3. 可扩展:方案可适配 Vue3 组件体系,不依赖复杂第三方库。


为此,我们需要用到几个关键技术点:



  • View Transitions API:提供 document.startViewTransition,可以对 DOM 状态切换设置过渡动画。

  • clip-path:通过 circle(r at x y) 定义动画圆形,从 0px 扩展到最大半径。

  • computeMaxRadius:计算从点击点到四角的最大距离,确保圆形覆盖全屏。

  • .animate:使用 document.documentElement.animate 精确控制过渡过程。


Math.hypot:计算平面上点到原点的距离


Math.hypot()是ES2017引入的一个JavaScript函数,用于计算所有参数平方和的平方根,即计算n维欧几里得空间中从原点到指定点的距离。


image.png

在深色模式切换动画中,我们使用它来计算覆盖整个屏幕的最大圆形半径:


斜边计算


Math.hypot(maxX, maxY):使用勾股定理计算从点击点到对角的距离


image.png


clip-path


recording.gif

clip-path是CSS属性,允许我们定义元素的可见区域,将其裁剪为基本形状或SVG路径。在深色模式切换动画中,我们用它创建从点击点向外扩散的圆形动画效果。


<basic-shape>一种形状,其大小和位置由 <geometry-box> 的值定义。如果没有指定 <geometry-box>,则将使用 border-box 用为参考框。取值可为以下值中的任意一个:



  • inset()


    定义一个 inset 矩形。


  • circle()


    定义一个圆形(使用一个半径和一个圆心位置)。


  • ellipse()


    定义一个椭圆(使用两个半径和一个圆心位置)。


  • polygon()


    定义一个多边形(使用一个 SVG 填充规则和一组顶点)。


  • path()


    定义一个任意形状(使用一个可选的 SVG 填充规则和一个 SVG 路径定义)。



这里使用circle()来实现效果


该函数接受以下参数:



  • 半径:定义圆形的大小(0px到计算的最大半径)

  • at关键词:分隔半径和中心点位置

  • 中心点位置:使用x y坐标指定圆形中心


startViewTransition:浏览器视图转换API


基本概念


document.startViewTransition()是View Transitions API的核心方法,它告诉浏览器DOM即将发生变化,并允许我们为这些变化创建平滑的过渡动画。


生命周期与关键事件



  1. 调用startViewTransition:浏览器准备开始视图转换

  2. 执行回调函数:DOM状态更新

  3. transition.ready事件:视图转换准备就绪,可以应用动画

  4. 视图转换完成:动画结束,新状态成为稳定状态


浏览器兼容性处理


在实际应用中,我们需要检查浏览器是否支持此API:


const isAppearanceTransition =
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!isAppearanceTransition) {
// 不支持View Transitions API时的降级处理
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
return;
}

这种处理确保在不支持新特性的浏览器中,功能仍然可用,只是没有动画效果。


核心实现:从逻辑到代码


graph TD

A[用户点击切换按钮] --> B{浏览器是否支持<br/>View Transitions API?}
B -- 否 --> C[直接切换主题变量<br/>无动画效果]
B -- 是 --> D[获取点击坐标X,Y]
D --> E[计算覆盖全屏的最大半径]
E --> F[启动视图转换]
F --> G[执行回调函数<br/>更新isDark状态]
G --> H[设置HTML的dark class<br/>更新CSS变量]
H --> I[等待DOM更新完成<br/>nextTick]
I --> J[视图转换准备就绪]
J --> K[应用clipPath动画<br/>从点击点向外扩散]
K --> L[动画完成<br/>主题切换完成]

style B fill:#f9f,stroke:#333,stroke-width:2px
style K fill:#9cf,stroke:#333,stroke-width:2px


  1. 用户交互:用户点击切换按钮,触发主题切换流程

  2. 浏览器兼容性检查:判断当前浏览器是否支持View Transitions API

  3. 降级处理:在不支持API的浏览器中直接切换主题

  4. 动画核心逻辑



    • 获取点击位置作为动画起点

    • 计算覆盖全屏的最大半径

    • 启动视图转换过程



  5. 状态更新:实际执行主题状态更新和CSS类设置

  6. 动画触发:在视图转换准备就绪后,应用clipPath动画效果

  7. 完成:动画结束,新主题状态稳定


步骤 1:封装主题切换


    function setupThemeClass(isDark) {
document.documentElement.classList.toggle("dark", isDark);
localStorage.setItem("theme", isDark ? "dark" : "light");
}

作用:控制 html.dark 类名,完成主题切换。




步骤 2:计算扩散最大半径


    function computeMaxRadius(x, y) {
const maxX = Math.max(x, window.innerWidth - x);
const maxY = Math.max(y, window.innerHeight - y);
return Math.hypot(maxX, maxY); // √(maxX² + maxY²)
}


作用:确保无论点击哪里,扩散圆都能覆盖屏幕。




步骤 3:触发 View Transition


    function onToggleClick(event) {
const isSupported =
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (!isSupported) {
// 回退方案:直接切换
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
return;
}

const x = event.clientX;
const y = event.clientY;
const endRadius = computeMaxRadius(x, y);

// 开启视图过渡
const transition = document.startViewTransition(async () => {
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
await nextTick(); // 等 Vue DOM 更新
});

transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
];

document.documentElement.animate(
{
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
},
{
duration: 450,
easing: "ease-in",
pseudoElement: isDark.value
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
);
});
}

要点:


*startViewTransition 接收一个回调函数,里面执行 DOM 更新(切换主题)。


*transition.ready.then(...) 可以在 DOM 更新后定义动画效果。


*clipPath 数组定义了从 小圆 → 大圆 的扩散过程。


*pseudoElement 控制是对 新视图 还是 旧视图 应用动画。




步骤 4:覆盖默认过渡样式



::view-transition-new(root),
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}

::view-transition-old(root) {
z-index: 1;
}

::view-transition-new(root) {
z-index: 2147483646;
}

html.dark::view-transition-old(root) {
z-index: 2147483646;
}

html.dark::view-transition-new(root) {
z-index: 1;
}

作用:取消默认动画,手动用 clipPath 控制。通过 z-index 确保层级正确,否则可能看到“旧页面覆盖新页面”的异常。




效果演示


recording.gif

运行后:



  • 点击切换按钮时,以点击点为圆心,圆形扩散覆盖全屏,主题在扩散动画过程中完成切换。

  • 若浏览器不支持 View Transitions API(如 Safari),则自动降级为普通切换,不影响使用。


完整demo





延伸与避坑



  1. 兼容性问题



    • View Transitions API 目前在 Chromium 内核浏览器(Chrome 111+、Edge)可用,Safari/Firefox 尚未支持。

    • 可加上 isSupported 判断,优雅降级。



  2. 性能优化



    • 动画时建议避免页面过多重绘(如大量图片加载),否则会掉帧。

    • clip-path 本身是 GPU 加速属性,性能较好。



  3. 扩展思路



    • 除了圆形扩散,还可以用 polygon() 实现“百叶窗切换”或“对角线切换”。

    • 可以结合 路由切换 做“页面级过渡动画”。






总结


本文我们用 Vue3 + Element Plus + View Transitions API 实现了一个点击扩散式的深色模式切换动画,核心要点:



  • startViewTransition:声明 DOM 状态切换的动画上下文。

  • clipPath + animate:控制过渡动画形状与过程。

  • computeMaxRadius:计算圆形覆盖全屏的半径。

  • 优雅降级:确保不支持 API 的浏览器仍能正常切换。


作者:张海潮
来源:juejin.cn/post/7546326670648328219
收起阅读 »

为VSCode扩展开发量身打造的UI库 - vscode-elements

web
大家好,我是农村程序员,独立开发者,行业观察员,前端之虎陈随易。我会在这里分享关于 独立开发、编程技术、思考感悟 等内容,欢迎关注。 技术群与交朋友请在个人网站联系我,网站 1️⃣:chensuiyi.me,网站 2️⃣:me.yicode.tech。 如果你...
继续阅读 »

大家好,我是农村程序员,独立开发者,行业观察员,前端之虎陈随易。我会在这里分享关于 独立开发编程技术思考感悟 等内容,欢迎关注。


技术群与交朋友请在个人网站联系我,网站 1️⃣:chensuiyi.me,网站 2️⃣:me.yicode.tech


如果你觉得本文有用,一键三连 (点赞评论转发),就是对我最大的支持~





最近抽空在做我的 VSCode 插件 fnMap (函数地图) 的重构工作。



项目结构主要分为 3 部分:



  1. src-extension 是扩展的核心能力部分 (相当于后端)。

  2. src-webview 是界面展示部分 (相当于前端)。

  3. src-wasm 是新添加的部分,使用国产编程语言 MoonBit 来写,主要功能就是提供性能优化与部分核心代码加密。



那么重构呢,我想把 UI 换一下,目前用的是 arco-design-vue,字节出品的一个 UI 框架。



本来蛮喜欢的,但根据最近的更新来看,官方主要做 React 版本去了,Vue 版本 4 个多月没动静了。


而且,VSCode 有主题功能,框架如果要适配主题,那得进行不少魔改微调。



于是,我上下求索,找到这样一个为 VSCode 量身打造的 UI 库 vscode-elements


这效果,与 VSCode 简直绝配。


开源地址在这:https://github.com/vscode-elements/elements


如果你也在开发 VSCode 扩展,不妨了解一下这个。



在我这目前看到的唯一的缺点呢,就是还没有 Vue 版本。



目前的主要版本是基于 Lit 这个框架开发的,也就是前端标准的 Web Components 技术。



我跟踪这个框架几个星期了,今天 vscode-elements 刚发布 v2.0 版本,是时候为我的 fnMap 提供一份力量了。


也欢迎大家来体验我的 fnMap 插件,8月份 (本月) 将会发布 v9.0 版本,在进化的路上,一路前进。


作者:前端之虎陈随易
来源:juejin.cn/post/7533807870188470311
收起阅读 »

忍了一年多,我终于对i18n下手了

web
前言 大家好,我是奈德丽。 过去一年,我主要参与国际机票业务的开发工作,因此每天都要和多语言(i18n)打交道。熟悉我的朋友都知道,我这个人比较“惜力”(并不是,实际上只是忍不下去了),对于重复笨拙的工作非常抵触,于是,我开始思考如何优化团队的多语言管理模式。...
继续阅读 »

前言


大家好,我是奈德丽。


过去一年,我主要参与国际机票业务的开发工作,因此每天都要和多语言(i18n)打交道。熟悉我的朋友都知道,我这个人比较“惜力”(并不是,实际上只是忍不下去了),对于重复笨拙的工作非常抵触,于是,我开始思考如何优化团队的多语言管理模式。


痛点背景


先说说我们在机票项目中遇到的困境。


目前机票项目分为 H5 和 PC 两端,团队在维护多语言时主要通过在线 Excel进行管理:



  • 一个 Excel 文件,H5 和 PC 各自占一个 sheet 页;

  • 每次更新语言,需要先导出 Excel,然后手动跑脚本生成语言文件,再拷贝到项目中。


听起来还算凑合,但随着项目规模的扩大,问题逐渐显现:



  1. Key 命名混乱



    • 有的首字母大写,有的小驼峰、大驼峰混用;

    • 没有统一规则,难以模块化管理。



  2. 不支持模块化



    • 目前已有数千条 key

    • 查找、修改、维护都非常痛苦。



  3. 更新流程繁琐



    • 需要手动进入脚本目录,用 node 跑脚本;

    • 生成后再手动复制到项目中。




下面是一个实际的 Excel 片段,可以感受一下当时的混乱程度:


image.png


用原node脚本生成的语言文件如图


image.png


在这样的场景下,每次迭代多语言文件更新都像噩梦一样

尤其是我们很多翻译是通过AI 机翻生成,后续频繁修改的成本极高。


然而,机票项目的代码量太大、历史包袱太重,短期内几乎不可能彻底改造


image.png


新项目,新机会


机票项目虽然不能动,但在我们启动酒店业务新项目时,我决定不能再重蹈覆辙。

因此,在酒店项目中,我从零搭建了这套更高效的 i18n 管理方案。


目标很简单:



  1. 统一 key 规则,支持模块化,模块与内容间用.隔开,内容之间用下划线隔开;

  2. 自动化生成多语言 JSON 文件,集成到项目内,不再需要查找转化脚本的位置;

  3. 一条命令搞定更新,不需要手动拷贝。


于是,我在项目中新增了一个 scripts 目录,并编写了一个 excel-to-json.js 脚本。

package.json 中添加如下命令:


{
"scripts": {
"i18n:excel-to-json": "node scripts/excel-to-json.js"
}
}

以后,只需要运行下面一行命令,就能完成所有工作:


pnpm i18n:excel-to-json

再也不用手动寻找脚本路径,也不用手动复制粘贴,效率直接起飞 🚀


脚本实现


核心逻辑就是:

从 Excel 读取内容 → 转换为 JSON → 输出到项目 i18n 目录


完整代码如下:


import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import XLSX from 'xlsx'

/**
* 语言映射表:Excel 表头 -> 标准语言码
*/

const languageMap = {
'English': 'en',
'简中': 'zh-CN',
'Chinese (Traditional)': 'zh-TW',
'Korean': 'ko',
'Spanish': 'es',
'German Edited': 'de',
'Italian': 'it',
'Norwegian': 'no',
'French': 'fr',
'Arabic': 'ar',
'Thailandese': 'th',
'Malay': 'ms',
}

// 读取 Excel 文件
function readExcel(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`❌ Excel 文件未找到: ${filePath}`)
}
const workbook = XLSX.readFile(filePath)
const sheet = workbook.Sheets[workbook.SheetNames[0]]
return XLSX.utils.sheet_to_json(sheet)
}

/**
* 清空输出目录
*/

function clearOutputDir(dirPath) {
if (fs.existsSync(dirPath)) {
fs.readdirSync(dirPath).forEach(file => fs.unlinkSync(path.join(dirPath, file)))
console.log(`🧹 已清空目录: ${dirPath}`)
} else {
fs.mkdirSync(dirPath, { recursive: true })
console.log(`📂 创建目录: ${dirPath}`)
}
}

/**
* 生成 JSON 文件
*/

function generateLocales(rows, outputDir) {
const locales = {}

rows.forEach(row => {
const key = row.Key
if (!key) return

// 遍历语言列
Object.entries(languageMap).forEach(([columnName, langCode]) => {
if (!locales[langCode]) locales[langCode] = {}

const value = row[columnName] || ''
const keys = key.split('.')
let current = locales[langCode]

keys.forEach((k, idx) => {
if (idx === keys.length - 1) {
current[k] = value
} else {
current[k] = current[k] || {}
current = current[k]
}
})
})
})

// 输出文件
Object.entries(locales).forEach(([lang, data]) => {
const filePath = path.join(outputDir, `${lang}.json`)
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
console.log(`✅ 生成文件: ${filePath}`)
})
}

/**
* 检测缺失翻译
*/

function detectMissingTranslations(rows) {
const missing = []
rows.forEach(row => {
const key = row.Key
if (!key) return

Object.entries(languageMap).forEach(([columnName, langCode]) => {
const value = row[columnName]
if (!value?.trim()) {
missing.push({ key, lang: langCode })
}
})
})
return missing
}

function logMissingTranslations(missingList) {
if (missingList.length === 0) {
console.log('\n🎉 所有 key 的翻译完整!')
return
}

console.warn('\n⚠️ 以下 key 缺少翻译:')
missingList.forEach(item => {
console.warn(` - key: "${item.key}" 缺少语言: ${item.lang}`)
})
}

function main() {
const desktopPath = path.join(os.homedir(), 'Desktop', 'hotel多语言.xlsx')
const outputDir = path.resolve('src/i18n/locales')

const rows = readExcel(desktopPath)
clearOutputDir(outputDir)
generateLocales(rows, outputDir)
logMissingTranslations(detectMissingTranslations(rows))
}

main()

成果展示


这是在线语言原文档


image.png


这是生成后的多语言文件和内容
image.png


现在的工作流大幅简化:


操作旧流程新流程
运行脚本手动找脚本路径pnpm i18n:excel-to-json
文件生成位置生成后手动拷贝自动输出到项目
检测缺失翻译自动提示
key 命名管理无统一规则模块化、规范化

这套机制目前在酒店项目中运行良好,团队反馈也很积极。


总结


这次改造让我最大的感触是:



旧项目难以推翻重来,但新项目一定要趁早做好架构设计。



通过这次优化,我们不仅解决了多语言维护的痛点,还提升了团队整体开发效率。

而这套方案在未来如果机票项目有机会重构,也可以直接平滑迁移过去。


作者:奈德丽
来源:juejin.cn/post/7553105607417053194
收起阅读 »

实现一个 AI 编辑器 - 行内代码生成篇

web
我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。 本文作者:佳岚 什么是行内代码生成? 通过一组快捷键(一般为cmd + k)在选中代码块或者光标处唤起 Prompt 命令...
继续阅读 »

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。




本文作者:佳岚



什么是行内代码生成?


通过一组快捷键(一般为cmd + k)在选中代码块或者光标处唤起 Prompt 命令弹窗,并且快速的应用生成的代码。


提示词系统


首先是完成一个简易的提示词系统,不同功能对应的提示词与提供的上下文不同, 定义不同的功能场景:


export enum PromptScenario {
SYNTAX_COMPLETION = 'syntax_completion', // 语法补全
CODE_GENERATION = 'code_generation', // 代码生成
CODE_EXPLANATION = 'code_explanation', // 代码解释
CODE_OPTIMIZATION = 'code_optimization', // 代码优化
ERROR_FIXING = 'error_fixing', // 错误修复
}

每种场景都有对应的系统 prompt 和用户 prompt 模板:


export const PROMPT_TEMPLATES: Record<PromptScenario, PromptTemplate> = {
[PromptScenario.SYNTAX_COMPLETION]: {
id: 'syntax_completion',
scenario: PromptScenario.SYNTAX_COMPLETION,
title: 'SQL语法补全',
description: '基于上下文进行智能的SQL语法补全',
systemPromptTemplate: ``,
userPromptTemplate: `<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>`,
temperature: 0.2,
maxTokens: 256
},

[PromptScenario.CODE_GENERATION]: {
id: 'code_generation',
scenario: PromptScenario.CODE_GENERATION,
title: 'SQL代码生成',
description: '根据需求描述生成相应的SQL代码',
systemPromptTemplate: `你是{languageName}数据库专家。根据用户需求生成高质量的{languageName}代码。

语言特性:{languageFeatures}

生成要求:
1. 严格遵循 {languageName} 语法规范
2. {syntaxNotes}
3. 生成完整、可执行的SQL语句
4. {performanceTips}
5. 考虑代码的可读性和维护性
6. 回答不要包含任何对话解释内容
7. 保持缩进与参考代码一致`
,
userPromptTemplate: `用户需求:{userPrompt}

参考代码:
\`\`\`sql
{selectedCode}
\`\`\`

请生成符合需求的{languageName}代码:`
,
temperature: 0.3,
maxTokens: 512
},
// ...其他略
}

收集以下上下文信息并动态替换掉提示词模板的变量以生成最终传递给大模型的提示词:


/**
* 上下文信息
*/

export interface PromptContext {
/** 当前语言ID */
languageId: string;
/** 光标前的代码 */
prefix?: string;
/** 光标后的代码 */
suffix?: string;
/** 当前文件完整代码 */
fullCode?: string;
/** 当前打开的文件名 */
activeFile?: string;
/** 用户输入的提示 */
userPrompt?: string;
/** 选中的代码 */
selectedCode?: string;
/** 错误信息 */
errorMessage?: string;
/** 额外的上下文信息 */
metadata?: Record<string, any>;
}


ViewZone


观察该 Widget 可以发现它是实际占据了一段代码行高度,撑开了上下代码,但没有行号,这是通过 ViewZone实现的。



monaco-editor 中的 viewZone 是一种可以在编辑器的文本行之间自定义插入可视区域的机制,不属于实际代码内容,但可以渲染任意自定义 DOM 内容或空白空间。


核心只有一个changeViewZones,必须使用其回调中的accessor来实现新增删除ViewZone操作


新增示例:


editor.changeViewZones(function (accessor) {
accessor.addZone({
afterLineNumber: 10, // 插入在哪一行后(基于原始代码行号)
heightInLines: 3, // zone 的高度(按行数)
heightInPx: 10, // zone 的高度(按像素), 与heightInLines二选一
domNode: document.createElement('div'), // 需要插入的 DOM 节点
});
});

删除示例:


editor.changeViewZones(accessor => {
if (zoneIdRef.current !== null) {
accessor.removeZone(zoneIdRef.current);
}
});

但需要注意的是,ViewZones 的视图层级是在可编辑区之下的,我们通过 domNode 创建弹窗后,无法响应点击,所以需要手动为 domNode 添加 z-Index。



但我们咱不用 domNode 直接渲染我们的弹窗组件,而是通过 ViewZone 结合 OverlayWidget 的方式去添加我们要的元素。


OverlayWidget 的层级比可编辑区域的更高,无需考虑层级覆盖问题。


其次,我们需要将 Overlay 的元素通过绝对定位移动到 ViewZone 上,这需要利用 ViewZone 的 onDomNodeTop来实时同步两者的定位。


monaco-editor 中的代码行与 ViewZone 使用了虚拟列表,它们的 top 在滚动时会随着可见性不断变化,所以需要随时同步 ,onDomNodeTop会在每次 ViewZone 的top属性变化时执行。


此外,OverlayWidget 是以整个编辑器最左边为基准的,计算时需要考虑上


editorInstance.changeViewZones((changeAccessor) => {
viewZoneId = changeAccessor.addZone({
// ...略
onDomNodeTop: (top) => {
// 这里的domNode为overlayWidget所绑定创建的节点
if (domNode) {
// 获取编辑器左侧偏移量(行号、代码折叠等组件的宽度)
const layoutInfo = editorInstance.getLayoutInfo();
const leftOffset = layoutInfo.contentLeft;

domNode.style.top = `${top}px`;
domNode.style.left = `${leftOffset}px`;
domNode.style.width = `${layoutInfo.contentWidth}px`;
}
}
});
});

创建 OverlayWidget :


let overlayWidget: editor.IOverlayWidget | null = null;
let domNode: HTMLDivElement | null = null;
let reactRoot: any = null;

domNode = document.createElement('div');
domNode.className = 'code-generation-overlay-widget';
domNode.style.position = 'absolute';

reactRoot = createRoot(domNode);
reactRoot.render(<CodeGenerationWidget />)

overlayWidget = {
getId: () => `code-generation-overlay-${position.lineNumber}-${Date.now()}`,
getDomNode: () => domNode!,
getPosition: () => null
};

editorInstance.addOverlayWidget(overlayWidget);

// 唤起时,将 widget 滚动到视口
editorInstance.revealLineInCenter(targetLineNumber);

CodeGenerationWidget 动态高度


接下来我们实现 Prompt 输入框根据内容动态调整高度。



输入框部分我们可以直接用 rc-textarea 组件来实现回车自动新增高度。


监听整个容器高度变化触发 onHeightChange 以通知 ViewZone


	useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(() => {
onHeightChange?.();
});
observer.observe(containerRef.current);

return () => {
observer.disconnect();
};
}, [containerRef]);

注意 ViewZone 只能增或删,不能手动改变其高度,所以需要重新创建一个:


reactRoot.render(
<CodeGenerationWidget
editorInstance={editorInstance}
initialPosition={position}
initialSelection={selection}
widgetWidth={widgetWidth}
onClose={() =>
dispose()}
onHeightChange={() => {
// 高度变化时需要更新ViewZone
if (viewZoneId && domNode) {
const actualHeight = domNode.clientHeight;
editorInstance.changeViewZones((changeAccessor) => {
changeAccessor.removeZone(viewZoneId!);
viewZoneId = changeAccessor.addZone({
afterLineNumber: Math.max(0, targetLineNumber - 1),
heightInPx: actualHeight + 8,
domNode: document.createElement('div'),
onDomNodeTop: (top) => {
if (domNode) {
// 获取编辑器左侧偏移量(行号、代码折叠等组件的宽度)
const layoutInfo = editorInstance.getLayoutInfo();
const leftOffset = layoutInfo.contentLeft;

domNode.style.top = `${top}px`;
domNode.style.left = `${leftOffset}px`;
}
}
});
});
}
}}
/>

);

这里如果使用 ViewZone 的 domNode 来渲染组件的方法的话,由于每次高度变化创建新的 ViewZone , 其 domNode 会被重新挂载,那么就会导致每次高度变化时输入框都会失焦。


生成代码 diff 展示


对于选择了代码行后生成,会对原始代码进行编辑修改,我们需要配合行 diff 进行编辑应用结果的展示。对于删除的行使用 ViewZone 进行插入,对于新增的行使用 Decoration 进行高亮标记。



首先需要实现 diff 计算出这些行的信息。 我们需要以最少的操作实现从原始代码到目标代码的转化。



其核心问题是 最长公共子序列(LCS)。最长公共子序列(LCS )是指在两个或多个序列中,找出一个最长的子序列,使得这个子序列在这些序列中都出现过。与子串不同,子序列不需要在原序列中占用连续的位置。


如 ABCDEF 至 ACEFG , 那么它们的最长公共子序列是 ACEF 。


其算法可以参考 cloud.tencent.com/developer/a… 学习,这里我们直接就使用现成的库jsdiff 去实现了。


完整实现:


export enum DiffLineType {
UNCHANGED = 'unchanged',
ADDED = 'added',
DELETED = 'deleted'
}

export interface DiffLine {
type: DiffLineType;
originalLineNumber?: number; // 原始行号
newLineNumber?: number; // 新行号
content: string; // 行内容
}

/**
* 计算两个字符串数组的diff
*/

export const calculateDiff = (originalLines: string[], newLines: string[]): DiffLine[] => {
const result: DiffLine[] = [];

// 将字符串数组转换为字符串
const originalText = originalLines.join('\n');
const newText = newLines.join('\n');

// 使用 diff 库计算差异
const diffs = diffLines(originalText, newText);

let originalLineNumber = 1;
let newLineNumber = 1;

diffs.forEach(diff => {
if (diff.added) {
// 添加的行
const lines = diff.value.split('\n').filter((line, index, arr) =>
// 过滤掉最后一个空行(如果存在)
!(index === arr.length - 1 && line === '')
);

lines.forEach(line => {
result.push({
type: DiffLineType.ADDED,
newLineNumber: newLineNumber++,
content: line
});
});
} else if (diff.removed) {
// 删除的行
const lines = diff.value.split('\n').filter((line, index, arr) =>
// 过滤掉最后一个空行(如果存在)
!(index === arr.length - 1 && line === '')
);

lines.forEach(line => {
result.push({
type: DiffLineType.DELETED,
originalLineNumber: originalLineNumber++,
content: line
});
});
} else {
// 未变化的行
const lines = diff.value.split('\n').filter((line, index, arr) =>
// 过滤掉最后一个空行(如果存在)
!(index === arr.length - 1 && line === '')
);

lines.forEach(line => {
result.push({
type: DiffLineType.UNCHANGED,
originalLineNumber: originalLineNumber++,
newLineNumber: newLineNumber++,
content: line
});
});
}
});

return result;
};


那么接下来我们只要根据计算出的 diffLines 对删除行和新增行进行视觉展示即可。


我们封装一个 applyDiffDisplay 方法用来展示 diffLines


有以下步骤:



  1. 清除之前的结果

  2. 直接将选区内容替换为生成内容

  3. 遍历 diffLinesADDEDDELETED 的行:对于 DELETED 的行,可以多个连续行组成一个 ViewZone 创建以优化性能;对于ADDED的行,通过 deltaDecorations 添加背景装饰


const applyDiffDisplay =
(diffLines: DiffLine[]) => {
// 先清除之前的展示
clearDecorations();
clearDiffOverlays();

if (!initialSelection) return;

const model = editorInstance.getModel();
if (!model) return;

// 获取语言ID用于语法高亮
const languageId = getLanguageId();

// 首先替换原始内容为新内容(包含unchanged的行)
const newLines = diffLines
.filter((line) => line.type !== DiffLineType.DELETED)
.map((line) => line.content);
const newContent = newLines.join('\n');

// 执行替换
editorInstance.executeEdits('ai-code-generation-diff', [
{
range: initialSelection,
text: newContent,
forceMoveMarkers: true
}
]);

// 计算新内容的范围
const resultRange = new Range(
initialSelection.startLineNumber,
initialSelection.startColumn,
initialSelection.startLineNumber + newLines.length - 1,
newLines.length === 1
? initialSelection.startColumn + newContent.length
: newLines[newLines.length - 1].length + 1
);

let currentLineNumber = initialSelection.startLineNumber;
let deletedLinesGr0up: DiffLine[] = [];

for (const diffLine of diffLines) {
if (diffLine.type === DiffLineType.DELETED) {
// 收集连续的删除行
deletedLinesGr0up.push(diffLine);
} else {
if (deletedLinesGr0up.length > 0) {
addDeletedLinesViewZone(deletedLinesGr0up, currentLineNumber - 1, languageId);
deletedLinesGr0up = [];
}

if (diffLine.type === DiffLineType.ADDED) {
// 添加绿色背景色
const addedDecorations = editorInstance.deltaDecorations(
[],
[
{
range: new Range(
currentLineNumber,
1,
currentLineNumber,
model.getLineContent(currentLineNumber).length + 1
),
options: {
className: 'added-line-decoration',
isWholeLine: true
}
}
]
);
decorationsRef.current.push(...addedDecorations);
}

currentLineNumber++;
}
}

// 处理最后的删除行组
if (deletedLinesGr0up.length > 0) {
addDeletedLinesViewZone(deletedLinesGr0up, currentLineNumber - 1, languageId);
}

return resultRange;
}



删除行的视觉呈现


删除行使用 ViewZone 插入到 originalLineNumber - 1 的位置, 对于删除行直接使用 ViewZone 自身的 domNode 进行展示了,因为不太需要考虑层级问题。


export const createDeletedLinesOverlayWidget = (
editorInstance: editor.IStandaloneCodeEditor,
deletedLines: DiffLine[],
afterLineNumber: number,
languageId: string,
onDispose?: () => void
): { dispose: () => void } => {
let domNode: HTMLDivElement | null = null;
let reactRoot: any = null;
let viewZoneId: string | null = null;

domNode = document.createElement('div');
domNode.className = 'deleted-lines-view-zone-container';

reactRoot = createRoot(domNode);

reactRoot.render(<DeletedLineViewZone lines={deletedLines} languageId={languageId} />);

const heightInLines = Math.max(1, deletedLines.length);
editorInstance.changeViewZones((changeAccessor) => {
viewZoneId = changeAccessor.addZone({
afterLineNumber,
heightInLines,
domNode: domNode!
});
});

const dispose = () => {
// 清除
};

return { dispose };
};


添加命令快捷键


使用 cmd + k 唤起弹窗


editorInstance.onKeyDown((e) => {
if ((e.ctrlKey || e.metaKey) && e.keyCode === KeyCode.KeyK) {
e.preventDefault();
e.stopPropagation();

const selection = editorInstance.getSelection();
const position = selection ? selection.getPosition() : editorInstance.getPosition();

if (!position) return;

// 如果有选择范围,则将其传递给widget供后续替换使用
const selectionRange = selection && !selection.isEmpty() ? selection : null;

// 如果已经有viewZone,先清理
if (activeCodeGenerationViewZone) {
activeCodeGenerationViewZone.dispose();
activeCodeGenerationViewZone = null;
}

// 创建新的ViewZone
activeCodeGenerationViewZone = createCodeGenerationOverlayWidget(
editorInstance,
position,
selectionRange,
undefined, // widgetWidth
() => {
// 当viewZone被dispose时清理全局状态
activeCodeGenerationViewZone = null;
}
);
}

最终实现效果:


未来优化方向:



  1. 实现流式生成:对于未选区的代码生成,我们不需要应用diff,所以流式很好实现,但对于进行选区后进行的代码修改,每次输出一行就要执行一次diff计算与展示,diff结果可能不同,会产生视觉上的重绘,实现起来也相对比较麻烦。


  2. 接收或者拒绝后能够进行撤回,回到等待响应生成结果时的状态


其他计划



  • [已完成] 行内补全

  • [已完成] 代码生成

  • 行内补全的缓存设计

  • 完善的上下文系统

  • 实现 Agent 模式


在线预览


jackwang032.github.io/monaco-sql-…


仓库代码:github1s.com/JackWang032…


最后


欢迎关注【袋鼠云数栈UED团队】~

袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star



作者:袋鼠云数栈UED团队
来源:juejin.cn/post/7545087770776616986
收起阅读 »

【前端效率工具】:告别右键另存,不到 50 行代码一键批量下载网页图片

web
前端还原页面你肯定干过吧?像仿 xxx 首页那种。收素材时最烦的就是一张张存图,慢不说还老漏。跟我用 10 分钟做个chrome小插件,点一下,整页图片全下到本地先看效果:在素材网站一键批量保存所有图片废话不多说,直接上手!项目结构image-download...
继续阅读 »

前端还原页面你肯定干过吧?像仿 xxx 首页那种。收素材时最烦的就是一张张存图,慢不说还老漏。

跟我用 10 分钟做个chrome小插件,点一下,整页图片全下到本地

先看效果:在素材网站一键批量保存所有图片

批量下载.gif

废话不多说,直接上手!

项目结构

image-downloader-extension
├── manifest.json # 扩展的"身-份-证"
└── background.js # 插件后台脚本
  1. 创建文件夹 image-downloader-extension

  2. 创建manifest.json文件

这个文件是插件的身-份-证,告诉浏览器你的插件是谁、能干啥。

{
"manifest_version": 3,
"name": "我的下载插件",
"version": "1.0.0",
"permissions": ["contextMenus", "downloads", "scripting"],
"host_permissions": [""],
"background": {
"service_worker": "background.js"
}
}

关键点解读:

字段说明
manifest_version: 3使用最新的 Manifest V3 扩展规范
name插件名称
version插件版本号
permissions申请权限(contextMenus 创建右键菜单,downloads下载)
  1. 创建background.js文件

background.js后台脚本负责创建并响应右键菜单等事件来下载页面图片

// 1. 插件安装时创建右键菜单
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'downloadAllImages', // 菜单唯一标识
title: '我要下载所有图片', // 菜单显示的文字
contexts: ['page'], // 在页面任意位置右键时显示
});
});

// 2. 监听右键菜单点击事件
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'downloadAllImages') {
// 使用 scripting API 在当前页面执行脚本获取所有图片
chrome.scripting.executeScript(
{
target: { tabId: tab.id },
func: getImagesFromPage,
},
(results) => {
// 获取执行结果
if (!results || !results[0]?.result || results[0].result.length === 0) {
console.log('未找到图片');
return;
}
const images = results[0].result;
// 批量下载图片
images.forEach((url, index) => {
setTimeout(() => {
chrome.downloads.download({
url: url,
filename: `images/image_${index + 1}.jpg`, // 保存路径
saveAs: false, // 不弹出保存对话框
});
}, index * 500); // 每张图片间隔 500ms,避免浏览器限制
});
}
);
}
});

// 在页面中执行的函数,用于获取所有图片URL
function getImagesFromPage() {
const images = Array.from(document.images)
.map((img) => img.src)
.filter((src) => src.startsWith('http'));

return images;
}

API 文档速查

4. 加载插件到浏览器

接下来我们将插件加载到浏览器中

插件加载.gif

步骤:
4.1 打开扩展管理页面 在 Chrome 地址栏输入 chrome://extensions/ 并回车
4.2 开启开发者模式
4.3 点击 “加载未打包的扩展程序”

选择刚刚创建的image-downloader-extension文件夹进行加载

4.4 插件加载成功

你会看到插件出现在列表中

企业微信截图_17601472945027.png

至此,我们的下载插件就搞完了,是不是非常容易?

测试(验证功能)

接下来我们随便打开一个网站,点击鼠标右键,就会发现右键菜单多了一个选项

右键选项.png

点击“我要下载所有图片” 即可实现我们的需求了

调试(查看 background.js日志与断点)

如下图:点击插件的 Service Worker 入口,会弹出调试面板。

在该面板中你可以

  • 实时查看 background.js 的 console日志输出;
  • 在代码中设置断点调试以排查问题。

serviceWorker调试.gif

总结

这一次带你用一个小巧的 Chrome 插件,一键把当前网页的所有图片下载下来,希望对你有所帮助


作者:不一样的少年_
来源:juejin.cn/post/7559124639323242506
收起阅读 »

Token已过期,我是如何实现无感刷新Token的?

web
我们来想象一个场景:你正在一个电商网站上,精心挑选了半小时的商品,填好了复杂的收货地址,满心欢喜地点击提交订单 Button。 突然,页面Duang🎈地一下,跳转到了登录页,并提示你:“登录状态已过期,请重新登录”。 那一刻,你的内心是什么感受?我想大概率是崩...
继续阅读 »

image.png


我们来想象一个场景:你正在一个电商网站上,精心挑选了半小时的商品,填好了复杂的收货地址,满心欢喜地点击提交订单 Button。


突然,页面Duang🎈地一下,跳转到了登录页,并提示你:“登录状态已过期,请重新登录”。


那一刻,你的内心是什么感受?我想大概率是崩溃的,并且想把这个网站拉进黑名单。


这就是一个典型的、因为Token过期处理不当,而导致的灾难级用户体验。作为一个负责任的开发者,这是我们绝对不能接受的。


今天就聊聊,我们团队是如何通过请求拦截队列控制,来实现无感刷新Token的。让用户即使在Token过期的情况下,也能无缝地继续操作,就好像什么都没发生过一样。




先讲基础知识


为什么需要两个Token?


要实现无感刷新,我们首先需要后端同学的配合,采用双Token的认证机制。



  1. accessToken: 这是我们每次请求业务接口时,都需要在请求头里带上的令牌。它的特点是生命周期短(比如1小时),因为暴露的风险更高。

  2. refreshToken: 它的唯一作用,就是用来获取一个新的accessToken。它的特点是生命周期长(比如7天),并且需要被安全地存储(比如HttpOnly的Cookie里)。


流程是这样的:用户登录成功后,后端会同时返回accessTokenrefreshToken。前端将accessToken存在内存(或LocalStorage)里,然后在后续的请求中,通过refreshToken来刷新。
image.png




解决思路,利用axios的请求拦截器


我们整个方案的核心,是利用axios(或其他HTTP请求库)提供的请求拦截器(Interceptor) 。它就像一个哨兵,可以在请求发送前和响应返回后,对请求进行拦截和改造。


我们的目标是:



  1. 响应拦截器里,捕获到后端返回的accessToken已过期的错误(通常是401状态码)。

  2. 当捕获到这个错误时,暂停所有后续的API请求。

  3. 使用refreshToken,悄悄地在后台发起一个获取新accessToken的请求。

  4. 拿到新的accessToken后,更新我们本地存储的Token

  5. 最后,把之前失败的请求和被暂停的请求,用新的Token重新发送出去。


这个过程对用户来说,是完全透明的。他们最多只会感觉到某一次API请求,比平时慢了一点点。




具体怎么实现?


下面是我们团队在项目中,实际使用的axios拦截器伪代码。


import axios from 'axios';

// 创建一个新的axios实例
const api = axios.create({
baseURL: '/api',
timeout: 5000,
});

// ------------------- 请求拦截器 -------------------
api.interceptors.request.use(config => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
}, error => {
return Promise.reject(error);
});


// ------------------- 响应拦截器 -------------------

// 用于标记是否正在刷新token
let isRefreshing = false;
// 用于存储因为token过期而被挂起的请求
let requestsQueue = [];

api.interceptors.response.use(
response => {
return response;
},
async error => {
const { config, response } = error;

// 如果返回的HTTP状态码是401,说明access_token过期了
if (response && response.status === 401) {

// 如果当前没有在刷新token,那么我们就去刷新token
if (!isRefreshing) {
isRefreshing = true;

try {
// 调用刷新token的接口
const { data } = await axios.post('/refresh-token', {
refreshToken: localStorage.getItem('refreshToken')
});

const newAccessToken = data.accessToken;
localStorage.setItem('accessToken', newAccessToken);

// token刷新成功后,重新执行所有被挂起的请求
requestsQueue.forEach(cb => cb(newAccessToken));
// 清空队列
requestsQueue = [];

// 把本次失败的请求也重新执行一次
config.headers.Authorization = `Bearer ${newAccessToken}`;
return api(config);

} catch (refreshError) {
// 如果刷新token也失败了,说明refreshToken也过期了
// 此时只能清空本地存储,跳转到登录页
console.error('Refresh token failed:', refreshError);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
// window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
} else {
// 如果当前正在刷新token,就把这次失败的请求,存储到队列里
// 返回一个pending的Promise,等token刷新后再去执行
return new Promise((resolve) => {
requestsQueue.push((newAccessToken) => {
config.headers.Authorization = `Bearer ${newAccessToken}`;
resolve(api(config));
});
});
}
}

return Promise.reject(error);
}
);

export default api;

这段代码的关键点,也是面试时最能体现你思考深度的地方:



  1. isRefreshing 状态锁:


    这是为了解决并发问题。想象一下,如果一个页面同时发起了3个API请求,而accessToken刚好过期,这3个请求会同时收到401。如果没有isRefreshing这个锁,它们会同时去调用/refresh-token接口,发起3次刷新请求,这是完全没有必要的浪费,甚至可能因为并发问题导致后端逻辑出错。


    有了这个锁,只有第一个收到401的请求,会真正去执行刷新逻辑。


  2. requestsQueue 请求队列:


    当第一个请求正在刷新Token时(isRefreshing = true),后面那2个收到401的请求怎么办?我们不能直接抛弃它们。正确的做法,是把它们的resolve函数推进一个队列(requestsQueue)里,暂时挂起。


    等第一个请求成功拿到新的accessToken后,再遍历这个队列,把所有被挂起的请求,用新的Token重新执行一遍。





无感刷新Token这个功能,用户成功的时候,是感知不到它的存在的。


但恰恰是这种无感的细节,区分出了一个能用的应用和一个好用的应用。


因为一个资深的开发者,他不仅关心功能的实现,更应该关心用户体验整个系统的健壮性


希望这一套解决思路,能对你有所帮助🤞😁。


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

前端仔如何在公司搭建 AI Review 系统

web
一、前言在上一篇 《AI 应用开发入门:前端也可以学习 AI》中,我给大家分享了前端学习 AI 应用开发的入门相关知识。我相信很多同学,看完应该都有了一定的收获。未来我会把关于前端学习 AI 的文章都放在这个 《前端学习 AI 之路》&nb...
继续阅读 »

一、前言

在上一篇 《AI 应用开发入门:前端也可以学习 AI》中,我给大家分享了前端学习 AI 应用开发的入门相关知识。我相信很多同学,看完应该都有了一定的收获。未来我会把关于前端学习 AI 的文章都放在这个 《前端学习 AI 之路》 专栏进行更新~

本篇会更偏向实际的应用,我将会运用之前分享的技术和概念,给大家分享如何通过 nodejs + LLM 搭建一个简易的 AI Review 系统的。

在本篇你将收获到:

  1. 设计 AI 应用的思路
  2. 设计提示词的思路
  3. 如何用 NodeJS 结合 LLM 分析代码

二、背景

我相信大家在团队中,都会有 Code Review 这个流程。但是有时候随着人手不够、项目周期紧张,就会出现 review 流程被忽视、或者 review 质量不高的问题。于是,我就在想,是否可以把这种费时、费精力且需要专注的事情,交给一个专门的“AI 员工”去完成呢?答案是可以的。

三、整体效果

目前在我们团队,已经全面的在使用 AI 进行 Review 了,涵盖了前端、后端大大小小 20 + 的项目。得益于在集团内可以使用像(“GPT-4.1、 Calude”)这样更大上下文、更强推理能力的模型,所以整体效果是非常不错的。有时候一些很细微的安全隐患、性能、业务逻辑等问题,AI 都能比人更容易发现。

下面是我用演示的项目呈现的效果,也就是我们即将动手搭建的这个项目。

3.1 评论模式

通过 AI 分析提交的代码,然后会在有问题的代码下,评论出问题类型以及问题的具体原因。

3.2 报告模式

还一种是报告的展示形式。它会在提交的 MR 下输出一个评审报告,列出所有问题的标题、所在位置、以及具体原因。但是,这两种模式实现的本质都一样,只不过是展示结果的方式有不同,这个看你个人喜欢。

四、思路分析

那这个 AI Code Review 应用要怎么实现呢?下面给大家分享一下具体的思路。

4.1 人为流程

首先要做的,就是分析你现有团队人工 review 代码的规范,然后总结出一个具体流程。为什么要这样做?因为让 AI 帮你做事的本质,就是让它模仿你做事。如果连你自己都不清楚具体的执行流程,就更别期待 AI 能把这个事情做好了。

下面是我举例的一个 review 流程,看完后你可以思考一下,自己平时是怎么 review 代码的,有没有一个固定的流程或者方案。如果有,则按照下面的这个“行为 + 目的”的格式记录下来。

  1. 行为:收到的 MR 的提示了;目的:知道有需要 review 的 MR 提交

  1. 行为:查看 commit message;目的:确认本次提交的主题是什么。

  1. 行为:查看改动哪些文件;目的:确认改动范围,主要判断改了哪些业务模块、是否改了公共、或者高风险文件等

  1. 行为:查看文件路径;目的:确认该文件关联的业务、所属的模块等信息,当做后续 diff 评审的前置判断信息。

  1. 行为:查看 diff 内容;目的:判断改动代码的逻辑、安全、性能是否存在问题。结合相关的业务和需求信息,判断是否有实现不合理的地方。
  2. 行为:在有问题的相关代码下,发出评论;目的:在有问题的代码下面,给出修改建议,让开发的同事能够注意和修改一下当前的问题。

4.2 程序流程

上面列举的是一个完整的人为评审代码的流程。但是,如果想让 AI 完全模仿,其实是存在一定的复杂性的。比如,人在评审某处 diff 时,会思考关联的业务、模块等前置信息,然后再做出评论。而不单单只是评审代码表面的编码问题。如果想要 AI 也这样做,还需要引入 RAG 等相关的技术,目的则是为了补充给更多的上下文信息。

为了不增加大家的实现和理解难度,本篇我们实现的是一个简化版本的 AI Code Review。下面是我梳理的 review 流程和与之对应的 AI 应用流程。

画板

4.2 核心问题

这次搭建的 AI Code Review 应用,本质上是一个 NodeJS 服务。这个服务通过感知 MR 事件,获取 diff 交给 LLM 分析,得到结论以后,会输出评论到 GitLab。整体流程图如下:

所以,我们要面对这些核心问题是

  1. node 服务如何感知 GitLab 的 MR 提交
  2. 如何获取 MR 中每个文件改动的 diff
  3. 如何让编写提示词,让大模型评审和分析并输出结构化的数据
  4. 如何解析数据以及异常的处理
  5. 如何发送评论到 gitlab
  6. 如何推送状态到企微

接下来,我们带着上面的问题,来一步步实现这个 AI Code Review 应用。

五、具体实现

5.1 创建项目

创建一个 NestJS 的项目(用什么技术框架都可以,你可以使用你最熟悉的 Node 开发框架。重点是关注实现的核心步骤和思路,这个演示的项目我开源了,可以在 GitHub 上查看完整的代码)

nvm use 20

使用 nest 命令初始化一个项目

nest new mr-agent

5.2 实现 webhook 接口

首先我们来解决 node 服务如何感知 MR 事件的问题

Webhook

像 GitLab、GitHub 都会允许用户在项目中配置 webhook。它是干嘛的呢? webhook 可以让外部的服务,感知到 git 操作的相关事件(如 push 、merge 等事件)。比如我在合并代码时,gitlab 就会把 MR 事件,通过这个 hook 发送到我们搭建的服务上。

画板

以 GitLab 为例,它会允许开发者在每个项目中配置多个 webhook 接口。比如,咱们配置一个 http://example.com/webhook/trigger 的地址。当发生相关 git 事件时,GitLab 就会往这个地址推送消息。

代码实现

所以,我们要做的第一件事,就是定义一个接口 url,用于接收 GitLab 的 webhook 事件。下面的代码中,实现了一个处理/webhook/trigger路由的 controller,它的主要职责是接收 MR 事件并且解析 body 和 header 中的参数,代码如下(完整代码

Body

body 中会包含很多有用的的信息,如 Git 仓库信息、分支信息、MR 信息、提交者信息等,这些数据是 GitLab 调用 webhook 接口时发送过来的,在后续的逻辑中,都会用到里面的数据。

  • object_type/object_kind:描述的事件的类型,例如 merge 事件、push 事件等。
  • project:主要是描述仓库相关的信息,例如项目 id、名称等
  • object_attributes: 主要包含本次 MR 相关的信息,如目标分支、源分支、mr 的 id 等等
  • user:提交者的信息

Header

header 中是我们自己目定义的配置信息,核心有三个

  • x-ai-mode:评论的模式(report 报告模式、 comment 评论模式)
  • x-push-url:用于推送状态的地址(推送到企微、或者飞书的机器人)
  • x-gitlab-token:gitlab 中的 access token,用于后续 GitLab API 调用鉴权

调试问题

调试开发的这个接口确实是一个比较麻烦的问题。因为 GitLab 基本都是内网部署,想要真实调试接口,一是需要真实代码仓库,二是需要想办法把 GitLab 的请求转发到本地来。这里我给大家分享三个办法:

内网转发

使用内网转发的办法,第三方的例如 ngroklocaltunnelfrp 等。如果你们公司的部署平台本身支持流量转发到本地,那就更好了(我用的是这个办法)。

ApiFox、Postman

先将服务部署到你们公司 GitLab 可以访问的服务器上,手动触发 MR 事件

image.png

然后在日志上打印完整的 header 和 body,然后复制到 ApiFox、Postman 上在本地模拟请求

image.png

问 AI

😁 最后一个办法就是,根据你的场景,问问 AI 怎么做 image.png

5.3 获取 diff 内容

在能够接受到 GitLab 发送的 MR 事件后,就要解决如何获取 diff 的问题。这一步很简单, 调用 GitLab 官方的 API 就可以。重点就是两个核心逻辑:

  1. 获取全部文件的 diff 内容
  2. 过滤非代码文件

获取 diff 内容

gitlab 的 api 路径一般是一样的。唯一的区别就是不同公司的部署域名不同。baseUrl 需要配置成你公司的域名,projectId 和 mrId 都可以在 body 中取到(完整代码

调用成功以后,获取的数据如下,changes 中会包含每个文件的 diff

过滤文件

因为并不是所有的文件都需要让 LLM 进行 review ,例如像 package.json、package-lock.json 等等。所以需要把这部分非代码文件过滤出来。

5.4 设计提示词

有了每个文件的 diff 数据以后,就是解决如何分析 diff 内容并输出有效结论的问题。其实这个问题的本质,就是如何设计系统提示词。

提示词思路

首先我们先思考一下编写提示词的目的是什么?我们期望的是,通过提示词指引 LLM,当输入 diff 文本的时候,它能够分析里面的代码并输出结构化的数据。

画板

我们希望 LLM 返回的是一个数组,数组的每一项是对每一个问题的描述,里面包含标题、文件路径、行号、具体的内容等,数据结构如下:

interface Review {
// 表示修改后的文件路径
newPath: string;
// 表示修改前的文件路径
oldPath: string;
// 表示评审的是旧代码还是新代码,如果评审的是 + 部分的代码,那么 type 就是 new,如果评审的是 - 部分的代码,那么 type 就是 old。
type: 'old' | 'new';
// 如果是 old 类型,那么 startLine 表示的是旧代码的第 startLine 行,否则表示的是新代码的第 startLine 行
startLine: number;
// 如果是 new 类型,那么 endLine 表示的是旧代码的第 endLine 行,否则表示的是新代码的第 endLine 行
endLine: number;
// 对于存在问题总结的标题,例如(逻辑错误、语法错误、安全风险等),尽可能不超过 6 个字
issueHeader: string;
// 清晰的描述代码中存在、需要注意或者修改的问题,并给出明确建议
issueContent: string;
}

interface MRReview {
reviews: Review[];
}

之所以需要这种结构化的数据,是因为后续在调用 GitLab API 发送评论的时候,需要用到这些参数。

整体思路确定好了,接下来我们就来编写具体的系统提示词。

角色设定

角色设定就是告诉 LLM 扮演什么角色以及它的具体要做什么事情

你是一个代码 MR Review 专家,你的任务是评审 Git Merge Request 中提交的代码,如果存在有问题的代码,你要提供有价值、有建设性值的建议。
注意,你评审时,应重点关注 diff 中括号后面带 + 或 - 的代码。

输入内容

上面有说到,我们不仅需要 LLM 分析代码的问题,还需要它把问题代码所在的文件路径、行号分析出来。

但是,如果你直接把原生的 diff 内容输入给它,它是不知道这些信息。因为原生的 diff 并没有具体的行号、新旧文件路径信息的。

@@ -1,16 +1,13 @@
import { Injectable } from '@nestjs/common';

-interface InputProps {
- code_diff: string;
- code_context: string;
- rules?: string;
-}
+type InputProps = Record;

interface CallDifyParams {

所以我们需要扩展输入的 diff,给它增加新旧文件的路径、以及每一行具体的行号,例如 (1, 1) 表示的是当前行,是旧文件中的第 1 行,新文件中的第 1 行。这个后面会说如何扩展,这里我们只是要先设计好,并告诉 LLM 我们会输入什么格式的内容

## new_path: src/agent/agent.service.ts
## old_path: src/agent/agent.service.ts
@@ -1,16 +1,13 @@
(1, 1) import { Injectable } from '@nestjs/common';
(2, 2)
(3, ) -interface InputProps {
(4, ) - code_diff: string;
(5, ) - code_context: string;
(6, ) - rules?: string;
(7, ) -}
( , 8) +type InputProps = Record;
(9, 9)
(10, 10) interface CallDifyParams {

有了这些完善的信息,LLM 才知道有问题的代码在哪个文件以及它所在的具体行号

加解释

diff 经过我们的扩展以后,就不再是标准的描述 diff 的 Unified Format 格式了,所以必须向 LLM 解释一下格式的含义,增强它对输入的理解,避免它随便臆想。

我们将使用下面的格式来呈现 MR 代码的 diff 内容:

## new_path: src/agent/agent.service.ts
## old_
path: src/agent/agent.service.ts

@@ -1,16 +1,13 @@
(1, 1) import { Injectable } from '@nestjs/common';
(2, 2)
(3, ) -interface InputProps {
(4, ) - code_diff: string;
(5, ) - code_
context: string;
(6, ) - rules?: string;
(7, ) -}
( , 8) +type InputProps = Record;
(9, 9)
(10, 10) interface CallDifyParams {

- 以 ”## new_path“ 开头的行内容,表示修改后的文件路径
- 以 ”## old_
path“ 开头的行内容,表示修改前的文件路径
- @@ -1,16 +1,13 @@ 是统一差异格式(Unified Diff Format)中的hunk header,用于描述文件内容的具体修改位置和范围
- 每一行左侧括号内的两个数字,左边表示旧代码的行号,右边表示新代码的行号
- 括号后的 + 表示的是新增行
- 括号后的 - 表示的是删除行
- 引用代码中的变量、名称或文件路径时,请使用反引号(`)而不是单引号(')。

加限制

加限制的主要目的是指引 LLM 按照固定的数据类型进行输出。这里我们会告诉 LLM 具体的 TS 类型,避免它输出一些乱七八糟的类型,导致后续在代码中解析和使用的时候报异常。例如,数字变成字符串、字符串变成数组等。

你必须根据下面的 TS 类型定义,输出等效于MRReview类型的YML对象:

```ts
interface Review {
// 表示修改后的文件路径
newPath: string;
// 表示修改前的文件路径
oldPath: string;
// 表示评审的是旧代码还是新代码,如果评审的是 + 部分的代码,那么 type 就是 new,如果评审的是 - 部分的代码,那么 type 就是 old。
type: 'old' | 'new';
// 如果是 old 类型,那么 startLine 表示的是旧代码的第 startLine 行,否则表示的是新代码的第 startLine 行
startLine: number;
// 如果是 new 类型,那么 endLine 表示的是旧代码的第 endLine 行,否则表示的是新代码的第 endLine 行
endLine: number;
// 对于存在问题总结的标题,例如(逻辑错误、语法错误、安全风险等),尽可能不超过 6 个字
issueHeader: string;
// 清晰的描述代码中存在、需要注意或者修改的问题,并给出明确建议
issueContent: string;
}

interface MRReview {
reviews: Review[];
}
```

在限制的类型中,最好是增加一些注解,让 LLM 能够理解每个字段的含义。

加示例

加示例的主要目的是告诉 LLM 按照固定的文件格式进行输出,这样我们就可以直接拿 LLM 的输出,进行标准化的解析,转换成实例的数据进行使用,伪代码如下:

// 调用 LLM 的接口
const result = await callLLM('xxxxx');

// 解析数据
const data = yaml.load(result);

// 操作数据
data.reviews.forEach(() => { })

提示词描述如下

输出模板(注意,我只需要 yaml 格式的内容。yaml 内容的前后不要有其他内容):

```yaml
reviews:
- newPath: |
src/agent/agent.service.ts
oldPath: |
src/agent/agent.service.ts
startLine: 1
endLine: 1
type: |
old
issueHeader: |
逻辑错误
issueContent: |
...
- newPath: |
src/webhook/decorators/advanced-header.decorator.ts
oldPath: |
src/webhook/decorators/commmon-header.decorator.ts
startLine: 1
endLine: 1
type: |
new
issueHeader: |
性能风险
issueContent: |
...
```

这里简单说一下,为什么选择 yaml 而不是 json。因为在实践的过程中,我们发现 json 解析异常的概率会比 yaml 高很多,因为 json 的 key 和 value 是需要双引号("")包裹的,如果 issueContent 中包含了代码相关的内容且存在一些双引号、单引号之类的符号,就很容易导致报错,而且比较难通过一些替换规则进行兜底处理。

最后完整的提示词这里:提示词

调试

这里再告诉大家一个提示词的调试技巧,你可以先在 Coze、Dify 这样的平台上,通过工作台不断调试你的提示词,直到它能够稳定的输出你满意的结果。

image.png

5.5 扩展、组装 diff

上面我们有说到,通过 GitLab 获取的原始 diff 是没有新旧文件路径和具体的新旧行号的,这个需要通过代码计算来补全这些信息。这一小节,我们就来解决 diff 的扩展、组装问题。

扩展

扩展主要做两个事:

  • 在 diff 头部加新旧文件的路径
  • 在每一行加新旧文件中的行号

加路径比较简单,可以在获取每个文件的 diff 数据的时候,拿到新旧文件的路径的,取值后加上即可。

image.png 加行号稍微麻烦一点,我们需要将当前文件的 diff 按照 hunk 拆分成不同的块,然后会根据 hunk head 计算每行在新旧文件中的真实行号。

image.png

为了防止有些同学不清楚 diff 格式的结构,我这里简单标注一下。 在下面这个 diff 中,像 “@@ -1,16 +1,13 @@” 这样的内容就是 Hunk Head,用于描述后续 diff 内容在新旧文件中的起始行号。用框住的第一个 hunk 为例:

  • -1,16: 表示 import { Injectable } from '@nestjs/common'; 是在旧文件中的第 1 行,改动范围是往后的一共 16 行,需要忽略 “+” 加号开头的行。
  • +1,13:表示是import { Injectable } from '@nestjs/common';在新文件中的第 1 行,改动范围是往后的一共 13 行,需要忽略 “-” 加号开头的行。

然后图中被我用红框标注的连续代码片段就是 hunk,它一般由 hunk header + 连续的代码组成。一个文件的 diff 可能会有多个 hunk。

  • hunk 中 “+” 开头的行,表示新文件中增加的行
  • “-” 开头的行,表示旧文件中被删除的行 image.png

这里需要先遍历每个文件的 diff,然后按 hunk head 来分割内容块。

const hunks = splitHunk(diffFile.diff);

代码如下:

逻辑是将 diff 按 “\n” 分割成包含所有行的数组,然后遍历每一行。每当遍历到一个 hunk head 就创建一个新的 hunk 结构,然后通过正则提取里面的起始行号,并将后续遍历到的行都保存起来,直到它遇到一个新的 hunk head。

接着就是遍历 hunk,计算每个 hunk 中每一行的具体行号。

comptuedHunkLineNumer 的代码如下:

核心逻辑是:

  1. 使用 oldLineNumber、newLineNumber 两个独立计数器,记录新旧文件的当前行号
  2. 遍历到 “-” 开头的行,oldLineNumber + 1,记录行号(oldLineNumber + 1, )
  3. 遍历到 “+” 开头的行,newLineNumber + 1,记录行号( , newLineNumber + 1)
  4. 遍历常规的行,oldLineNumber 和 newLineNumber 都 + 1,记录行号(oldLineNumber + 1, newLineNumber + 1)

为了让你更清晰理解这个逻辑,我在 diff 中标注一下。下面是计算旧文件中的行号,我们只会对“-”开头的行和普通的行进行计数,忽律 “+” 开头的行。

计算新文件中的行,此时我将不计算 “-” 开头的行。所以type InputProps = Record;这行代码,在合并后的新文件中,真正的行号是在第 15 行。

处理后 diff 的每一行,都会带上新旧文件中的行号

@@ -1,16 +1,13 @@
(1, 1) import { Injectable } from '@nestjs/common';
(2, 2)
(3, ) -interface InputProps {
(4, ) - code_diff: string;
(5, ) - code_context: string;
(6, ) - rules?: string;
(7, ) -}
( , 8) +type InputProps = Record;
(9, 9)
(10, 10) interface CallDifyParams {
(11, 11) input: InputProps;
(12, 12) query: string;
(13, 13) conversation_id?: string;
(14, 14) user: string;
( , 15) + apiKey: string;
(16, 16) }
(17, 17)

组装

得到每个文件扩展的 diff 以后,便是将 commit message 和所有文件 diff 拼接到一个字符串中,后续会把这个拼接好的字符串直接输入给 LLM 进行分析。

commit message: feat: 调整 review 触发逻辑,增加请求拦截器

##new_path: src/agent/agent.service.ts
##old_path: src/agent/agent.service.ts
@@ -1,16 +1,13 @@
(1, 1) import { Injectable } from '@nestjs/common';
(2, 2)
(3, ) -interface InputProps {
(4, ) - code_diff: string;
(5, ) - code_context: string;
(6, ) - rules?: string;
(7, ) -}
( , 8) +type InputProps = Record;

## new_path: src/webhook/decorators/advanced-header.decorator.ts
## old_path: src/webhook/decorators/advanced-header.decorator.ts
@@ -0,0 +1,152 @@
( , 1) +import {
( , 2) + createParamDecorator,
( , 3) + ExecutionContext,
( , 4) + BadRequestException,
( , 5) +} from '@nestjs/common';
( , 6) +
( , 7) +/**
( , 8) + * 高级 Header 装饰器,支持类型转换和验证
( , 9) + */
( , 10) +export const AdvancedHeader = createParamDecorator(

5.6 对接 LLM

现在我们已经有了系统提示词、处理好的 diff 内容,接着就是如何调用 LLM 分析结果。

画板

申请 DeepSeek

演示的案例中,我用的是 DeepSeek-v3 的模型。如果能够使用 GPT-4.1 或者 Calude 模型的同学,你可以优先选择使用这两个模型。

这里你需要去到 DeepSeek 官网申请一个 API Key

然后去充值个几块钱,你就可以使用 DeepSeek 这个模型了。

具体申请和使用步骤,官网文档都讲得很清楚了,这里不过多赘述。

调用 LLM

申请完 DeepSeek 的 API Key 以后,就可以通过接口调用了

这里主要关注一下调用接口的入参:

  • model: 如果是 deepseek 的话,你选择 deepseek-chat还是 deepseek-reasoner都可以
  • messages: 这里我们输入两个 message,一个是系统提示词,一个是扩展的 diff
  • temperature:设置成 0.2,提高输出的精确性

如果一切调用成功的话,你应该会得到 LLM 一个这样的回复:

```yaml
reviews:
- newPath: |
src/agent/agent.service.ts
oldPath: |
src/agent/agent.service.ts
startLine: 8
endLine: 8
type: |
new
issueHeader: |
类型定义不严谨
issueContent: |
将 `InputProps` 从具体的接口类型改为 `Record
`,虽然提升了灵活性,但丢失了原有的类型约束,容易导致后续代码中出现属性拼写错误或类型不一致的问题。建议保留原有字段定义,并在需要扩展时通过继承或联合类型实现更好的类型安全。
- newPath: |
src/webhook/webhook.controller.ts
oldPath: |
src/webhook/webhook.controller.ts
startLine: 38
endLine: 40
type: |
new
issueHeader: |
参数注入冗余与未使用参数
issueContent: |
在 `trigger` 方法中注入了 `@GitlabToken()`、`@QwxRobotUrl()` 等参数,但实际方法体内并未使用这些参数,而是继续从 headers 中解析相关信息(已被删除)。建议移除未用到的装饰器参数,或者直接替换原有 header 获取逻辑,避免混乱和冗余。
```


5.7 数据解析和异常处理

有了 LLM 回复的数据以后,接着要做的就是将字符串解析成数据,以及处理解析过程中的异常问题

数据解析

这里主要做两个是事,一个是提取 yaml 的内容

提取完字符串以后,然后通过 js-yaml这个包解析数据

const mrReview = yaml.load(yamlContent) as MRReview;

至此,你已经得到一份经过 LLM 分析后产生的实例化的数据了

异常处理

但是你以为到这里就结束了吗?实际的情况却是 LLM 会因为它的黑盒性和不确定性,偶然的输出一些奇奇怪怪的字符或格式,导致出现解析的异常。

场景1:多余的 '\n' 符号

有时候 LLM 在输出的时候,会给 type 字段多加一个 '\n' 符号

{
newPath: "src/agent/agent.service.ts",
oldPath: "src/agent/agent.service.ts",
startLine: 10,
endLine: 12,
type: "new\n"
....
}

看日志的时候,感觉一直没问题。可是到一些具体场景判断的时候,就会开始怀疑人生。当时一些关于 type 的判断,我想破脑袋也没想明白为什么 new 会走到 old 的逻辑里面,结果仔细一看,还有一个换行符……

所以针对这个场景,需要单独加一些处理逻辑。通过 replace 把字符串中的换行符全部去掉。

场景2:多余的空格符号

我们知道 yaml 的字段结构是按空格来控制的,但有时候 LLM 偏偏就在某些字段前面少一个或者多个空格,排查的时候也是非常的头痛,例如下面的 issueHeader、issueContent 因为少了空格,而导致 yaml 解析异常…

我的办法就是让 AI 写了一个兜底处理方法。在解析异常的时候,通过兜底方法再解析一次。 具体代码(查看里面的 fixYamlFormatIssues 方法)

更多场景

因为 LLM 偶现的不稳定性,会导致出现各种奇奇怪怪的问题。目前的解决思路有三个:

  1. 使用更强大的模型,并调低 temperature 参数
  2. 调试出更完善的提示词,通过加限制、加示例等技巧,提高提示词的准确性
  3. 特殊场景,特殊手段。例如通过编码等手段,提前防范这些异常

5.8 上下文分割

还有一个需要解决的问题就 LLM 的上下文长度的限制。像 GPT-4.1 上下文长度有 100w 个 token,但是你用 deepseek 的话,可能只有 64000 个。

一旦你输入的提示词 + diff 内容超过这个上下文,就会报错导致 LLM 无法正常解析。这时我们就不得不把输入的 diff 拆分成多份,然后并行调用 LLM,最后整合数据。

画板

解决这个问题的思路也很简单,每次调用 LLM 前,计算一下系统提示词 + Diff 内容需要消耗的 token,如果超了就把 diff 多差几份。

import { encoding_for_model, TiktokenModel } from '@dqbd/tiktoken';

const encoding = encoding_for_model(this.modelName);
const tokens = encoding.encode(text);
const count = tokens.length;
encoding.free();

我用的是 @dqbd/tiktoken 这个包计算 token,它里面包含了大多数模型的 token 计算方式。

5.9 发送结果

在有了处理好的 review 数据以后,我们就可以调用 GitLab 的接口发送评论了

从上面方法的入参可以看到,newPath、oldPath、endLine、issuceContent 等数据,都是在通过 LLM 分析以后得出来的。

5.10 小结

至此,这个 AI Code Review 的关键流程,我已经讲完了。下面再来总结一下两个流程:

  • 逻辑流程
  • 使用流程

逻辑流程

  • 部署 NodeJS 服务
  • 开发 webhook 接口,接受 MR 事件
  • 收到事件后,获取 Diff 内容
  • 有了 Diff 内容后,扩展行号、文件路径,拼成一个字符串
  • 进行 token 分析,超了就分多份进行分析
  • 调用 LLM,输入系统提示词、Diff
  • 拿到 YAML 结构的分析数据
  • 解析数据、处理异常
  • 发送评论到 GitLab

使用流程

  • 申请 access token
  • 配置 webhook
  • 发起 MR
  • 收到 AI 分析的评论

六、最后

6.1 期待

本篇给大家分享了一个 AI Code Review 应用开发的简单案例。我希望大家可以看完以后,可以在自己的业务或者个人项目中去实践落地,然后再回到评论区给与反馈,展示你的成果。

6.2 学习方法

如果看到文章中有任何不懂的,我建议你都可以直接问 AI。我看掘金自带的这个 AI 助手也挺方便的。我们既然要学习 AI,就要多用 AI 的方式去学习。当然,你也可以直接留言问我。

image.png

6.3 关注

最后呢,也是希望大家关注我,我会持续在这个专栏更新我的文章。本想着坚持能够一个月输出两篇,但是在工作忙碌 + 文章质量的不断权衡中,还是写了很久,才写出这一篇。原创不易,需转载请私信我~

这个演示的项目地址:github.com/zixingtangm… (可以的话,也帮忙点点 star ⭐️ 哈哈)


作者:唐某人丶
来源:juejin.cn/post/7532596434031149106

收起阅读 »

使用 AI 助手提升前端代码质量:自动代码审查实战

web
最近在带团队的时候,发现代码审查(Code Review)总是成为项目进度的一个瓶颈。一方面,高级工程师的时间很宝贵,不可能审查每一行代码;另一方面,初级工程师又急需及时的反馈来提升。于是我就在想:能不能用 AI 来解决这个问题? 经过一番研究和实践,我搭建了...
继续阅读 »

最近在带团队的时候,发现代码审查(Code Review)总是成为项目进度的一个瓶颈。一方面,高级工程师的时间很宝贵,不可能审查每一行代码;另一方面,初级工程师又急需及时的反馈来提升。于是我就在想:能不能用 AI 来解决这个问题?


经过一番研究和实践,我搭建了一个 AI 代码审查助手,效果出乎意料的好!今天就来分享下这个小工具是怎么做的。


为什么需要 AI 代码审查?


说实话,最开始团队里有不少质疑的声音:"AI 能审查什么代码?""能发现真正的问题吗?"但是经过一段时间的使用,大家发现 AI 代码审查确实能解决很多痛点:



  1. 人工审查的问题



    • 😫 审查疲劳:谁能一直盯着代码看?

    • ⏰ 反馈延迟:等高级工程师有空可能需要好几天

    • 🤔 标准不一:每个人的审查重点和标准都不太一样



  2. AI 审查的优势



    • 🤖 24/7 全天候服务,随时可用

    • 🎯 审查标准统一且可配置

    • ⚡️ 秒级反馈,再也不用等人了

    • 📚 会不断学习和改进




实战:搭建 AI 代码审查助手



温馨提示:这个项目用到了 OpenAI API,需要自己准备 API Key。新账号有免费额度,够用来测试了。



1. 项目初始化


mkdir ai-code-review
cd ai-code-review
npm init -y
npm install openai eslint prettier

2. 核心代码实现


这是最关键的部分,我们需要:



  1. 处理各种代码格式

  2. 设置合适的提示词(prompt)

  3. 处理 API 限流和错误


// src/codeReviewer.ts
import { Configuration, OpenAIApi } from 'openai'
import { rateLimit } from '@/utils/rate-limit'

class AICodeReviewer {
private openai: OpenAIApi
private reviewCache: Map<string, string>

constructor(apiKey: string) {
const configuration = new Configuration({
apiKey: apiKey
})
this.openai = new OpenAIApi(configuration)
this.reviewCache = new Map() // 缓存常见问题的反馈
}

private async generateReviewPrompt(code: string, language: string): string {
// 根据不同语言定制提示词
const basePrompt = `作为一个资深的${language}开发专家,请审查以下代码,重点关注:
1. 代码质量和最佳实践
2. 潜在的性能问题
3. 安全隐患
4. 可维护性
5. 错误处理

请用中文回复,按严重程度排序,并给出具体的修改建议。

代码:
${code}`


return basePrompt
}

async reviewCode(code: string, language: string = 'JavaScript'): Promise<string> {
try {
// 检查缓存
const cacheKey = this.generateCacheKey(code)
if (this.reviewCache.has(cacheKey)) {
return this.reviewCache.get(cacheKey)!
}

// 限流检查
if (!await rateLimit.check()) {
throw new Error('请求太频繁,请稍后再试')
}

const prompt = await this.generateReviewPrompt(code, language)

const completion = await this.openai.createChatCompletion({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: '你是一个资深的代码审查专家,擅长发现代码中的问题并提供建设性的改进建议。'
},
{ role: 'user', content: prompt }
],
temperature: 0.7, // 让回复更有创意一些
})

const review = completion.data.choices[0].message?.content || ''

// 缓存结果
this.reviewCache.set(cacheKey, review)

return review
} catch (error: any) {
console.error('代码审查失败:', error)
throw new Error(this.formatError(error))
}
}

private generateCacheKey(code: string): string {
// 简单的缓存 key 生成
return code.trim().substring(0, 100)
}

private formatError(error: any): string {
if (error.response?.status === 429) {
return '当前请求较多,请稍后再试'
}
return '代码审查服务暂时不可用,请重试'
}
}

export default AICodeReviewer

3. VSCode 扩展实现


这是我们团队最常用的功能,可以直接在编辑器里获取 AI 反馈:


// extension.ts
import * as vscode from 'vscode'
import AICodeReviewer from './codeReviewer'

export function activate(context: vscode.ExtensionContext) {
// 注册命令
let disposable = vscode.commands.registerCommand('aiCodeReview.review', async () => {
const editor = vscode.window.activeTextEditor
if (!editor) {
vscode.window.showErrorMessage('请先打开要审查的代码文件')
return
}

// 获取当前文件的语言
const language = editor.document.languageId
const code = editor.document.getText()

// 显示加载状态
const statusBarItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left
)
statusBarItem.text = '$(sync~spin) AI 正在审查代码...'
statusBarItem.show()

try {
const reviewer = new AICodeReviewer(
vscode.workspace.getConfiguration().get('aiCodeReview.apiKey') as string
)
const review = await reviewer.reviewCode(code, language)

// 在侧边栏显示结果
const panel = vscode.window.createWebviewPanel(
'aiCodeReview',
'AI 代码审查报告',
vscode.ViewColumn.Two,
{}
)

panel.webview.html = `
<!DOCTYPE html>
<html>
<head>
<style>
body { padding: 15px; }
.review { white-space: pre-wrap; }
.severity-high { color: #d73a49; }
.severity-medium { color: #e36209; }
.severity-low { color: #032f62; }
</style>
</head>
<body>
<h2>AI 代码审查报告</h2>
<div class="review">${this.formatReview(review)}</div>
</body>
</html>
`

} catch (error: any) {
vscode.window.showErrorMessage(error.message)
} finally {
statusBarItem.dispose()
}
})

context.subscriptions.push(disposable)
}

实战经验分享


经过几个月的使用,我总结了一些经验:


1. 提示词(Prompt)很重要



  • 🎯 要让 AI 关注特定领域的最佳实践

  • 📝 提供具体的评审标准和格式要求

  • 🌐 针对不同编程语言定制提示词


2. 合理的使用场景



  • ✅ 适合:代码风格检查、基本的逻辑问题、文档完整性

  • ❌ 不适合:业务逻辑正确性、系统架构决策、性能调优


3. 成本控制


在实际使用中,我发现几个控制成本的好办法:



  1. 缓存常见问题



    • 类似的代码问题可以复用审查结果

    • 显著减少 API 调用次数



  2. 合理的调用策略



    • 不是每次保存都触发审查

    • 设置合适的调用间隔

    • 批量处理多个文件的审查



  3. 优化 token 使用



    • 只发送必要的代码片段

    • 限制单次审查的代码长度

    • 选择合适的模型(3.5 通常就够用)




实际效果


使用这个工具后,我们团队有了一些明显的改善:



  1. 代码质量



    • 基础问题大幅减少

    • 代码风格更统一

    • 新人学习曲线变缓



  2. 开发效率



    • PR 审查时间减少 50%

    • 反馈速度提升

    • 开发体验更好



  3. 团队氛围



    • 减少了代码审查的争议

    • 新人更敢提问和讨论

    • 代码审查不再是负担




写在最后


这个 AI 代码审查助手现在已经成为我们团队日常开发的好帮手了。它不是来替代人工代码审查的,而是帮我们过滤掉那些基础问题,让我们能把精力放在更有价值的讨论上。


如果你也想尝试,建议从小范围开始,慢慢调整和优化。毕竟每个团队的情况都不一样,找到最适合自己团队的方式才是关键。



如果觉得有帮助,别忘了点赞关注!之后我还会分享更多提升开发效率的实战经验~



作者:Ethan独立开发
来源:juejin.cn/post/7440818887455604736
收起阅读 »

小红书小组件开发 最早踩坑版

web
前言 是这样的,这段小红书逛的多,发现有一篇关于小红书小组件的介绍,介绍里提到的是[AI调酒]这款小组件,在内容里可以直接挂载。我试玩了一下,还挺有趣,交互感挺强的。 然后下面提到说,留言即可有机会获取内测开发资格,内测时可以免费使用里面的AI功能。 想着能...
继续阅读 »

前言



是这样的,这段小红书逛的多,发现有一篇关于小红书小组件的介绍,介绍里提到的是[AI调酒]这款小组件,在内容里可以直接挂载。我试玩了一下,还挺有趣,交互感挺强的。
然后下面提到说,留言即可有机会获取内测开发资格,内测时可以免费使用里面的AI功能。
想着能白嫖就报名了,正好加入小红书生态,好宣传自己的app一波,hhh


没想到过一天就受到了科技署的邀请,加入了内测群!


开发


进群后,就令我感到有点诧异了,群里陆续进去了21个人,有15个工作人员,6个被邀请的开发。进去后,组织者发了一个操作文档:


各位好,我们是小组件项目的产品和研发,各位可以先提供下自己的小红书账号,我们为大家添加测试白名单

添加后需要完成的事项:

1、前往小红书开放平台创建开发者账号(开白后可申请个人主体账号,若已有账号可忽略)
https://miniapp.xiaohongshu.com/home

2、查阅小组件和智能体的开发文档,下载开平IDE工具,进行设计和开发
小组件开发介绍:https://miniapp.xiaohongshu.com/doc/DC026740
智能体开发介绍(如果需要在小组件中内嵌AI服务):https:/
/miniapp.xiaohongshu.com/doc/DC783288
最佳实践:https:/
/miniapp.xiaohongshu.com/doc/DC246551

3、由于小组件是无需备案的,因此平台会承担一定风险,因此有明确的创作方向后,需要开发者提供简易的demo图,我们会做内部的产品&研发&安全的可行性评估

🌟🌟 在过程中,大家如果有流程、开发、设计上的问题,都可以群内和我们沟通,由于这是小组件第一波内测,所以不可避免地可能还有些问题,大家提出来后我们也会及时处理优化,也感谢大家理解[合十]

🌟🌟 也辛苦各位重点关注:小组件整体的定位是「轻量、简单」,以及整体的UI设计也希望能「简介、美观」,更符合小红书社区氛围,这样更容易在社区总传播


意思就是,大家按照文档操作就行,基本没什么大问题。


但是小问题还是挺多的。


里面的很多开发,基本都有小红书小程序的开发经验了,这次感觉纯属了为了捧场或者是和我一样,做完后有没有什么推流。他们基本上很快就做完了。
我就不一样了,有主职工作而且一直做的是移动原生,虽然之前学过一丢丢微信小程序开发,但都已经过了差不多3年了。


但来都来了。


小组件



小组件开发可以独立进行,不依赖其他的三方。如果可以的话,你可以开发个很简易的demo上去,当然你还得经过小红书官方的审核,如果太基础的话就不太行,这个大家都懂得。


一般来讲,小组件需要依赖后台服务,或者是小红书他们提供AI智能体,毕竟咋们是奔着它去的。而且给的demo也是关于智能体。


第一步下载编辑器

编辑器
编辑器好像是需要这个版本才行,是官方人员直接在群里发的。最新版本的编辑器融合了AI功能,真的很给力,我自己写好了核心的逻辑后,让它来美化UI真是太省事了,而且美化的UI和小红书官方的小组件交互效果有点类似,有点红薯风,对于我这种没有UI审美的开发来说是一大福星,而且比免费版本的cursor好用。


跟着文档开发后

跟着文档开发,这里就不贴具体的过程了,因为文档也会更新,会更完善。因为我们是第一版本,所以文档里有很多遗漏的和错误的,这里补充一下这部分,如果再有人开发到这一步,可能会用的到。


隐私协议:


xhs.openClipLegalPolicy();

小组件核心代码
因为我是调用的是智能体,调用智能体的代码用官方的那样写是有问题的,写了好久有跑不通,咨询了很久才得到正确的代码:


初始化agent


  // 初始化 Agent  env: 'production'按需选择线上和测试
async initAgent() {

try {
const agentId = "test6baffa154e6db2d96e64ef310a6e";
const agent = xhs.cloud.AI.createAgent({
agentId,
env: 'production',
version: '0.1.8'
});

this.setData({
agent: agent
});

console.log("Agent初始化成功");
} catch (error) {
console.error("Agent初始化失败: ", error);
xhs.showToast({
title: "Agent初始化失败",
icon: "none",
});
}
},

// 调用智能体 解梦
if (this.data.agent == null) return;

const agentInfo = this.data.agent.getAgentInfo();
console.log("res", agentInfo);

// 使用回调方式发送消息
const { message, onMessage, onSuccess, onError } = this.data.agent.sendMessage({
msg: dreamContent,
history: [],
});


onSuccess((result) => {
this.setData({
isOver:true
})
console.log("请求成功:", result);
console.log("API调用成功,返回结果:", result);

// result.data.data
xhs.hideLoading();

});

// 监听流式消息
onMessage((chunkStr) => {
// console.log("收到消息块:", chunkStr, "api-message", message);
xhs.hideLoading();

if (chunkStr === "[DONE]") {
return;
}

let chunk = null;
try {
chunk = JSON.parse(chunkStr);
} catch (error) {
console.error("解析消息块失败:", error);
return;
}

// 解析消息块
if (chunk!=null&&chunk.choices && chunk.choices[0] && chunk.choices[0].message) {
const message = chunk.choices[0].message;

console.log("收到消息块 message:", message.content);


// 处理回复内容
if (message.content) {
this.setData({
dreamInterpretation:this.data.dreamInterpretation + message.content
})


this.setData({
isLoading: false,
showResult: true,
resultDream: dreamContent,
dreamInterpretation: this.data.dreamInterpretation
});

}
}
});

// 监听错误回调
onError((error) => {
console.error("请求失败:", error);
xhs.hideLoading();
xhs.showToast({
title: "生成失败,请重试",
icon: "none",
});
});

智能体



智能体分为流式和非流式的。
看具体的业务需求了,如果是很快的生成 和 生成的文本很短,就像[AI调酒],只需要简单的json即可,那就可以用非流式的。
像我这种需要生成长文本的就有点不太适合了,所以这里选择的是流式输出的形式。


核心代码:


// {user_mood: '开心',user_taste: '随便'}
async sendMessage(input) {
console.log('message -- '+ JSON.stringify(input))
console.log('--')
console.log('msg -- '+ input.msg)

const model = this.createModel('deepseek-v3')

const messages = [
{
role:'system',
content: systemPrompt
},
{
role:'user',
content:{
type:'text',
content: input.msg
// content: JSON.stringify(input)
// content: '{\'user_mood\': \'开心\', \'user_taste\': \'随便\'}'
}
}
]

const modelResponse = await model.streamText({
enable_thinking:false,
temperature:1,
messages: [
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content: {
type: 'text',
// JSON.stringify(input.msg)
content: JSON.stringify(input)
// context:input.msg
}
}
]}
)
console.log('aaaa')
for await (const chunk of modelResponse) {
this.sseSender.send({ data: chunk });
}
console.log('bbbb')
this.sseSender.end();
}

systemPrompt 指的是提示词,提示词是ai的核心,这里可以返回json或者是markdown或是html样式。如果不会写提示词,也可以让ai给你写提示词,hhh


End



最后附一张截图。各位大佬有兴趣可以在小红书里搜索AI解梦小组件 ,里面有笔记进行挂载。


发布后发现,官方根本没有推流,而且后续也没提小组件这回事了🤡。
不过就当自己玩玩了,可以使用免费的ai服务进行快速解梦~


作者:景彬
来源:juejin.cn/post/7564540677478301759
收起阅读 »

为了搞一个完美的健身APP,我真是费尽心机

web
作为一个强迫症患者,当我需要一个简单、好用、流畅、无广告的健身记录软件时,撸铁记就诞生了。 为什么我要开发撸铁记 我应该是2018年接触健身的,那个时候我的教练每次给我上课,都会拿着一个文件夹记录我的每一次训练。但是纸制记录最大的问题是难保存,而且只能教练一个...
继续阅读 »

作为一个强迫症患者,当我需要一个简单、好用、流畅、无广告的健身记录软件时,撸铁记就诞生了。


为什么我要开发撸铁记


我应该是2018年接触健身的,那个时候我的教练每次给我上课,都会拿着一个文件夹记录我的每一次训练。但是纸制记录最大的问题是难保存,而且只能教练一个人看,于是我写了第一个健身记录软件,叫FitnessRecord,然后我就在知乎上分享了自己的应用,没想到真的有人用!



后来,在朋友的撺掇下,我正式决定将撸铁记推上线,然后就是(巴拉巴拉极其费劲的上线了!)



个人开发者有多痛苦


一个完美的软件,最重要的,不仅要好看,还得好用,于是,就出现了下面这些设计


暗黑模式


一个 APP,如果不支持暗黑模式,那你让强迫症怎么活?


image.png


image.png
但是...你以为这就完了吗?细节藏在魔鬼里😄


绝对黑


记得前两年各大手机厂商还在卷屏幕的时候,苹果率先推出了“绝对黑”,强调OLED屏幕通过像素关闭实现的物理级纯黑效果。so~为了实现在暗黑模式下,软件用的更爽,撸铁记的 APP 的背景色使用了#000000,也就是纯黑色


这样做的好处是在暗黑模式下,撸铁记可以与屏幕完美的融为一体。但是!问题来了。纯黑色真的很难设计,作为一个程序员出身的我,头发都抓掉了好几把。


有细心的小伙伴们或许已经发现了,亮色模式下跟暗黑模式的主题色其实不是一个颜色


image.png


我们发现在暗黑模式下,亮色模式下的主题色与黑色之间的对比度不够明显,导致整体色调暗沉,因此,亮色模式的主题色是:#3B7AEF 暗黑模式下则是:#2E6FEC


虚拟导航键适配


Android 的虚拟导航键如果适配不好,有多丑相信懂得都懂,为了能够在弹窗模式下也能够让弹窗与导航栏完美无瑕的融为一体,我设计了一个 BaseDialog,专门用来管理弹窗状态,确保在任何页面,虚拟导航栏都不会影响到 APP 的整体颜值!


image.png


左滑展示更多功能


作为一个专业的记录软件,各种各样的功能总要有吧?


全部堆叠到更多菜单中是不是很傻?如果在屏幕排列出来展示是不是更傻?所以,左滑删除这种很合理的交互是不是得有?


IOS 设备是常态,但是能够完美的搬到 Android 机器上,该怎么做?鸿蒙系统又该怎么适配?!


但是!我说的是但是,为了更漂亮的 UI,更合理的交互,我又熬了个通宵,最终完美解决!


image.png



好的交互就得多看,多学



每个人的习惯都不同,比如有的用户希望能够在倒计时 120s 之后有一个声音提示,有的则希望可以按照训练顺序,对卡片自动排序,那么问题来了,这些功能又该堆叠在哪里呢?


我的灵感来源是一款不太出名的 P 图软件


在训练详情页面的左侧,有一根很不起眼的线,当你触摸这条线的时候,就会弹出训练设置的总菜单啦!(不用担心很难触摸,我已经将触摸范围调整到了最合适的大小,既不会误触,也不会很难点👍)


image.png


其实,APP 还有很多为了“好看”而做的设计,但是一个好的 APP,只是静态的好看怎么能行!


完美的入场动效


我该如何像您吹嘘这系统级的丝滑动效?请看 VCR(希望掘金支持视频链接😂):
http://www.bilibili.com/video/BV1sb…
http://www.bilibili.com/video/BV1Pb…


如何?是否足够丝滑???


当然,功能性才是核心


除了记录的易用性和强大复杂的功能,为了能够 360° 覆盖健身所需要的所有场景,我还开发了各种各样的功能


赛博智能


赛博智能,我希望这个功能可以像赛博机器人一样,对我的身体状况进行 360° 评估。


鄙人不才,晒一下我的身体状态评估分析:


image.png


一个超级大长图,几乎涵盖了你想要知道的一切~当然,后续还会继续丰富其他功能😁


日历统计


image.png
这个月你偷懒了吗


是的,你的每一滴汗水,都会浓缩破到这一张小小的日历表格中,如果你偷懒了,那就是一张空空的日历,那么,你会努力填满每一天的,对吧?


最后的最后


按原本的计划,我想要从设计到功能,认真的介绍一下撸铁记的所有方方面面,但是回头看看,文章真的太长了,所以,就留一点悬念给大家,希望需要的小伙伴自行探索😁


其实,每一个细节,我都改过很多次,后续依旧会不断的改来改去,因为我只想要最好~


最后,祝愿所有喜欢健身的朋友,都可以收获自己成功~


作者:AskSky
来源:juejin.cn/post/7524504350250205238
收起阅读 »

Electron 内网离线打包全攻略

web
一、背景与问题核心 近期维护一个内网传统网页开发项目,该项目采用「网页+Electron壳」的架构。由于原Electron版本过旧,导致项目中依赖的Antv G6 v5图表库出现兼容性问题(表现为图表渲染异常或功能报错)。升级Electron本身可参考官方文档...
继续阅读 »

一、背景与问题核心


近期维护一个内网传统网页开发项目,该项目采用「网页+Electron壳」的架构。由于原Electron版本过旧,导致项目中依赖的Antv G6 v5图表库出现兼容性问题(表现为图表渲染异常或功能报错)。升级Electron本身可参考官方文档快速实现,但内网环境下打包时无法联网下载预编译二进制文件,成为本次迁移的核心难点。本文将详细记录完整解决方案,帮助有类似需求的开发者避坑。


二、完整实操流程


整个过程分为「依赖安装配置」和「离线缓存准备」两大步骤,确保开发环境运行与内网打包均正常。


Step 1:安装并配置Electron依赖


Electron默认会根据当前系统自动下载对应平台的预编译包,但内网环境需提前指定下载参数,避免后续打包时依赖网络。


1.1 创建.npmrc文件固定下载参数

在项目根目录新建.npmrc文件,写入以下内容(根据实际需求修改平台和架构,此处以Windows x64为例):


# .npmrc 配置
# 系统架构(x64/arm64等)
arch=x64
# 操作系统(win32/mac/linux等)
platform=win32
# 可选:设置npm镜像(非必需,若内网有镜像可配置)
# registry=https://your-internal-npm-mirror.com/

1.2 安装核心依赖

执行以下命令安装Electron及打包工具@electron-forge/cli(用于后续打包操作):


# 安装开发依赖
npm install electron @electron-forge/cli --save-dev
# 将项目导入Electron Forge(自动生成打包配置文件forge.config.js)
npx electron-forge import

1.3 验证开发环境

确保package.json中已指定Electron入口文件(通过main字段配置,如"main": "main.js"),随后运行以下命令启动项目,验证Antv G6兼容性是否解决:
npm run start
💡 注意:若启动后图表仍异常,可检查Antv G6是否与当前Electron版本匹配(参考G6官方兼容性说明),或清除node_modules后重新安装依赖。


Step 2:准备离线缓存文件


Electron打包时需依赖预编译二进制文件,内网环境无法联网下载,因此需提前在外网环境下载对应文件并配置缓存路径。


2.1 下载对应版本的Electron预编译包


  1. 查看项目中实际安装的Electron版本:在package.json中找到electron的版本号(如"electron": "^38.4.0");

  2. 访问Electron镜像地址:npmmirror.com/mirrors/ele…

  3. 下载以下文件(以win32-x64为例):


- electron-v38.4.0-win32-x64.zip(对应系统的预编译包)

- SHASUMS256.txt(文件校验码,用于验证完整性)

2.2 校验文件完整性(可选但推荐)

下载完成后,通过校验工具(如Windows的PowerShell、Linux/macOS的终端)验证文件哈希值是否与SHASUMS256.txt一致,避免文件损坏:


Windows PowerShell示例(替换文件名和版本号)


Get-FileHash .\electron-v38.4.0-win32-x64.zip -Algorithm SHA256 | Select-Object Hash
将输出的哈希值与SHASUMS256.txt中对应文件名的哈希值对比,一致则文件完整。


2.3 配置forge.config.js离线缓存路径

在项目根目录创建electron-cache文件夹,将下载的预编译包和校验文件放入其中。然后修改forge.config.js,在packagerConfig中添加缓存配置:


const { FusesPlugin } = require("@electron-forge/plugin-fuses");
const { FuseV1Options, FuseVersion } = require("@electron/fuses");
const path = require("path");

module.exports = {
packagerConfig: {
asar: true, // 启用asar打包(可选,用于压缩和保护资源)
download: {
// 本地缓存镜像路径(绝对路径更稳妥)
mirror: path.resolve(__dirname, "electron-cache"),
cache: path.resolve(__dirname, "electron-cache"),
focus: false, // 禁用下载进度聚焦(避免终端干扰)
},
electronZipDir: path.resolve(__dirname, "electron-cache/"), // 预编译包所在目录
},
// ... 其他配置
};

2.4 执行内网离线打包

完成上述配置后,在项目根目录执行打包命令,Electron Forge将从本地缓存读取预编译包,无需联网:


npm run make

打包成功后,可在项目根目录的out文件夹中找到对应系统的安装包或绿色版程序。


三、关键注意事项



  • 路径必须准确:配置中使用path.resolve生成绝对路径,避免相对路径导致的缓存找不到问题;

  • 文件名严格匹配:本地缓存的预编译包文件名需与官方一致(如electron-v{version}-{platform}-{arch}.zip),否则无法识别;

  • 多系统适配:若需打包多个系统(如Windows和macOS),需下载对应平台的预编译包放入缓存文件夹,无需修改配置;

  • 版本号统一:确保package.json中的Electron版本与下载的预编译包版本完全一致,避免兼容性问题。


四、总结


本次Electron升级及内网打包的核心是「提前配置下载参数+本地缓存预编译包」。通过固定.npmrc参数确保依赖安装时匹配目标系统,再通过配置Forge的缓存路径实现离线打包,最终解决了Antv G6的兼容性问题和内网环境限制。若后续需升级Electron版本,只需重复「下载对应版本预编译包→更新缓存文件夹」的步骤即可。
如果遇到其他特殊场景(如内网npm镜像配置、asar包解压问题),欢迎在评论区交流讨论!


作者:猫七先生
来源:juejin.cn/post/7564472484067835923
收起阅读 »

【深入浅出Nodejs】异步非阻塞IO

web
概览:本文介绍了阻塞I/O、非阻塞I/O、多路复用I/O和异步I/O 四种模型,在实际的操作系统和计算机中I/O本质总是阻塞的,通过返回fd状态和轮询的方式来使I/O在应用层不阻塞,然后通过多路复用的方式更高效实现这种不阻塞的效果。然后介绍了Node中异步I/...
继续阅读 »

概览:本文介绍了阻塞I/O、非阻塞I/O、多路复用I/O和异步I/O 四种模型,在实际的操作系统和计算机中I/O本质总是阻塞的,通过返回fd状态和轮询的方式来使I/O在应用层不阻塞,然后通过多路复用的方式更高效实现这种不阻塞的效果。然后介绍了Node中异步I/O的实现,由于计算机本身的设计使得并不存在真正异步I/O,需要通过线程池来模拟出异步I/O。



I/O模式


I/O模式介绍


1.文件描述符


类unix操作系统将I/O抽象为文件描述符(file description,下面简称fd),可读/可写流都可以看做读一个“文件”,打开文件和创建Socket等都是获取到一个fd文件描述符。


2.操作I/O时发生了什么


操作流就是读和写(read/write),下面用read进行说明。read时需要CPU进入内核态等待操作系统处理数据,等操作系统完成后会响应结果。用户态切换到内核态仅仅是CPU执行模式切换,线程本身并未改变,CPU进入内核态才能进行外部设备(外设)的准备工作,从而支持后续数据复制到内核缓冲区,完成后再切换回用户态,然后真正的读数据到用户程序。


3.五种I/O模式


image.png


如图,操作系统有5种I/O模式。



  • blocking

  • nonblocking

  • multiplexing

  • signal-driven (很少使用,不介绍)

  • async I/O
    可以的话,不妨看完下面详细介绍后再回过头看这张图,对5种模式进行对比,相信你认识一定会更加深刻。


阻塞I/O (blocking)


image.png



  1. 当用户态调用read API读流时,操作系统陷入内核态开始准备数据。

  2. 此时read是阻塞的。CPU是会切换到其他线程,做其他事的。原因就是现代计算机(采用了DMA技术)对于这种磁盘读取工作中的数据传输部分CPU是不参与的,交给了DMA控制器负责,等处理好了DMA会发出一个CPU中断,通知CPU切换回原来的线程继续处理。

  3. 所以线程一定是阻塞的,当前线程的执行权让出去了,也就是说没有CPU时间片继续执行当前线程。

  4. 内核态数据准备完成,原来的Thread被唤醒,继续执行,表现为API读流返回了数据。


P.S. DMA是通知操作系统,唤醒原来Thread,继续执行。并不是通知Thread的具体某段程序执行,而是之前被阻塞时执行到哪,现在就继续执行哪里。


非阻塞I/O (non-blocking)


为甚么还要有非阻塞I/O?
显然,阻塞I/O会导致后面的代码不能继续执行,在要处理多个I/O的情况下就是串行发起I/O操作了。而非阻塞I/O就是希望发起I/O操作是并发的(不用等上一个流操作结束才发起下一个)。


非阻塞I/O: 调用read去读fd的数据时,立即返回fd的状态结果,不阻塞后面代码的执行。此时操作系统就需要考虑如何实现这种非阻塞,如管理多个I/O流。


/*伪代码*/
fd = openStream(); //打开文件,创建Socket等都能获得一个fd,不阻塞
n = read(fd); //读取这个fd的数据,不阻塞

image.png



  1. 当用户态调用read API读流时,操作系统陷入内核态检查数据是否就绪。

  2. 此时read是不阻塞的,可以继续执行后面的代码。但是后续需要不断「check」(就是read)来检查数据是否就绪。

  3. DMA通知唤醒Thread(如果Thread一直都是激活状态,不存在被唤醒这一动作)。「check」发现有fd的数据就绪,就进行数据处理。


非阻塞I/O 是指read读数据能立即返回fd状态,而不用等待,但是需要你主动去read。如下图所示(图来自《深入浅出Nodejs》):


image.png


C++伪代码实现


// 文件描述符集合
std::vector<int> fds = {fd1, fd2, fd3}; // 假设有3个需要监控的文件描述符

// 设置为非阻塞模式
for(auto& fd : fds) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

// 轮询循环
while(true) {
bool all_done = true;

// 应用层轮询每个文件描述符
for(auto fd : fds) {
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer)); // 非阻塞调用

if(n > 0) {
// 成功读取到数据
process_data(buffer, n);
}
else if(n == 0) {
// 连接关闭
remove_fd(fd);
}
else if(n < 0) {
if(errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据未就绪,立即返回 - 继续轮询其他fd
continue;
} else {
// 真实错误
handle_error(fd);
}
}

// 检查是否还有需要处理的数据
if(has_pending_operations()) {
all_done = false;
}
}

// 可选的短暂休眠避免CPU占用过高
if(all_done) {
usleep(1000); // 1s休眠
}

// 退出条件
if(should_exit) break;
}

此时,还需要我们手动一个个检查fd的状态。下面就介绍I/O多路复用,它做到了批量监听多个fd状态,不用我们手动去管理监听每一个fd了。


I/O多路复用(multiplexing)


类unix操作系统下,多路复用的方式有 select, poll, epoll(macos/freeBSD 上的替代品是 kqueue)。而在windows下面则直接使用IOCP(基于线程池的异步I/O方式),下面会介绍。
select、poll分别早在1983年、1986年就出现了,而epoll知道Linux2.6(大约2003)年才出现。
现代系统都是非阻塞I/O大都采用epoll或者IOCP的方式作为主流I/O并发方案了。


select

通过select()系统调用来监视多个fd的数组,返回一个int值(表示了fd就绪的个数),当调用select会阻塞,直到有一个fd就绪。


int select(int maxfdp, fd_set *readset, fd_set *writeset, fd_set *exceptset,struct timeval *timeout);
//maxfdp:被监听的文件描述符的总数;
//readset:读fd集合
//writeset:写fd集合
//exceptset
//timeout:用于设置select函数的超时时间,即告诉内核select等待多长时间之后就放弃等待。
//返回值:超时返回0;失败返回-1;成功返回大于0的整数,这个整数表示就绪描述符的数目。

下图展示了select方式(图来自《深入浅出Nodejs》):


image.png



具体过程大致如下:
  1、调用select()方法,上下文切换转换为内核态
  2、将fd从用户空间复制到内核空间
  3、内核遍历所有fd,查看其对应事件是否发生
  4、如果没发生,将进程阻塞,当设备驱动产生中断或者timeout时间后,将进程唤醒,再次进行遍历
  5、返回遍历后的fd
  6、将fd从内核空间复制到用户空间



poll

poll是对select差不多,当调用poll会阻塞。但进行了一定改进:使用链表维护fd集合(select内是使用数组),这样没有了maxfdp的限制。


int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// fds:polld结构体集合,每个结构体描述了fd及其事件
// nfs:指定 `fds`数组中的元素个数,类型 `nfds_t`通常为无符
// timeout:等待时间,`-1`表示阻塞等待直到有事件发生;`0`表示立即返回(非阻塞);大于 `0`则表示最长等待时间
// 返回值:超时返回0;失败返回-1;成功返回大于0的整数,这个整数表示就绪描述符的数目。

struct pollfd {
int fd; /* 文件描述符 */
short events; /* 需要监视的事件(输入) */
short revents; /* 实际发生的事件(输出) */
};

下图展示了poll方式(图来自《深入浅出Nodejs》):


image.png


poll方式伪代码


// 主循环
while (1) {
int ret = poll(fds, nfds, 3000); // 等待 3 秒
if (ret < 0) {
perror("poll error");
break;
} else if (ret == 0) {
printf("[poll] 超时,没有事件\n");
continue;
}

// 遍历所有 fd,检查哪些 revents 有标志
for (int i = 0; i < nfds; i++) {
if (fds[i].revents & POLLIN) {
char buf[1024];
ssize_t n = read(fds[i].fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
process_data(buf, n, fds[i].fd);
} else if (n == 0) {
// EOF,连接关闭
remove_fd(fds, &nfds, i);
i--; // 数组被压缩,重新检查当前位置
} else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
perror("read error");
remove_fd(fds, &nfds, i);
i--;
}
}
}

if (nfds == 0) {
printf("所有 fd 都关闭了,退出。\n");
break;
}
}

pollselect的区别不大,都是要遍历fd看是否有就绪。最大的区别在于poll没有监视的fd集合大小限制(因为采用的链表),而select有大小限制(因为内部采用的数组存储,可以通过参数maxfdp修改,默认1024)。


epoll


epoll_create创建一个 epoll 实例,同时返回一个引用该实例的文件描述符



int epoll_create(int size);


epoll_ctl 会将文件描述符 fd 添加到 epoll 实例的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event,如果红黑树中已经存在立刻返回。当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列上。



int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
// 表示要监听的目标文件描述符
// op 表示要对 fd 执行的操作, 例如为 fd 添加一个监听事件 event
// event 表示要监听的事件
// 返回值 0 或 -1,表示上述操作成功与否。

epoll 模型的主要函数epoll_wait,功能相当于 select。调用该函数时阻塞,等待事件通知唤醒进程。


int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
// epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
// events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
// maxevents 指定 events 的大小
// timeout 类似于 select 中的 timeout。如果没有文件描述符就绪,即就绪队列为空,则 epoll_wait 会阻塞 timeout 毫秒。如果 timeout 设为 -1,则 epoll_wait 会一直阻塞,直到有文件描述符就绪;如果 timeout 设为 0,则 epoll_wait 会立即返回
// 返回值表示 events 中存储的就绪描述符个数,最大不超过 maxevents。

下图展示了epoll方式(图来自《深入浅出Nodejs》):


image.png
epoll方式伪代码


int epfd = epoll_create(1024);
struct epoll_event ev, events[MAX_CONN];
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

while (1) {
int n = epoll_wait(epfd, events, MAX_CONN, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
// 处理可读事件
}
}
}

select和poll存在的缺点:



  • 内核线程需要遍历一遍fd集合,返回给用户空间后需要应用层再遍历一遍fd数组。

  • 每次select/poll都会内核空间到用户空间拷贝fd集合。

  • 性能开销随fd线性增加,时间复杂度O(n)


epoll主要改进点:



  • 通过epoll_ctl提前给fd设置一个事件回调函数,fd上有事件触发了就执行回调函数,把fd放到一个就绪队列上,这样在内核线程是不存在遍历fd集合的,时间复杂度O(1)。

  • epoll_wait不会对fd集合在内核空间和用户空间拷贝, 而是“利用mmap()文件映射内存加速与内核空间的消息传递,减少拷贝开销。”


到这里,我们可以试着总结non-blocking和多路复用区别和联系。
区别:



  • non-blocking I/O:靠不断“主动轮询”实现不阻塞

  • I/O 多路复用:靠“事件通知 + 轮询”实现更高效的不阻塞
    个人理解,广义的来说,多路复用本身也是一种非阻塞I/O。


异步I/O



尽管epoll已经利用了事件来降低CPU的耗用,但是休眠期间CPU几乎是闲置的,对于当前线程而言利用率不够,那么是否有一种理想的异步I/O呢?



下图展示了理想的异步I/O(图来自《深入浅出Nodejs》):


image.png


真正的异步I/O是在操作流时(发起异步操作)即不阻塞后续的代码执行,又不需要自己去主动轮询(read),只需要内核通知应用层执行回调(并且数据从内核空间读取到用户空间也是不阻塞的)。很遗憾,这种异步I/O几乎不存在(之所以说几乎,是因为Linux原生提供了一种这样的异步I/O——AIO,但存在缺陷)。


现实中的异步I/O,基本上都是通过线程池的方式来实现的,windows的IOCP也是内核级别实现了线程池。


在Node单线程中,通过让其他部分线程进行「阻塞I/O」或者「非阻塞I/O+轮询技术」来完成数据获取,等数据获取完成后通知主线程执行回调。此时主线程是不会让出CPU执行权的,可以一直继续执行其他代码。这样就实现了异步I/O。


下图展示了线程池模拟的异步I/O(图来自《深入浅出Nodejs》):


image.png


由于Windows*nix的平台差异,Node提供了libuv作为抽象封装层来对不同平台做兼容性判断。
下图展示了Node的libuv架构(图来自《深入浅出Nodejs》):


image.png


Node的事件循环


请求对象 :一个异步I/O的发起,libuv会产生一个封装好的请求对象。比如fs.open会产生一个FSReqWrap的对象。


观察者: 可以理解成观察者模式中的观察者,它主要是观察判断事件队列中是否有事件了,当有事件了就需要去处理这个事件。


这里我用一张流程图说明发起异步I/O是如何被线程池执行,然后通过事件通知主线程的流程。


image.png


当异步任务执行的结果放入了事件队列,此时观察者会在主线程同步任务执行完后,查看事件队列中是否有事件任务,有则取出执行。等这个任务(同步代码)执行完后接着取下一个任务执行,一直循环,这就是Node的事件循环


P.S.这里的事件队列是一个笼统的队列概念,可以理解成包括宏任务队列和微任务队列。


总结


本文介绍了阻塞I/O、非阻塞I/O、多路复用I/O和异步I/O 四种模型,在实际的操作系统和计算机中I/O本质总是阻塞的,通过返回fd状态和轮询的方式来使I/O在应用层不阻塞,然后通过多路复用的方式更高效实现这种不阻塞的效果。然后介绍了Node中异步I/O的实现,由于计算机本身的设计使得并不存在真正异步I/O,需要通过线程池来模拟出异步I/O。


在多路复用中,结合C++伪代码和图示的方式展示了select/poll/epoll的原理和差异,Linux中通常使用epoll(mac中有类似的kqueue)来实现非阻塞I/O,具备不用遍历fd集合和反复拷贝fd集合的性能优点。


最后,介绍了基于线程池的异步非阻塞I/O的实现原理,再结合事件队列和观察者实现了Node事件循环。


参考资料


Select、Poll、Epoll、 异步IO 介绍
【操作系统】I/O 多路复用,select / poll / epoll 详解
深入浅出Nodejs


作者:疯狂踩坑人
来源:juejin.cn/post/7564614473962733577
收起阅读 »

Vue3 后台分页写腻了?我用 1 个 Hook 删掉 90% 重复代码(附源码)

web
实战推荐: ⚡ 一个Vue自定义指令搞定丝滑拖拽列表,告别复杂组件封装 🔥 这才是 Vue 驱动的 Chrome 插件工程化正确打开方式 还在为每个列表页写重复的分页代码而烦恼吗? 还在复制粘贴 currentPage、pageSize、loadin...
继续阅读 »

实战推荐:





还在为每个列表页写重复的分页代码而烦恼吗? 还在复制粘贴 currentPage、pageSize、loading 等状态吗? 一个 Hook 帮你解决所有分页痛点,减少90%重复代码



背景与痛点


在后台管理系统开发中,分页列表查询非常常见,我们通常需要处理:



  • 当前页、页大小、总数等分页状态

  • 加载中、错误处理等请求状态

  • 搜索、刷新、翻页等分页操作

  • 数据缓存和重复请求处理


这些重复逻辑分散在各个组件中,维护起来很麻烦。


为了解决这个烦恼,我专门封装了分页数据管理 Hook。现在只需要几行代码,就能轻松实现分页查询,省时又高效,减少了大量重复劳动


使用前提 - 接口格式约定


查询接口返回的数据格式:


{
list: [ // 当前页数据数组
{ id: 1, name: 'user1' },
{ id: 2, name: 'user2' }
],
total: 100 // 数据总条数
}

先看效果:分页查询只需几行代码!


import usePageFetch from '@/hooks/usePageFetch' // 引入分页查询 Hook,封装了分页逻辑和状态管理
import { getUserList } from '@/api/user' // 引入请求用户列表的 API 方法

// 使用 usePageFetch Hook 实现分页数据管理
const {
currentPage, // 当前页码
pageSize, // 每页条数
total, // 数据总数
data, // 当前页数据列表
isFetching, // 加载状态,用于控制 loading 效果
search, // 搜索方法
onSizeChange, // 页大小改变事件处理方法
onCurrentChange // 页码改变事件处理方法
} = usePageFetch(
getUserList, // 查询API
{ initFetch: false } // 是否自动请求一次(组件挂载时自动拉取第一页数据)
)

这样子每次分页查询只需要引入hook,然后传入查询接口就好了,减少了大量重复劳动


解决方案


我设计了两个相互配合的 Hook:



  • useFetch:基础请求封装,处理请求状态和缓存

  • usePageFetch:分页逻辑封装,专门处理分页相关的状态和操作


usePageFetch (分页业务层)
├── 管理 page / pageSize / total 状态
├── 处理搜索、刷新、翻页逻辑
├── 统一错误处理和用户提示
└── 调用 useFetch (请求基础层)
├── 管理 loading / data / error 状态
├── 可选缓存机制(避免重复请求)
└── 成功回调适配不同接口格式

核心实现


useFetch - 基础请求封装


// hooks/useFetch.js
import { ref } from 'vue'

const Cache = new Map()

/**
* 基础请求 Hook
* @param {Function} fn - 请求函数
* @param {Object} options - 配置选项
* @param {*} options.initValue - 初始值
* @param {string|Function} options.cache - 缓存配置
* @param {Function} options.onSuccess - 成功回调
*/

function useFetch(fn, options = {}) {
const isFetching = ref(false)
const data = ref()
const error = ref()

// 设置初始值
if (options.initValue !== undefined) {
data.value = options.initValue
}

function fetch(...args) {
isFetching.value = true
let promise

if (options.cache) {
const cacheKey = typeof options.cache === 'function'
? options.cache(...args)
: options.cache || `${fn.name}_${args.join('_')}`

promise = Cache.get(cacheKey) || fn(...args)
Cache.set(cacheKey, promise)
} else {
promise = fn(...args)
}

// 成功回调处理
if (options.onSuccess) {
promise = promise.then(options.onSuccess)
}

return promise
.then(res => {
data.value = res
isFetching.value = false
error.value = undefined
return res
})
.catch(err => {
isFetching.value = false
error.value = err
return Promise.reject(err)
})
}

return {
fetch,
isFetching,
data,
error
}
}

export default useFetch


usePageFetch - 分页逻辑封装


// hooks/usePageFetch.js
import { ref, onMounted, toRaw, watch } from 'vue'
import useFetch from './useFetch' // 即上面的hook ---> useFetch
import { ElMessage } from 'element-plus'

/**
* 分页数据管理 Hook
* @param {Function} fn - 请求函数
* @param {Object} options - 配置选项
* @param {Object} options.params - 默认参数
* @param {boolean} options.initFetch - 是否自动初始化请求
* @param {Ref} options.formRef - 表单引用
*/

function usePageFetch(fn, options = {}) {
// 分页状态
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const data = ref([])
const params = ref()
const pendingCount = ref(0)

// 初始化参数
params.value = options.params

// 使用基础请求 Hook
const { isFetching, fetch: fetchFn, error, data: originalData } = useFetch(fn)

// 核心请求方法
const fetch = async (searchParams, pageNo, size) => {
try {
// 更新分页状态
page.value = pageNo
pageSize.value = size
params.value = searchParams

// 发起请求
await fetchFn({
page: pageNo,
pageSize: size,
// 使用 toRaw 避免响应式对象问题
...(searchParams ? toRaw(searchParams) : {})
})

// 处理响应数据
data.value = originalData.value?.list || []
total.value = originalData.value?.total || 0
pendingCount.value = originalData.value?.pendingCounts || 0
} catch (e) {
console.error('usePageFetch error:', e)
ElMessage.error(e?.msg || e?.message || '请求出错')
// 清空数据,提供更好的用户体验
data.value = []
total.value = 0
}
}

// 搜索 - 重置到第一页
const search = async (searchParams) => {
await fetch(searchParams, 1, pageSize.value)
}

// 刷新当前页
const refresh = async () => {
await fetch(params.value, page.value, pageSize.value)
}

// 改变页大小
const onSizeChange = async (size) => {
await fetch(params.value, 1, size) // 重置到第一页
}

// 切换页码
const onCurrentChange = async (pageNo) => {
await fetch(params.value, pageNo, pageSize.value)
}

// 组件挂载时自动请求
onMounted(() => {
if (options.initFetch !== false) {
search(params.value)
}
})

// 监听表单引用变化(可选功能)
watch(
() => options.formRef,
(formRef) => {
if (formRef) {
console.log('Form ref updated:', formRef)
}
}
)

return {
// 分页状态
currentPage: page,
pageSize,
total,
pendingCount,

// 数据状态
data,
originalData,
isFetching,
error,

// 操作方法
search,
refresh,
onSizeChange,
onCurrentChange
}
}

export default usePageFetch

完整使用示例


用element ui举例


<template>
<el-form :model="searchForm" >
<el-form-item label="用户名">
<el-input v-model="searchForm.username" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>

<!-- 表格数据展示,绑定 data 和 loading 状态 -->
<el-table :data="data" v-loading="isFetching">
<!-- ...表格列定义... -->
</el-table>


<!-- 分页组件,绑定当前页、页大小、总数,并响应切换事件 -->
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
/>

</template>
<script setup>
import { ref } from 'vue'
import usePageFetch from '@/hooks/usePageFetch' // 引入分页查询 Hook,封装了分页逻辑和状态管理
import { getUserList } from '@/api/user' // 引入请求用户列表的 API 方法

// 搜索表单数据,响应式声明
const searchForm = ref({
username: ''
})

// 使用 usePageFetch Hook 实现分页数据管理
const {
currentPage, // 当前页码
pageSize, // 每页条数
total, // 数据总数
data, // 当前页数据列表
isFetching, // 加载状态,用于控制 loading 效果
search, // 搜索方法
onSizeChange, // 页大小改变事件处理方法
onCurrentChange // 页码改变事件处理方法
} = usePageFetch(
getUserList,
{ initFetch: false } // 是否自动请求一次(组件挂载时自动拉取第一页数据)
)

/**
* 处理搜索操作
*/

const handleSearch = () => {
search({ username: searchForm.value.username })
}

</script>


高级用法


带缓存


const {
data,
isFetching,
search
} = usePageFetch(getUserList, {
cache: (params) => `user-list-${JSON.stringify(params)}` // 自定义缓存 key
})

设计思路解析



  • 职责分离:useFetch 专注请求状态管理,usePageFetch 专注分页逻辑

  • 统一错误处理:在 usePageFetch 层统一处理错误

  • 智能缓存机制:支持多种缓存策略

  • 生命周期集成:自动在组件挂载时请求数据


总结


这套分页管理 Hook 的优势:



  • 开发效率高,减少90%的重复代码,新增列表页从 30 分钟缩短到 5 分钟

  • 状态管理完善,自动处理加载、错误、数据状态

  • 缓存机制,避免重复请求

  • 错误处理统一,用户体验一致

  • 易于扩展,支持自定义配置和回调



如果觉得对您有帮助,欢迎点赞 👍 收藏 ⭐ 关注 🔔 支持一下!



作者:不一样的少年_
来源:juejin.cn/post/7549096640340426802
收起阅读 »

识别手写数字,居然可以只靠前端?

web
前言 之前一篇的神经网络文章,居然意外的受欢迎,有一万多的掘友们看过。github 的 star 数也是破了新高,非常感谢~ 文章链接:当一个前端学了很久的神经网络...👈🤣 github 链接:github.com/imoo666/neu… 但是之前边调...
继续阅读 »

前言


之前一篇的神经网络文章,居然意外的受欢迎,有一万多的掘友们看过。github 的 star 数也是破了新高,非常感谢~



文章链接:当一个前端学了很久的神经网络...👈🤣


github 链接:github.com/imoo666/neu…



但是之前边调研边写代码,还是有些乱的,我重新组织了一下代码,让大家能更清晰的了解前端使用神经网络的流程。


不过只是重新讲解一下流程就太水了,这篇就再来一个识别手写数字的项目,顺便理一下我们的思路。


步骤


很多同学反馈 担心前端入坑神经网络很难,但其实就是按部就班的几步,许多步骤都是调用 api,并不需要我们全部手写,还是比较容易的。


核心步骤有下:



  1. 加载和准备数据

  2. 定义模型

  3. 训练模型

  4. 使用模型进行预测


加载和准备数据


既然是手写数字识别,我们首先还是需要一些手写数字的图片,数据集我一般是去 kaggle 找的。



下载链接:http://www.kaggle.com/code/cdeott…



不过这次的数据是 csv 而非图片压缩包,先下载打开看看怎么个事。
image.png


可以观察到是一个 784 * n 的一个表格,表格中的数在 0-255 之间,对图片敏感的同学应该已经反应过来了,784 === 28 * 28,也就是每一行代表了一个 28 * 28 的灰度 图片。


可以简单写一个渲染图片的方法来看一下效果:


image.png


看起来跟我们猜想的一样,另外,第一行是表头,第一列是该行的实际数字,用于做验证。


知道这些,那就可以开始加载数据了,目标是将这堆数据转化为可以供 模型训练 的数据。


  const loadCsvData = async () => {

// 先加载
const response = await fetch("src/pages/mnist/assets/mnist.csv");
const text = await response.text();

// 忽略第一行的表头
const lines = text.trim().split("\n").slice(1);

// 将每一行都转化为张量
const samples: DigitSample[] = lines.map((line) => {
const values = line.split(",").map(Number);
const label = values[0];
const pixels = tf
.tensor3d(values.slice(1), [28, 28, 1])
.div(255) as tf.Tensor3D;
return { pixels, label };
});

// 打乱数组
tf.util.shuffle(samples);

// 将后 50 条作为测试集,其余作为训练集
const train = samples.slice(0, samples.length - 50);
const test = samples.slice(-50);

// 独热编码,一共 10 个可能
const xTrain = tf.stack(train.map((s) => s.pixels)) as tf.Tensor4D;
const yTrain = tf.oneHot(
train.map((s) => s.label),
10
) as tf.Tensor2D;

return { xTrain, yTrain, test };
};

定义模型


这次是手写数字的识别,我们需要用到图片识别比较经典的 卷积层 + 最大池化层 的组合,除此之外,这次还添加了一个防止过拟合的 dropout 层。


  const defineModel = () => {
const model = tf.sequential({
layers: [
// 最大池化层,用于降低图片大小
tf.layers.maxPooling2d({
poolSize: 2,
strides: 2,
inputShape: [28, 28, 1],
}),
// 卷积层,用 32个卷积核进行提取特征
tf.layers.conv2d({
filters: 32,
kernelSize: 3,
activation: "relu",
padding: "same",
}),
// 将提取结果平铺
tf.layers.flatten(),
// 一个普通的隐藏层计算关系
tf.layers.dense({ units: 64, activation: "relu" }),
// 防止过拟合
tf.layers.dropout({ rate: 0.3 }),
// 分类
tf.layers.dense({
units: 10,
activation: "softmax",
}),
],
});

model.compile({
optimizer: "adam",
loss: "categoricalCrossentropy",
metrics: ["accuracy"],
});

return model;
};

训练模型


训练模型没什么需要写的,只是需要配置几个参数(如轮数,批处理数量等),然后按照固定逻辑调用 api 即可


 const trainModel = async () => {
setModelState({ model: null, isTraining: true, logs: [] });

const model = defineModel();
const { xTrain, yTrain, test } = await loadCsvData();

await model.fit(xTrain, yTrain, {
epochs: 20, // 轮数
batchSize: 8, // 批处理数量
validationSplit: 0.2, // 用于验证的比例
callbacks: {
onEpochEnd: (epoch, logs) => {
if (!logs) return;
setModelState((prev) => ({
...prev,
logs: [
...prev.logs,
{
epoch: epoch + 1,
loss: Number(logs.loss?.toFixed(4)),
accuracy: Number((logs.acc ?? logs.accuracy ?? 0).toFixed(4)),
},
],
}));
},
},
});

predict(model, test);
setModelState((prev) => ({ ...prev, model, isTraining: false }));
tf.dispose([xTrain, yTrain]);
};

image.png


等待模型训练完毕后,model 就是可用的模型,可以用其去预测不同的图片,我选择了 50 张图片用于我们测试正确率。


使用模型进行预测


核心就是调用一下 model.predict() 这个方法用于预测,不过最后给出的结果会是一个十个元素的数组,分别代表是某个数字的概率,我们需要手动取出最高概率的一个作为我们的预测结果。


const predict = (model: tf.Sequential, samples: DigitSample[]) => {
const results: PredictionResult[] = samples.map((sample) => {
const input = sample.pixels.expandDims(0); // 格式化
const output = model.predict(input) as tf.Tensor; // 预测
const probs = output.dataSync(); // 张量转数组
const predicted = output.argMax(1).dataSync()[0]; // 拿到最大的位
const confidence = Number((probs[predicted] * 100).toFixed(1));
tf.dispose([input, output]);
return {
imageTensor: sample.pixels,
actual: sample.label,
predicted,
confidence,
correct: predicted === sample.label,
};
});
setPredictions(results);
};

image.png


其他


最后可以看一下我们的完整页面


image.png


感兴趣的同学可以查看源码,相较于之前的版本做了许多整理工作,都按照本文的步骤进行了函数的划分:github.com/imoo666/neu…


又变强了一步!一起加油前端仔!


image.png


作者:imoo
来源:juejin.cn/post/7514250027041964083
收起阅读 »

TabFlow: 一款简洁的 Chrome 标签页域名分类器

web
TabFlow:打造智能化的Chrome标签页管理扩展 前言 在日常的Web开发和浏览过程中,我们经常会同时打开大量的标签页。当标签页数量超过10个时,浏览器的标签栏就会变得拥挤不堪,找到特定的页面变得困难。为了解决这个痛点,我开发了TabFlow——一个智能...
继续阅读 »

TabFlow:打造智能化的Chrome标签页管理扩展


前言


在日常的Web开发和浏览过程中,我们经常会同时打开大量的标签页。当标签页数量超过10个时,浏览器的标签栏就会变得拥挤不堪,找到特定的页面变得困难。为了解决这个痛点,我开发了TabFlow——一个智能的Chrome标签页分组管理扩展。


项目概述


TabFlow是一个基于Chrome Extension Manifest V3的标签页管理工具,它能够:



  • 🏷️ 智能分组:自动按域名对标签页进行分组

  • 🎨 视觉区分:为不同域名分配独特的颜色标识

  • 📊 实时统计:显示标签页数量和域名统计

  • 性能优化:采用防抖、缓存等技术确保流畅体验


截屏2025-09-28 23.15.21.png


技术架构


1. 项目结构


TabFlow/
├── manifest.json # 扩展配置文件
├── background.js # 后台服务脚本
├── popup.html # 弹窗界面
├── popup.js # 弹窗逻辑
└── icons/ # 图标资源
├── icon16.png
├── icon48.png
└── icon128.png

2. 核心技术栈



  • Chrome Extensions API:标签页和分组管理

  • Manifest V3:最新的扩展开发标准

  • Service Worker:后台处理逻辑

  • Modern CSS:毛玻璃效果和渐变设计


核心功能实现


1. 智能域名解析


首先,我们需要从URL中提取有意义的域名信息:


function parseUrl(url) {
if (domainCache.has(url)) {
return domainCache.get(url);
}

try {
const urlObj = new URL(url);
let hostname = urlObj.hostname;

// 移除www前缀
if (hostname.startsWith('www.')) {
hostname = hostname.substring(4);
}

// 提取主域名
const parts = hostname.split('.');
let mainDomain = hostname;

if (parts.length >= 2) {
const commonTLDs = ['com', 'org', 'net', 'edu', 'gov', 'cn', 'jp', 'uk'];
const lastPart = parts[parts.length - 1];

if (commonTLDs.includes(lastPart)) {
mainDomain = parts.slice(-2).join('.');
} else {
mainDomain = parts.slice(-3).join('.');
}
}

const displayName = mainDomain.split('.')[0].toUpperCase();
const result = { mainDomain, displayName };

// 缓存结果,限制缓存大小
if (domainCache.size > 100) {
const firstKey = domainCache.keys().next().value;
domainCache.delete(firstKey);
}
domainCache.set(url, result);

return result;
} catch (e) {
return null;
}
}

技术亮点



  • 使用Map缓存解析结果,避免重复计算

  • 智能处理各种TLD(顶级域名)

  • 限制缓存大小防止内存泄漏


2. 自动分组机制


当检测到同域名的多个标签页时,自动创建或更新分组:


async function autoGr0upTabsByDomain(mainDomain, displayName, tabs, color) {
try {
// 使用缓存的分组信息
let existingGr0ups;
if (groupCache.has('groups')) {
existingGr0ups = groupCache.get('groups');
} else {
existingGr0ups = await chrome.tabGr0ups.query({});
groupCache.set('groups', existingGr0ups);
setTimeout(() => groupCache.delete('groups'), 5000);
}

const targetGr0up = existingGr0ups.find(group =>
group.title.includes(displayName)
);

if (targetGr0up) {
// 添加到现有分组
const ungroupedTabs = tabs.filter(tab => tab.groupId === -1);
if (ungroupedTabs.length > 0) {
const tabIds = ungroupedTabs.map(tab => tab.id);
await chrome.tabs.group({ tabIds, groupId: targetGr0up.id });

// 获取分组中的实际标签页数量
const groupTabs = await chrome.tabs.query({ groupId: targetGr0up.id });
await chrome.tabGr0ups.update(targetGr0up.id, {
title: `${displayName} (${groupTabs.length})`
});
}
} else if (tabs.length > 1) {
// 创建新分组
const tabIds = tabs.map(tab => tab.id);
const groupId = await chrome.tabs.group({ tabIds });

await chrome.tabGr0ups.update(groupId, {
title: `${displayName} (${tabs.length})`,
color: GR0UP_COLORS[color] || 'grey',
collapsed: false
});
}

groupCache.delete('groups');
} catch (e) {
console.log('分组失败:', e);
}
}

截屏2025-09-28 23.17.15.png


3. 实时数量更新


这是项目中的一个技术难点。Chrome的标签页分组API在标签页数量变化时不会自动更新分组标题,需要我们主动监听和更新:


// 监听标签页移除,更新分组标题
chrome.tabs.onRemoved.addListener(async (tabId, removeInfo) => {
try {
const groups = await chrome.tabGr0ups.query({});

for (const group of groups) {
const groupTabs = await chrome.tabs.query({ groupId: group.id });
if (groupTabs.length > 0) {
const titleMatch = group.title.match(/^(.+?)\s*\(/);
if (titleMatch) {
const displayName = titleMatch[1];
await chrome.tabGr0ups.update(group.id, {
title: `${displayName} (${groupTabs.length})`
});
}
}
}
} catch (e) {
console.log('更新分组标题失败:', e);
}
});

// 定期更新分组标题(每5秒检查一次)
async function updateAllGr0upTitles() {
try {
const groups = await chrome.tabGr0ups.query({});

for (const group of groups) {
const groupTabs = await chrome.tabs.query({ groupId: group.id });

const titleMatch = group.title.match(/^(.+?)\s*\(/);
if (titleMatch && groupTabs.length > 0) {
const displayName = titleMatch[1];
const currentTitle = `${displayName} (${groupTabs.length})`;

if (group.title !== currentTitle) {
await chrome.tabGr0ups.update(group.id, {
title: currentTitle
});
}
}
}
} catch (e) {
console.log('更新分组标题失败:', e);
}
}

setInterval(updateAllGr0upTitles, 5000);

技术亮点



  • 多重监听机制确保数量实时更新

  • 正则表达式解析分组标题

  • 定时器作为兜底方案


4. 现代化UI设计


采用了Apple设计语言,实现了毛玻璃效果和流畅的动画:


.container {
padding: 20px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}

.stats {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
padding: 16px 20px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
}

.domain-item {
display: flex;
align-items: center;
padding: 14px 16px;
margin-bottom: 10px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

.domain-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
}

截屏2025-09-28 23.18.18.png


性能优化策略


1. 缓存机制


let domainCache = new Map();     // 缓存域名解析结果
let groupCache = new Map(); // 缓存分组信息
let pendingUpdates = new Set(); // 防止重复处理

2. 防抖处理


let updateTimeout;
function debounceTabUpdate(callback, delay = 500) {
clearTimeout(updateTimeout);
updateTimeout = setTimeout(callback, delay);
}

3. 批量操作


// 并行处理分组
const groupPromises = Object.entries(domainGr0ups)
.filter(([, domainTabs]) => domainTabs.length > 1)
.map(async ([mainDomain, domainTabs]) => {
const urlInfo = parseUrl(domainTabs[0].url);
if (urlInfo) {
const color = assignColorToDomain(mainDomain);
return autoGr0upTabsByDomain(mainDomain, urlInfo.displayName, domainTabs, color);
}
});

await Promise.all(groupPromises);

图标设计


为了让扩展更具视觉吸引力,我设计了一套现代化的图标:


icon128.png


图标采用了:



  • 蓝紫色到粉色的渐变背景

  • 多层标签页卡片效果

  • 流动箭头指示分组功能

  • 现代化的扁平设计风格


开发过程中的挑战与解决方案


1. Manifest V3迁移


Chrome Extensions从V2迁移到V3带来了一些挑战:


问题:Background Scripts改为Service Worker
解决:重构代码结构,使用事件驱动模式


// Manifest V3配置
{
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": [
"tabs",
"storage",
"tabGr0ups"
]
}

2. 分组标题实时更新


问题:Chrome API不会自动更新分组标题中的数量
解决:实现多重监听机制 + 定时同步


3. 性能优化


问题:频繁的API调用导致性能问题
解决:引入缓存、防抖、批量处理等优化策略


安装TabFlow后,用户可以:



  1. 自动按域名分组标签页

  2. 通过颜色快速识别不同网站

  3. 实时查看每个分组的标签页数量

  4. 一键整理所有标签页


技术总结


通过开发TabFlow,我深入学习了:



  1. Chrome Extensions API:掌握了标签页、分组、存储等核心API

  2. 性能优化:学会了缓存、防抖、批量处理等优化技术

  3. 现代CSS:实践了毛玻璃效果、渐变、动画等现代设计

  4. 用户体验:理解了如何设计直观易用的界面


项目地址



未来规划



  1. 智能分组算法:基于用户习惯的智能分组

  2. 标签页搜索:快速搜索和定位标签页

  3. 数据同步:跨设备同步分组配置

  4. 快捷键支持:键盘快捷键操作


结语


TabFlow不仅解决了我个人的标签页管理痛点,也是一次完整的Chrome扩展开发实践。从需求分析到技术实现,从性能优化到用户体验,每一个环节都充满了学习和挑战。


希望这个项目能够帮助更多开发者提高浏览效率,也欢迎大家提出建议和贡献代码!




如果你觉得这篇文章对你有帮助,欢迎点赞和分享。如果你有任何问题或建议,也欢迎在评论区讨论!


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