分享前端项目常用的三个Skills--Vue、React 与 UI 核心 Skills
前言
在过去的几年里,前端开发经历了从手写每一行 HTML/CSS 或使用组件库(Ant Design, Element Plus),到 AI 辅助编程(GitHub Copilot, Cursor)的巨大跨越。然而,2025 年以来的技术浪潮正将我们推向一个新的阶段:从 “AI 辅助” 向 “AI Agent(智能体)” 转型。
在这种模式下,开发者不再只是接收代码建议,而是为 AI 提供一系列“技能包(Skills)”,让 AI 能够理解复杂的框架逻辑、项目结构乃至视觉审美。
什么是Skills? 用一句话概括就是: Skills = 领域专业知识 + 你的项目偏好 + 严厉的审查官。
1. 为什么需要 Skills?
在 AI 编程的语境下,Skills 的出现是为了解决大模型(LLM)“博而不精”的天然缺陷。
1.1 通才 AI:知识的“图书馆”
一个没有安装 Skills 的大模型(如 GPT-5 或 Claude 4.5)就像一个读过全世界所有代码的毕业生:
- 广度惊人:它知道几百种编程语言,甚至能背出 2014 年的过时语法。
- 深度断层:它不知道你公司内部的封装规范,不知道你的组件命名习惯,更不知道你项目中那个“不能动的老 Bug”。
- 执行模糊:给它一个需求,它会从 100 种可能的解法中随机选一个给你,不管这是否符合你现在的工程环境。
1.2 专才 AI:自带“十年工龄”的骨干
安装了 Skills 的 AI(Agent),则完成了从“大模型”到**“领域智能体”**的进化:
安装了 Skills 之后, 它不再是那个满嘴跑火车的机器人,而是一个被你强制要求“必须按这套规约写代码”的资深开发。
| 特性 | 通才 AI (Generalist) | 专才 AI (Specialist with Skills) |
|---|---|---|
| 思维边界 | 无边界,容易产生幻觉 | 有界思维:严格锁定在当前技术栈内 |
| 项目理解 | 盲人摸象,只看当前文件 | 全局视野:理解路由、状态管理、样式体系 |
| 输出质量 | “大概能跑”的代码 | **“符合工程直觉”**的生产级代码 |
| 角色定位 | 知识检索器 | 虚拟技术负责人 (Virtual Lead) |
2. Skills 里面到底装了什么
一个 Skills 文件夹里通常不是深奥的代码,而是三样东西:
- 规约(Rules) :一堆告诉 AI “准许做什么”和“严禁做什么”的指令(通常是
.cursorrules或.md文件)。 - 上下文(Context) :你项目的特殊结构。比如你的路由写在哪里,你的接口是怎么封装的。
- 工具(Tools) :一些自动化的小脚本。比如 AI 写完代码后,自动运行一下
npm lint检查有没有写错。
如果说通才 AI 提供的是“概率” (它觉得这段代码看起来像对的),那么 Skills 提供的就是“确定性”。它把行业内顶尖架构师的经验,封装成了 AI 触手可及的“条件反射”。
本文将分享前端高频使用的三个核心技能包:vue-skills、react-skills(agent-skills)以及 ui-skills(uipro-cli),剖析它们如何通过标准化的指令集,彻底改变我们的开发流。
Vue-Skills
Vue-Skills涵盖了 Vue 3 项目的最佳实践、TypeScript 类型安全增强、IDE 性能优化以及现代 Vue 编码模式。它们旨在解决大型 Vue 项目中常见的开发痛点,如 IDE 卡顿、类型推断错误、构建配置以及代码可维护性问题。
1.1 核心规则
可以将这些规则概括为以下 5 个主要方面:
1. 开发体验与 IDE 性能优化 (IDE & Performance)
关注如何解决大型项目中 VSCode 和 Volar 插件的性能问题。
- codeactions-save-performance.md: 解决在大型 Vue 项目中保存文件时耗时过长(30秒+)的问题,建议禁用或限制耗时的 Code Actions。
- volar-3-breaking-changes.md: 针对 Volar 升级带来的变更进行适配,确保插件功能正常。
- vue-directive-comments.md: 介绍了@vue-ignore, @vue-skip,@vue-expect-error等指令注释,用于在模版中精细控制类型检查行为(类似 @ts-ignore)。
2. TypeScript 类型安全与配置 (Type Safety & Configuration)
致力于提升 Vue 模版和组件的类型检查严格度,减少运行时错误。
- vue-tsc-strict-templates.md: 推荐开启 strictTemplates,在编译时捕获模版中未定义的组件和属性错误。
- vue-router-typed-params.md: 解决unplugin-vue-router,导致的路由参数类型丢失问题(Property does not exist),推荐更严格的路由参数类型定义。
- data-attributes-config.md: 配置 vueCompilerOptions 以支持
data-*属性的类型检查,避免误报类型错误。 - ** module-resolution-bundler.md**: 推荐在 tsconfig.json 设置 "moduleResolution": "bundler"
以适配现代构建工具。 - with-defaults-union-types.md: 修复在defineProps中使用联合类型(如 string | false)配合withDefaults 时的虚假警告问题。
3. Vue 3 现代编码模式 (Modern Patterns)
推广 Vue 3.4+ 的新特性及更优雅的代码组织方式。
- define-model-update-event.md: 推荐使用 Vue 3.4 的 defineModel()宏,替代手写的props+ emit('update:modelValue')模式。
- extract-component-props.md: 建议将复杂的defineProps类型提取为独立的 TS 接口,提高代码可读性和复用性。
- script-setup-jsdoc.md: 规范
<script setup>中的 JSDoc 使用,增强组件文档和类型提示。 - fallthrough-attributes.md: 关于 inheritAttrs: false和透传属性(Attributes Fallthrough)的最佳实践。
4. 运行时陷阱与调试 (Runtime Caveats & Debugging)
解决特定场景下的怪异 bug 和运行时问题。
- deep-watch-numeric.md: 警告在侦听(watch)数字数组时使用 deep: true的陷阱(新旧值相同),建议使用深拷贝。
- duplicate-plugin-detection.md: 防止 Vue 插件(如 Pinia)在微前端或特定环境下被重复注册。
- hmr-vue-ssr.md: 处理服务端渲染(SSR)场景下的热更新(HMR)问题。
5. 测试与样式 (Testing & Styling)
- pinia-store-mocking.md: 关于如何在测试中正确 Mock Pinia Store 的指南。
- strict-css-modules.md: 开启 CSS Modules 的严格模式,防止使用未定义的 class 名。
1.2 安装方法
通过简单的命令,你可以将此技能植入到你的 AI 工作流中:
npx add-skill hyf0/vue-skills
执行上面的指令后,会自动检查IDE,终端环境

选择安装在当前项目,还是对所有项目生效

选择下载一份每个项目建立软链接,还是将规则文件复制在每个项目下

安装完成之后的显示

1.3 实战案例
案例展示了当 AI Agent 加载了 vue-best-practices 技能包后,如何通过 “提取组件属性 (Extract Component Props)” 规则,优雅地解决二次封装组件时的类型继承问题。
场景描述:我们正在基于一个第三方库的 BaseButton.vue 组件,封装一个我们项目专用的 ProButton.vue。我们需要让 ProButton 继承 BaseButton 的所有属性(Props),同时增加一个自定义属性 loading。
1. 优化前:常规“盲写”模式
现象:如果没有技能包指导,开发者往往会手动重复定义属性,或者使用不推荐的 Vue 内部实例类型。
<!-- ProButton.vue -->
<script setup lang="ts">
// ❌ 错误做法 1:手动重复定义,维护成本极高
interface Props {
text: string;
color?: string;
loading: boolean; // 自定义属性
}
// ❌ 错误做法 2:使用 InstanceType,会包含大量的 Vue 内部属性,干扰类型提示
import type BaseButton from "./BaseButton.vue";
type BaseProps = InstanceType<typeof BaseButton>["$props"];
defineProps<BaseProps & { loading: boolean }>();
</script>
2. 优化后:使用 vue-component-type-helpers
技能规则应用:
- 规则名称:
extract-component-props.md - 核心逻辑:利用
vue-component-type-helpers库精确提取组件定义的 Props,排除内部干扰项。
重构结果:代码简洁,类型提示完美,且具备 100% 的继承安全性。
<!-- ProButton.vue -->
<script setup lang="ts">
import type { ComponentProps } from "vue-component-type-helpers";
import BaseButton from "./BaseButton.vue";
// ✅ 符合最佳实践:精确提取子组件的 Props 类型
type BaseButtonProps = ComponentProps<typeof BaseButton>;
// 扩展基础组件的属性
interface Props extends BaseButtonProps {
loading?: boolean;
size?: "sm" | "md" | "lg";
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
size: "md",
});
</script>
<template>
<div class="pro-button">
<!-- 将剩余属性透传给基础组件 -->
<BaseButton v-bind="$attrs" :loading="loading">
<slot />
</BaseButton>
</div>
</template>
3.核心价值总结
| 维度 | 传统方式 | Skills 方案 (Vue Best Practices) |
|---|---|---|
| 开发效率 | 需要翻阅源码查找子组件 Props | 自动提取,AI 自动完成类型桥接 |
| 类型提示 | 混杂大量 $props 内部属性,极难看清 | 纯净提示,仅显示业务定义的属性 |
| 维护性 | 子组件增加 Prop 后,包装组件需手动同步 | 自动同步,类型定义随子组件动态更新 |
| 代码洁癖 | 充满大量的 Hack 或冗余定义 | 标准工程化,符合 Vue 3 官方推荐模式 |
提示:安装技能包后,当你在写高阶组件(HOC)或二次封装组件时,AI 会自动识别场景并提示你使用
vue-component-type-helpers进行类型提取,确保你的 TypeScript 链路在全项目保持强类型约束。
2.1 核心功能
1. React 组件组合模式 (vercel-composition-patterns)
适用场景:
- 组件重构:当核心组件因布尔属性(Boolean props)爆炸(如
isLoading,isSmall等)而难以维护时。 - 库级开发:构建需要高度灵活、可扩展 API 的企业级 UI 组件库。
- 复杂交互设计:实现具有强父子联动逻辑的复合组件(如 Tabs, Select, Menu)。
核心规则:
- 架构优先组合:严禁无限制叠加布尔属性,推崇使用 复合组件 (Compound Components) 和 Context 共享状态。
- 接口解耦:Provider 集中管理状态逻辑,子组件仅通过约定的 Context 接口进行交互。
- 显式变体 (Explicit Variants):与其给一个组件加 10 个布尔值,不如创建明确命名的变体组件(如
PrimaryButton,IconButton)。 - 组合优于配置:优先通过
children进行 UI 拼装,而非通过庞大的配置对象或大量的renderXProps。
2. React & Next.js 性能最佳实践 (vercel-react-best-practices)
适用场景:
- 性能调优:页面响应慢、首屏渲染 (LCP) 时间长或存在明显的交互延迟。
- 现代 Web 构建:基于 Next.js App Router 架构的全栈开发。
- 大规模数据处理:需要并行获取多个 API 数据且必须避免渲染阻塞的场景。
核心规则:
- 消除瀑布流 (Waterfalls):性能优化的 头等大事。强制要求并行化独立异步操作,并利用
Suspense实现流式 (Streaming) 内容分发。 - 打包体积压缩:禁用 Barrel Files (单一入口导出文件) 以保护 Tree-shaking;强制使用
next/dynamic进行代码分割。 - 服务端性能 (RSC):利用
React.cache()进行请求级数据去重,最小化传输至客户端的序列化数据量。 - 重渲染控制:避免在 Effects 中处理同步状态,提倡使用派生状态 (Derived State);利用
startTransition处理非紧急更新。
3. React Native & Expo 移动开发指南 (vercel-react-native-skills)
适用场景:
- 移动端丝滑体验:针对 iOS 和 Android 优化长列表滑动和复杂手势动画。
- 跨端性能消除:解决由于 JS 线程与 UI 线程通信延迟导致的性能瓶颈。
- 原生功能集成:在 Expo 或原生环境中处理多媒体、字体和原生组件的高性能接入。
核心规则:
- 列表性能 (最关键):强制使用
FlashList替代FlatList;列表项必须经过memo处理以减少多余重绘。 - GPU 加速动画:动画属性仅限在
transform和opacity上操作,确保逻辑在 UI 线程直接执行。 - 原生 UI 适配:始终使用
expo-image优化图片加载;优先使用Pressable替代TouchableOpacity以获得更好的响应响应。 - 渲染规范:文本必须且只能包裹在
Text组件内;禁止在条件渲染中使用&&(防止在移动端渲染出数字 0)。
4. Web 界面设计指南 (web-design-guidelines)
适用场景:
- UI/UX 审计:项目发布前检查 UI 间距、颜色 Token 和视觉输出的一致性。
- 无障碍性 (A11y):确保网站符合 Web 辅助功能标准,提升产品包容性。
- 自动化 UI 审查:在 Code Review 阶段快速发现硬编码和非标准交互实现。
核心规则:
- 动态合规检查:通过远程拉取最新的设计系统准则,确保证审视标准始终是最新的。
- 无障碍强制约束:严格检查颜色对比度、ARIA 标签完整性以及键盘导航流程。
- 高精度反馈:能够精确到代码行指出不符合设计系统规范(如未使用的 Design Tokens)的地方。
2.2 安装方法
npx add-skill vercel-labs/agent-skills
可以只选择安装其中的一个规则集,比如说vercel-react-best-practices

其余步骤,与Vue-Skills的问询问题一模一样,不再赘述。




2.3 用法示例
在一个 Next.js App Router 项目的个人中心页面中,我们需要同时获取用户信息、订单列表和优惠券信息。
1. 优化前:串行瀑布流 (Sequential Waterfall)
现象:如果没有技能包约束,AI 可能会写出标准的串行代码。这种方案下,总耗时是三个接口请求时间的累加(T1 + T2 + T3)。
// ❌ 不符合最佳实践:串行阻塞
export default async function ProfilePage() {
// 请求 1:获取用户信息
const user = await fetchUser();
// 请求 2:依赖于 user.id,但在请求 1 完成前无法开始
const orders = await fetchOrders(user.id);
// 请求 3:不依赖于前二者,却被白白阻塞
const coupons = await fetchCoupons();
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<CouponList coupons={coupons} />
</div>
);
}
2. 优化后:并行获取 + 组件组合 (Parallel & Composition)
技能规则应用:
async-parallel:识别出不互相关联的请求,并并行启动。server-parallel-fetching:利用服务器组件的组合特性,减少主线程阻塞。
重构结果:页面总耗时缩短为(T1 + Max(T2, T3)),且实现了流式分发。
// ✅ 符合最佳实践:并行获取与解耦渲染
import { Suspense } from "react";
export default async function ProfilePage() {
// 1. 同时启动互不关联的异步任务,不加 await
const userPromise = fetchUser();
const couponsPromise = fetchCoupons(); // 并行开始
// 2. 仅等待必要的基础数据
const user = await userPromise;
return (
<div>
{/* 优先渲染用户信息 */}
<UserInfo user={user} />
{/* 3. 将耗时较长的“订单列表”逻辑下移至组件内部,并行获取 */}
<Suspense fallback={<Skeleton />}>
<OrderDataLayer userId={user.id} />
</Suspense>
{/* 4. 将预启动的“优惠券”Promise 传入组件 */}
<Suspense fallback={<Skeleton />}>
<CouponDataLayer promise={couponsPromise} />
</Suspense>
</div>
);
}
// 独立的异步数据层组件
async function OrderDataLayer({ userId }) {
const orders = await fetchOrders(userId); // 并行进行的请求
return <OrderList orders={orders} />;
}
async function CouponDataLayer({ promise }) {
const coupons = await promise; // 使用外部传入的 Promise
return <CouponList coupons={coupons} />;
}
3. 核心价值总结
| 优化点 | 传统方案 | Skills 方案 (Vercel Best Practices) |
|---|---|---|
| 请求速度 | 累加耗时 (Waterfall) | 并发执行,耗时大幅度缩减 |
| 用户感知 | 全黑屏等待,直到所有数据返回 | 流式渲染 (Streaming),局部内容先出 |
| 代码结构 | 逻辑逻辑堆在主页面,难以复用 | 原子化组件,数据获取逻辑与渲染高度内聚 |
| AI 表现 | 随机生成,依赖运气 | 确定性重构,严格执行 Vercel 性能规约 |
结论:通过注入
vercel-react-best-practices技能,AI Agent 从一个简单的“代码生成器”转变为具备“性能自觉”的高级架构师。
三、 UI-Skills
如果说前两个工具解决了“逻辑”问题,那么 uipro-cli 及其关联的 ui-skills 则是为了解决“审美与交付”问题。 UI/UX Pro Max:赋能 AI Agent 的专业设计大脑。uipro-cli 是一个功能强大的命令行工具,专门用于为各种 AI 编程助手(如 Claude Code, Cursor, Windsurf, Antigravity 等)一键注入 UI/UX Pro Max 专家级技能。它让 AI 不仅能写代码,更能像资深设计师一样思考。
2.1 核心功能
1. 多元化视觉风格 (67 种 UI 风格)
UI/UX Pro Max 内置了 67 种最前沿的视觉设计风格,确保 AI 生成的界面告别“通用感”。
- 现代趋势:支持 Glassmorphism(玻璃拟态)、Claymorphism(粘土拟态)、Minimalism(极简主义)。
- 特色风格:包括 Brutalism(新野兽派)、Neumorphism(新拟物化)、Bento Grid(便当网格)以及针对 AI 产品的 AI-Native UI 风格。
2. 行业深度色彩与排版 (96 行业色板 + 57 字体配对)
- 精准色板:提供 96 套针对特定行业(如 SaaS, 电商, 医疗, 金融金融, 美妆)优化的专业色板。
- 字体艺术:内置 57 组精心挑选的字体组合,无缝集成 Google Fonts,从视觉底层提升产品质感。
3. 跨平台技术栈适配 (13 种主流技术栈)
支持从 Web 到移动端的 13 种主流技术架构,生成的代码即学即用。
- Web 端:React, Next.js, Vue, Nuxt.js, Astro, Svelte, HTML+Tailwind, shadcn/ui。
- 移动端:React Native, Flutter, SwiftUI, Jetpack Compose 等。
4. 专家级 UX 准则与设计推理 (100+ 准则与推理规则)
内置专业的设计逻辑,让 AI 具备“审美自觉”:
- UX 指南:涵盖 99 条 UX 最佳实践、反模式规避和 A11y 无障碍规则。
- 设计推理:拥有 100 条行业推理规则(例如:金融类应用严禁使用 AI 风格的紫粉渐变,以确保稳重感),自动进行交付前的 UI/UX 质量自检。
2.2 安装方法
通过 uipro-cli,你可以在几秒钟内完成技能初始化:
1. 全局安装工具
npm install -g uipro-cli
2. 为指定编辑器初始化技能
# 为 Claude Code 初始化
uipro init --ai claude
# 为 Cursor 初始化
uipro init --ai cursor
# 为所有支持的 AI 助手同时初始化
uipro init --ai all
3. 实战案例
场景描述: 用户需要为一个 AI 内容创作平台设计一个数据看板(Dashboard)。用户希望界面现代、直观,并且符合当前流行的审美趋势。
1. 传统 AI 生成:平庸且缺乏设计感
现象:如果没有加载专业 UI 技能包,AI 通常只会根据组件库(如 shadcn/ui)的基础示例,堆砌出缺乏灵魂的列表和卡片。色调单一(通常只用蓝色或灰阶),布局死板。
// ❌ 传统 AI 的“直觉”:基础的 Grid 布局,没有任何样式特色
export default function Dashboard() {
return (
<div className="grid grid-cols-3 gap-4 p-4">
<Card title="总收入" value="$12,000" />
<Card title="活跃用户" value="1,240" />
<Card title="API 调用" value="45.2k" />
<div className="col-span-3">主要统计图表</div>
</div>
);
}
2. UI/UX Pro Max 介入:具备“审美自觉”的设计
技能规则应用:
- 风格锁定:选择了 Bento Grid(便当网格) 布局。
- 行业色板:套用了 SaaS 行业高阶调色盘。
- 设计推理:AI 会自动添加微交互动画、圆角分层(Glassmorphism 效果)以及符合产品气质的字体排版。
重构指令:
“使用 UI/UX Pro Max 技能,为我的 AI 创作平台设计一个 Bento Grid 风格的统计看板。色调选用‘高级钛金灰’配合‘赛博蓝’点缀,并要求包含毛玻璃质感。”
重构结果(语义描述与关键代码片段):
// ✅ 符合设计的组件 (自动生成的 Tailwind 样式)
// 使用了 Bento Grid 非对称网格布局
export function BentoDashboard() {
return (
<section className="bg-slate-950 p-8 min-h-screen font-sans text-slate-100">
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-6">
{/* 高度分层的毛玻璃卡片 (Glassmorphism) */}
<div
className="md:col-span-2 md:row-span-2 bg-slate-900/50 backdrop-blur-xl border border-white/10 rounded-3xl p-8
hover:bg-slate-800/60 transition-all duration-300 group"
>
<header className="flex justify-between">
<h3 className="text-xl font-bold tracking-tight">创作产出分析</h3>
<div className="h-2 w-2 bg-cyan-400 rounded-full animate-pulse" />
</header>
{/* AI 自动推荐的图表配色与坐标轴样式 */}
<MainAnalyticChart color="#22d3ee" />
</div>
{/* 紧凑型统计卡片 */}
<div className="bg-slate-900/50 backdrop-blur-md rounded-3xl p-6 border border-white/5">
<Subtitle>API 余额</Subtitle>
<Value className="text-3xl font-mono">84.2%</Value>
</div>
{/* 动态微交互元素 */}
<div className="bg-cyan-500/10 rounded-3xl p-6 border border-cyan-500/20 text-cyan-400">
<div className="text-sm font-semibold mb-2">服务状态</div>
<div className="text-lg">系统运行正常</div>
</div>
{/* 更多 Bento 网格单元... */}
</div>
</section>
);
}
3.核心价值总结
| 维度 | 传统构建 | UI/UX Pro Max (uipro) |
|---|---|---|
| 布局逻辑 | 简单的 1/2 或 1/3 等分 | 非对称动态布局 (如 Bento Grid, Masonry) |
| 色彩应用 | 基础颜色(#3b82f6),视觉单调 | 分层色彩系统,包含渐变、阴影与半透明层 |
| 细节打磨 | 只有基础功能 | 微交互、骨架屏、平滑过渡自动注入 |
| 设计自检 | 需要人工多次反馈返工 | 内置反模式自检,首次生成即达“可交付”标准 |
结语:前端开发者的角色转变
随着 vue-skills、react-skills 和 ui-skills 的普及,前端开发者的角色正在发生深刻变化。我们正在从 “代码编写者(Coder)” 转变为 “AI 指令师(Prompt Engineer)” 和 “技术评审员(Reviewer)” 。
传统的 AI 辅助仅仅是“搜索”的变种,而 Skills 模式代表了 “领域知识的预装载” 。
- 降低认知负荷:你不需要记住 Vue 3 的所有新特性或 Tailwind 的上千个类名,Skills 充当了你的“外部脑”。
- 代码风格统一:团队只需约定一套 Skill 脚本,就能保证所有 AI 生成的代码风格高度一致,甚至比人类手动编写的更规范。
- 快速原型到生产:它极大地缩短了从 MVP(最小可行性产品)到正式发布的时间,让前端开发者更关注于“业务价值”而非“语法实现”。
掌握这些 Skills 并不意味着放弃底层的学习,相反,只有深刻理解 Vue/React 原理和 UI 规范的人,才能通过这些技能包更好地引导 AI,释放出前所未有的生产力。
来源:juejin.cn/post/7599641289887055918
当你的Ant-Design成了你最大的技术债

大家好😁
如果你是一个前端,尤其是在B端(中后台)领域,Ant Design(antd)这个名字,你不可能没听过。
在过去的5年里,我们团队的所有新项目,技术选型里的第一行,永远是antd。它专业、开箱即用、文档齐全,拥有一切你想要的组件, 帮我们这些小团队,一夜之间就拥有了大厂的专业门面。
我们靠它,快速地交付了一个又一个项目。
Antd 虽好,但魔改样式确实让人头秃,转向 Headless UI 又怕重复造轮子。想兼顾灵活定制与开发效率?试试 RollCode 低代码平台,利用 自定义组件 快速封装 Headless 逻辑,通过 私有化部署 沉淀企业级资产,更能一键 静态页面发布(SSG + SEO),让 UI 自由不再昂贵。
但是,从去年开始,我发现,这个曾经的经典,正在变成我们团队脖子上最重的枷锁。
Ant Design,这个我们当初用来解决技术债的核心组件库,现在,却成了我们最大的技术债本身😖。
这是一篇团队血泪史, 讲一讲感想🤷♂️。
我们为什么会爱上 AntD?
我们必须承认,从无到有阶段,antd是无敌的。
你一个3人的小团队,用上antd,做出来的东西,看起来和阿里几百人团队做的系统,没什么区别。
Table、Form、Modal、Menu... 你需要的一切,它都以一种极其标准的方式给你了。你不再需要自己造轮子。
当你发现@ant-design/pro-components时,一个ProTable,直接帮你搞定了请求、分页、查询表单、工具栏... 你甚至都不用写useState了。
在那个阶段,我们以为我们找到了大结局。
当个性化成为 我们的 KPI
美好可能是短暂的,从我们的产品经理和UI设计师开始👇:
能不能...不要长得这么 Ant Design?🤣

这是我们设计师,在评审会上,小心翼翼提出来的第一句话。
老板也说:我们要做自己的品牌,现在的系统,太千篇一律了!!!
于是,我们接到了第一个简单的需求:把全局的主题色,从橙色改成我们的品牌红。
这很简单,不就是 ConfigProvider嘛🤔。我们改了。
然后,第二个需求来了:这个Modal弹窗的关闭按钮,能不能不要放在右上角?我们要放在左下角,和确认按钮放在一起。(有点反人类🤷♂️)
灾难,就从这里开始了。
antd的Modal组件,根本就没提供这个插槽或prop。我们唯一的办法,是 强改。
于是,我们的代码里,开始出现这种恶臭的CSS:
/* 一个高权重的全局CSS文件 */
.ant-modal-header {
/* ... */
}
/* 嘿,那个右上角的关闭按钮,给我藏起来! */
.ant-modal-close-x {
display: none !important;
}
为了把那个 X 藏起来,我们用了!important。我们亲手打开了潘多拉魔盒。
这个表格的筛选图标,能换成我们自己画的吗?😖
antd的Table,是一个重灾区。它太强大了,也很黑盒。
我们设计师,重新画了一套筛选、排序的图标。但我们发现,antd的Table组件,根本没想过让你换这个。
我们唯一的办法,就是用 CSS选择器,一层一层地穿进antd的DOM结构里,找到那个<span>,然后用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默认样式上的时间,已经远远超过了我们自己写一个组件的时间。
压死骆驼的最后一根稻草

我们用了ProTable,它的查询表单和表格是强耦合的。当产品经理提出一个我希望查询表单,在页面滚动时,吸附在顶部的需求时... 我们发现,我们改不动。我们被ProComponents的黑盒,锁死了。
然后我们的vendor.js打包出来,2.5MB。用webpack-bundle-analyzer一看,antd和@ant-design/icons,占了1.2MB。我们为了一个Button和Icon,引入了一个全家桶。antd的按需加载?别闹了,在ProComponents面前,它几乎是全量的。
而且 antd从v3到v4,我们花了一个月。从v4到v5,我们花了半个月。每一次升级,都是一次大型重构,因为我们那些写法一样被CSS覆盖,在新版里,全失效了🤷♂️。
我们本想找一个可靠的组件库,这么久过来,结果它成了债主。
我们真正需要的可能是轮子
我终于想明白了。
Ant Design,它不是一个组件库(Library),它是一个UI框架(Framework)。它是一套解决方案,它有它自己强势的 设计价值观。
当你的需求,和它的价值观一致时,它就是圣经。
当你的需求,和它的价值观不一致时,它就变成枷锁。
我们当初要的,其实是一个带样式的Button;而antd给我的,是一个内置了loading、disabled、onClick时会有水波纹动画、并且必须是蓝色或白色的Button。
我们的自救之路
在我们新的项目中,我忍痛做出了一个决定🤷♂️:
原则上,不再使用antd。
我们新的技术栈,转向了:
Tailwind CSS + Headless UI 方案(比如Radix UI)

这个组合,才是我们想要的:
Headless UI:它只提供功能和无障碍。比如,一个Dialog(模态框),它帮我搞定了按Esc关闭、焦点管理。但它没有任何样式。Tailwind CSS:我拿到了这个无样式的Dialog,然后用Tailwind的class,在5分钟内,在AI的帮助下,把它拼成了我们设计师想要的、独一无二的弹窗。
我们拿回了CSS的完全控制权,同时又享受了 AI + 组件开发的便利。
我依然尊敬Ant Design,它在前端B端历史上,是个丰碑。
对于那些从0到1的、对UI没有要求的内部系统,我可能依然会用它。
但对于那些需要品牌、体验、个性化的核心产品,我必须和它说再见了。

因为,当你的组件库开始控制你的设计和性能时,它就不是你的资产了。
而变成你最大的技术债🙌。
来源:juejin.cn/post/7571176484515659828
H5性能优化-打开效率提升了62%
一、达成的结果
app嵌套h5 加载效率提升了62%。时间从平均2.259s 降到了0.852s
二、优化过程
思路:优化原生webview+h5 ,先测试webview点击到创建时间 120ms(无需优化)。webview 提供了 api 测试 加载url 的进度。但是没提供具体的类似pc端网络的工具。所以就通过ai搜索一些工具。发现阿里云的arms 方便引入。
测试设备 android荣耀50、华为meta9、华为p40、oppo
开发环境测试
测试样本:优化前后各测试10次。
测试移动端h5 引入了阿里云的arms arms.console.aliyun.com/ 可以查看加载某个url的时候 请求的js css、图片、网络请求 跟网页版的网络类似。
import ArmsRum from '@arms/rum-browser'
ArmsRum.init({
pid: 'ha63j3v892@efe71d242023cd5',
endpoint: 'https://ha63j3v892-default-cn.rum.aliyuncs.com',
// 设置环境信息,参考值:'prod' | 'gray' | 'pre' | 'daily' | 'local'
env: 'prod',
// 设置路由模式, 参考值:'history' | 'hash'
spaMode: 'hash',
collectors: {
// 页面性能指标监听开关,默认开启
perf: true,
// WebVitals指标监听开关,默认开启
webVitals: true,
// Ajax监听开关,默认开启
api: true,
// 静态资源开关,默认开启
staticResource: true,
// JS错误监听开关,默认开启
jsError: true,
// 控制台错误监听开关,默认开启
consoleError: true,
// 用户行为监听开关,默认开启
action: true,
},
// 链路追踪配置开关,默认关闭
tracing: false,
})
export default ArmsRum
步骤一、懒加载echarts
以前的代码
import Api from '@/api'
import * as echarts from 'echarts'
import dayjs from 'dayjs'
export default {
}
现在的代码
async getRepairChart() {
const echarts = await import('echarts')
// xxx
const chartDom = document.getElementById('repairChart')
const myChart = echarts.init(chartDom)
const option = {
}
myChart.setOption(option)
},
通过F12查看网络 发现加载列表的时候有出现echartsxxx.js 而且有800多k ,所以就考虑加载列表不应该去加载数据看板的数据。
优化前,测试前日志打印

平均是3.234s 然后 当然还测试了华为p40 、oppo机器 由于系统不一样时间有些差别
测试后日志打印

平均是1.71s
华为meta9 9年前的老手机打印log

平均也2s多
优化后

不到0.8s
步骤二、禁用预加载、路由懒加载
通过查看网络发现请求dev-haolipei.cias.cn/app/#/takeO… 超级多的js 跟css
一张图都截取不完。当时就感觉肯定请求了很多无关紧要的资源。

npm run build 执行后查看dist目录index.html 的确很触目惊心 加载了太多没必要的资源了。

module.exports = {
publicPath,
devServer: {
disableHostCheck: true,
// host: 'localhost.cias.cn',
proxy: {
'/api': {
target,
changeOrigin: true,
// cookieDomainRewrite在手机调试时用得上
// cookieDomainRewrite: '',
pathRewrite: {
'^/api': '',
},
},
'/media': {
target,
changeOrigin: true,
pathRewrite: {
'^/media': '',
},
},
},
},
chainWebpack: config => {
// 禁用预加载
config.plugins.delete('preload')
config.plugins.delete('prefetch')
},
}
禁用它们的核心好处
1. 减少不必要的网络请求,节省带宽
- preload 可能会强制加载一些 “非关键资源”(如配置不当的情况下,预加载了体积大但当前页面暂时用不到的资源),导致带宽浪费。
- prefetch 会预加载未来可能访问的路由 chunk(如用户可能不会点击的低频页面),如果用户最终没有访问这些页面,预加载的资源就成了 “无效请求”,尤其对移动端用户(流量有限)不友好。
禁用后,资源仅在明确需要时才会加载(如用户进入对应路由时),避免 “提前加载但用不上” 的浪费。
2. 避免阻塞关键资源加载,提升首屏速度
- 浏览器对同一域名的并发请求数有限制(HTTP/1.1 通常为 6 个)。preload 加载的资源会占用并发名额,可能阻塞当前页面真正需要的关键资源(如核心 JS/CSS),导致首屏渲染延迟。
- 例如:若 preload 预加载了一个 2MB 的非关键 chunk,可能会挤占首屏 JS 的加载带宽,导致页面 “白屏时间” 变长。
禁用后,浏览器的并发资源会优先分配给当前页面的核心资源,减少阻塞。
3. 避免缓存资源被 “无效资源” 占用
浏览器缓存空间有限,prefetch 预加载的大量 “未来可能用到” 的 chunk 会占用缓存空间,可能导致真正需要长期缓存的核心资源(如 chunk-vendors.js)被挤出缓存,下次访问时需要重新加载。
禁用后,缓存可优先保留关键资源,提升二次访问速度。
4. 适配低网速 / 弱网环境
在 3G、偏远地区等弱网环境下,preload/prefetch 的预加载行为会加剧网络拥堵:
- 预加载的资源可能耗时过长,导致当前页面的核心资源加载超时。
- 禁用后,资源加载更 “轻量化”,优先保证当前页面可用,符合弱网环境的用户体验需求。
5. 减少开发环境的冗余加载
在开发环境中,Webpack 会频繁编译资源,preload/prefetch 可能导致每次热更新时加载大量无关资源,拖慢开发服务器响应速度,影响开发体验。禁用后可简化开发环境的资源加载逻辑,提升热更新效率。
注意:并非所有场景都适合禁用
preload/prefetch 本身是性能优化手段,若项目存在以下情况,可能需要保留或部分配置:
- 首屏依赖的关键资源(如核心 CSS、字体)体积大,preload 可加速其加载。
- 高频访问的路由(如首页→列表页),prefetch 可提前加载列表页 chunk,提升跳转速度。
因此,禁用的合理性取决于项目场景:资源体积大、用户网络不稳定、低频路由多的项目(如移动端 H5)更适合禁用;高频路由明确、网络环境好的项目可选择性保留。
路由懒加载
必须写上 /* webpackChunkName: "system-setting" */
以前的代码
{
path: '/takeOrder',
name: 'HomeTakeOrder',
component: () =>
import(
'@/views/baosi/orderList.vue'
),
meta: {
title: '推返修列表',
keepAlive: true,
},
}
懒加载路由模式
const HomeTakeOrder = () =>
import(/* webpackChunkName: "take-order" */ '@/views/baosi/orderList.vue')
{
path: '/takeOrder',
name: 'HomeTakeOrder',
component: HomeTakeOrder,
meta: {
title: '推返修列表',
keepAlive: true,
},
},
这样的好处 打包后 会有路由名称
对比

继续测试打印log

可以看看网络请求 就只有5个js文件 总体积不到300k

平均是0.852,基本达成要求
通过npm run build 本地打包看看文件对比大小。
1、体积巨减!!!
2、加载的文件名称是路由名称+hash值
优化前

优化后

三、内部项目具体实践
(增值移动端h5项目可以参考以下操作)
1、禁用预加载
module.exports = {
publicPath,
devServer: {
disableHostCheck: true,
// host: 'localhost.cias.cn',
proxy: {
'/api': {
target,
changeOrigin: true,
// cookieDomainRewrite在手机调试时用得上
// cookieDomainRewrite: '',
pathRewrite: {
'^/api': '',
},
},
'/media': {
target,
changeOrigin: true,
pathRewrite: {
'^/media': '',
},
},
},
},
chainWebpack: config => {
// 禁用预加载
config.plugins.delete('preload')
config.plugins.delete('prefetch')
},
}
2、将路由全部改成懒加载
并且路由需要/* webpackChunkName:"take-order" */
{
path: '/takeOrder',
name: 'HomeTakeOrder',
component: () =>
import(
/* webpackChunkName:"take-order" */ '@/views/baosi/orderList.vue'
),
meta: {
title: '推返修列表',
keepAlive: true,
},
}
3、组件懒加载
以前常见写法 就是一进来 把所有组件的都加载进来
比如这个车牌号组件有120k 对于比较大一点的组件可以用懒加载 。
<van-popup v-model="showPlatePopup" position="bottom" :overlay="false">
<keyboard
v-model="baseInfo.plateNumber"
:show.sync="showPlatePopup"
@input="handlePlateInput"
></keyboard>
import Keyboard from '@/components/numberplate/vnp-keyboard.vue'
export default {
name: 'CreateOrder',
components: {
MultiSelectPopup,
DispatchingPopup,
DispatchFailPopup,
DispatchFinishPopup,
Keyboard,
},
}
懒加载代码
用这个方式可以直接在网络中查看到对应组件的名称和大小
<van-popup
v-if="showPlatePopup"
v-model="showPlatePopup"
position="bottom"
:overlay="false"
>
<keyboard
v-model="baseInfo.plateNumber"
:show.sync="showPlatePopup"
@input="handlePlateInput"
></keyboard>
</van-popup>
export default {
name: 'CreateOrder',
components: {
MultiSelectPopup,
DispatchingPopup,
DispatchFailPopup,
DispatchFinishPopup,
Keyboard: () =>
import(
/* webpackChunkName: "keyboard" */
'@/components/numberplate/vnp-keyboard.vue'
),
},
}
还有 特别大的第三方库 echart 懒加载 按需引入
四、总结
目前通过禁用预加载、路由懒加载、和第三方组件懒加载使用方式 基本能达到很大程度的优化效果。
但是看图 每次加载前面2个文件一个683k 另外一个285k 还是压缩了的文件 ,
我猜应该是main.js 引入了很多东西,后续还可以有优化空间。移动端的h5 入口文件尽量简洁。

(自己写的页面 一定多注意看一下 网络 有多少个js 和css 图片) 资源过大就要考虑优化了。
性能优化一定要多测试验证、多测试验证、多测试验证,保证业务正常情况下优化性能。
性能优化参考。
来源:juejin.cn/post/7572301616168583177
深度复刻小米AI官网交互动画
近日在使用小米AI大模型MIMO时,被其顶部的透视跟随动画深深吸引,移步官网( mimo.xiaomi.com/zh/ )
效果演示

1. 交互梳理
- 初始状态底部有浅色水印,且水印奇数行和偶数行有错位
- 初始状态中间文字为黑色的汉字
- 鼠标移入后,会在以鼠标为中心形成一个黑色圆形,黑色圆中有第二种背景水印,且水印依旧奇数行和偶数行有错位
- 鼠标移动到中间汉字部分,会有白色英文显示
- 鼠标迅速移动时,会根据鼠标移动轨迹有一个拉伸椭圆跟随,然后恢复成圆形的动画效果
现在基于这个交互的拆解,逐步来复刻交互效果
2. 组件结构与DOM设计
2.1 模板结构
采用「静态底层+动态上层」的双层视觉结构,通过CSS绝对定位实现图层叠加,既保证初始状态的视觉完整性,又能让交互效果精准作用于上层,不干扰底层基础展示。两层分工明确,具体如下:
| 图层 | 类名 | 内容 | 功能 |
|---|---|---|---|
| 底层 | .z-1 | 中文标题 "你好,世界!" 和灰色 "HELLO" 文字矩阵 | 静态背景展示 |
| 上层 | .z-2 | 英文标题 "Hello , World!" 和白色 "HELLO" 文字矩阵 | 鼠标交互时的动态效果层 |
2.2 核心 DOM 结构
<div class="container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousemove="onMouseMove">
<!-- 底层内容 -->
<div class="z-1">
<div class="line" v-for="line in 13">
<span class="line-item" v-for="item in 13">HELLO</span>
</div>
</div>
<h1 class="title-1">你好,世界!</h1>
<!-- 上层交互内容 -->
<div class="z-2" :style="{ 'clip-path': circleClipPath }">
<div class="hidden-div">
<div class="line" v-for="line in 13">
<span class="line-item" v-for="item in 13">HELLO</span>
</div>
</div>
<h1 class="title-2">Hello , World!</h1>
</div>
</div>
关键说明:hidden-div用于包裹上层文字矩阵,配合.z-2的定位规则,确保遮罩效果精准覆盖;两层文字矩阵尺寸一致,保证视觉对齐,增强透视沉浸感。
3. 技术实现
3.1 核心功能模块
3.1.1 轨迹点系统
轨迹点系统是实现平滑鼠标跟随效果的核心,通过维护6个轨迹点的位置信息,创建出具有弹性延迟的跟随动画。
// 轨迹点系统
const trailSystem = ref({
targetX: 0,
targetY: 0,
trailPoints: Array(6).fill(null).map(() => ({ x: 0, y: 0 })),
animationId: 0,
isInside: false
});
设计思路:6个轨迹点是兼顾流畅度与性能的平衡值——点太少则拖尾效果不明显,点太多则增加计算开销,配合递减阻尼系数,实现“头快尾慢”的自然跟随。
3.1.2 动态 Clip-Path 计算
通过计算鼠标位置和轨迹点的关系,动态生成 clip-path CSS 属性值,实现跟随鼠标的圆形/椭圆形遮罩效果。
// 计算clip-path值
const circleClipPath = computed(() => {
if (!showCircle.value) {
return 'circle(0px at -300px -300px)'; // 完全隐藏状态
}
// 复制轨迹系统数据进行计算
const system = JSON.parse(JSON.stringify(trailSystem.value));
// 更新轨迹点
for (let t = 0; t < 6; t++) {
const prevX = t === 0 ? system.targetX : system.trailPoints[t - 1].x;
const prevY = t === 0 ? system.targetY : system.trailPoints[t - 1].y;
const damping = 0.7 - 0.04 * t; // 阻尼系数,后面的点移动更慢
const deltaX = prevX - system.trailPoints[t].x;
const deltaY = prevY - system.trailPoints[t].y;
// 平滑插值
system.trailPoints[t].x += deltaX * damping;
system.trailPoints[t].y += deltaY * damping;
}
// 获取第一个点(头部)和最后一个点(尾部)
const head = system.trailPoints[0];
const tail = system.trailPoints[5];
const diffX = head.x - tail.x;
const diffY = head.y - tail.y;
const distance = Math.sqrt(diffX * diffX + diffY * diffY);
let clipPathValue = '';
if (distance < 10) { // 如果距离很近,显示圆形
clipPathValue = `circle(200px at ${head.x}px ${head.y}px)`;
} else {
// 创建椭圆形的polygon,连接头尾两点
const angle = Math.atan2(diffY, diffX); // 连接角度
const points = [];
// 从头部开始,画半个椭圆
for (let i = 0; i <= 30; i++) {
const theta = angle - Math.PI / 2 + Math.PI * i / 30;
const x = head.x + 200 * Math.cos(theta);
const y = head.y + 200 * Math.sin(theta);
points.push(`${x}px ${y}px`);
}
// 从尾部开始,画另半个椭圆
for (let i = 0; i <= 30; i++) {
const theta = angle + Math.PI / 2 + Math.PI * i / 30;
const x = tail.x + 200 * Math.cos(theta);
const y = tail.y + 200 * Math.sin(theta);
points.push(`${x}px ${y}px`);
}
clipPathValue = `polygon(${points.join(', ')})`;
}
return clipPathValue;
});
3.1.3 鼠标事件处理
实现了完整的鼠标交互逻辑,包括鼠标进入、离开和移动时的状态管理和动画控制。
| 事件 | 处理函数 | 功能 |
|---|---|---|
| mouseenter | onMouseEnter | 激活交互效果,初始化轨迹点 |
| mouseleave | onMouseLeave | 停用交互效果,重置轨迹点 |
| mousemove | onMouseMove | 更新目标点位置,驱动动画 |
4. 技术亮点
4.1 轨迹点系统算法
核心原理:使用6个轨迹点,每个点跟随前一个点移动,并应用不同的阻尼系数,实现平滑的拖尾效果。
技术优势:
- 实现了自然的物理运动效果,比简单的线性跟随更具视觉吸引力
- 通过阻尼系数的递减,创建出层次感和深度感
- 算法复杂度低,性能消耗小,适合实时交互场景
4.2 动态 Clip-Path 技术
核心原理:利用CSS clip-path属性的动态特性,结合轨迹点位置计算,实时生成不规则遮罩,替代Canvas/SVG的图形绘制方案,用更轻量化的方式实现复杂视觉效果。
技术优势:
- 无依赖轻量化:无需引入任何图形库,纯CSS+JS即可实现,减少项目依赖体积,降低集成成本
- 平滑过渡无卡顿:通过数值插值计算,实现圆形与椭圆形遮罩的无缝切换,无帧断裂感,视觉连贯性强
- 渲染性能优化:配合
will-change: clip-path提示浏览器,提前分配渲染资源,减少重排重绘,提升动画流畅度
5. 性能优化
- 渲染性能:
- 使用
will-change: clip-path提示浏览器优化渲染 - 合理使用 Vue 的响应式系统,避免不必要的重计算
- 使用
- 事件处理:
- 仅在鼠标在容器内时更新目标点位置,减少计算量
- 鼠标离开时停止动画,释放资源
- 动画性能:
- 使用
requestAnimationFrame实现流畅的动画效果 - 鼠标离开时取消动画帧请求,避免内存泄漏
- 使用
6. 总结与扩展
本次复刻的小米MiMo透视动画,核心价值在于“用简单技术组合实现高级视觉效果”——无需复杂图形库,仅依托Vue3响应式能力与CSS clip-path属性,就能打造出兼具质感与性能的交互组件。其核心亮点可概括为三点:
- 交互创新:轨迹点系统与动态clip-path结合,打破传统静态标题的交互边界,带来自然流畅的鼠标跟随体验
- 视觉精致:双层文字矩阵的分层设计,配合遮罩形变,营造出兼具深度感与品牌性的视觉效果
- 性能可控:轻量化技术方案+多维度优化策略,在保证视觉效果的同时,兼顾页面性能与可维护性
扩展方向
该组件的实现思路可灵活迁移至其他场景:
- 弹窗过渡动画:将clip-path遮罩用于弹窗进入/退出效果,实现不规则形状的过渡动画。
- 滚动动效:结合滚动事件替换鼠标事件,实现页面滚动时的元素透视跟随效果。
- 移动端适配:增加触摸事件支持,将鼠标交互替换为触摸滑动,适配移动端场景。
完整代码
<template>
<div class="hero-container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousemove="onMouseMove">
<div class="z-1">
<div class="line" v-for="line in 13">
<span class="line-item" v-for="item in 13">HELLO</span>
</div>
</div>
<h1 class="title-1">你好,世界</h1>
<!-- 第二个div,鼠标移入后需要显示的内容,通过clip-path:circle(0px at -300px -300px)达到隐藏效果 -->
<div class="z-2" :style="{ 'clip-path': circleClipPath }">
<div class="hidden-div">
<div class="line" v-for="line in 13">
<span class="line-item" v-for="item in 13">HELLO</span>
</div>
</div>
<h1 class="title-2">HELLO , World</h1>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const showCircle = ref(false)
const containerRef = ref(null)
const trailSystem = ref({
targetX: 0,
targetY: 0,
trailPoints: Array(6)
.fill(null)
.map(() => ({ x: 0, y: 0 })),
animationId: 0,
isInside: false,
})
const circleClipPath = computed(() => {
if (!showCircle.value) {
return 'circle(0px at -300px -300px)'
}
// 复制轨迹系统数据进行计算
const system = JSON.parse(JSON.stringify(trailSystem.value))
// 更新轨迹点
for (let t = 0; t < 6; t++) {
const prevX = t === 0 ? system.targetX : system.trailPoints[t - 1].x
const prevY = t === 0 ? system.targetY : system.trailPoints[t - 1].y
const damping = 0.7 - 0.04 * t // 阻尼系数,后面的点移动更慢
const deltaX = prevX - system.trailPoints[t].x
const deltaY = prevY - system.trailPoints[t].y
// 平滑插值
system.trailPoints[t].x += deltaX * damping
system.trailPoints[t].y += deltaY * damping
}
// 获取第一个点(头部)和最后一个点(尾部)
const head = system.trailPoints[0]
const tail = system.trailPoints[5]
const diffX = head.x - tail.x
const diffY = head.y - tail.y
const distance = Math.sqrt(diffX * diffX + diffY * diffY)
let clipPathValue = ''
if (distance < 10) {
// 如果距离很近,显示圆形
clipPathValue = `circle(200px at ${head.x}px ${head.y}px)`
} else {
// 创建椭圆形的polygon,连接头尾两点
const angle = Math.atan2(diffY, diffX) // 连接角度
const points = []
// 从头部开始,画半个椭圆
for (let i = 0; i <= 30; i++) {
const theta = angle - Math.PI / 2 + (Math.PI * i) / 30
const x = head.x + 200 * Math.cos(theta)
const y = head.y + 200 * Math.sin(theta)
points.push(`${x}px ${y}px`)
}
// 从尾部开始,画另半个椭圆
for (let i = 0; i <= 30; i++) {
const theta = angle + Math.PI / 2 + (Math.PI * i) / 30
const x = tail.x + 200 * Math.cos(theta)
const y = tail.y + 200 * Math.sin(theta)
points.push(`${x}px ${y}px`)
}
clipPathValue = `polygon(${points.join(', ')})`
}
return clipPathValue
})
// 动画循环函数
const animate = () => {
if (showCircle.value) {
// 更新轨迹点
for (let t = 0; t < 6; t++) {
const prevX = t === 0 ? trailSystem.value.targetX : trailSystem.value.trailPoints[t - 1].x
const prevY = t === 0 ? trailSystem.value.targetY : trailSystem.value.trailPoints[t - 1].y
const damping = 0.7 - 0.04 * t // 阻尼系数,后面的点移动更慢
const deltaX = prevX - trailSystem.value.trailPoints[t].x
const deltaY = prevY - trailSystem.value.trailPoints[t].y
// 平滑插值
trailSystem.value.trailPoints[t].x += deltaX * damping
trailSystem.value.trailPoints[t].y += deltaY * damping
}
// 请求下一帧
trailSystem.value.animationId = requestAnimationFrame(animate)
}
}
const onMouseEnter = (event) => {
const container = event.currentTarget
const rect = container.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
showCircle.value = true
// 初始化目标位置和轨迹点
trailSystem.value.targetX = x
trailSystem.value.targetY = y
trailSystem.value.isInside = true
// 初始化所有轨迹点到当前位置
for (let i = 0; i < 6; i++) {
trailSystem.value.trailPoints[i] = { x, y }
}
// 开始动画
if (!trailSystem.value.animationId) {
trailSystem.value.animationId = requestAnimationFrame(animate)
}
}
const onMouseLeave = (event) => {
const container = event.currentTarget
const rect = container.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
showCircle.value = false
trailSystem.value.isInside = false
// 将目标点移出容器边界,使轨迹点逐渐拉回
let targetX = x
let targetY = y
if (x <= 0) targetX = -400
else if (x >= rect.width) targetX = rect.width + 400
if (y <= 0) targetY = -400
else if (y >= rect.height) targetY = rect.height + 400
trailSystem.value.targetX = targetX
trailSystem.value.targetY = targetY
// 停止动画
if (trailSystem.value.animationId) {
cancelAnimationFrame(trailSystem.value.animationId)
trailSystem.value.animationId = 0
}
}
const onMouseMove = (event) => {
if (showCircle.value) {
const container = event.currentTarget
const rect = container.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
trailSystem.value.targetX = x
trailSystem.value.targetY = y
}
}
</script>
<style scoped>
.hero-container {
cursor: crosshair;
background: #faf7f5;
border-bottom: 1px solid #000;
justify-content: center;
align-items: center;
width: 100%;
height: 500px;
display: flex;
position: relative;
overflow: hidden;
}
.z-1 {
pointer-events: auto;
-webkit-user-select: none;
user-select: none;
flex-direction: column;
justify-content: flex-start;
width: 100%;
height: 100%;
display: flex;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
}
.z-1 .line {
display: flex;
align-items: center;
white-space: nowrap;
color: #0000000d;
letter-spacing: 0.3em;
flex-wrap: nowrap;
font-size: 52px;
font-weight: 700;
line-height: 1.6;
display: flex;
}
.z-1 .line-item {
cursor: default;
flex-shrink: 0;
margin-right: 0.6em;
transition:
color 0.3s,
text-shadow 0.3s;
font-family: inherit !important;
}
.z-1 .line:nth-child(odd) {
margin-left: -2em;
background-color: rgb(245, 235, 228);
}
.title-1 {
z-index: 1;
color: #000;
letter-spacing: 0.02em;
text-align: center;
margin: 0;
font-size: 72px;
font-weight: 700;
}
.z-2 {
pointer-events: none;
z-index: 10;
will-change: clip-path;
background: #000;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
display: flex;
position: absolute;
top: 0;
left: 0;
}
.z-2 .hidden-div {
pointer-events: none;
-webkit-user-select: none;
user-select: none;
flex-direction: column;
justify-content: flex-start;
width: 100%;
height: 100%;
display: flex;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
}
.z-2 .hidden-div .line {
white-space: nowrap;
color: #ffffff1f;
letter-spacing: 0.3em;
flex-wrap: nowrap;
font-size: 32px;
font-weight: 700;
line-height: 1.6;
display: flex;
}
.z-2 .hidden-div .line:nth-child(odd) {
margin-left: -0.5em;
}
.title-2 {
font-size: 72px;
color: #fff;
letter-spacing: 0.02em;
text-align: center;
white-space: nowrap;
margin: 0;
font-size: 72px;
font-weight: 700;
}
</style>
小米的前端一直很牛,非常有创意,我也通过F12学习源码体会到了新的思路,希望大家也多多关注小米和小米的技术~
来源:juejin.cn/post/7598005428258340927
Tailwind CSS都更新到4.0了,你还在抵触吗?
Tailwind CSS的体量
Tailwind CSS有多火爆呢?
几组数据告诉你?


一组数据告诉你 Tailwind CSS 有多受欢迎:
- github 86.1K的 Star, 足以证明它的受欢迎程度。
- NPM 周下载量已突破 1000 万, 前端开发者的不二之选
- 被无数大公司采用,如 GitHub、Vercel、Laravel 等。
- 被很多框架和打包工具推荐,如Vite,Nuxt,React等
从数据上看,Tailwind CSS 已经成为前端开发的主流选择之一。
Tailwind CSS有多火爆呢?
几组数据告诉你?


一组数据告诉你 Tailwind CSS 有多受欢迎:
- github 86.1K的 Star, 足以证明它的受欢迎程度。
- NPM 周下载量已突破 1000 万, 前端开发者的不二之选
- 被无数大公司采用,如 GitHub、Vercel、Laravel 等。
- 被很多框架和打包工具推荐,如Vite,Nuxt,React等
从数据上看,Tailwind CSS 已经成为前端开发的主流选择之一。
原子化CSS
什么是原子化CSS
原子化 CSS 是一种 CSS 架构,它提倡使用高度可复用的小类名,每个类名通常只控制单一的样式属性。例如:
<div class="text-red-500 font-bold p-4 text-[14px]">Hello Tailwinddiv>
其中:
- text-red-500: 代表文字颜色
- font-bold: 代表加粗
- p-4: 代表内边距
- text-[14px]: 代表字体大小为14px
这种方式避免了传统 CSS 中复杂的层叠规则,让样式控制更加直观。
原子化 CSS 是一种 CSS 架构,它提倡使用高度可复用的小类名,每个类名通常只控制单一的样式属性。例如:
<div class="text-red-500 font-bold p-4 text-[14px]">Hello Tailwinddiv>
其中:
- text-red-500: 代表文字颜色
- font-bold: 代表加粗
- p-4: 代表内边距
- text-[14px]: 代表字体大小为14px
这种方式避免了传统 CSS 中复杂的层叠规则,让样式控制更加直观。
原子化CSS和传统CSS的区别

说了这么一通,我相信用过的都说“真香”,用过一次后就离不开了。
关键是没用过的呢?是不是心里还在嘀咕。别急,正餐来了!

说了这么一通,我相信用过的都说“真香”,用过一次后就离不开了。
关键是没用过的呢?是不是心里还在嘀咕。别急,正餐来了!
Tailwind CSS宝藏库
为什么说Tailwind CSS是一个宝藏,因为你担忧和抵触的地方,Tailwind CSS都给你解决了
为什么说Tailwind CSS是一个宝藏,因为你担忧和抵触的地方,Tailwind CSS都给你解决了
类名难记
你可能在担忧,我是不是每次用都要查文档呢?那么多css好不容易记住了,现在又让我再学一遍?
答案是:不用。完全和你使用css一样简单。只需要记住几个关键字,智能提示帮你搞定

你可能在担忧,我是不是每次用都要查文档呢?那么多css好不容易记住了,现在又让我再学一遍?
答案是:不用。完全和你使用css一样简单。只需要记住几个关键字,智能提示帮你搞定

HTML又长又乱
首先,不可否认,将所有的类名整合到html中,会让你的html变得比较长。但是,当你写的代码又长又乱的时候,你就要停下来想想
- 是否违背了创作者的初衷
- 架构是否设计不合理
为此,我们简单分析一下,到底是人的问题还是工具的问题。根据以上2点,分析一下你的HTML为什么又长又乱?
- 因为太长导致太乱
没有合并之前,你的代码可能是这样的
class="flex justify-center items-center">
clsss="bg-blue-500 text-white py-2 px-4 rounded">提交
合并后是这样的
.flex-center {
@apply flex justify-center items-center;
}
.btn-submit {
@apply bg-blue-500 text-white py-2 px-4 rounded;
}
class="flex-center">
clsss="btn-submit">提交
现在是不是很清晰了呢?
我敢说,只要你使用@apply合并类名,时刻记着复用样式,你的HMLT至少减少1/3,甚至也可以写出像诗一样的代码
- 因为没有分组和顺序性导致太乱
没有顺序和分组的书写,是这样的
<div class="p-2 font-bold text-[14px] mt-4 color-[#333333] bg-white">Hello world!div>
想到哪写到哪,会让你的代码一眼望上去比较乱,时间长了,一眼看上去很难维护...
下面我们就着手解决这2个问题
- 类排序
使用 Prettier 进行类排序(Class sorting with Prettier)
Tailwind CSS 维护了一个官方 Prettier 插件,它会自动按照我们的 推荐的类顺序 对你的类进行排序。
使用插件后,代码这样的
<div class="bg-white color-[#333333] mt-4 p-2 text-[14px] font-bold">Hello world!div>
现在是不是清晰很多了呢?
不过还不够
- 分组
首先,不可否认,将所有的类名整合到html中,会让你的html变得比较长。但是,当你写的代码又长又乱的时候,你就要停下来想想
- 是否违背了创作者的初衷
- 架构是否设计不合理
为此,我们简单分析一下,到底是人的问题还是工具的问题。根据以上2点,分析一下你的HTML为什么又长又乱?
- 因为太长导致太乱
没有合并之前,你的代码可能是这样的
class="flex justify-center items-center">
clsss="bg-blue-500 text-white py-2 px-4 rounded">提交
合并后是这样的
.flex-center {
@apply flex justify-center items-center;
}
.btn-submit {
@apply bg-blue-500 text-white py-2 px-4 rounded;
}
class="flex-center">
clsss="btn-submit">提交
现在是不是很清晰了呢?
我敢说,只要你使用@apply合并类名,时刻记着复用样式,你的HMLT
至少减少1/3,甚至也可以写出像诗一样的代码 - 因为没有分组和顺序性导致太乱
没有顺序和分组的书写,是这样的
<div class="p-2 font-bold text-[14px] mt-4 color-[#333333] bg-white">Hello world!div>
想到哪写到哪,会让你的代码一眼望上去比较乱,时间长了,一眼看上去很难维护...
下面我们就着手解决这2个问题
- 类排序
使用 Prettier 进行类排序(Class sorting with Prettier)
Tailwind CSS 维护了一个官方 Prettier 插件,它会自动按照我们的 推荐的类顺序 对你的类进行排序。
使用插件后,代码这样的
<div class="bg-white color-[#333333] mt-4 p-2 text-[14px] font-bold">Hello world!div>
现在是不是清晰很多了呢?
不过还不够
- 分组
我们根据样式进行的类别分组,比如颜色,字体,定位,间距等等,每个类别一行,这样你写出的代码会清晰无比
<div class="
bg-white color-[#333333]
mt-4 p-2
text-[14px] font-bold">
Hello world!div>
现在代码是不是清晰的多了
全局类名
不用担心公共类的问题,@apply帮你搞定。
使用 @apply 合并类,前面已经讲过了,就不展开了
样式冲突
也许你还在担心tailwind 的 class 名和我已有的 class 冲突了咋办?我怎么处理兼容问题
别担心,给你的类名加个前缀prefix就搞定了
@import "tailwindcss" prefix(tw);
<div class="tw:flex tw:bg-red-500 tw:hover:bg-red-600"> div>
拥抱Tailwind CSS
Tailwind CSS为什么受到追捧
- 再也不用忍受css上下切换的痛苦了
- 再也不用花时间去取语义化类名了
不用纠结container, wrapper, box等被使用的问题后,如何起名的问题了
- 为了加权重,不断的加父级类名,甚至!important,永远不知道哪个样式起作用了。冗长的css让项目很难维护!
- 简单完成伪类、伪元素、媒体查询等变体的书写
- 再也不用忍受css上下切换的痛苦了
- 再也不用花时间去取语义化类名了
不用纠结container, wrapper, box等被使用的问题后,如何起名的问题了
- 为了加权重,不断的加父级类名,甚至!important,永远不知道哪个样式起作用了。冗长的css让项目很难维护!
- 简单完成伪类、伪元素、媒体查询等变体的书写

Tailwind CSS、PrimeFlex、UnoCSS评测
在CSS工具类框架中,除了Tailwind CSS之外,还有其他很多工具类。如PrimeFlex和UnoCSS,它们各有特点,下面我简单的评测一下
- PrimeFlex: 生态系统较小,多适用于Prime生态,如PrimevVue,PrimeReact。样式和较Tailwind CSS低。只能构建起简单样式框架。
最让我吐槽的是,样式竟然用!important。你想替换某个属性,麻烦程度想骂人!

- UnoCSS: 未构建良好的生态系统,多用于自定义规则和项目优化
总结
TailwindCSS 已经成为前端开发的趋势之一,随着4.0 版本的发布,它的性能更强大、使用更方便。如果你还在抵触,不妨试试看,它可能会彻底改变你的 CSS 编写方式!
来源:juejin.cn/post/7480734875723415552
秒懂 Headless:为什么现在的软件都要“去头”?
简单来说, “Headless”(无头) 在软件开发中指的是:只有逻辑(后端/内核),没有预设界面(前端/GUI) 的软件架构模式。
这里的“Head(头)”比喻的是用户界面(UI/GUI) ,“Body(身体)”比喻的是核心业务逻辑或引擎。
Headless = 砍掉自带的 UI,只给你提供 API 或核心逻辑,让你自己去画界面。
1. 核心概念图解
想象一下 “传统的软件”(比如 Word):它像一家堂食餐厅。你有厨房(逻辑),也有固定的桌椅板凳和装修风格(UI)。你必须在它提供的环境里吃饭,无法改变装修。
而 “Headless 软件”:它像一个中央厨房(外卖工厂)。它只负责做饭(逻辑),不提供桌椅(UI)。
- 你想把菜送到五星级酒店摆盘(Web 端高级定制 UI)?可以。
- 你想把菜送到路边摊(手机 App)?可以。
- 你想把菜送到自动售货机(小程序)?也可以。
2. 具体例子
A. 无头浏览器 (Headless Browser)
- 传统的浏览器(如 Chrome): 你打开它,能看到窗口、地址栏、渲染出来的网页,你能用鼠标点击。
- 无头浏览器(如 Puppeteer, Playwright):
- 定义: 它是浏览器内核(Chrome/Webkit),但没有可视化的窗口。它在后台(命令行/服务器)运行。
- 怎么用? 你写代码控制它:“打开百度 -> 输入关键词 -> 截图”。
- 有什么用?
- 自动化测试: 模拟用户点击,快速跑通几千个测试用例,不需要真的弹出一千个窗口。
- 爬虫: 爬取那些需要 JS 渲染的复杂网页。
- 生成截图/PDF: 在服务器端把网页渲染成图片或 PDF 报告。
B. 无头编辑器 (Headless Editor)
- 传统的编辑器(如 CKEditor 旧版, Quill):
- 你引入它,它就自带一套“加粗、斜体、插入图片”的工具栏,自带一套 CSS 样式。
- 缺点: 如果设计师说“把工具栏按钮变成圆形的,而且要悬浮在文字上方”,你就要疯狂覆盖它的默认 CSS,非常痛苦。
- 无头编辑器(如 Tiptap, Plate, Slate.js):
- 定义: 它只提供文字处理的核心逻辑(比如:选中文本、按下 Ctrl+B 变粗体、撤销重做逻辑)。它不提供任何 UI(没有工具栏,没有按钮)。
- 怎么用? 你需要自己写一个
<button>,自己写样式,然后调用它的 APIeditor.toggleBold()。 - 有什么用? 你可以完全自由地定制编辑器的长相。比如 Notion、飞书文档那种高度定制的 UI,必须用无头编辑器开发。
3. 还有哪些常见的 Headless?
除了浏览器和编辑器,现在的开发趋势中还有:
C. 无头组件库 (Headless UI)
- 例子: Radix UI, Headless UI, React Aria。
- 解释: 以前我们用 Ant Design 或 Bootstrap,按钮长什么样是库定好的。Headless UI 库只提供交互逻辑(比如下拉菜单怎么打开,键盘怎么选,无障碍怎么读),不提供任何 CSS。
- 好处: 完美配合 Tailwind CSS,长相由你完全控制。
D. 无头 CMS (Headless CMS)
- 例子: Strapi, Contentful。
- 解释: 以前用 WordPress,后台管理内容,前台页面也是 WordPress 生成的(耦合)。Headless CMS 只提供后台管理和 API。
- 好处: 你的一份内容(API)可以同时发给 网站、App、智能手表、甚至冰箱屏幕。
总结:为什么现在流行 Headless?
虽然 Headless 意味着开发者要写更多的代码(因为要自己画 UI),但它解决了现代开发最大的痛点:定制化。
| 维度 | 传统 (Coupled) | Headless (无头) |
|---|---|---|
| 上手难度 | 低 (开箱即用) | 高 (需要自己写 UI) |
| 自由度 | 低 (改样式很难) | 极高 (随心所欲) |
| 适用场景 | 快速做个标准后台 | 像 Notion/Figma 这种需要极致体验的产品 |
| 比喻 | 方便面 (有面有调料包,味道固定) | 生鲜面条 (只有面,想做炸酱面还是汤面随你) |
一句话总结:Headless 就是把“业务逻辑”和“界面表现”彻底分家,让你拥有无限的 UI 定制权。
来源:juejin.cn/post/7582118218649288730
为了让 iframe 支持 keepAlive,我连夜写了个 kframe
前几天收到一个bug,说是后台管理系统每次切换标签栏后,xxx内容区自动刷新,操作进度也丢失了,给用户造成很大困扰。作为结丹期修士的我自然不容允许此等存在,开干!

问题分析
该后台管理系统基于 vue3 全家桶开发,多标签页模式,标签页默认
KeepAlive,本文以 demo 示例。
切换标签后内容区自动刷新,操作进度丢失?首先想到的是 KeepAlive 问题,但经过排查后才发现,KeepAlive 是正常的,异常的是内嵌于页面的 iframe 内容区,页面每次 onActivated 时,iframe 内容区都会重新加载一次,导致进度丢失。

iframe 并没有被 keep 住,为什么?
通过查阅 Vue 文档得知,KeepAlive缓存的只是 Vue 组件实例,组件实例包含组件状态和 VNode (虚拟 DOM 节点)等。当组件 activated 时,组件 VNode 已经转为真实 DOM 节点插入文档中了,而组件 deactivated 时,已经从文档中移除了组件对应的真实 DOM 节点并缓存组件实例。


VNode 是对真实 DOM 节点的映射,包含节点标签名、节点属性等信息。我们打开控制台选中 iframe 元素,右侧那栏就是其对应的 VNode 了。

从上图可看出,iframe 的内容并不属于节点信息,是个独立的 browsing context(浏览上下文),无法被缓存;iframe 每次渲染(如 DOM 节点插入、移动)都会触发完整的加载过程(相当于打开新窗口)。故组件每次 activated 时,iframe 都会重新加载,创建了新的上下文,之前的操作进度自然是丢失了。
至此,问题原因已找到,接下来看下如何处理。
解决方案
iframe 无法保存于 VNode 中,又不能将 iframe 从文档中移动或移除,那么就想办法在某个地方把 iframe 存起来,比如 body 节点下,然后通过样式控制 iframe 展示与隐藏,顺着思路捋一下整体流程。

有了上述流程,开始设计下细节。 Iframe 组件是对 iframe 操作流的封装,方便在 vue 项目中使用,内部涉及 iframe 创建、插入、设置样式、移除等操作,为方便操作,将其封装为 Iframe 类;分散的 Iframe 类操作,稍有不当可能造成内存占用过多,故为了统一管理,再设计一个 IframeManage 来统一管理 Iframe。
相关的类关系图如下
classDiagram
class Iframe {
-instance: HTMLIFrameElement
-ops: IframeOptions
+init()
+hide()
+show(rect: IFrameRect)
+resize(rect: IFrameRect)
+destroy()
}
class IFrameManager {
+static frames: Map<string, Iframe>
+static createFrame()
+static showFrame()
+static hideFrame()
+static destroyFrame()
+static resizeFrame()
+static getFrame()
}
class VueComponent {
-frameContainer: Ref
+createFrame()
+destroyFrame()
+showFrame()
+resizeFrame()
-handleLoaded()
-handleError()
}
VueComponent --> IFrameManager : 使用
IFrameManager --> Iframe : 创建/管理
Iframe --> HTMLIFrameElement : 封装
对应的时序图如下
sequenceDiagram
participant VueComponent
participant IFrameManager
participant Iframe
participant DOM
VueComponent->>IFrameManager: createFrame()
IFrameManager->>Iframe: new Iframe(ops)
Iframe->>DOM: createElement('iframe')
Iframe->>DOM: appendChild()
VueComponent->>IFrameManager: resizeFrame()
IFrameManager->>Iframe: resize()
Iframe->>DOM: setElementStyle()
VueComponent->>IFrameManager: destroyFrame()
IFrameManager->>Iframe: destroy()
Iframe->>DOM: remove()
至此思路清晰,开始进入编码
编码实战
首先是 Iframe 类的实现
interface IframeOptions {
uid: string
src: string
name?: string
width?: string
height?: string
className?: string
style?: string
allow?: string
onLoad?: (e: Event) => void
onError?: (e: string | Event) => void
}
type IframeRect = Pick<DOMRect, 'left' | 'top' | 'width' | 'height'> & { zIndex?: number | string }
class Iframe {
instance: HTMLIFrameElement | null = null
constructor(private ops: IframeOptions) {
this.init()
}
init() {
const {
src,
name = `Iframe-${Date.now()}`,
className = '',
style = '',
allow,
onLoad = () => {},
onError = () => {},
} = this.ops
this.instance = document.createElement('iframe')
this.instance.name = name
this.instance.className = className
this.instance.style.cssText = style
this.instance.onload = onLoad
this.instance.onerror = onError
if (allow) this.instance.allow = allow
this.hide()
this.instance.src = src
document.body.appendChild(this.instance)
}
setElementStyle(style: Record<string, string>) {
if (this.instance) {
Object.entries(style).forEach(([key, value]) => {
this.instance!.style.setProperty(key, value)
})
}
}
hide() {
this.setElementStyle({
display: 'none',
position: 'absolute',
left: '0px',
top: '0px',
width: '0px',
height: '0px',
})
}
show(rect: IframeRect) {
this.setElementStyle({
display: 'block',
position: 'absolute',
left: rect.left + 'px',
top: rect.top + 'px',
width: rect.width + 'px',
height: rect.height + 'px',
border: '0',
'z-index': String(rect.zIndex) || 'auto',
})
}
resize(rect: IframeRect) {
this.show(rect)
}
destroy() {
if (this.instance) {
this.instance.onload = null
this.instance.onerror = null
this.instance.remove()
this.instance = null
}
}
}
其次是 IFrameManager 类的实现
export class IFrameManager {
static frames = new Map()
static createFame(ops: IframeOptions, rect: IframeRect) {
const existFrame = this.frames.get(ops.uid)
if (existFrame) {
existFrame.destroy()
}
const frame = new Iframe(ops)
this.frames.set(ops.uid, frame)
frame.show(rect)
return frame
}
static showFrame(uid: string, rect: IframeRect) {
const frame = this.frames.get(uid)
frame?.show(rect)
}
static hideFrame(uid: string) {
const frame = this.frames.get(uid)
frame?.hide()
}
static destroyFrame(uid: string) {
const frame = this.frames.get(uid)
frame?.destroy()
this.frames.delete(uid)
}
static resizeFrame(uid: string, rect: IframeRect) {
const frame = this.frames.get(uid)
frame?.resize(rect)
}
static getFrame(uid: string) {
return this.frames.get(uid)
}
}
最后是 Iframe 组件的实现
<template>
<div ref="frameContainer" class="k-frame">
<span v-if="!src" class="k-frame-tips">
<slot name="placeholder">暂无数据</slot>
</span>
<span v-else-if="isLoading" class="k-frame-tips">
<slot name="loading">加载中... </slot>
</span>
<span v-else-if="isError" class="k-frame-tips"> <slot name="error">加载失败 </slot></span>
</div>
</template>
<script setup lang="ts">
import { onActivated, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { IFrameManager, getIncreaseId } from './core'
import { useResizeObserver, useThrottleFn } from '@vueuse/core'
defineOptions({
name: 'KFrame',
})
const props = withDefaults(
defineProps<{
src: string
zIndex?: string | number
keepAlive?: boolean
}>(),
{
src: '',
keepAlive: true,
},
)
const emits = defineEmits(['loaded', 'error'])
const uid = `kFrame-${getIncreaseId()}`
const frameContainer = ref()
const isLoading = ref(false)
const isError = ref(false)
let readyFlag = false
const getFrameContainerRect = () => {
const { x, y, width, height } = frameContainer.value?.getBoundingClientRect() || {}
return {
left: x || 0,
top: y || 0,
width: width || 0,
height: height || 0,
zIndex: props.zIndex ?? 'auto',
}
}
const createFrame = () => {
isError.value = false
isLoading.value = true
IFrameManager.createFame(
{
uid,
name: uid,
src: props.src,
onLoad: handleLoaded,
onError: handleError,
allow: 'fullscreen;autoplay',
},
getFrameContainerRect(),
)
}
const handleLoaded = (e: Event) => {
isLoading.value = false
emits('loaded', e)
}
const handleError = (e: string | Event) => {
isLoading.value = false
isError.value = true
emits('error', e)
}
const showFrame = () => {
IFrameManager.showFrame(uid, getFrameContainerRect())
}
const hideFrame = () => {
IFrameManager.hideFrame(uid)
}
const resizeFrame = useThrottleFn(() => {
IFrameManager.resizeFrame(uid, getFrameContainerRect())
})
const destroyFrame = () => {
IFrameManager.destroyFrame(uid)
}
const getFrame = () => {
return IFrameManager.getFrame(uid)
}
useResizeObserver(frameContainer, () => {
resizeFrame()
})
onBeforeUnmount(() => {
destroyFrame()
readyFlag = false
})
onDeactivated(() => {
if (props.keepAlive) {
hideFrame()
} else {
destroyFrame()
}
})
onActivated(() => {
if (props.keepAlive) {
showFrame()
return
}
if (readyFlag) {
createFrame()
}
})
watch(
() => [frameContainer.value, props.src],
(el, src) => {
if (el && src) {
createFrame()
readyFlag = true
} else {
destroyFrame()
readyFlag = false
}
},
{
immediate: true,
},
)
defineExpose({
getRef: () => getFrame()?.instance,
})
</script>
<style lang="scss" scoped>
.k-frame {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
&-tips {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
</style>
看看效果

小结
管理后台多页签切换,iframe 区操作进度丢失,根本原因在于 KeepAlive 缓存机制与iframe 的独立浏览上下文特性存在本质冲突。本文通过物理隔离与视觉映射的双重策略,将 iframe 的真实 DOM 节点与Vue 组件实例解耦,实现了 keepAlive 的效果。
当然,该方案在代码实现还有很大优化空间,如 IFrameManager 目前是单例模式、Iframe 池未设计淘汰缓存机制(如 LRU )。嘀嘀嘀...产品催着上线了,没时间优化了,下次一定。
相关代码已上传只 github,欢迎道友们给个 star,在此谢过了

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

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

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

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

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

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


- 特点:内容会被拉伸变形,以完全填充视口框。这种方式可以确保视口内没有空白区域,但可能会导致内容变形。
- 适用场景:适用于对内容变形不敏感的场景,例如全屏背景图。
2. 保持比例(contain)


- 特点:内容保持原始比例,不会被拉伸变形。如果内容的宽高比与视口不一致,会在视口内出现空白区域(黑边)。这种方式可以确保内容不变形,但可能会留白。
- 适用场景:适用于需要保持内容原始比例的场景,例如视频或图片展示。
3. 滚动显示(scroll)


- 特点:内容不会被拉伸变形,当内容超出视口时会添加滚动条。这种方式可以确保内容完整显示,但用户需要滚动才能查看全部内容。
- 适用场景:适用于内容较多且需要完整显示的场景,例如长列表或长文本。
4. 隐藏超出(hidden)


- 特点:内容不会被拉伸变形,当内容超出视口时会隐藏超出部分。这种方式可以避免滚动条的出现,但可能会隐藏部分内容。
- 适用场景:适用于内容较多但不需要完整显示的场景,例如仪表盘。
三、为什么不能同时满足所有要求?
这四种适配模式各有优缺点,但它们在逻辑上是相互矛盾的。具体来说:
- 不留白:要求内容完全填充视口,没有任何空白区域。这通常需要拉伸或缩放内容以适应视口的宽高比。
- 不变形:要求内容保持其原始宽高比,不被拉伸或压缩。这通常会导致内容无法完全填充视口,从而出现空白区域(黑边)。
- 没滚动条:要求内容完全适应视口,不能超出视口范围。这通常需要隐藏超出部分或限制内容的大小。
这三个要求在逻辑上是相互矛盾的:
- 如果内容完全填充视口(不留白),则可能会变形。
- 如果内容保持原始比例(不变形),则可能会出现空白区域(留白)。
- 如果内容超出视口范围,则需要滚动条或隐藏超出部分。
四、【fitview】插件快速实现大屏适配
fitview 是一个视口自适应的 JavaScript 插件,它支持多种适配模式,能够快速实现大屏自适应效果。
github地址:github.com/pbstar/fitv…
在线预览:pbstar.github.io/fitview
以下是它的基本使用方法:
配置
- el: 需要自适应的 DOM 元素
- fit: 自适应模式,字符串,可选值为 fill、contain(默认值)、scroll、hidden
- resize: 是否监听元素尺寸变化,布尔值,默认值 true
安装引入
npm 安装
npm install fitview
esm 引入
import fitview from "fitview";
cdn 引入
<script src="https://unpkg.com/fitview@[version]/lib/fitview.umd.js"></script>
使用示例
<div id="container">
<div style="width:1920px;height:1080px;"></div>
</div>
const container = document.getElementById("container");
new fitview({
el: container,
});
五、总结
大屏适配是一个复杂的问题,不同的项目有不同的需求。虽然不能同时满足“不留白”“不变形”和“没滚动条”这三个要求,但可以通过合理选择适配模式来尽量满足大部分需求。在实际开发中,我们需要根据项目的具体需求和用户体验来权衡,选择最合适的适配方案。
在选择适配方案时,fitview 这个插件可以提供很大的帮助。它支持多种适配模式,能够快速实现大屏自适应效果。如果你正在寻找一个简单易用的适配工具,fitview 值得一试。你可以通过 npm 安装或直接使用 CDN 引入,快速集成到你的项目中。
希望这篇文章能帮助你更好地理解和选择大屏适配方案。如果你有更多问题或建议,欢迎在评论区留言。
来源:juejin.cn/post/7513059488417497123
Vue3 生态再一次加强,网站开发无敌!
如果你正在做官网开发,还在辛苦的手动实现那些动画特效,那今天推荐的这个库,至少让你提前4小时开始摸鱼!
以前,面对设计师的那些炫酷动画,实现起来是最耗头发的;产品经理还时不时的说一下,这效果不好看,我要的是五彩斑斓的黑!
还抱着 Element UI + Animate.css 在那里辛苦调试,苦苦思考好好的效果怎么到了 safari 就变形了呢 ?
现如今,时代变了!
什么是 Inspira UI
Inspira UI 是专门为 Vue3/Nuxt 开发的可复用的动画组件集合。

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

视频文字

图库

3d文字

走马灯

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

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

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

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

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

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

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

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

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

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

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

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

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

原理与实现
1. 判断吸顶状态
IntersectionObserver 是一个现代浏览器 API,可以异步观察目标元素与其祖先或顶级视窗的交叉状态,而无需在主线程上执行高频计算。
在我们的方案中,我们将辅助线作为观察目标。当它向上滚动并与视窗顶部完全分离(isIntersecting 变为 false)时,就意味着 Tab 栏的顶部即将触碰到导航栏的底部。此时,我们只需更新一个状态(例如 isSticky = true),即可触发 Tab 吸顶。这种方式性能优异且逻辑清晰。
2. 获取吸顶临界值
为什么辅助线的 offsetTop 就是吸顶时的滚动距离呢?让我们通过简单的几何关系来证明。
- 吸顶临界点:如图所示,当 Tab 栏的顶部需要滚动到导航栏(
navbar)的底部时,页面滚动的距离pageScrollTop应为:pageScrollTop = tabOffsetTop - navbarHeight
- 辅助线的位置:根据我们的设计,辅助线位于 Tab 上方
navbarHeight的位置。因此,它距离页面顶部的距离lineOffsetTop为:lineOffsetTop = tabOffsetTop - navbarHeight
结合以上两个等式,可以清晰地得出:
pageScrollTop = lineOffsetTop
这证明了我们可以在页面加载后,直接通过读取辅助线的 offsetTop 属性,预先获得精确的吸顶滚动临界值。
3. 代码示例:React Hooks 实现
下面是一个基于 React Hooks 的简单实现,展示了如何将上述原理付诸实践。
import React, { useState, useEffect, useRef } from 'react';
const StickyTabs = ({ navbarHeight }) => {
const [isSticky, setIsSticky] = useState(false);
const [stickyScrollTop, setStickyScrollTop] = useState(0);
// Ref 指向我们的辅助线
const helperLineRef = useRef(null);
useEffect(() => {
const helperLineEl = helperLineRef.current;
if (!helperLineEl) {
return;
}
// 1. 获取吸顶临界值:页面加载后,直接读取 offsetTop
setStickyScrollTop(helperLineEl.offsetTop);
// 2. 监听辅助线可见性,判断吸顶状态
const observer = new IntersectionObserver(
([entry]) => {
// 当辅助线与视窗不再交叉时,意味着 Tab 需要吸顶
setIsSticky(!entry.isIntersecting);
},
// root: null 表示观察与视窗的交叉
// threshold: 0 表示元素刚进入或刚离开视窗时触发
{ root: null, threshold: 0 }
);
observer.observe(helperLineEl);
return () => observer.disconnect();
}, [navbarHeight]);
return (
<div>
{/* ... 其他页面内容 ... */}
<div style={{ position: 'relative' }}>
{/* 辅助线:绝对定位到 Tab 上方 navbarHeight 的位置 */}
<div
ref={helperLineRef}
style={{ position: 'absolute', top: -`${navbarHeight}px`, height: '1px' }}
/>
{/* Tab 组件 */}
<div
style={{
position: isSticky ? 'fixed' : 'static',
top: isSticky ? `${navbarHeight}px` : 'auto',
width: '100%',
zIndex: 10,
// ... 其他样式
}}
>
{/* Tabs... */}
div>
div>
{/* ... 列表等内容 ... */}
div>
);
};
在这个例子中:
helperLineRef指向我们的辅助线。useEffect在组件挂载后执行:- 通过
helperLineRef.current.offsetTop一次性获取并存储吸顶临界值stickyScrollTop。 - 创建
IntersectionObserver监听辅助线,当它离开视窗时,将isSticky设为true,反之则为false。
- 通过
- Tab 组件的
position样式根据isSticky状态动态切换,从而实现吸顶和取消吸顶的效果。
总结
通过引入一条简单的辅助线,我们将一个动态、复杂的滚动计算问题,巧妙地转化为了一个静态、简单的布局问题。
这种方法的优势显而易见:
- 逻辑清晰:用
IntersectionObserver判断状态,用offsetTop获取临界值,职责分明,代码易于理解和维护。 - 性能更优:避免了高频的
scroll事件监听和其中复杂的计算,将性能开销降到最低。 - 实现简单:无需引入复杂的第三方库,仅依靠浏览器原生 API 即可优雅地实现功能。
我是印刻君,一位前端程序员,关注我,了解更多有温度的轻知识,有深度的硬内容。
来源:juejin.cn/post/7572539461479546923
为什么有些人边框不用border属性
1) border 会改变布局(占据空间)
border 会参与盒模型,增加元素尺寸。
例如,一个宽度 200px 的元素加上 border: 1px solid #000,实际宽度会变成:
200 + 1px(left) + 1px(right) = 202px
如果不想影响布局,就很麻烦。
使用 box-shadow: 0 0 0 1px #000不会改变大小,看起来像 border,但不占空间。
2) border 在高 DPI 设备上容易出现“模糊/不齐”
特别是 0.5px border(发丝线),在某些浏览器上有锯齿、断线。
transform: scale(0.5) 或伪元素能做更稳定的发丝线。
3) border 圆角 + 发丝线 常出现不规则效果
border + border-radius 在不同浏览器的渲染不一致,容易出现不均匀、颜色不一致的问题。
用 outline / box-shadow 圆角更稳定。
4) border 不适合做阴影/多层边框
如果你需要两层边框:
双层边框用 border 很难做
而用:
box-shadow: 0 0 0 1px #333, 0 0 0 2px #999;
非常简单。
5) border 和背景裁剪一起用时容易出 bug
比如 background-clip、overflow: hidden 配合 border 会出现背景被挤压、不应该被裁剪却裁剪等问题。
6) hover/active 等状态切换时会“跳动”
因为 border 会改变元素大小。
例子:
.btn { border: 0; }
.btn:hover { border: 1px solid #000; }
鼠标移上去会抖动,因为尺寸变大了。
用 box-shadow 的话就不会跳。
25/11/25更新,来自评论区大佬补充
除了动态外有时候 overflow 也会导致原本刚刚好的布局不会删除滚动条,由于有了 border 1px 导致刚好出现滚动条但其实根本滚不了。
总结
边框可以分别使用border、outline、box-shadow三种方式去实现,其中outline、box-shadow不会像border一样占据空间。而box-shadow可以用来解决两个元素相邻时边框变宽的问题。不使用border并不是因为它不好,而是因为outline和box-shadow的兼容性和灵活性相对border会更好一点。
来源:juejin.cn/post/7575065042158633010
如果产品经理突然要你做一个像抖音一样流畅的H5
从前端到爆点!抖音级 H5 如何炼成?
在万物互联的时代,H5 页面已成为产品推广的利器。当产品经理丢给你一个“像抖音一样流畅的 H5”任务时,是挑战还是机遇?别慌,今天就带你走进抖音 H5 的前端魔法世界。
一、先看清本质:抖音 H5 为何丝滑?
抖音 H5 之所以让人欲罢不能,核心在于两点:极低的卡顿率和极致的交互反馈。前者靠性能优化,后者靠精心设计的交互逻辑。比如,你刷视频时的流畅下拉、点赞时的爱心飞舞,背后都藏着前端开发的“小心机”。
二、性能优化:让页面飞起来
(一)懒加载与预加载协同作战
懒加载是 H5 性能优化的经典招式,只在用户即将看到某个元素时才加载它。但光靠懒加载还不够,聪明的抖音 H5 还会预加载下一个可能进入视野的元素。以下是一个基于 IntersectionObserver 的懒加载示例:
document.addEventListener('DOMContentLoaded', () => {
const lazyImages = [].slice.call(document.querySelectorAll('img.lazy'));
if ('IntersectionObserver' in window) {
let lazyImageObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
let lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImageObserver.unobserve(lazyImage);
}
});
});
lazy Images.forEach((lazyImage) => {
lazyImageObserver.observe(lazyImage);
});
}
});
(二)图片压缩技术大显神威
图片是 H5 的“体重”大户。抖音 H5 常用 WebP 格式,它在保证画质的同时,能将图片体积压缩到 JPEG 的一半。你可以用以下代码轻松实现图片格式转换:
function compressImage(inputImage, quality) {
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = inputImage.naturalWidth;
canvas.height = inputImage.naturalHeight;
ctx.drawImage(inputImage, 0, 0, canvas.width, canvas.height);
const compressedImage = new Image();
compressedImage.src = canvas.toDataURL('image/webp', quality);
compressedImage.onload = () => {
resolve(compressedImage);
};
});
}
三、交互设计:让用户欲罢不能
(一)微动画营造沉浸感
在点赞、评论等关键操作上,抖音 H5 会加入精巧的微动画。比如点赞时的爱心从手指位置飞出,这其实是一个 CSS 动画加 JavaScript 事件监听的组合拳。以下是一个简易版的点赞动画代码:
@keyframes flyHeart {
0% {
transform: scale(0) translateY(0);
opacity: 0;
}
50% {
transform: scale(1.5) translateY(-10px);
opacity: 1;
}
100% {
transform: scale(1) translateY(-20px);
opacity: 0;
}
}
.heart {
position: fixed;
width: 30px;
height: 30px;
background-image: url('../assets/heart.png');
background-size: contain;
background-repeat: no-repeat;
animation: flyHeart 1s ease-out;
}
document.querySelector('.like-btn').addEventListener('click', function(e) {
const heart = document.createElement('div');
heart.className = 'heart';
heart.style.left = e.clientX + 'px';
heart.style.top = e.clientY + 'px';
document.body.appendChild(heart);
setTimeout(() => {
heart.remove();
}, 1000);
});
(二)触摸事件优化
在移动设备上,触摸事件的响应速度直接影响用户体验。抖音 H5 通过精准控制触摸事件的捕获和冒泡阶段,减少了延迟。以下是一个优化触摸事件的示例:
const touchStartHandler = (e) => {
e.preventDefault(); // 防止页面滚动干扰
// 处理触摸开始逻辑
};
const touchMoveHandler = (e) => {
// 处理触摸移动逻辑
};
const touchEndHandler = (e) => {
// 处理触摸结束逻辑
};
const element = document.querySelector('.scrollable-container');
element.addEventListener('touchstart', touchStartHandler, { passive: false });
element.addEventListener('touchmove', touchMoveHandler, { passive: false });
element.addEventListener('touchend', touchEndHandler);
四、音频处理:让声音为 H5 增色
抖音 H5 的音频体验也很讲究。它会根据用户的操作实时调整音量,甚至在不同视频切换时平滑过渡音频。以下是一个简单的声音控制示例:
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioElement = document.querySelector('audio');
const audioSource = audioContext.createMediaElementSource(audioElement);
const gainNode = audioContext.createGain();
audioSource.connect(gainNode);
gainNode.connect(audioContext.destination);
// 调节音量
function setVolume(level) {
gainNode.gain.value = level;
}
// 音频淡入效果
function fadeInAudio() {
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + 1);
}
// 音频淡出效果
function fadeOutAudio() {
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 1);
}
五、跨浏览器兼容:让 H5 无处不在
抖音 H5 能在各种浏览器上保持一致的体验,这离不开前端开发者的兼容性优化。常用的手段包括使用 Autoprefixer 自动生成浏览器前缀、为老浏览器提供 Polyfill 等。以下是一个为 CSS 动画添加前缀的示例:
const autoprefixer = require('autoprefixer');
const postcss = require('postcss');
const css = '.example { animation: slidein 2s; } @keyframes slidein { from { transform: translateX(0); } to { transform: translateX(100px); } }';
postcss([autoprefixer]).process(css).then(result => {
console.log(result.css);
/*
输出:
.example {
animation: slidein 2s;
}
@keyframes slidein {
from {
-webkit-transform: translateX(0);
transform: translateX(0);
}
to {
-webkit-transform: translateX(100px);
transform: translateX(100px);
}
}
*/
});
打造一个像抖音一样的流畅 H5,需要前端开发者在性能优化、交互设计、音频处理和跨浏览器兼容等方面全方位发力。希望这些技术点能为你的 H5 开发之旅提供助力,让你的产品在激烈的市场竞争中脱颖而出!
来源:juejin.cn/post/7522090635908251686
写 CSS 用 px?这 3 个单位能让页面自动适配屏幕
在网页开发中,CSS 单位是控制元素尺寸、间距和排版的基础。
长期以来,px(像素)因其直观、精确而被广泛使用。
然而,随着设备屏幕尺寸和用户需求的多样化,单纯依赖 px 已难以满足现代 Web 对可访问性、灵活性和响应式能力的要求。
什么是 px?
px 是 CSS 中的绝对长度单位,代表像素(pixel)。
在标准密度屏幕上,1px 通常对应一个物理像素点。
开发者使用 px 可以精确控制元素的大小,例如:
.container {
width: 320px;
font-size: 16px;
padding: 12px;
}
这种写法简单直接,在固定尺寸的设计稿还原中非常高效。但问题也正源于它的绝对性。
px 存在哪些问题?
1. 缺乏响应能力
px 的值是固定的,不会随屏幕宽度、容器大小或用户设置而变化。
在一个 320px 宽的手机上显示良好的按钮,在 4K 显示器上可能显得微不足道,反之亦然。
2. 不利于可访问性
许多用户(尤其是视力障碍者)会调整浏览器的默认字体大小。
但使用 px 定义的字体不会随之缩放,导致内容难以阅读。
相比之下,使用相对单位(如 rem)能尊重用户的偏好设置。
更好的选择
为解决上述问题,CSS 提供了一系列更智能、更灵活的单位和功能。以下是几种核心方案:
1. 相对单位:rem 与 em
rem(root em):相对于根元素()的字体大小。默认情况下,1rem = 16px,但可通过设置html { font-size: 18px }改变基准。em:相对于当前元素或其父元素的字体大小,常用于局部缩放。
示例:
html {
font-size: 16px; /* 基准 */
}
.title {
font-size: 1.5rem; /* 24px */
margin-bottom: 1em; /* 相对于自身字体大小 */
}
优势:支持用户自定义缩放,便于构建比例一致的排版系统。
2. 视口单位:vw、vh、vmin、vmax
这些单位基于浏览器视口尺寸:
1vw= 视口宽度的 1%1vh= 视口高度的 1%vmin取宽高中较小者,vmax取较大者
用途:适合全屏布局、动态高度标题等场景。
示例:
.hero {
height: 80vh; /* 占视口高度的 80% */
font-size: 5vw; /* 字体随屏幕宽度缩放 */
}
注意:在移动端,vh 可能受浏览器地址栏影响,需谨慎使用。
3. clamp() 函数:实现流体响应
clamp() 是 CSS 的一个重要进步,允许你在一个属性中同时指定最小值、理想值和最大值:
font-size: clamp(16px, 4vw, 32px);
含义:
- 在小屏幕上,字体不小于 16px;
- 在中等屏幕,按 4vw 动态计算;
- 在大屏幕上,不超过 32px。
这行代码即可替代多个 @media 查询,实现平滑、连续的响应效果。
更推荐结合相对单位使用:
font-size: clamp(1rem, 2.5vw, 2rem);
这样既保留了可访问性,又具备响应能力。
4. 容器查询(Container Queries)
过去,响应式布局只能基于整个视口(通过 @media)。
但组件常常需要根据自身容器的大小来调整样式——这就是容器查询要解决的问题。
使用步骤:
- 为容器声明
container-type:
.card-wrapper {
container-type: inline-size; /* 基于内联轴(通常是宽度) */
}
- 使用
@container编写查询规则:
@container (min-width: 300px) {
.card-title {
font-size: 1.25rem;
}
}
@container (min-width: 500px) {
.card-title {
font-size: 1.75rem;
}
}
现在,只要 .card-wrapper 的宽度变化,内部元素就能自动响应,无需关心页面整体布局。这对构建可复用的 UI 组件库至关重要。
容器查询已在主流浏览器(Chrome 105+、Firefox 116+、Safari 16+)中得到支持。
建议
- 避免在字体大小、容器宽度、内边距等关键布局属性中使用纯
px。 - 优先使用
rem作为全局尺寸基准,em用于局部比例。 - 对需要随屏幕缩放的元素,使用
clamp()+vw/rem组合。 - 构建组件时,考虑启用容器查询,使其真正“自适应”。
- 保留
px仅用于不需要缩放的场景,如边框(border: 1px solid)、固定图标尺寸等。
本文首发于公众号:程序员大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期内容
ThreadLocal 在实际项目中的 6 大用法,原来可以这么简单
重构了20个SpringBoot项目后,总结出这套稳定高效的架构设计
来源:juejin.cn/post/7593292445300899859
CSS终于支持渐变色的过渡了🎉
背景
在做项目时,总会遇到UI给出渐变色的卡片或者按钮,但在做高亮的时候,由于没有过渡,显得尤为生硬。
过去的解决方案
在过去,我们如果要实现渐变色的过渡,通常会使用如下几种方法:
- 添加遮罩层,通过改变遮罩层的透明度做出淡入淡出的效果,实现过渡。
- 通过
background-size/position使得渐变色移动,实现渐变色移动的效果。 - 通过
filter: hue-rotate滤镜实现色相旋转,实现过渡。
但这几种方式都有各自的局限性:
- 遮罩层的方式看似平滑,但不是真正的过渡,差点意思。
background-size/position的方式需要计算好background-size和background-position,否则会出现渐变不完整的情况。并且只是实现了渐变的移动,而不是过渡。filter: hue-rotate也需要计算好旋转角度,实现复杂度高,过渡的也不自然。
@property新规则
@property规则可以定义一个自定义属性,并且可以指定该属性的语法、是否继承、初始值等。
@property --color {
syntax: '<color>';
inherits: false;
initial-value: #000000;
}
我们只需要把这个自定义属性--color应用到linear-gradient中,在特定的时候改变它的值,非常轻松就可以实现渐变色的过渡了。
我们再看看@property规则中这些属性的含义。
Syntax语法描述符
Syntax用于描述自定义属性的数据类型,必填项,常见值包括:
<number>数字(如0,1,2.5)<percentage>百分比(如0%,50%,100%)<length>长度单位(如px,em,rem)<color>颜色值<angle>角度值(如deg,rad)<time>时间值(如s,ms)<image>图片<*>任意类型
Inherits继承描述符
Inherits用于描述自定义属性是否从父元素继承值,必填项:
true从父元素继承值false不继承,每个元素独立
Initial-value初始值描述符
Initial-value用于描述自定义属性的初始值,在Syntax为通用时为可选。
兼容性
@property目前仍是实验性规则,但主流浏览器较新版本都已支持。

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



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

具体效果如下:

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

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

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

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


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

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

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


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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



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

感谢马蜂窝 4 年的陪伴:

感谢穷游网 4 年的陪伴:

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

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

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

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

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

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

来源:juejin.cn/post/7594266018304737343












