阅读视图
🚀 一文看懂 “Next.js 全栈 + 微服务 + GraphQL” 的整体样貌
🧩 一、为什么是这三者?
| 技术栈 | 解决的问题 | 关键词 |
|---|---|---|
| Next.js | 实现前后端一体化渲染,快速交互 | SSR、ISR、React全栈 |
| 微服务 | 模块化、解耦业务系统 | 独立部署、水平扩展 |
| GraphQL | 高效数据查询与聚合 | 类型系统、单一接口、数据裁剪 |
当你把三者拼在一起时,系统突然就“呼吸顺畅”了:
- Next.js 负责前端与 Serverless 网关;
- 微服务各自提供核心业务(如用户、订单、支付等);
- GraphQL 作为数据层“接线板”,整合这些微服务接口,让前端只写一行“我要什么”,后端自觉地“给你什么”。
🌐 二、Next.js:从用户到边缘节点的第一站
Next.js 是 全栈框架,既能写 UI,又能写 API。
目前的 Next.js App Router 模式(基于 React Server Components),使得它具备以下特点:
- 混合渲染模式:SSR(服务端渲染)、SSG(静态生成)与 ISR(增量静态更新)自由组合。
- Edge Functions 支持:可在边缘节点运行逻辑(如Vercel Edge Network)。
-
API 路由封装:
app/api/*下可直接定义后端服务。
💡 换句话说,Next.js 不仅是“网站”,而是你的“轻量网关 + 微前端入口”。
🧱 三、微服务:从单体到分布式的蜕变
在传统的单体应用中,数据库查询、业务逻辑、视图生成往往都被绑在一个服务里。随着系统变大,维护成本直线飙升。
微服务化的策略,是将业务拆成一个个可独立运行的小系统:
| 微服务 | 职责 | 技术选型(示例) |
|---|---|---|
| 用户服务(User Service) | 注册、登录、权限认证 | Node.js + PostgreSQL |
| 订单服务(Order Service) | 订单创建与状态管理 | NestJS + MongoDB |
| 支付服务(Payment Service) | 聚合多支付渠道 | Go/Rust + Redis |
| 通知服务(Notification Service) | 邮件/SMS/推送 | Node.js + Kafka |
各个微服务通常以 HTTP / gRPC / Message Queue 通信。
而在前端访问时——谁来聚合这些接口?
👉 GraphQL 正好上场。
⚙️ 四、GraphQL:用“声明式查询”统一世界
GraphQL 是 API 的中间语言,让前端可以“声明我要什么”,而不是被动“接受后端给的什么”。
举个例子:
query {
user(id: "A123") {
name
orders {
id
total
}
}
}
GraphQL Gateway 会负责调用:
- 用户服务:获取用户基本信息;
- 订单服务:获取该用户的订单信息;
然后合并这些数据,返回一个 JSON。
GraphQL 从而成为 “🪄 数据编排层”:
- 前端不用关心微服务结构;
- 后端不用频繁改接口格式;
- 整体性能通过 Resolver 并行执行 实现高效聚合。
🧭 五、它们的关系图(简易版)
<div style="max-width: 720px; margin:auto; text-align:center; font-family:Arial;">
<svg width="100%" height="340" viewBox="0 0 600 340" xmlns="http://www.w3.org/2000/svg">
<!-- Next.js -->
<rect x="200" y="30" width="200" height="50" rx="8" fill="#A1C4FD" stroke="#333"/>
<text x="300" y="60" text-anchor="middle" font-size="14">Next.js (前后端一体)</text>
<!-- GraphQL Gateway -->
<rect x="230" y="110" width="140" height="50" rx="8" fill="#FDD692" stroke="#333"/>
<text x="300" y="140" text-anchor="middle" font-size="14">GraphQL Gateway</text>
<!-- 微服务 -->
<rect x="60" y="210" width="120" height="50" rx="8" fill="#C2E9FB" stroke="#333"/>
<text x="120" y="240" text-anchor="middle" font-size="13">用户服务</text>
<rect x="240" y="210" width="120" height="50" rx="8" fill="#C2E9FB" stroke="#333"/>
<text x="300" y="240" text-anchor="middle" font-size="13">订单服务</text>
<rect x="420" y="210" width="120" height="50" rx="8" fill="#C2E9FB" stroke="#333"/>
<text x="480" y="240" text-anchor="middle" font-size="13">支付服务</text>
<!-- Lines -->
<line x1="300" y1="80" x2="300" y2="110" stroke="#333" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="300" y1="160" x2="120" y2="210" stroke="#333" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="300" y1="160" x2="300" y2="210" stroke="#333" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="300" y1="160" x2="480" y2="210" stroke="#333" stroke-width="2" marker-end="url(#arrow)"/>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#333" />
</marker>
</defs>
</svg>
<p style="font-size:13px;color:#555;">▲ 图:Next.js + GraphQL + 微服务的协作关系</p>
</div>
🧩 六、代码链路举例说明
以下是一个简化的全栈调用示意(伪代码):
前端(Next.js 客户端组件)
import { gql, useQuery } from "@apollo/client";
const USER_QUERY = gql`
query {
user(id: "A123") {
name
orders {
id
total
}
}
}
`;
export default function UserProfile() {
const { data, loading } = useQuery(USER_QUERY);
if (loading) return <p>加载中...</p>;
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
GraphQL Gateway(聚合层)
import { ApolloServer } from "apollo-server";
import { mergeSchemas } from "@graphql-tools/schema";
import { userSchema, orderSchema } from "./services";
const gatewaySchema = mergeSchemas({
schemas: [userSchema, orderSchema], // 合并多个微服务的Schema
});
new ApolloServer({ schema: gatewaySchema }).listen(4000);
微服务(一个示例)
// 用户服务 (User Service)
import express from "express";
const app = express();
app.get("/user/:id", (req, res) => {
res.json({ id: req.params.id, name: "Dr. Lambda" });
});
app.listen(5001);
一条请求路径:
Next.js → GraphQL Gateway → User Service / Order Service → GraphQL response
🧠 七、设计哲学:全栈与分布式的平衡点
| 维度 | Next.js职责 | 微服务职责 | GraphQL职责 |
|---|---|---|---|
| 表现层 | 渲染与交互 | ❌ | ❌ |
| 逻辑层 | 可实现轻度Serverless逻辑 | 核心业务逻辑 | ❌ |
| 数据层 | ❌ | 存取真实数据库 | 聚合与裁剪 |
| 运维层 | 部署、前端缓存、边缘加速 | 持续集成与扩展 | Schema治理与API监控 |
这种架构让你的系统既能:
- 像单体一样快速开发;
- 又能像分布式一样自由扩展;
- 最终通过 GraphQL,让数据世界变成有类型、有边界、有弹性的知识网格。
🧾 八、结语
Next.js 是前端的门户,
微服务是后端的骨骼,
GraphQL 是连接两者的神经网络。
快到 2026 年了:为什么我们还在争论 CSS 和 Tailwind?
最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777
老实说,我对 Tailwind CSS 的看法有些复杂。它像是一个总是表现完美的朋友,让我觉得自己有些不够好。 我一直是纯 CSS 的拥护者,直到最近,我才意识到,Tailwind 也有其独特的优点。 然而,虽然我不喜欢 Tailwind 的一些方面,但它无疑为开发带来了更多的选择,让我反思自己做决定的方式。
问题
大家争论的,不是 "哪个更好",而是“哪个让你觉得更少痛苦”。 对我来说,Tailwind 有时带来的是压力。比如:
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg shadow-md hover:shadow-lg transition duration-300 ease-in-out transform hover:-translate-y-1"
>
Click me
</button>
它让我想:“这已经不再是简单的 HTML,而是样式类的拼凑。” 而纯 CSS 则让我感到平静、整洁:
.button {
background-color: #3b82f6;
color: white;
font-weight: bold;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease-in-out;
}
.button:hover {
background-color: #2563eb;
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1);
transform: translateY(-4px);
}
纯 CSS 让我觉得自己在“写代码”,而不是“编排类名”。
背景说明
为什么要写这篇文章呢?因为到了 2026 年,CSS 和 Tailwind 的争论已经不再那么重要。
- Tailwind 发布了 v4,速度和性能都大大提升。
- 纯 CSS 也在复兴,容器查询(container queries)、CSS 嵌套(nesting)和 Cascade Layers 这些新特性令人振奋。
- 还有像 Panda CSS、UnoCSS 等新兴工具在不断尝试解决同样的问题。 这让选择变得更加复杂,也让开发变得更加“累”。
Tailwind 的优缺点
优点:
- 减少命名烦恼:你不再需要为类命名。只需使用 Tailwind 提供的类名,省去了命名的麻烦。
- 设计一致性:使用 Tailwind,你的设计系统自然一致,避免了颜色和间距不统一的麻烦。
- 编辑器自动补全:Tailwind 的 IntelliSense 使得开发更加高效,输入类名时有智能提示。
- 响应式设计更简单:通过简单的类名就能实现响应式设计,比传统的媒体查询更简洁。
缺点:
- HTML 看起来乱七八糟:多个类名叠加在一起,让 HTML 看起来复杂且难以维护。
- 构建步骤繁琐:你需要一个构建工具链来处理 Tailwind,这对某些项目来说可能显得过于复杂。
- 调试困难:开发者工具中显示的类名多而杂,调试时很难快速找到问题所在。
- 不够可重用:Tailwind 的类名并不具备良好的可重用性,你可能会不断复制粘贴类,而不是通过自定义组件来实现复用。
纯 CSS 的优缺点
优点:
- 更干净的代码结构:HTML 和 CSS 分离,代码简洁易懂。
- 无构建步骤:只需简单的
<link>标签引入样式表,轻松部署。 - 现代特性强大:2025 年的 CSS 已经非常强大,容器查询和 CSS 嵌套让你可以更加灵活地进行响应式设计。
- 自定义属性:通过 CSS 变量,你可以轻松实现全站的样式管理,改一个变量,所有样式立即生效。
缺点:
- 命名仍然困难:即使有 BEM 或 SMACSS 等方法,命名仍然是一项挑战。
- 保持一致性需要更多约束:没有像 Tailwind 那样的规则,纯 CSS 需要更多的自律来保持一致性。
- 生态碎片化:不同团队和开发者采用不同的方式来组织 CSS,缺少统一标准。
- 没有编辑器自动补全:不像 Tailwind,纯 CSS 需要手动编写所有的类名和样式。
到 2026 年,你该用哪个?
Tailwind 适合:
- 使用 React、Vue 或 Svelte 等组件化框架的开发者
- 需要快速开发并保证一致性的团队
- 不介意添加构建步骤并依赖工具链的人
纯 CSS 适合:
- 小型项目或静态页面
- 喜欢简洁代码、分离 HTML 和 CSS 的开发者
- 想要完全掌控样式并避免复杂构建步骤的人
两者结合:
- 你可以在简单的页面中使用纯 CSS,在复杂的项目中使用 Tailwind 或两者结合,以此来平衡灵活性与效率。
真正值得关注的 2026 年趋势
- 容器查询:响应式设计不再依赖视口尺寸,而是根据容器的尺寸进行调整。
- CSS 嵌套原生支持:你可以直接在 CSS 中使用嵌套,避免了依赖预处理器。
- Cascade Layers:这让你能更好地管理 CSS 优先级,避免使用
!important来解决冲突。 - View Transitions API:它让页面过渡更平滑,无需依赖 JavaScript。
这些新特性将极大改善我们的开发体验,无论是使用纯 CSS 还是借助 Tailwind。
结尾
不管是 Tailwind 还是纯 CSS,都有它们的优缺点。关键是要根据项目需求和个人偏好做出选择。 至于我:我喜欢纯 CSS,因为它更干净,HTML 更直观。但是如果项目需求更适合 Tailwind,那我也会使用它。 2026 年的开发趋势,将让我们有更多选择,让我们能够用最适合的工具解决问题,而不是纠结于某种工具是否“最好”。
TypeScript枚举:让你的代码更有"选择权"
前言
大家好,我是小杨。在平时的开发中,我经常遇到需要定义一组相关常量的情况。比如订单状态、用户角色、颜色主题等等。最开始我都是用普通的常量来定义,直到我发现了TypeScript的枚举(Enum)特性,它让我的代码变得更加清晰和类型安全。今天就来和大家聊聊这个既实用又有趣的特性。
什么是枚举?从选择题到代码
想象一下你在做选择题:A、B、C、D四个选项,每个选项都有明确的含义。枚举就是代码世界中的"选择题",它让我们可以定义一组命名的常量,让代码更易读、更安全。
第一个枚举例子
让我从一个最简单的例子开始:
// 定义一个表示星期的枚举
enum DayOfWeek {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday
}
// 使用枚举
const today: DayOfWeek = DayOfWeek.Wednesday;
const isWeekend = (day: DayOfWeek): boolean => {
return day === DayOfWeek.Saturday || day === DayOfWeek.Sunday;
};
console.log(isWeekend(today)); // 输出: false
console.log(isWeekend(DayOfWeek.Sunday)); // 输出: true
看到这里你可能会有疑问:这些枚举值对应的是什么?让我打印出来看看:
console.log(DayOfWeek.Sunday); // 输出: 0
console.log(DayOfWeek.Monday); // 输出: 1
console.log(DayOfWeek.Tuesday); // 输出: 2
默认情况下,枚举成员从0开始自动编号。但我们可以自定义这些值。
枚举的多种用法
1. 数字枚举
// 自定义数字值
enum HttpStatus {
OK = 200,
Created = 201,
BadRequest = 400,
Unauthorized = 401,
NotFound = 404,
InternalServerError = 500
}
function handleResponse(status: HttpStatus) {
switch (status) {
case HttpStatus.OK:
console.log("请求成功");
break;
case HttpStatus.NotFound:
console.log("资源未找到");
break;
case HttpStatus.InternalServerError:
console.log("服务器错误");
break;
default:
console.log("其他状态码:", status);
}
}
// 使用
handleResponse(HttpStatus.OK); // 输出: 请求成功
2. 字符串枚举
在实际项目中,我更喜欢使用字符串枚举,因为它们更容易调试:
enum UserRole {
Admin = "ADMIN",
Editor = "EDITOR",
Viewer = "VIEWER",
Guest = "GUEST"
}
enum ApiEndpoints {
Login = "/api/auth/login",
Logout = "/api/auth/logout",
Users = "/api/users",
Products = "/api/products"
}
function checkPermission(role: UserRole): boolean {
return role === UserRole.Admin || role === UserRole.Editor;
}
// 使用
const currentUserRole = UserRole.Admin;
console.log(checkPermission(currentUserRole)); // 输出: true
console.log(ApiEndpoints.Login); // 输出: "/api/auth/login"
3. 常量枚举
如果你关心性能,常量枚举是个不错的选择:
const enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT"
}
// 使用常量枚举
const move = (dir: Direction) => {
console.log(`Moving ${dir}`);
};
move(Direction.Up); // 编译后:move("UP")
常量枚举在编译时会被完全删除,只保留具体的值,可以减少代码体积。
4. 异构枚举
虽然不常用,但TypeScript也支持数字和字符串混合的枚举:
enum MixedEnum {
No = 0,
Yes = "YES",
Maybe = 2
}
实战场景:枚举在项目中的应用
场景1:状态管理
在我的电商项目中,枚举在订单状态管理中发挥了重要作用:
enum OrderStatus {
Pending = "PENDING",
Confirmed = "CONFIRMED",
Processing = "PROCESSING",
Shipped = "SHIPPED",
Delivered = "DELIVERED",
Cancelled = "CANCELLED",
Refunded = "REFUNDED"
}
class Order {
constructor(
public id: number,
public status: OrderStatus,
public createdAt: Date
) {}
canBeCancelled(): boolean {
const cancellableStatuses = [
OrderStatus.Pending,
OrderStatus.Confirmed,
OrderStatus.Processing
];
return cancellableStatuses.includes(this.status);
}
getStatusText(): string {
const statusTexts = {
[OrderStatus.Pending]: "待处理",
[OrderStatus.Confirmed]: "已确认",
[OrderStatus.Processing]: "处理中",
[OrderStatus.Shipped]: "已发货",
[OrderStatus.Delivered]: "已送达",
[OrderStatus.Cancelled]: "已取消",
[OrderStatus.Refunded]: "已退款"
};
return statusTexts[this.status];
}
}
// 使用示例
const order = new Order(1, OrderStatus.Pending, new Date());
console.log(order.canBeCancelled()); // 输出: true
console.log(order.getStatusText()); // 输出: 待处理
场景2:配置系统
在应用配置中,枚举让配置更加类型安全:
enum Environment {
Development = "development",
Staging = "staging",
Production = "production"
}
enum LogLevel {
Error = "error",
Warn = "warn",
Info = "info",
Debug = "debug"
}
enum Theme {
Light = "light",
Dark = "dark",
Auto = "auto"
}
class AppConfig {
constructor(
public env: Environment,
public logLevel: LogLevel,
public theme: Theme,
public features: {
analytics: boolean;
notifications: boolean;
}
) {}
isProduction(): boolean {
return this.env === Environment.Production;
}
shouldLogDebug(): boolean {
return this.logLevel === LogLevel.Debug;
}
}
// 使用示例
const config = new AppConfig(
Environment.Development,
LogLevel.Debug,
Theme.Auto,
{ analytics: true, notifications: false }
);
console.log(config.isProduction()); // 输出: false
场景3:权限系统
在用户权限管理中,枚举让权限检查更加清晰:
enum Permission {
ReadUsers = "users:read",
WriteUsers = "users:write",
DeleteUsers = "users:delete",
ReadProducts = "products:read",
WriteProducts = "products:write",
DeleteProducts = "products:delete",
ManageOrders = "orders:manage"
}
enum UserRole {
SuperAdmin = "SUPER_ADMIN",
Admin = "ADMIN",
Manager = "MANAGER",
Customer = "CUSTOMER"
}
class PermissionManager {
private rolePermissions: Record<UserRole, Permission[]> = {
[UserRole.SuperAdmin]: Object.values(Permission),
[UserRole.Admin]: [
Permission.ReadUsers,
Permission.WriteUsers,
Permission.ReadProducts,
Permission.WriteProducts,
Permission.ManageOrders
],
[UserRole.Manager]: [
Permission.ReadProducts,
Permission.WriteProducts,
Permission.ManageOrders
],
[UserRole.Customer]: [
Permission.ReadProducts
]
};
hasPermission(role: UserRole, permission: Permission): boolean {
return this.rolePermissions[role]?.includes(permission) || false;
}
getPermissionsForRole(role: UserRole): Permission[] {
return this.rolePermissions[role] || [];
}
}
// 使用示例
const permissionManager = new PermissionManager();
const canDeleteUsers = permissionManager.hasPermission(
UserRole.Admin,
Permission.DeleteUsers
);
console.log(canDeleteUsers); // 输出: false
场景4:国际化
在多语言项目中,枚举可以帮助管理语言选项:
enum Language {
English = "en",
Chinese = "zh",
Japanese = "ja",
Korean = "ko",
French = "fr"
}
enum Currency {
USD = "USD",
CNY = "CNY",
JPY = "JPY",
EUR = "EUR",
KRW = "KRW"
}
class LocalizationService {
private translations: Record<Language, Record<string, string>> = {
[Language.English]: {
welcome: "Welcome",
goodbye: "Goodbye",
error: "An error occurred"
},
[Language.Chinese]: {
welcome: "欢迎",
goodbye: "再见",
error: "发生错误"
}
// 其他语言...
};
constructor(private language: Language) {}
setLanguage(language: Language): void {
this.language = language;
}
translate(key: string): string {
return this.translations[this.language]?.[key] || key;
}
formatCurrency(amount: number, currency: Currency): string {
const formatters: Record<Currency, string> = {
[Currency.USD]: `$${amount.toFixed(2)}`,
[Currency.CNY]: `¥${amount.toFixed(2)}`,
[Currency.JPY]: `¥${amount}`,
[Currency.EUR]: `€${amount.toFixed(2)}`,
[Currency.KRW]: `₩${amount.toLocaleString()}`
};
return formatters[currency];
}
}
// 使用示例
const i18n = new LocalizationService(Language.Chinese);
console.log(i18n.translate("welcome")); // 输出: 欢迎
console.log(i18n.formatCurrency(100, Currency.CNY)); // 输出: ¥100.00
枚举的最佳实践
1. 使用字符串枚举提高可读性
// 推荐:字符串枚举
enum UserAction {
Login = "USER_LOGIN",
Logout = "USER_LOGOUT",
UpdateProfile = "USER_UPDATE_PROFILE"
}
// 不推荐:数字枚举(调试时难以理解)
enum UserActionOld {
Login, // 0
Logout, // 1
UpdateProfile // 2
}
2. 使用常量枚举优化性能
// 在性能敏感的场景使用常量枚举
const enum ResponseCode {
Success = 200,
NotFound = 404,
ServerError = 500
}
// 编译后,这些枚举引用会被替换为具体数值
const code = ResponseCode.Success; // 编译为: const code = 200
3. 避免异构枚举
// 不推荐:混合数字和字符串
enum BadExample {
A = 1,
B = "B",
C = 2
}
// 推荐:保持一致性
enum GoodExample {
A = "A",
B = "B",
C = "C"
}
enum GoodExample2 {
A = 1,
B = 2,
C = 3
}
4. 使用命名空间扩展枚举
enum NotificationType {
Email = "EMAIL",
SMS = "SMS",
Push = "PUSH"
}
namespace NotificationType {
export function isUrgent(type: NotificationType): boolean {
return type === NotificationType.SMS || type === NotificationType.Push;
}
export function getChannels(): NotificationType[] {
return Object.values(NotificationType);
}
}
// 使用示例
console.log(NotificationType.isUrgent(NotificationType.Email)); // false
console.log(NotificationType.getChannels()); // ["EMAIL", "SMS", "PUSH"]
枚举的替代方案
虽然枚举很强大,但在某些情况下,使用联合类型可能更合适:
// 使用联合类型
type OrderStatus = "PENDING" | "CONFIRMED" | "SHIPPED" | "DELIVERED";
// 使用对象常量
const ORDER_STATUS = {
PENDING: "PENDING",
CONFIRMED: "CONFIRMED",
SHIPPED: "SHIPPED",
DELIVERED: "DELIVERED"
} as const;
type OrderStatusType = typeof ORDER_STATUS[keyof typeof ORDER_STATUS];
选择建议:
- 需要迭代枚举成员时:使用枚举
- 只需要类型安全时:使用联合类型
- 需要同时使用值和类型时:使用对象常量 + 类型
结语
枚举是TypeScript中一个非常实用的特性,它让我们的代码更加清晰、安全。通过合理使用枚举,我们可以更好地表达业务逻辑中的各种状态和选项。
记住,工具虽好,但也要用在合适的地方。希望今天的分享能帮助你在实际项目中更好地使用TypeScript枚举!
⭐ 写在最后
请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.
✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式
✅ 认为我部分代码过于老旧,可以提供新的API或最新语法
✅ 对于文章中部分内容不理解
✅ 解答我文章中一些疑问
✅ 认为某些交互,功能需要优化,发现BUG
✅ 想要添加新功能,对于整体的设计,外观有更好的建议
✅ 一起探讨技术加qq交流群:906392632
最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!
TypeScript类:面向对象编程的超级武器
前言
大家好,我是小杨。还记得我刚接触编程时,面对"类"这个概念总是觉得有些抽象。直到在TypeScript中深入使用类之后,我才真正体会到面向对象编程的魅力。今天,我想和大家分享我在实际项目中运用TypeScript类的一些心得和技巧。
类的基础:从蓝图到实物
想象一下你要建房子,首先需要一张蓝图,这张蓝图定义了房子的结构:有几个房间、门窗的位置、水电布局等。然后根据这张蓝图,你可以建造出很多具体的房子。
TypeScript中的类就是这样的"蓝图",而根据类创建的对象就是一栋栋具体的"房子"。
最简单的类
让我从一个最简单的例子开始:
class Car {
brand: string;
color: string;
constructor(brand: string, color: string) {
this.brand = brand;
this.color = color;
}
drive() {
console.log(`The ${this.color} ${this.brand} is driving.`);
}
}
// 创建类的实例
const myCar = new Car("Toyota", "red");
myCar.drive(); // 输出: The red Toyota is driving.
这就是最基本的类:它有属性(brand、color)和方法(drive)。
类的进阶特性
1. 访问修饰符:控制可见性
访问修饰符就像房子的访问权限:有些区域对所有访客开放,有些只对家人开放,有些则是私密空间。
class BankAccount {
public readonly accountNumber: string; // 公开但只读
private balance: number; // 完全私有
protected owner: string; // 受保护的
constructor(accountNumber: string, initialBalance: number, owner: string) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
this.owner = owner;
}
public deposit(amount: number): void {
if (amount > 0) {
this.balance += amount;
console.log(`Deposited $${amount}. New balance: $${this.balance}`);
}
}
public withdraw(amount: number): boolean {
if (amount > 0 && amount <= this.balance) {
this.balance -= amount;
console.log(`Withdrew $${amount}. Remaining balance: $${this.balance}`);
return true;
}
console.log("Insufficient funds");
return false;
}
public getBalance(): number {
return this.balance;
}
}
// 使用示例
const myAccount = new BankAccount("123456", 1000, "Alice");
myAccount.deposit(500); // ✅ 允许
console.log(myAccount.accountNumber); // ✅ 允许
// myAccount.balance = 9999; // ❌ 编译错误:balance是私有的
// console.log(myAccount.balance); // ❌ 编译错误
2. 继承:代码复用的艺术
继承让类可以建立在现有类的基础上,就像子承父业一样。
class Animal {
constructor(public name: string, protected age: number) {}
speak() {
console.log(`${this.name} makes a sound.`);
}
sleep() {
console.log(`${this.name} is sleeping.`);
}
}
class Dog extends Animal {
private breed: string;
constructor(name: string, age: number, breed: string) {
super(name, age); // 调用父类的constructor
this.breed = breed;
}
// 重写父类方法
speak() {
console.log(`${this.name} barks!`);
}
// 子类特有方法
fetch() {
console.log(`${this.name} is fetching the ball.`);
}
getInfo() {
return `${this.name} is a ${this.breed} and ${this.age} years old.`;
}
}
// 使用示例
const myDog = new Dog("Buddy", 3, "Golden Retriever");
myDog.speak(); // 输出: Buddy barks!
myDog.sleep(); // 输出: Buddy is sleeping.
myDog.fetch(); // 输出: Buddy is fetching the ball.
console.log(myDog.getInfo());
3. 抽象类:定义框架
抽象类就像建筑的设计规范,它定义了结构但不提供完整实现。
abstract class Shape {
constructor(public name: string) {}
// 抽象方法,必须在子类中实现
abstract calculateArea(): number;
// 具体方法
displayInfo() {
console.log(`Shape: ${this.name}, Area: ${this.calculateArea()}`);
}
}
class Circle extends Shape {
constructor(private radius: number) {
super("Circle");
}
calculateArea(): number {
return Math.PI * this.radius * this.radius;
}
}
class Rectangle extends Shape {
constructor(private width: number, private height: number) {
super("Rectangle");
}
calculateArea(): number {
return this.width * this.height;
}
}
// 使用示例
const circle = new Circle(5);
const rectangle = new Rectangle(4, 6);
circle.displayInfo(); // 输出: Shape: Circle, Area: 78.53981633974483
rectangle.displayInfo(); // 输出: Shape: Rectangle, Area: 24
实战场景:类在项目中的应用
场景1:数据模型层
在我的电商项目中,类帮助我们构建了清晰的数据模型:
class Product {
constructor(
public id: number,
public name: string,
public price: number,
private stock: number
) {}
// 业务方法
reduceStock(quantity: number): boolean {
if (this.stock >= quantity) {
this.stock -= quantity;
return true;
}
return false;
}
addStock(quantity: number): void {
this.stock += quantity;
}
getStock(): number {
return this.stock;
}
// 静态方法
static createFromData(data: any): Product {
return new Product(data.id, data.name, data.price, data.stock);
}
}
class ShoppingCart {
private items: Map<number, { product: Product; quantity: number }> = new Map();
addItem(product: Product, quantity: number): void {
if (product.reduceStock(quantity)) {
const existingItem = this.items.get(product.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.set(product.id, { product, quantity });
}
}
}
removeItem(productId: number): void {
const item = this.items.get(productId);
if (item) {
item.product.addStock(item.quantity);
this.items.delete(productId);
}
}
getTotal(): number {
let total = 0;
for (const [_, item] of this.items) {
total += item.product.price * item.quantity;
}
return total;
}
checkout(): Order {
return new Order(this);
}
}
class Order {
private orderId: string;
private orderDate: Date;
constructor(private cart: ShoppingCart) {
this.orderId = this.generateOrderId();
this.orderDate = new Date();
}
private generateOrderId(): string {
return `ORD-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}
场景2:UI组件系统
在React项目中,类组件虽然现在较少使用,但在某些场景下仍然很有价值:
abstract class BaseComponent<P = {}, S = {}> {
protected state: S;
protected props: P;
constructor(props: P) {
this.props = props;
this.state = {} as S;
}
abstract render(): string;
setState(newState: Partial<S>): void {
this.state = { ...this.state, ...newState };
this.onStateUpdate();
}
protected onStateUpdate(): void {
// 触发重新渲染的逻辑
console.log("State updated, triggering re-render");
}
}
class CounterComponent extends BaseComponent<{ initialCount: number }, { count: number }> {
constructor(props: { initialCount: number }) {
super(props);
this.state = { count: props.initialCount };
}
increment = (): void => {
this.setState({ count: this.state.count + 1 });
};
decrement = (): void => {
this.setState({ count: this.state.count - 1 });
};
render(): string {
return `
<div class="counter">
<h2>Count: ${this.state.count}</h2>
<button onclick="counter.increment()">+</button>
<button onclick="counter.decrement()">-</button>
</div>
`;
}
}
// 使用示例
const counter = new CounterComponent({ initialCount: 0 });
console.log(counter.render());
场景3:服务层和工具类
在服务层设计中,类提供了很好的封装:
class ApiService {
private baseURL: string;
private token: string | null = null;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
setToken(token: string): void {
this.token = token;
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
try {
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint);
}
async post<T>(endpoint: string, data: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
}
// 单例模式的应用
class ConfigManager {
private static instance: ConfigManager;
private config: Map<string, any> = new Map();
private constructor() {
// 私有构造函数,防止外部实例化
}
static getInstance(): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager();
}
return ConfigManager.instance;
}
set(key: string, value: any): void {
this.config.set(key, value);
}
get(key: string): any {
return this.config.get(key);
}
}
最佳实践和注意事项
1. 组合优于继承
// 不推荐:过度使用继承
class AnimalWithSwim extends Animal {
swim() { /* ... */ }
}
class AnimalWithFly extends Animal {
fly() { /* ... */ }
}
class AnimalWithBoth extends Animal {
swim() { /* ... */ }
fly() { /* ... */ }
}
// 推荐:使用组合
class Swimmer {
swim() {
console.log("Swimming...");
}
}
class Flyer {
fly() {
console.log("Flying...");
}
}
class Duck extends Animal {
private swimmer = new Swimmer();
private flyer = new Flyer();
swim() {
this.swimmer.swim();
}
fly() {
this.flyer.fly();
}
}
2. 合理使用Getter和Setter
class User {
private _email: string = "";
constructor(public name: string) {}
get email(): string {
return this._email;
}
set email(value: string) {
if (this.isValidEmail(value)) {
this._email = value;
} else {
throw new Error("Invalid email address");
}
}
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+.[^\s@]+$/;
return emailRegex.test(email);
}
}
const user = new User("Alice");
user.email = "alice@example.com"; // ✅ 有效
// user.email = "invalid-email"; // ❌ 抛出错误
3. 接口与类的结合
interface IRepository<T> {
findById(id: number): T | null;
save(entity: T): void;
delete(id: number): boolean;
}
class UserRepository implements IRepository<User> {
private users: Map<number, User> = new Map();
private nextId: number = 1;
findById(id: number): User | null {
return this.users.get(id) || null;
}
save(user: User): void {
if (!user.id) {
user.id = this.nextId++;
}
this.users.set(user.id, user);
}
delete(id: number): boolean {
return this.users.delete(id);
}
}
结语
TypeScript的类为我们提供了强大的面向对象编程能力,从简单的数据封装到复杂的系统架构,类都能发挥重要作用。通过合理的类设计,我们可以创建出更加健壮、可维护的应用程序。
记住,类的设计不是越多越好,而是要找到适合项目需求的平衡点。希望今天的分享能帮助你在实际项目中更好地运用TypeScript类!
⭐ 写在最后
请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.
✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式
✅ 认为我部分代码过于老旧,可以提供新的API或最新语法
✅ 对于文章中部分内容不理解
✅ 解答我文章中一些疑问
✅ 认为某些交互,功能需要优化,发现BUG
✅ 想要添加新功能,对于整体的设计,外观有更好的建议
✅ 一起探讨技术加qq交流群:906392632
最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!
TypeScript接口:打造你的代码“契约”之道
前言
大家好,我是小杨。在日常的TypeScript开发中,接口(Interface)是我最得力的助手之一。它就像一份严谨的"契约",让我的代码更加可靠和可维护。今天就来和大家聊聊这个看似简单却威力强大的特性。
什么是接口?从生活到代码的类比
想象一下,你要购买一台笔记本电脑。你不需要知道内部每个零件的具体品牌,但你肯定关心:必须有键盘、屏幕、USB接口、电池等。这就是一种"接口"约定——定义了设备应该具备的能力,而不关心具体实现。
在TypeScript中,接口也是这样的存在:它定义了对象应该长什么样子,类应该具备什么方法,但不包含具体的实现细节。
基础用法:从简单对象开始
让我从一个最简单的例子开始:
// 定义一个用户接口
interface IUser {
id: number;
name: string;
email: string;
age?: number; // 可选属性
}
// 使用接口
const myUser: IUser = {
id: 1,
name: "Alice",
email: "alice@example.com"
// age是可选的,所以可以不写
};
// 如果缺少必需属性,TypeScript会报错
const invalidUser: IUser = {
id: 2,
name: "Bob"
// 缺少email,编译时会报错
};
在这个例子中,我定义了一个用户接口,任何声称自己是IUser的对象都必须包含id、name和email这三个属性。
接口的进阶玩法
1. 函数类型的接口
接口不仅可以描述对象,还可以描述函数:
interface ISearchFunc {
(source: string, keyword: string): boolean;
}
// 使用函数接口
const mySearch: ISearchFunc = function(src, kw) {
return src.indexOf(kw) > -1;
};
// 测试
console.log(mySearch("hello world", "hello")); // true
2. 可索引类型的接口
当我们需要处理数组或字典时,索引接口就派上用场了:
interface IStringArray {
[index: number]: string;
}
interface IUserDictionary {
[key: string]: IUser;
}
const usersArray: IStringArray = ["Alice", "Bob", "Charlie"];
const usersDict: IUserDictionary = {
"user1": { id: 1, name: "Alice", email: "alice@example.com" },
"user2": { id: 2, name: "Bob", email: "bob@example.com" }
};
3. 接口继承
接口可以继承其他接口,这在构建复杂类型系统时特别有用:
interface IPerson {
name: string;
age: number;
}
interface IEmployee extends IPerson {
employeeId: string;
department: string;
}
interface IManager extends IEmployee {
teamSize: number;
}
// 现在IManager必须包含所有继承链上的属性
const myManager: IManager = {
name: "Carol",
age: 35,
employeeId: "E002",
department: "Engineering",
teamSize: 8
};
实战场景:接口在项目中的应用
场景1:API响应数据建模
在我最近的项目中,接口在处理API响应时发挥了巨大作用:
// 定义API响应接口
interface IApiResponse<T> {
code: number;
message: string;
data: T;
timestamp: number;
}
interface IProduct {
id: number;
title: string;
price: number;
inventory: number;
}
interface IOrder {
orderId: string;
products: IProduct[];
totalAmount: number;
}
// 使用泛型接口
async function fetchOrder(orderId: string): Promise<IApiResponse<IOrder>> {
const response = await fetch(`/api/orders/${orderId}`);
const result: IApiResponse<IOrder> = await response.json();
return result;
}
// 使用时获得完整的类型提示
const orderResponse = await fetchOrder("123");
console.log(orderResponse.data.products[0].title); // 完整的类型安全!
场景2:组件Props的类型定义
在React项目中,我用接口来定义组件Props:
interface IButtonProps {
text: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
size?: 'small' | 'medium' | 'large';
}
const MyButton: React.FC<IButtonProps> = ({
text,
onClick,
variant = 'primary',
disabled = false,
size = 'medium'
}) => {
return (
<button
className={`btn btn-${variant} btn-${size}`}
onClick={onClick}
disabled={disabled}
>
{text}
</button>
);
};
// 使用组件时获得自动补全和类型检查
<MyButton
text="点击我"
onClick={() => console.log("clicked")}
variant="primary"
size="large"
/>
场景3:配置对象类型安全
在应用配置管理中,接口确保配置的正确性:
interface IAppConfig {
api: {
baseURL: string;
timeout: number;
retries: number;
};
features: {
darkMode: boolean;
analytics: boolean;
notifications: boolean;
};
ui: {
theme: 'light' | 'dark' | 'auto';
language: string;
};
}
const appConfig: IAppConfig = {
api: {
baseURL: "https://api.mysite.com",
timeout: 5000,
retries: 3
},
features: {
darkMode: true,
analytics: true,
notifications: false
},
ui: {
theme: 'auto',
language: 'zh-CN'
}
};
// 如果有人误写配置,TypeScript会立即提示
const wrongConfig: IAppConfig = {
api: {
baseURL: "https://api.mysite.com",
timeout: "5000", // 错误:应该是number而不是string
retries: 3
},
// ... 其他配置
};
接口 vs 类型别名:如何选择?
很多初学者会困惑于接口和类型别名的区别,这里是我的使用经验:
// 接口 - 适合对象类型,支持继承
interface IPoint {
x: number;
y: number;
}
interface I3DPoint extends IPoint {
z: number;
}
// 类型别名 - 更适合联合类型、元组等
type ID = number | string;
type Coordinates = [number, number];
type Direction = 'up' | 'down' | 'left' | 'right';
// 我的经验法则:
// - 需要继承或实现时,用接口
// - 需要联合类型、元组或其他复杂类型时,用类型别名
// - 对象类型两者都可以,但在团队中保持一致性更重要
最佳实践和踩坑经验
1. 接口命名约定
在我的项目中,通常使用这样的命名规则:
// 普通接口
interface User {}
interface Product {}
// 带前缀的接口(在某些规范中使用)
interface IUser {} // 匈牙利命名法
interface UserInterface {} // 后缀命名法
// 选择一种并保持团队一致
2. 避免过度使用可选属性
// 不推荐:太多可选属性会让接口失去意义
interface IWeakContract {
name?: string;
age?: number;
email?: string;
phone?: string;
// ... 很多可选属性
}
// 推荐:明确区分必需和可选
interface IStrongContract {
// 必需的核心属性
id: number;
name: string;
// 可选的附加属性
metadata?: {
createdAt?: Date;
updatedAt?: Date;
tags?: string[];
};
}
3. 使用只读属性保护数据
interface IReadonlyUser {
readonly id: number;
readonly createdAt: Date;
name: string;
email: string;
}
const user: IReadonlyUser = {
id: 1,
createdAt: new Date(),
name: "Alice",
email: "alice@example.com"
};
user.name = "Bob"; // ✅ 允许
user.id = 2; // ❌ 编译错误:id是只读的
结语
TypeScript接口就像是我们代码世界的"法律条文",它规定了各个部分应该如何协作。通过合理使用接口,我大大减少了运行时错误,提高了代码的可读性和可维护性。
记住,好的接口设计不是一蹴而就的,它需要在实际项目中不断实践和调整。希望我的这些经验能够帮助你在TypeScript的道路上走得更顺畅!
⭐ 写在最后
请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.
✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式
✅ 认为我部分代码过于老旧,可以提供新的API或最新语法
✅ 对于文章中部分内容不理解
✅ 解答我文章中一些疑问
✅ 认为某些交互,功能需要优化,发现BUG
✅ 想要添加新功能,对于整体的设计,外观有更好的建议
✅ 一起探讨技术加qq交流群:906392632
最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!
React项目使用useMemo优化性能指南和应用场景
1. 前言
在 React 应用开发过程中,性能优化是一个绕不开的话题。随着应用功能不断丰富,组件层级日益复杂,很容易出现一些不必要的计算和渲染,导致应用运行卡顿,影响用户体验。而useMemo钩子就是 React 为我们提供的一个强大的性能优化工具,合理使用它,能让我们的应用运行得更加流畅高效。
2. 渲染机制与性能问题
在 React 中,当组件的状态(state)或 props 发生变化时,组件就会重新渲染。这原本是一个很好的机制,能确保页面及时更新展示最新的数据。但如果不加以控制,可能会引发一些性能问题。
比如,在一个复杂的组件中,存在一些计算量较大的函数,每次组件重新渲染时,这些函数都会重新执行,即使相关的数据并没有发生变化。举个简单的例子:
import { useState } from'react';
const ExpensiveCalculation = () => {
const [count, setCount] = useState(0);
const calculateSum = () => {
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum += i;
}
return sum;
};
const result = calculateSum();
return (
<div>
<p>计算结果: {result}</p>
<button onClick={() => setCount(count + 1)}>点击增加计数</button>
</div>
);
};
在上述代码中,calculateSum函数执行了大量的循环计算,每次点击按钮更新count状态,组件重新渲染时,calculateSum都会重新执行一遍,即使我们并不需要重新计算(因为计算逻辑本身没有依赖的数据变化),这无疑是对性能的浪费。
3. 基本原理与语法
useMemo钩子的作用就是对计算结果进行缓存,只有当依赖项发生变化时,才会重新计算并返回新的结果,否则直接返回上一次缓存的结果。它的语法如下:
import { useMemo } from'react';
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
这里,computeExpensiveValue是一个函数,用于执行具体的计算逻辑,a 和 b 是该计算逻辑所依赖的数据。useMemo会返回computeExpensiveValue函数的执行结果,并将其缓存起来。只有当 a 或 b 发生变化时,computeExpensiveValue函数才会重新执行,否则直接返回缓存的结果。
4. 实际应用场景
在实际应用中,useMemo可以用于以下场景:
4.1 复杂计算结果的缓存
还是以上面的ExpensiveCalculation组件为例,我们可以使用useMemo来优化:
import React, { useState } from'react';
const ExpensiveCalculation = () => {
const [count, setCount] = useState(0);
const result = useMemo(() => {
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum += i;
}
return sum;
}, []);
return (
<div>
<p>计算结果: {result}</p>
<button onClick={() => setCount(count + 1)}>点击增加计数</button>
</div>
);
};
在优化后的代码中,由于useMemo的依赖项数组为空,这意味着只有在组件首次渲染时,computeExpensiveValue函数(即内部的循环计算部分)才会执行一次,后续无论count如何变化,组件重新渲染时,result都会直接返回缓存的结果,避免了大量不必要的计算,提升了性能。
4.2 在列表渲染中优化子组件
当我们在渲染列表时,如果列表项组件接受一些计算后的 props,使用useMemo可以避免子组件不必要的重新渲染。例如:
import React, { useState, useMemo } from'react';
const ListItem = ({ item }) => {
console.log(`渲染列表项: ${item}`);
return <li>{item}</li>;
};
const List = () => {
const [count, setCount] = useState(0);
const list = [1, 2, 3, 4, 5];
const memoizedList = useMemo(() => list.map(item => ({ value: item })), [list]);
return (
<div>
<ul>
{memoizedList.map(({ value }) => (
<ListItem key={value} item={value} />
))}
</ul>
<button onClick={() => setCount(count + 1)}>点击增加计数</button>
</div>
);
};
在这个例子中,memoizedList使用useMemo进行了处理,在第二个参数数组里监听了list,只有当list数组发生变化时,memoizedList才会重新计算。当我们点击按钮更新count状态时,由于memoizedList没有变化,ListItem组件不会重新渲染,从而提高了列表渲染的性能。
4.3 函数作为 props 传递时的优化
在 React 中,经常会将函数作为 props 传递给子组件。如果这个函数在父组件每次渲染时都重新创建,可能会导致子组件不必要的重新渲染。使用useMemo可以解决这个问题。
import React, { useState, useMemo } from'react';
const ChildComponent = ({ onClick }) => {
console.log('子组件渲染');
return <button onClick={onClick}>点击我</button>;
};
const ParentComponent = () => {
const [count, setCount] = useState(0);
const handleClick = useMemo(() => () => {
setCount(count + 1);
}, [count]);
return (
<div>
<ChildComponent onClick={handleClick} />
<p>计数: {count}</p>
</div>
);
};
在上述代码中,handleClick函数使用useMemo进行了优化。只有当count发生变化时,handleClick函数才会重新创建,否则在父组件每次渲染时,handleClick都会返回相同的函数实例,避免了ChildComponent因为 props 函数的变化而不必要地重新渲染。
5. 注意事项
-
正确设置依赖项数组:
- 依赖项数组的设置至关重要,如果遗漏了必要的依赖项,可能会导致缓存的结果不准确;而添加了不必要的依赖项,则会失去useMemo的优化效果。所以,一定要仔细分析计算逻辑真正依赖的数据,准确设置依赖项数组。
-
不要滥用useMemo:
- 虽然useMemo能优化性能,但也不要过度使用。对于一些简单的计算或者本身更新频率较高的数据,使用useMemo可能带来的性能提升有限,反而会增加代码的复杂性和维护成本。
6. 总结
总之,useMemo钩子是 React 性能优化的有力武器,通过合理运用它,我们可以有效地减少不必要的计算和渲染,提升应用的运行效率。在实际开发中,需要结合具体的业务场景,正确使用useMemo,让我们的 React 应用更加流畅、高效。
本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~
PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~
往期文章
- 纯前端提取图片颜色插件Color-Thief教学+实战完整指南
- react-konva实战指南:Canvas高性能+易维护的组件化图形开发实现教程
- React无限滚动插件react-infinite-scroll-component的配置+优化+避坑指南
- 前端音频兼容解决:音频神器howler.js从基础到进阶完整使用指南
- 使用React-OAuth进行Google/GitHub登录的教程和案例
- 纯前端人脸识别利器:face-api.js手把手深入解析教学
- 关于React父组件调用子组件方法forwardRef的详解和案例
- React跨组件数据共享useContext详解和案例
- Web图像编辑神器tui.image-editor从基础到进阶的实战指南
- 开发个人微信小程序类目选择/盈利方式/成本控制与服务器接入指南
- 前端图片裁剪Cropper.js核心功能与实战技巧详解
- 编辑器也有邪修?盘点VS Code邪门/有趣的扩展
- js使用IntersectionObserver实现目标元素可见度的交互
- Web前端页面开发阿拉伯语种适配指南
- 让网页拥有App体验?PWA 将网页变为桌面应用的保姆级教程PWA
- 使用nvm管理node.js版本以及更换npm淘宝镜像源
- 手把手教你搭建规范的团队vue项目,包含commitlint,eslint,prettier,husky,commitizen等等
大规模图片列表性能优化:基于 IntersectionObserver 的懒加载与滚动加载方案
📝 背景与目标
在 渲染大量图片的功能场景中,千级图片一次性渲染会引发系列性能问题,包括首屏渲染阻塞、内存占用激增、滚动交互卡顿及网络带宽浪费。本方案的核心目标是,在保障用户体验不受损的前提下,通过 “按需渲染、按需加载、渐进获取” 三大核心策略,将大规模图片列表的渲染成本与网络开销控制在合理范围。
这篇文章将详细拆解实现方案:基于 IntersectionObserver API 构建的 “图片懒加载 + 滚动加载更多” 组合方案,涵盖抽象设计、核心代码实现、细节优化策略及可扩展方向,为同类大规模媒体列表场景提供可复用的技术参考。
🏗️ 系统设计总览
架构分层
系统采用 “组件层 - 状态层 - 工具层” 三层架构,职责边界清晰,便于复用与测试:
-
组件层(View):
ImageFavoriteModal.vue负责图片网格渲染,整合搜索、懒加载触发、滚动加载调度、图片预览 / 下载 / 取消收藏等交互逻辑。 -
状态层(Store):
useImageStore统一管理收藏图片数据的获取、分页状态维护及数据追加合并,提供标准化数据接口。 -
工具层(Utils):
imageLazyLoad.js封装IntersectionObserverAPI,提供图片懒加载观察器与滚动触底加载更多观察器两大核心能力。
设计核心原则
- 首屏直出:固定渲染并加载首批 12 张图片,平衡 “快速可见” 与 “资源可控”。
- 视口触发加载:通过观察器监听 DOM 元素可见状态,仅当图片进入视口时触发真实地址加载。
- 渐进式数据获取:采用分页加载模式,单页请求 100 张图片,触底阈值触发下一页请求。
- 分层解耦:组件专注 UI 渲染与交互,状态层负责数据管理,工具层封装通用能力,降低耦合度。
🛠️ 核心能力抽象与职责拆分
1. 图片懒加载观察器(按需加载核心)
核心职责
监听图片 DOM 元素的视口进入状态,仅当元素进入视口(含预加载阈值)时,标记为 “可加载” 状态,触发 <el-image> 组件拉取真实图片资源。
核心实现代码
// ai_multimodal_web/src/utils/imageLazyLoad.js
/**
* 图片懒加载工具函数
* 基于Intersection Observer API实现图片元素可见性监听
* @param {Function} callback - 元素进入视口时的回调函数
* @param {Object} options - 观察器配置项(覆盖默认配置)
* @returns {Object} 观察器操作方法(observe/unobserve/disconnect)
*/
export function createImageLazyLoader(callback, options = {}) {
// 默认配置:提前100px触发加载,提升滚动流畅度
const defaultOptions = {
root: null,
rootMargin: '100px',
threshold: 0.1,
...options
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// 元素进入视口时执行回调
if (entry.isIntersecting) {
callback(entry);
}
});
}, defaultOptions);
// 单个元素观察
const observe = (element) => {
if (element instanceof HTMLElement) observer.observe(element);
};
// 单个元素取消观察
const unobserve = (element) => {
if (element) observer.unobserve(element);
};
// 销毁观察器
const disconnect = () => {
observer.disconnect();
};
return { observe, unobserve, disconnect };
}
批量观察封装(提升开发效率)
// ai_multimodal_web/src/utils/imageLazyLoad.js
/**
* 批量图片元素懒加载监听
* @param {HTMLElement[]} elements - 需要监听的图片元素数组
* @param {Function} onIntersect - 元素进入视口时的回调(参数:目标元素、观察器条目)
* @param {Object} options - 观察器配置项
* @returns {Object} 批量观察操作方法
*/
export function observeImageElements(elements, onIntersect, options = {}) {
const loader = createImageLazyLoader((entry) => {
if (entry.target) {
onIntersect(entry.target, entry);
}
}, options);
// 批量观察所有元素
const observeAll = () => {
if (!elements || elements.length === 0) return;
Array.from(elements).forEach((element) => {
if (element instanceof HTMLElement) {
loader.observe(element);
}
});
};
// 批量取消观察
const unobserveAll = () => {
if (!elements || elements.length === 0) return;
Array.from(elements).forEach((element) => {
if (element) loader.unobserve(element);
});
};
return {
observe: observeAll,
unobserve: unobserveAll,
disconnect: loader.disconnect
};
}
设计关键要点
-
预加载阈值:通过
rootMargin: '100px'配置,提前加载即将进入视口的图片,避免滚动时出现空白。 - 批量操作封装:简化组件层调用逻辑,避免重复创建观察器实例,提升代码复用性。
-
类型校验:增加
HTMLElement类型判断,增强工具函数鲁棒性。
2. 滚动加载更多观察器(按需获取数据)
核心职责
监听列表底部的 “触底触发哨兵元素”,当元素进入视口(含预加载阈值)时,触发下一页数据请求,实现列表数据的渐进式追加。
核心实现代码
// ai_multimodal_web/src/utils/imageLazyLoad.js
/**
* 滚动加载更多工具函数
* 基于Intersection Observer API监听触底触发元素
* @param {Function} onLoadMore - 触发加载更多时的回调函数
* @param {Object} options - 配置项(triggerElement:触发元素,threshold:预加载阈值)
* @returns {Object} 观察器操作方法(observe/updateTrigger/disconnect)
*/
export function createScrollLoadMore(onLoadMore, options = {}) {
const { triggerElement = null, threshold = 200 } = options;
let observer = null;
// 初始化观察器
const setupObserver = (targetElement) => {
// 若已有观察器,先销毁避免内存泄漏
if (observer) observer.disconnect();
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
onLoadMore();
}
});
}, {
root: null,
rootMargin: `${threshold}px`, // 预加载阈值,提前触发请求
threshold: 0.1
});
if (targetElement) observer.observe(targetElement);
};
// 开始观察(支持传入触发元素)
const observe = (element = null) => {
const target = element || triggerElement;
if (target) setupObserver(target);
};
// 更新触发元素(适用于列表刷新场景)
const updateTrigger = (element) => {
setupObserver(element);
};
// 销毁观察器
const disconnect = () => {
if (observer) observer.disconnect();
observer = null;
};
return { observe, updateTrigger, disconnect };
}
设计关键要点
-
预加载阈值:通过
threshold配置(默认 200px),提前触发数据请求,掩盖网络延迟,提升用户体验。 -
幂等性保障:触发加载后通过业务层
isLoadingMore锁控制,避免重复请求。 -
动态更新支持:提供
updateTrigger方法,适配列表数据刷新后触发元素位置变更的场景。
3. 组件层整合实现(首屏直出 + 懒加载 + 分页加载)
ImageFavoriteModal.vue 作为核心组件,整合三大核心能力,实现 “首屏快速呈现、滚动平滑加载” 的交互体验。
核心逻辑设计
- 首屏优化:直接渲染并加载前 12 张图片,确保用户快速看到有效内容。
-
加载状态管理:通过
loadedImageIndices集合记录已进入视口的图片索引,控制<el-image>的src绑定时机。 - 观察器生命周期:组件初始化时创建观察器,数据追加后重建观察器,组件卸载时销毁观察器。
- 分页调度:首屏加载第 1 页(100 张)数据,触底时加载下一页,数据更新后同步更新观察器。
关键代码实现
1. 加载状态判断逻辑
// ai_multimodal_web/src/components/aiStudio/ImageFavoriteModal.vue
/**
* 判断图片是否需要加载
* @param {Number} index - 图片在列表中的索引
* @returns {Boolean} 是否加载图片
*/
const shouldLoadImage = (index) => {
// 首屏前12张直接加载
if (index < 12) return true;
// 其余图片需已进入视口(通过索引集合判断)
return loadedImageIndices.value.has(index);
};
2. 图片列表渲染与 src 绑定
<el-image
ref="imageItemRefs"
:data-image-index="index"
:src="shouldLoadImage(index) ? getImageFullUrl(image.imageUrl) : undefined"
:lazy="true"
fit="cover"
:preview-src-list="previewImageList"
@error="handleImageError"
@load="handleImageLoad(index)"
:z-index="3000"
:preview-teleported="true"
:initial-index="index"
class="favorite-image"
:class="{
'lazy-loading': !shouldLoadImage(index),
'loaded': shouldLoadImage(index)
}">
<template #placeholder>
<div class="image-placeholder">加载中...</div>
</template>
<template #error>
<div class="image-error">图片加载失败</div>
</template>
</el-image>
3. 懒加载观察器初始化
// ai_multimodal_web/src/components/aiStudio/ImageFavoriteModal.vue
/**
* 初始化图片懒加载观察器
* 跳过首屏12张图片,仅监听后续元素
*/
const setupImageLazyLoad = () => {
// 销毁现有观察器,避免内存泄漏
if (imageLazyLoader) imageLazyLoader.disconnect();
// 筛选需要监听的图片元素(非首屏+有效DOM)
const imageElements = imageItemRefs.value
.filter((el, index) => el && index >= 12)
.filter(Boolean);
if (imageElements.length === 0) return;
// 创建批量观察器
imageLazyLoader = observeImageElements(
imageElements,
(element) => {
// 从DOM数据集获取图片索引
const index = parseInt(element.dataset?.imageIndex) || 0;
// 标记为已加载,触发src绑定
if (!loadedImageIndices.value.has(index) && index >= 12) {
loadedImageIndices.value.add(index);
}
},
{ rootMargin: '100px', threshold: 0.1 }
);
// 启动观察
imageLazyLoader.observe();
};
4. 滚动加载更多初始化
// ai_multimodal_web/src/components/aiStudio/ImageFavoriteModal.vue
/**
* 初始化滚动加载更多观察器
*/
const setupScrollLoadMore = () => {
// 销毁现有观察器
if (scrollLoader) scrollLoader.disconnect();
// 无更多数据或无触发元素时,不初始化
if (!hasMore.value || !loadMoreTriggerRef.value) return;
// 创建滚动加载观察器
scrollLoader = createScrollLoadMore(async () => {
await loadMore();
}, { threshold: 200 });
// 监听触底触发元素
scrollLoader.observe(loadMoreTriggerRef.value);
};
5. 分页数据加载逻辑
// ai_multimodal_web/src/components/aiStudio/ImageFavoriteModal.vue
/**
* 加载下一页图片数据
*/
const loadMore = async () => {
// 加载中或无更多数据时,阻止重复请求
if (isLoadingMore.value || !hasMore.value) return;
try {
isLoadingMore.value = true;
// 计算下一页页码
const nextPage = imageStore.favoritePagination.page + 1;
// 从状态层获取数据(追加模式)
await imageStore.loadFavoriteImages(nextPage, PAGE_SIZE, true);
// 等待DOM更新完成后,重建观察器
await nextTick();
setupImageLazyLoad();
setupScrollLoadMore();
} finally {
// 无论成功失败,都关闭加载状态
isLoadingMore.value = false;
}
};
💾 数据层设计:稳定的分页与数据格式化
状态层 useImageStore 承担数据管理核心职责,为组件层提供标准化、稳定的数据接口,屏蔽数据请求与格式化细节。
核心职责
- 支持两种数据更新模式:替换模式(首次加载 / 刷新)与追加模式(滚动加载更多)。
- 数据格式化:统一图片数据字段(
imageUrl、imageId、timestamp等),避免组件层分支判断。 - 分页状态维护:基于接口返回数据,计算并维护
hasNext、hasPrev等状态,为加载更多提供依据。
核心实现代码
// ai_multimodal_web/src/stores/image.js
import { defineStore } from 'pinia';
import { getCollectedImages } from '@/api/image';
export const useImageStore = defineStore('image', () => {
// 收藏图片列表数据
const favoriteImages = ref([]);
// 分页状态:page-当前页,limit-单页条数,total-总条数,totalPages-总页数,hasNext-是否有下一页,hasPrev-是否有上一页
const favoritePagination = ref({
page: 1,
limit: 20,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false
});
/**
* 加载收藏图片列表
* @param {Number} page - 页码(默认1)
* @param {Number} limit - 单页条数(默认20)
* @param {Boolean} append - 是否追加模式(默认false:替换模式)
* @returns {Array} 格式化后的图片列表
*/
const loadFavoriteImages = async (page = 1, limit = 20, append = false) => {
// 发起接口请求(隐藏加载态,避免频繁弹窗)
const response = await getCollectedImages({ page, limit }, { showLoading: false });
// 数据格式化:统一字段格式,适配组件层渲染需求
const formattedImages = response.data?.map(item => ({
imageId: item.id || item.imageId,
imageUrl: item.url || item.imageUrl,
timestamp: item.createTime || item.timestamp,
// 其他需要的字段...
})) || [];
// 数据更新:替换或追加
if (append) {
favoriteImages.value = [...favoriteImages.value, ...formattedImages];
} else {
favoriteImages.value = formattedImages;
}
// 更新分页状态
const total = response.total || 0;
const totalPages = Math.ceil(total / limit);
favoritePagination.value = {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
};
return formattedImages;
};
return {
favoriteImages,
favoritePagination,
loadFavoriteImages
// 其他辅助方法...
};
});
🚀 性能与体验优化细节
1. 首屏加载优化
- 固定首屏加载 12 张图片,平衡 “快速可见” 与 “资源占用”,缩短首屏渲染时间。
- 首屏图片直接绑定
src,无需等待观察器触发,提升感知性能。
2. 滚动体验优化
-
懒加载预加载阈值:
rootMargin: '100px',提前加载即将进入视口的图片,避免滚动时出现空白。 -
滚动加载预请求:
threshold: 200px,提前触发下一页数据请求,掩盖网络延迟。
3. 资源与内存优化
-
观察器生命周期管理:组件卸载、弹窗关闭时,及时调用
disconnect销毁观察器,释放 DOM 监听资源,避免内存泄漏。 - 分页大小适配:当前设置 100 张 / 页,平衡网络请求次数与单次请求开销,可根据图片平均体积、网络环境微调。
4. 交互体验优化
-
占位与错误态:为
<el-image>配置占位态与错误态,避免加载过程中页面布局抖动,提供友好反馈。 -
预览列表缓存:
preview-src-list基于filteredImages映射生成,避免每次预览时临时创建大数组,提升预览打开速度。 -
跨域下载兼容:针对跨域图片资源,通过
fetch -> blob -> ObjectURL转换流程,避免浏览器跨域下载限制。
🛡️ 易错点与防御性编程策略
1. 重复加载问题
-
加载锁控制:通过
isLoadingMore状态变量,阻止加载过程中重复触发loadMore。 - 观察器防抖:数据加载完成前,避免多次触发观察器回调,确保单次分页请求唯一。
2. DOM 与数据一致性问题
-
DOM 更新时机:数据追加后,需通过
await nextTick()等待 DOM 渲染完成,再重建观察器,避免获取不到新渲染的 DOM 元素。 -
索引一致性:通过
data-image-index为图片元素绑定固定索引,配合loadedImageIndices集合,确保删除 / 过滤图片后加载状态准确。
3. 兼容性与降级处理
-
浏览器兼容性:
IntersectionObserver在部分低端浏览器或 SSR 环境下不支持,可通过if ('IntersectionObserver' in window)检测,降级为scroll事件节流监听方案。 -
接口异常处理:为
loadFavoriteImages添加异常捕获,避免请求失败导致列表加载中断,可提供重试机制。
💡 方案选型:懒加载 + 分页 vs. 虚拟滚动
技术选型对比
| 方案 | 核心逻辑 | 优势 | 适用场景 |
|---|---|---|---|
| 懒加载 + 分页 | 渲染全部 DOM,仅按需加载图片资源;分页控制列表长度 | 实现简单、无额外依赖、改造成本低;交互流畅 | 数据量中等(千级以内)、单条 Item 结构简单的场景 |
| 虚拟滚动 | 仅渲染视口内 DOM,通过滚动位移复用 DOM 节点 | 极致节省 CPU / 内存;支持万级以上大数据量 | 数据量极大(万级以上)、单条 Item 结构复杂的场景 |
当前方案合理性说明
- 业务规模匹配:当前收藏图片量多为千级以内,懒加载+分页方案已能满足性能需求,无需引入复杂依赖。
- 开发与维护成本:方案基于原生 API 实现,无额外第三方依赖,开发成本低、维护便捷,可快速复用到其他场景。
-
平滑升级路径:若未来收藏量增长至万级以上,可基于现有架构平滑升级为虚拟滚动方案(如集成
vue-virtual-scroller),无需重构核心逻辑。
📈 可扩展优化方向
1. 动态适配能力
- 动态首屏数量:基于容器可视区域高度与单张图片占位高度,计算最优首屏渲染数量,适配不同屏幕尺寸。
-
智能分页大小:根据图片平均体积、用户网络质量(通过
navigator.connection.effectiveType获取),动态调整单页加载数量。
2. 性能与可靠性优化
- 请求缓存与去重:对已加载过的分页数据进行缓存,避免重复请求;同一页码请求进行去重处理,减少无效接口调用。
- 加载失败重试:为单张图片加载失败提供重试按钮,或实现自动重试机制(限制重试次数),提升加载成功率。
-
滚动节流增强:极端场景下(如快速滚动),为
onLoadMore添加节流控制(如 200ms 间隔),避免频繁触发请求。
3. 功能扩展
- 图片预加载策略:针对用户高频操作(如预览过的图片),提前加载相关图片资源,提升二次访问速度。
- 批量操作优化:支持批量下载、批量取消收藏时,优化数据更新与观察器重建逻辑,避免操作卡顿。
✅ 方案总结
本方案基于 IntersectionObserver API,通过 “工具层抽象、组件层整合、状态层支撑” 的架构设计,实现了大规模图片列表的性能优化。核心价值如下:
- 解耦设计:将视口监听逻辑抽象为通用工具,组件层专注 UI 与交互,状态层统一数据管理,提升代码复用性与可维护性。
- 成本可控:通过 “首屏直出 + 按需加载 + 渐进获取” 组合策略,有效降低首屏渲染压力、网络带宽开销与内存占用。
- 体验与性能平衡:通过预加载阈值、占位态、错误处理等细节优化,确保性能提升的同时不牺牲用户体验。
- 可扩展性强:方案架构灵活,支持根据业务规模平滑升级,可快速复用到其他媒体列表场景(如视频列表、文件列表)。
关键文件清单
-
工具层:
src/utils/imageLazyLoad.js(懒加载与滚动加载观察器封装) -
状态层:
src/stores/image.js(分页请求、数据格式化与状态管理) -
组件层:
src/components/aiStudio/ImageFavoriteModal.vue(UI 渲染、交互整合与观察器绑定)
本方案已在实际业务中落地验证,性能与体验均达到预期,可作为同类大规模媒体列表性能优化的参考模板。
多模态Ai项目全流程开发中,从需求分析,到Ui设计,前后端开发,部署上线,感兴趣打开链接(带项目功能演示),多模态AI项目开发中...
90%前端面试必问的12个JS核心,搞懂这些直接起飞!
你是不是也遇到过这样的场景?面试官抛出一个闭包问题,你支支吾吾答不上来;团队代码review时,看到同事用的Promise链一脸懵逼;明明功能实现了,性能却总是差那么一点...
别慌!今天我整理了12个JavaScript核心概念,这些都是2024年各大厂面试的高频考点,也是日常开发中真正实用的硬核知识。搞懂它们,不仅能轻松应对面试,更能让你的代码质量提升一个档次!
变量与作用域
先来看个最常见的面试题:
// 经典面试题:猜猜输出什么?
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出:3 3 3
}, 100);
}
为什么会这样?因为var声明的变量存在变量提升,而且没有块级作用域。换成let就正常了:
// 使用let的正确写法
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出:0 1 2
}, 100);
}
这里涉及到两个关键概念:变量提升和块级作用域。let和const是ES6引入的,它们有块级作用域,不会出现var的那些奇怪问题。
闭包与内存管理
闭包可能是最让人头疼的概念了,但其实理解起来并不难:
// 闭包的实际应用:计数器
function createCounter() {
let count = 0; // 这个变量被"封闭"在函数内部
return {
increment: () => ++count,
decrement: () => --count,
getValue: () => count
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
闭包就是函数能够记住并访问其词法作用域中的变量,即使函数在其作用域外执行。但要注意内存泄漏问题:
// 潜在的内存泄漏
function createHeavyObject() {
const largeObject = new Array(1000000); // 大对象
return () => {
// 即使外部不再需要,largeObject仍然被引用
console.log('对象还在内存中');
};
}
原型与继承
JavaScript的继承是基于原型的,这和传统的类继承很不一样:
// 原型链示例
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function() {
console.log(`${this.name} makes a noise.`);
};
function Dog(name) {
Animal.call(this, name); // 调用父类构造函数
}
// 设置原型链
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.speak = function() {
console.log(`${this.name} barks.`);
};
const dog = new Dog('Rex');
dog.speak(); // Rex barks.
ES6的class语法让这变得更简单:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a noise.`);
}
}
class Dog extends Animal {
speak() {
console.log(`${this.name} barks.`);
}
}
异步编程演进
从回调地狱到async/await,异步编程经历了很大变化:
// 1. 回调地狱
function oldWay(callback) {
readFile('file1.txt', (err, data1) => {
if (err) return callback(err);
processData(data1, (err, result1) => {
if (err) return callback(err);
// 更多嵌套...
});
});
}
// 2. Promise链
function promiseWay() {
return readFilePromise('file1.txt')
.then(processDataPromise)
.then(data => {
// 更清晰的流程
})
.catch(error => {
// 统一错误处理
});
}
// 3. async/await(推荐)
async function modernWay() {
try {
const data = await readFilePromise('file1.txt');
const result = await processDataPromise(data);
return result;
} catch (error) {
console.error('处理失败:', error);
}
}
Promise深度解析
Promise是现代JavaScript异步编程的基石:
// 手写一个简易Promise
class MyPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.onFulfilledCallbacks = [];
const resolve = (value) => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach(cb => cb(value));
}
};
executor(resolve);
}
then(onFulfilled) {
return new MyPromise((resolve) => {
if (this.state === 'fulfilled') {
const result = onFulfilled(this.value);
resolve(result);
} else {
this.onFulfilledCallbacks.push((value) => {
const result = onFulfilled(value);
resolve(result);
});
}
});
}
}
事件循环机制
这是JavaScript并发模型的核心:
// 理解事件循环的执行顺序
console.log('1. 同步任务开始');
setTimeout(() => {
console.log('6. 宏任务执行');
}, 0);
Promise.resolve().then(() => {
console.log('4. 微任务执行');
});
console.log('2. 同步任务继续');
Promise.resolve().then(() => {
console.log('5. 另一个微任务');
});
console.log('3. 同步任务结束');
// 输出顺序:1 2 3 4 5 6
ES6+新特性实战
现代JavaScript提供了很多好用特性:
// 解构赋值
const user = { name: '小明', age: 25, city: '北京' };
const { name, age } = user;
// 扩展运算符
const arr1 = [1, 2, 3];
const arr2 = [...arr1, 4, 5]; // [1, 2, 3, 4, 5]
// 可选链操作符
const street = user?.address?.street; // 不会报错
// 空值合并
const displayName = user.nickname ?? '匿名用户'; // 只有null/undefined时使用默认值
函数式编程概念
JavaScript很适合函数式编程风格:
// 高阶函数
const users = [
{ name: '小明', age: 25 },
{ name: '小红', age: 30 },
{ name: '小刚', age: 28 }
];
// 函数组合
const getAdultNames = users
.filter(user => user.age >= 18)
.map(user => user.name)
.sort();
// 柯里化
const multiply = a => b => a * b;
const double = multiply(2);
console.log(double(5)); // 10
模块化系统
从IIFE到ES Modules的演进:
// 现代ES Modules
// math.js
export const add = (a, b) => a + b;
export const PI = 3.14159;
// app.js
import { add, PI } from './math.js';
console.log(add(PI, 2)); // 5.14159
// 动态导入
const loadModule = async () => {
const module = await import('./math.js');
console.log(module.add(1, 2));
};
类型系统与TypeScript
虽然JavaScript是动态类型,但类型检查很重要:
// 类型检查工具函数
const typeCheck = {
isString: value => typeof value === 'string',
isFunction: value => typeof value === 'function',
isObject: value => value !== null && typeof value === 'object'
};
// TypeScript带来的类型安全
interface User {
name: string;
age: number;
email?: string;
}
function createUser(user: User): User {
// TypeScript会在编译时检查类型
return { ...user };
}
性能优化技巧
写出高性能的JavaScript代码:
// 防抖函数:避免频繁调用
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// 使用Web Worker处理密集型任务
const worker = new Worker('heavy-task.js');
worker.postMessage({ data: largeData });
worker.onmessage = (event) => {
console.log('计算结果:', event.data);
};
现代开发工具链
2024年的前端开发离不开这些工具:
// Vite配置示例
// vite.config.js
export default {
plugins: [vue(), eslint()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router']
}
}
}
}
};
// 现代测试工具
import { describe, it, expect } from 'vitest';
describe('工具函数测试', () => {
it('应该正确计算加法', () => {
expect(add(1, 2)).toBe(3);
});
});
这12个核心概念就像JavaScript的基石,理解它们不仅能让你在面试中游刃有余,更能写出更健壮、更易维护的代码。
每日一题-计算子数组的 x-sum II🔴
给你一个由 n 个整数组成的数组 nums,以及两个整数 k 和 x。
数组的 x-sum 计算按照以下步骤进行:
- 统计数组中所有元素的出现次数。
- 仅保留出现次数最多的前
x个元素的每次出现。如果两个元素的出现次数相同,则数值 较大 的元素被认为出现次数更多。 - 计算结果数组的和。
注意,如果数组中的不同元素少于 x 个,则其 x-sum 是数组的元素总和。
返回一个长度为 n - k + 1 的整数数组 answer,其中 answer[i] 是 子数组 nums[i..i + k - 1] 的 x-sum。
子数组 是数组内的一个连续 非空 的元素序列。
示例 1:
输入:nums = [1,1,2,2,3,4,2,3], k = 6, x = 2
输出:[6,10,12]
解释:
- 对于子数组
[1, 1, 2, 2, 3, 4],只保留元素 1 和 2。因此,answer[0] = 1 + 1 + 2 + 2。 - 对于子数组
[1, 2, 2, 3, 4, 2],只保留元素 2 和 4。因此,answer[1] = 2 + 2 + 2 + 4。注意 4 被保留是因为其数值大于出现其他出现次数相同的元素(3 和 1)。 - 对于子数组
[2, 2, 3, 4, 2, 3],只保留元素 2 和 3。因此,answer[2] = 2 + 2 + 2 + 3 + 3。
示例 2:
输入:nums = [3,8,7,8,7,5], k = 2, x = 2
输出:[11,15,15,15,12]
解释:
由于 k == x,answer[i] 等于子数组 nums[i..i + k - 1] 的总和。
提示:
nums.length == n1 <= n <= 1051 <= nums[i] <= 1091 <= x <= k <= nums.length
一题多解:有序集合/懒删除堆/SortedList模拟/树状数组(Python)
首先,一定会采取的算法是,用哈希表$cnt$维护滑动窗口$x$的出现次数,并且比较时分别以$cnt[x], x$为第一、二关键字。后续的问题是,如何维护滑动窗口的前$m$大二元组$(cnt[x], x)$,以及前$m$大二元组对应的数字之和。(本题解与题面的变量名称有所不同)
1. 有序集合
仿照滑动窗口中位数,用两个有序集合维护较小一半和较大一半元素:本题我们用两个有序集合$small$和$big$分别维护较小和较大二元组;通过把$small$的最大二元组移入$big$、或者把$big$的最小二元组移入$small$,始终保持$len(big) <= m$;维护$big$对应的数字和$s$,即可直接得到滑动窗口的计算结果。
其他人的讲解已经很详细了,这里不再赘述。以下代码仅在灵神题解上稍作修改。
###Python
from sortedcontainers import SortedList as SL
class Solution:
def findXSum(self, nums: List[int], k: int, m: int) -> List[int]:
cnt = defaultdict(int)
small, big = SL(), SL()
s = 0
def add(x: int):
# 将 x 加入有序集合
nonlocal s
if not cnt[x]:
return
p = (cnt[x], x)
if not small or p > small[-1]:
big.add(p)
s += p[0] * p[1]
else:
small.add(p)
def remove(x: int):
# 将 x 移出有序集合
nonlocal s
if not cnt[x]:
return
p = (cnt[x], x)
if big and p >= big[0]:
big.remove(p)
s -= p[0] * p[1]
else:
small.remove(p)
def update(x: int, flag: bool):
# flag 为 True/False 表示将 x 加入/移出滑动窗口
remove(x)
cnt[x] += 1 if flag else -1
add(x)
def adjust():
nonlocal s
# 将 big 的最小值移入 small
while len(big) > m:
p = big[0]
big.remove(p)
s -= p[0] * p[1]
small.add(p)
# 将 small 的最大值移入 big
while small and len(big) < m:
p = small[-1]
small.remove(p)
big.add(p)
s += p[0] * p[1]
for i in range(k):
update(nums[i], True)
adjust()
ans = [s]
for i in range(k, len(nums)):
update(nums[i], True)
update(nums[i-k], False)
adjust()
ans.append(s)
return ans
2. 懒删除堆
$small$和$big$需要使用支持插入、删除、获取最大/小元素操作的数据结构。为此我们可以用懒删除堆代替有序集合。懒删除堆的思想是:在删除元素时并不真正地从堆中移除元素,而是用一个哈希表记录待删除的元素及删除次数,等到元素交换至堆顶再实际进行删除,同时更新哈希表。滑动窗口中位数也可以采用类似的方法。
本题我们需要用哈希表$todel$记录$small$和$big$待删除的二元组,还需要用$ssize$和$bsize$记录$small$和$big$中未被删除的元素个数、亦即真正的大小。据此在解法一的基础上稍作修改即可。Python 中只有小根堆,本题写起来非常不方便,其他语言会是本解法更好的选择。
###Python
class Solution:
def findXSum(self, nums: List[int], k: int, m: int) -> List[int]:
cnt = defaultdict(int)
small, big = [], [] # 大根堆,小根堆
s = 0
ssize, bsize = 0, 0
todel = defaultdict(int)
def add(x: int):
# 将 x 入堆
nonlocal s, ssize, bsize
c = cnt[x]
if not c:
return
p, pinv = (c, x), (-c, -x)
if not small or pinv < small[0]:
heappush(big, p)
bsize += 1
s += p[0] * p[1]
else:
heappush(small, pinv)
ssize += 1
def remove(x: int):
# 将 x 从堆中懒删除
nonlocal s, ssize, bsize
c = cnt[x]
if not c:
return
p, pinv = (c, x), (-c, -x)
if big and p >= big[0]:
todel[p] += 1
bsize -= 1
s -= p[0] * p[1]
else:
todel[pinv] += 1
ssize -= 1
def update(x: int, flag: bool):
# flag 为 True/False 表示将 x 加入/移出滑动窗口
remove(x)
cnt[x] += 1 if flag else -1
add(x)
def adjust():
nonlocal s, ssize, bsize
# 将 big 的最小值移入 small
while bsize > m:
p = heappop(big)
if todel[p]:
todel[p] -= 1
else:
bsize -= 1
s -= p[0] * p[1]
heappush(small, (-p[0], -p[1]))
ssize += 1
# 将 small 的最大值移入 big
while ssize > 0 and bsize < m:
p = heappop(small)
if todel[p]:
todel[p] -= 1
else:
ssize -= 1
s += p[0] * p[1]
heappush(big, (-p[0], -p[1]))
bsize += 1
for i in range(k):
update(nums[i], True)
adjust()
ans = [s]
for i in range(k, len(nums)):
update(nums[i], True)
update(nums[i-k], False)
adjust()
ans.append(s)
return ans
3. SortedList 模拟
SortedList 作为 Python 的黑科技,不仅具有有序集合的性质,还可以通过下标直接访问列表元素。我们可以直接用一个 SortedList $sl$维护滑动窗口的所有二元组,在向滑动窗口添加/删除$x$的过程中,$sl$中的二元组最多发生4次改变:原来的$(cnt[x], x)$移出前$m$大,原来的第$m+1$大项移入前$m$大;新的$(cnt[x] \pm 1, x)$插入前$m$大,原来的第$m$项移出前$m$大。据此即可维护前$m$大的二元组对应的数字和。Treap、平衡树等其他数据结构也可以采用本解法。
###Python
from sortedcontainers import SortedList as SL
class Solution:
def findXSum(self, nums: List[int], k: int, m: int) -> List[int]:
cnt = defaultdict(int)
sl = SL()
s = 0
def add(x: int):
# 将 x 加入有序列表
nonlocal s
c = cnt[x]
if not c: return
p = (c, x)
idx = sl.bisect_left(p)
if idx > len(sl)-m:
if len(sl) >= m:
s -= sl[len(sl)-m][0] * sl[len(sl)-m][1]
s += c*x
# 先修改 s,再修改 sl,因为 sl 的长度会发生变化
sl.add(p)
def remove(x: int):
# 将 x 移出有序列表
nonlocal s
c = cnt[x]
if not c: return
p = (c, x)
idx = sl.bisect_left(p)
if idx >= len(sl)-m:
if len(sl) > m:
s += sl[len(sl)-m-1][0] * sl[len(sl)-m-1][1]
s -= c*x
# 先修改 s,再修改 sl,因为 sl 的长度会发生变化
sl.remove(p)
def update(x: int, flag: bool):
# flag 为 True/False 表示将 x 加入/移出滑动窗口
remove(x)
cnt[x] += 1 if flag else -1
add(x)
for i in range(k):
update(nums[i], True)
ans = [s]
for i in range(k, len(nums)):
update(nums[i], True)
update(nums[i-k], False)
ans.append(s)
return ans
4. 树状数组
树状数组同样需要维护滑动窗口内的变量,不过思想有很大不同。维护滑动窗口内所有的二元组,查找第$m$大二元组可以通过树状数组上二分(注意不是二分+树状数组),而求前$m$大二元组对应的数字和可以通过树状数组前缀和。
具体地:我们可以预处理出所有$(cnt[x], x)$,其总长度是$\sum_x cnt[x] = n$,对$(cnt[x], x)$排序并离散化,就得到了其唯一名次;使用一个树状数组$ranktree$记录二元组名次、一个树状数组$sumtree$记录数字和;在$ranktree$上二分(具体实现见下文代码),就可以得到第$m$大二元组对应位置$r$,据此计算$sumtree$在$[0:r]$的前缀和即可。本解法使用线段树亦可。
###Python
class Fenwick:
__slots__ = 'nums', 'tree'
def __init__(self, n):
self.nums = [0] * n
self.tree = [0] * (n+1)
def add(self, i, delta):
self.nums[i] += delta
i += 1
while i <= len(self.nums):
self.tree[i] += delta
i += i & -i
def lsum(self, i):
# 闭区间 [0:i]
res = 0
i += 1
while i > 0:
res += self.tree[i]
i &= i - 1
return res
def bisect_left(self, x):
# 二分查找向前缀和数组插入 x 的下标
# 也是权值树状数组查找第 x 小
n = len(self.nums)
i = 0
s = 0
for b in range(n.bit_length(), -1, -1):
j = i + (1 << b)
# 每次尝试向 s 加入部分和
if j <= n and s + self.tree[j] < x:
s += self.tree[j]
i = j
return i
class Solution:
def findXSum(self, nums: List[int], k: int, m: int) -> List[int]:
n = len(nums)
cnt = defaultdict(int)
pairs = []
for x in nums:
cnt[x] += 1
pairs.append((cnt[x], x))
# 离散化
pairs.sort(reverse=True)
mp = {p: i for i, p in enumerate(pairs)}
ranktree = Fenwick(n)
sumtree = Fenwick(n)
cnt.clear()
ans = []
def modify(x: int, flag: bool):
# flag 为 True/False 表示对树状数组在 x 的对应位置赋值/重置
c = cnt[x]
if not c: return
sign = 1 if flag else -1
r = mp[(c, x)]
ranktree.add(r, sign)
sumtree.add(r, sign*c*x)
def update(x: int, flag: bool):
# flag 为 True/False 表示将 x 加入/移出滑动窗口
modify(x, False)
cnt[x] += 1 if flag else -1
modify(x, True)
def answer():
r = ranktree.bisect_left(m) # 不会越界
res = sumtree.lsum(r)
ans.append(res)
for i in range(k):
update(nums[i], True)
answer()
for i in range(k, n):
if nums[i] != nums[i-k]:
update(nums[i], True)
update(nums[i-k], False)
answer()
return ans
两个有序集合维护前 x 大二元组(Python/Java/C++/Go)
前置题目:
在 3013 题中,我们用两个有序集合维护前 $k-1$ 小元素及其总和。
本题要维护前 $x$ 大的二元组 $(\textit{cnt}[x], x)$,以及 $\textit{cnt}[x]\cdot x$ 的总和。其中 $\textit{cnt}[x]$ 表示 $x$ 在子数组(滑动窗口)中的出现次数。
当元素进入窗口时:
- 把 $(\textit{cnt}[x], x)$ 从有序集合中移除。
- 把 $\textit{cnt}[x]$ 加一。
- 把 $(\textit{cnt}[x], x)$ 加入有序集合。
当元素离开窗口时:
- 把 $(\textit{cnt}[x], x)$ 从有序集合中移除。
- 把 $\textit{cnt}[x]$ 减一。
- 把 $(\textit{cnt}[x], x)$ 加入有序集合。
添加删除的同时维护 $\textit{cnt}[x]\cdot x$ 的总和。
其余逻辑同 3013 题。
本题视频讲解,欢迎点赞关注~
###py
from sortedcontainers import SortedList
class Solution:
def findXSum(self, nums: List[int], k: int, x: int) -> List[int]:
cnt = defaultdict(int)
L = SortedList() # 保存 tuple (出现次数,元素值)
R = SortedList()
sum_l = 0 # L 的元素和
def add(val: int) -> None:
if cnt[val] == 0:
return
p = (cnt[val], val)
if L and p > L[0]: # p 比 L 中最小的还大
nonlocal sum_l
sum_l += p[0] * p[1]
L.add(p)
else:
R.add(p)
def remove(val: int) -> None:
if cnt[val] == 0:
return
p = (cnt[val], val)
if p in L:
nonlocal sum_l
sum_l -= p[0] * p[1]
L.remove(p)
else:
R.remove(p)
def l2r() -> None:
nonlocal sum_l
p = L[0]
sum_l -= p[0] * p[1]
L.remove(p)
R.add(p)
def r2l() -> None:
nonlocal sum_l
p = R[-1]
sum_l += p[0] * p[1]
R.remove(p)
L.add(p)
ans = [0] * (len(nums) - k + 1)
for r, in_ in enumerate(nums):
# 添加 in_
remove(in_)
cnt[in_] += 1
add(in_)
l = r + 1 - k
if l < 0:
continue
# 维护大小
while R and len(L) < x:
r2l()
while len(L) > x:
l2r()
ans[l] = sum_l
# 移除 out
out = nums[l]
remove(out)
cnt[out] -= 1
add(out)
return ans
###java
class Solution {
private final TreeSet<int[]> L = new TreeSet<>((a, b) -> a[0] != b[0] ? a[0] - b[0] : a[1] - b[1]);
private final TreeSet<int[]> R = new TreeSet<>(L.comparator());
private final Map<Integer, Integer> cnt = new HashMap<>();
private long sumL = 0;
public long[] findXSum(int[] nums, int k, int x) {
long[] ans = new long[nums.length - k + 1];
for (int r = 0; r < nums.length; r++) {
// 添加 in
int in = nums[r];
del(in);
cnt.merge(in, 1, Integer::sum); // cnt[in]++
add(in);
int l = r + 1 - k;
if (l < 0) {
continue;
}
// 维护大小
while (!R.isEmpty() && L.size() < x) {
r2l();
}
while (L.size() > x) {
l2r();
}
ans[l] = sumL;
// 移除 out
int out = nums[l];
del(out);
cnt.merge(out, -1, Integer::sum); // cnt[out]--
add(out);
}
return ans;
}
// 添加元素
private void add(int val) {
int c = cnt.get(val);
if (c == 0) {
return;
}
int[] p = new int[]{c, val};
if (!L.isEmpty() && L.comparator().compare(p, L.first()) > 0) { // p 比 L 中最小的还大
sumL += (long) p[0] * p[1];
L.add(p);
} else {
R.add(p);
}
}
// 删除元素
private void del(int val) {
int c = cnt.getOrDefault(val, 0);
if (c == 0) {
return;
}
int[] p = new int[]{c, val};
if (L.contains(p)) {
sumL -= (long) p[0] * p[1];
L.remove(p);
} else {
R.remove(p);
}
}
// 从 L 移动一个元素到 R
private void l2r() {
int[] p = L.pollFirst();
sumL -= (long) p[0] * p[1];
R.add(p);
}
// 从 R 移动一个元素到 L
private void r2l() {
int[] p = R.pollLast();
sumL += (long) p[0] * p[1];
L.add(p);
}
}
###cpp
class Solution {
public:
vector<long long> findXSum(vector<int>& nums, int k, int x) {
using pii = pair<int, int>; // 出现次数,元素值
set<pii> L, R;
long long sum_l = 0; // L 的元素和
unordered_map<int, int> cnt;
auto add = [&](int x) {
pii p = {cnt[x], x};
if (p.first == 0) {
return;
}
if (!L.empty() && p > *L.begin()) { // p 比 L 中最小的还大
sum_l += (long long) p.first * p.second;
L.insert(p);
} else {
R.insert(p);
}
};
auto del = [&](int x) {
pii p = {cnt[x], x};
if (p.first == 0) {
return;
}
auto it = L.find(p);
if (it != L.end()) {
sum_l -= (long long) p.first * p.second;
L.erase(it);
} else {
R.erase(p);
}
};
auto l2r = [&]() {
pii p = *L.begin();
sum_l -= (long long) p.first * p.second;
L.erase(p);
R.insert(p);
};
auto r2l = [&]() {
pii p = *R.rbegin();
sum_l += (long long) p.first * p.second;
R.erase(p);
L.insert(p);
};
vector<long long> ans(nums.size() - k + 1);
for (int r = 0; r < nums.size(); r++) {
// 添加 in
int in = nums[r];
del(in);
cnt[in]++;
add(in);
int l = r + 1 - k;
if (l < 0) {
continue;
}
// 维护大小
while (!R.empty() && L.size() < x) {
r2l();
}
while (L.size() > x) {
l2r();
}
ans[l] = sum_l;
// 移除 out
int out = nums[l];
del(out);
cnt[out]--;
add(out);
}
return ans;
}
};
###go
import "github.com/emirpasic/gods/v2/trees/redblacktree"
type pair struct{ c, x int } // 出现次数,元素值
func less(p, q pair) int {
return cmp.Or(p.c-q.c, p.x-q.x)
}
func findXSum(nums []int, k, x int) []int64 {
L := redblacktree.NewWith[pair, struct{}](less)
R := redblacktree.NewWith[pair, struct{}](less)
sumL := 0 // L 的元素和
cnt := map[int]int{}
add := func(x int) {
p := pair{cnt[x], x}
if p.c == 0 {
return
}
if !L.Empty() && less(p, L.Left().Key) > 0 { // p 比 L 中最小的还大
sumL += p.c * p.x
L.Put(p, struct{}{})
} else {
R.Put(p, struct{}{})
}
}
del := func(x int) {
p := pair{cnt[x], x}
if p.c == 0 {
return
}
if _, ok := L.Get(p); ok {
sumL -= p.c * p.x
L.Remove(p)
} else {
R.Remove(p)
}
}
l2r := func() {
p := L.Left().Key
sumL -= p.c * p.x
L.Remove(p)
R.Put(p, struct{}{})
}
r2l := func() {
p := R.Right().Key
sumL += p.c * p.x
R.Remove(p)
L.Put(p, struct{}{})
}
ans := make([]int64, len(nums)-k+1)
for r, in := range nums {
// 添加 in
del(in)
cnt[in]++
add(in)
l := r + 1 - k
if l < 0 {
continue
}
// 维护大小
for !R.Empty() && L.Size() < x {
r2l()
}
for L.Size() > x {
l2r()
}
ans[l] = int64(sumL)
// 移除 out
out := nums[l]
del(out)
cnt[out]--
add(out)
}
return ans
}
复杂度分析
- 时间复杂度:$\mathcal{O}(n\log k)$,其中 $n$ 是 $\textit{nums}$ 的长度。
- 空间复杂度:$\mathcal{O}(n)$。
专题训练
见下面数据结构题单的「§5.7 对顶堆」。
分类题单
- 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
- 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
- 单调栈(基础/矩形面积/贡献法/最小字典序)
- 网格图(DFS/BFS/综合应用)
- 位运算(基础/性质/拆位/试填/恒等式/思维)
- 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
- 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
- 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
- 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
- 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
- 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
- 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)
欢迎关注 B站@灵茶山艾府
对顶堆
解法:对顶堆
我们简单改写一下题意:在长度为 $k$ 的滑动窗口中,求出现次数前 $x$ 大的元素之和。
设 $(c_v, v)$ 表示元素 $v$ 的频率和值,我们其实就需要一个数据结构,求前 $x$ 大的 pair 的 $c_v \times v$ 之和。那么这种数据元素需要支持哪些操作呢?
考虑滑动窗口从 $[i, i + k - 1]$ 滑动到 $[i + 1, i + k]$ 会发生什么。其实就是从数据元素中删除了 $(c_{a_i}, a_i)$ 和 $(c_{a_{i + k}}, a_{i + k})$ 这两个 pair,又新增了 $(c_{a_i} - 1, a_i)$ 和 $(c_{a_{i + k}} + 1, a_{i + k})$ 这两个 pair。因此这个数据元素要支持这些操作:
- 加入一个元素
- 删除一个元素
- 求前 $k$ 大元素的和
这就是经典的对顶堆(因为需要删除元素,其实是对顶 multiset),详见 leetcode 295. 数据流的中位数。复杂度 $\mathcal{O}(n\log n)$。
参考代码(c++)
###cpp
// 对顶堆模板开始,注意以下模板维护的其实是前 K 小的元素
struct Magic {
int K;
typedef pair<int, int> pii;
multiset<pii> st1, st2;
long long sm1;
Magic(int K): K(K) {
sm1 = 0;
}
// 把第一个堆的大小调整成 K
void adjust() {
while (!st2.empty() && st1.size() < K) {
pii p = *(st2.begin());
st1.insert(p); sm1 += 1LL * p.first * p.second;
st2.erase(st2.begin());
}
while (st1.size() > K) {
pii p = *prev(st1.end());
st2.insert(p);
st1.erase(prev(st1.end())); sm1 -= 1LL * p.first * p.second;
}
}
// 加入元素 p
void add(pii p) {
if (!st2.empty() && p >= *(st2.begin())) st2.insert(p);
else st1.insert(p), sm1 += 1LL * p.first * p.second;
adjust();
}
// 删除元素 p
void del(pii p) {
auto it = st1.find(p);
if (it != st1.end()) st1.erase(it), sm1 -= 1LL * p.first * p.second;
else st2.erase(st2.find(p));
adjust();
}
};
// 对顶堆模板结束
class Solution {
public:
vector<long long> findXSum(vector<int>& nums, int k, int x) {
int n = nums.size();
vector<long long> ans;
unordered_map<int, int> cnt;
Magic magic(x);
for (int i = 0; i < k; i++) cnt[nums[i]]++;
// 因为模板维护的是前 x 小的元素,所以这里元素全部取反
for (auto &p : cnt) magic.add({-p.second, -p.first});
for (int i = 0; ; i++) {
ans.push_back(magic.sm1);
if (i + k == n) break;
// 滑动窗口滑动一格
magic.del({-cnt[nums[i]], -nums[i]});
cnt[nums[i]]--;
if (cnt[nums[i]] > 0) magic.add({-cnt[nums[i]], -nums[i]});
if (cnt[nums[i + k]] > 0) magic.del({-cnt[nums[i + k]], -nums[i + k]});
cnt[nums[i + k]]++;
magic.add({-cnt[nums[i + k]], -nums[i + k]});
}
return ans;
}
};
[开源] 从零到一打造在线 PPT 编辑器:React + Zustand + Zundo
技术栈
- React 框架
- TS 语言
- Umi 脚手架
- Zustand 状态管理
- Zundo 状态回滚记录
- Svg 图形
- pptxgenjs导出为PPT,还没做
- Ant Design UI框架
核心设计与实现
这个在线 PPT 编辑器的核心可以拆解为几个关键模块:数据模型、画布渲染器、状态管理器和属性面板
1. 数据模型:JSON
首先,我们需要定义数据结构。整个演示文稿、每一页幻灯片以及幻灯片上的每一个元素(文本、形状、图片等)都应该被结构化、序列化为 JSON 对象
// 单个元素(文本、形状、图片等)
export interface PPTElement {
id: string;
type: 'text' | 'image' | 'shape' | 'line';
left: number;
top: number;
width: number;
height: number;
rotate?: number;
content: string; // 文本内容、图片 URL、或形状类型
style?: {
// ... 样式属性
};
}
// 单张幻灯片
export interface Slide {
id: string;
elements: PPTElement[];
background?: {
color?: string;
image?: string;
};
}
// 整个演示文稿
export interface Presentation {
title: string;
slides: Slide[];
}
这种设计使得状态的读取、更新和持久化都变得非常直观
2. 状态管理:Zustand + Zundo
状态管理是编辑器的灵魂。我使用 Zustand 创建了一个 presentationStore 来统一管理所有的状态和操作。
Zundo 的集成异常简单,只需将你的状态创建函数包裹在 zundo 中间件里即可。
import { create } from 'zustand';
import { temporal } from 'zundo'; // 引入 zundo
export const usePresentationStore = create(
temporal( // 使用 temporal 中间件包裹
(set, get) => ({
slides: initialSlides, // 初始幻灯片数据
selectedSlideId: initialSlides[0].id,
selectedElementIds: [],
// 添加元素
addElement: (element) => {
// ...
},
// 更新元素
updateElement: (id, patch) => {
// ...
},
// ... 其他所有操作 slides 的方法
}),
{
// Zundo 配置项
limit: 50, // 最多记录 50 步历史
}
)
);
现在,presentationStore 自动拥有了撤销 (undo) 和重做 (redo) 的能力。我们只需要在组件中调用它们:
import { useTemporalStore } from '@/stores/presentationStore';
const CanvasHeader = () => {
const { undo, redo } = useTemporalStore.temporal.getState();
return (
<div>
<Tooltip title="撤销">
<Button icon={<UndoOutlined />} onClick={() => undo()} />
</Tooltip>
<Tooltip title="重做">
<Button icon={<RedoOutlined />} onClick={() => redo()} />
</Tooltip>
{/* ... */}
</div>
);
};
复杂的状态历史追溯功能被 Zundo 优雅地解决了,真香!
3. 画布与渲染器
画布是用户与 PPT 交互的核心区域。它负责根据当前 Slide 的数据,渲染出所有元素。
我创建了一个 ElementRenderer 组件,它会根据元素的 type 字段,动态地渲染出不同的子组件(如 TextElement、ImageElement 等)。
const ElementRenderer = ({ element }: { element: PPTElement }) => {
switch (element.type) {
case 'text':
return <div style={...}>{element.content}</div>;
case 'image':
return <img src={element.content} style={...} />;
case 'shape':
return <ShapeElement type={element.content} style={...} />;
default:
return null;
}
};
缩略图列表的实现:我没有为缩略图单独编写一套渲染逻辑,而是直接复用了 渲染器组件,只是通过 props 传入一个缩放比例 scale,并禁用其交互
const SlideThumbnail = ({ slide, size }) => {
const scale = size.width / 1920; // 假设画布标准宽度为 1920
return (
<div style={{ width: size.width, height: size.height }}>
<Canvas
slide={slide}
scale={scale}
interactive={false} // 禁用交互
embedded={true} // 嵌入模式
/>
</div>
);
};
4. 属性面板:响应式交互
当用户选中一个元素时,右侧的属性面板会显示其对应的可编辑属性(如颜色、字体大小、位置等)
这里的逻辑是:
- 监听
presentationStore中的selectedElementIds - 当选中元素变化时,从
slides数据中找到该元素的详细信息 - 将元素属性绑定到属性面板的输入框中
- 当用户修改输入框时,调用
store中的updateElement方法来更新状态
数据驱动视图的理念
未来路线图 (Roadmap)
这个项目还有很大的想象空间,我计划在未来加入更多的功能:
-
PPT 导出:支持将编辑好的内容导出为
.pptx文件 - 完备的快捷键:增加更多快捷键
- 更多的属性配置:支持配置多种多样的样式
- 元素对齐与分布:提供辅助线、元素吸附、水平/垂直分布等高级编辑功能
- 动画效果:为元素添加入场、退场动画
- 主题与模板:内置更多精美的设计模板
- 多人实时协作:这是最具挑战性的功能,也是在线文档的终极形态
写在最后
这个项目目前还处于早期阶段,有很多不完善之处。非常欢迎大家提出宝贵的建议、报告 Bug,甚至参与到开发中来。勿喷! Star!!!!
React 中 useCallback 的基本使用和原理解析
React 中 useCallback 的基本使用方法
useCallback 是 React 的一个核心 Hook,用于缓存函数定义,避免组件重新渲染时重复创建函数实例。以下是其基本使用方法:
1. 基本语法
const memoizedCallback = useCallback(
() => {
// 函数逻辑 (例如更新状态、调用API等)
doSomething(a, b);
},
[a, b] // 依赖项数组
);
- 第一个参数:需要缓存的函数。
- 第二个参数:依赖项数组(Dependency Array),当数组中的变量变化时,函数会重新创建。
2. 核心作用
-
避免不必要的函数重建:默认情况下,组件每次渲染都会创建新的函数实例,使用
useCallback后可复用函数。 -
优化子组件渲染:当缓存的函数作为 props 传递给子组件(配合
React.memo)时,可避免子组件不必要的重渲染。
3. 使用示例
import React, { useState, useCallback } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// 缓存函数:依赖项为空数组,函数只创建一次
const increment = useCallback(() => {
setCount(prev => prev + 1); // 使用函数式更新避免闭包问题
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
</div>
);
}
- 依赖项
[]表示函数仅在组件初次渲染时创建。 - 使用
setCount(prev => prev + 1)替代setCount(count + 1)可避免闭包陷阱(函数捕获过时状态)。
4. 适用场景
useCallback,本质上是用于缓存函数。
如果函数,是以props的方式,传递给子组件,为了每次避免子组件的渲染,建议使用useCallback进行包裹。
但是每一次,使用useCallback,我们考虑的首要问题是,这样真的优化了组件的性能吗?其实大多数场景,如果不是类似列表渲染的场景,这样不一定会优化了性能。
也就是,函数作为props传递给性能敏感的子组件的场景,才是使用useCallback的时候。
useCallback 的原理解析
-
useCallback的主要目的是在依赖项不变的情况下,返回同一个函数引用,避免函数重复创建,从而优化性能。 -
useCallback它会在首次渲染时(或依赖项变化时)创建一个新的函数,并将其缓存起来。在后续渲染中,如果依赖项没有变化,则返回缓存的函数;否则,就重新创建函数并更新缓存。 - 简易的伪代码,可能如下所示
let lastDeps; // 上一次的依赖项
let lastCallback; // 上一次缓存的函数
function useCallback(callback, deps) {
if (lastDeps === undefined) {
// 第一次调用
lastDeps = deps;
lastCallback = callback;
return callback;
}
// 检查依赖项是否变化
const hasChanged = deps.some((dep, index) => dep !== lastDeps[index]);
if (hasChanged) {
lastDeps = deps;
lastCallback = callback;
}
return lastCallback;
}
每次掉用useCallback,返回的函数,取决于依赖项有没有发生变化。
React内部是咋样的呢?
1、Fiber 节点存储机制
React 在 Fiber 节点(组件实例对应的数据结构)中维护一个 memorizedState 链表,专门存储 Hooks 状态。
function updateCallback(callback, deps) {
const hook = updateWorkInProgressHook(); // 获取当前 Hook 节点
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState; // 读取缓存的上次状态
// 依赖项对比:使用浅比较(shallow equal)
if (prevState !== null && areHookInputsEqual(nextDeps, prevState[1])) {
return prevState[0]; // 返回缓存的函数
}
// 依赖变化:缓存新函数
hook.memoizedState = [callback, nextDeps];
return callback;
}
2、依赖项对比算法
源码中的 areHookInputsEqual 对依赖数组进行浅比较(类似 Object.is):
function areHookInputsEqual(nextDeps, prevDeps) {
if (prevDeps === null) return false;
for (let i = 0; i < prevDeps.length; i++) {
if (!Object.is(nextDeps[i], prevDeps[i])) {
return false;
}
}
return true;
}
这种优化避免了深度比较的性能损耗
Safari 中文输入法的诡异 Bug:为什么输入 @ 会变成 @@? ## 开头 做 @ 提及功能的时候,测试同学用 Safari 测出了个奇怪的问题
Safari 中文输入法的诡异 Bug:为什么输入 @ 会变成 @@?
开头
做 @ 提及功能的时候,测试同学用 Safari 测出了个奇怪的问题——输入框里按一下 @ 键,结果出现了两个 @@。
更诡异的是:
- Chrome 上正常,只有一个
@ - Safari 用英文输入法也正常
- 只有 Safari + 中文输入法会重复
第一反应是"我代码写错了",但看了半天逻辑没毛病啊。难道是浏览器的 Bug?
深入研究才发现,这是 Safari 在处理中文输入法时的特殊行为,涉及到 compositionend 事件和 beforeinput 事件的微妙差异。更有意思的是,这个问题还让我重新理解了一个问题:为什么中文输入需要"合成",而英文直接映射就行?
问题复现
测试场景
代码逻辑很简单:在 keydown 阶段监听 @ 键,插入 @ 字符并弹出用户列表。
// 简化的问题代码
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === '@') {
e.preventDefault();
// 手动插入 @ 字符
insertText('@');
// 显示用户列表弹窗
showMentionPopup();
}
};
诡异的现象
| 浏览器 | 输入法 | 输入 @ 后的结果 |
|---|---|---|
| Chrome | 中文 |
@ ✅ |
| Chrome | 英文 |
@ ✅ |
| Safari | 英文 |
@ ✅ |
| Safari | 中文 |
@@ ❌ |
问题稳定复现,但只在一个特定组合下出现:Safari + 中文输入法。
深入原因
事件触发顺序对比
先来看看正常情况下的事件流:
Chrome(正常):
1. keydown (key='@')
2. preventDefault() 阻止默认行为
3. ✅ 后续 beforeinput、input 事件都不会触发
Safari + 中文输入法(异常):
1. keydown (key='@')
2. preventDefault() 在 keydown 阶段生效
3. compositionstart (输入法激活)
4. compositionend (输入法合成完成)
5. ❌ beforeinput 事件竟然还是触发了!(data='@')
6. input 事件触发,字符被插入
关键差异在第 5 步:Safari 在 compositionend 后重新发起了 beforeinput 事件,完全无视之前在 keydown 阶段调用的 preventDefault()。
为什么会有两个 @?
sequenceDiagram
participant User as 用户
participant IME as 中文输入法
participant Safari as Safari 浏览器
participant Handler as 事件处理器
User->>IME: 按下 @ 键
IME->>Safari: keydown event
Safari->>Handler: 触发 handleKeyDown
rect rgb(255, 200, 200)
Note over Handler: ❌ 第一次插入
Handler->>Handler: preventDefault()
Handler->>Handler: insertText('@')
Note right of Handler: segments = ['@']
end
Note over IME,Safari: Safari 的特殊行为
IME->>Safari: compositionstart
Safari->>Safari: 输入法激活
IME->>Safari: compositionend
rect rgb(255, 200, 200)
Note over Safari: ❌ Safari 忽略了之前的 preventDefault
Safari->>Handler: beforeinput (data='@')
Handler->>Handler: insertText('@')
Note right of Handler: segments = ['@', '@']
end
双重插入的本质:
-
keydown阶段:我们手动插入了第一个@ -
compositionend后:Safari 认为"输入法合成完成了,该插入字符了",重新触发beforeinput,又插入了第二个@
为什么只有中文输入法有问题?
这就要从输入法的原理说起了。
英文输入(直接映射):
按键 'A' → 字符 'A'(一对一)
按键 '@' → 字符 '@'(一对一)
- 英文字母只有 26 个 + 数字 10 个 + 符号几十个
- 标准键盘有 104 个按键
- 键盘够用,所以可以直接映射
- 不需要输入法参与
中文输入(需要合成):
按键 'n' 'i' 'h' 'a' 'o' → 需要输入法处理 → '你好'
- 常用汉字 3500+ 个
- 标准键盘只有 104 个按键
- 键盘远远不够
- 必须通过输入法合成:多个按键 → 一个汉字
那为什么 @ 也要合成?
理论上 @ 这种单字符完全可以直接映射,不需要走输入法。但实际中文输入法的实现是这样的:
// 中文输入法的实现逻辑(简化)
class ChineseIME {
isActive = true; // 输入法始终激活
onKeyPress(key) {
// ❌ 为了统一处理,所有按键都启动合成
this.startComposition();
if (this.needsCandidates(key)) {
// 字母:显示拼音候选
this.showCandidates();
} else {
// 标点:立即结束合成
this.endComposition(key);
}
}
}
为什么要这样设计?
- 状态机简化 - 统一处理比特殊判断简单
-
标点歧义 - 某些标点有全角/半角之分(
,vs,) -
词库扩展 - 现代输入法把符号也加入候选(输入
haha可能出现 😄) - 历史包袱 - 早期输入法这样设计,沿用至今
所以即使是 @,中文输入法也会走完整的 compositionstart → compositionend 流程。
Safari 为什么特殊?
Chrome 的逻辑:
// Chrome 记住了 preventDefault 标记
keydown.preventDefault()
→ 标记该按键"已阻止"
→ compositionend 后检查标记
→ 发现"已阻止"
→ 不触发 beforeinput ✅
Safari 的逻辑:
// Safari 把 composition 当作独立流程
keydown.preventDefault()
→ 只影响 keydown 自己
→ compositionend 后
→ 重新评估"是否该插入字符"
→ 触发 beforeinput ❌
本质是 Safari 将 composition 流程视为独立的输入事件链,不继承 keydown 阶段的 preventDefault() 状态。
修复方案
核心思路
问题根源:在 keydown 阶段手动插入字符,与 Safari 后续的 beforeinput 冲突。
解决方法:不在 keydown 插入字符,统一在 beforeinput 阶段处理。
修复前的代码
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === '@' && supportMention) {
e.preventDefault();
// ❌ 问题:在 keydown 阶段手动插入
handleSegmentChange({
changeType: ESegmentChangeType.Add,
changeInfo: {
start, end,
contentType: InputSegmentType.Text,
content: '@', // ← 第一次插入
},
});
// 显示弹窗
setShowMentionList(true);
inputRef.current?.blur(); // ❌ 失焦,无法继续输入
}
};
// ❌ Safari 会在 compositionend 后再次触发
const handleBeforeInput = (e: React.FormEvent) => {
const inputEvent = e.nativeEvent as InputEvent;
if (inputEvent.data) {
handleSegmentChange({
changeType: ESegmentChangeType.Add,
changeInfo: {
content: inputEvent.data, // ← 第二次插入 '@'
},
});
}
};
修复后的代码
const mentionTriggerIndexRef = useRef<number | null>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === '@' && supportMention) {
e.preventDefault();
// ✅ 改进:只记录位置,不插入字符
const { start } = getCursorRange(inputRef.current);
mentionTriggerIndexRef.current = start;
// 显示弹窗
setShowMentionList(true);
setMentionKeyword('');
// 计算弹窗位置
const { left, bottom } = getCursorPositionPx(inputRef.current) || {};
setMentionPosition({ left: 12 + left, bottom });
// ✅ 保持焦点,允许继续输入
inputRef.current?.focus();
}
};
// beforeinput 会自然触发,插入 @(只插入一次)
const handleBeforeInput = (e: React.FormEvent) => {
const inputEvent = e.nativeEvent as InputEvent;
if (inputEvent.data) {
handleSegmentChange({
changeType: ESegmentChangeType.Add,
changeInfo: {
content: inputEvent.data, // ✅ 只会插入一次
},
});
}
};
// 在 handleSegmentChange 后更新 mention 追踪
const updateMentionTracking = useCallback(
(nextRawText: string, cursorPosition: number) => {
if (!showMentionList) return;
const triggerIndex = mentionTriggerIndexRef.current;
if (triggerIndex === null) return;
// 提取 @ 后的搜索词
const keyword = nextRawText.slice(triggerIndex + 1, cursorPosition);
// 包含空格则关闭弹窗
if (/\s/.test(keyword)) {
closeMentionList();
return;
}
// 更新搜索关键词
setMentionKeyword(keyword);
},
[showMentionList, closeMentionList]
);
关键改动点
| 改动项 | 修复前 | 修复后 |
|---|---|---|
| 字符插入 | keydown 手动插入 | beforeinput 自然插入 |
| 位置记录 |
isMentioningSaveRangeRef(需要 +1/-1) |
mentionTriggerIndexRef(直接记录) |
| 焦点管理 |
blur() 失焦 |
focus() 保持焦点 |
| 状态追踪 | 在 keydown 阶段设置 | 在 handleSegmentChange 后更新 |
修复后的事件流
效果:
- Chrome:
beforeinput被preventDefault()阻止,用户继续输入其他字符 - Safari:
beforeinput触发,插入@(只插入一次),然后更新追踪状态
技术要点总结
1. 不要在 keydown 中插入普通字符
// ❌ 错误做法
handleKeyDown = (e) => {
if (e.key === 'a') {
e.preventDefault();
insertText('a'); // ← 可能导致重复插入
}
};
// ✅ 正确做法
handleKeyDown = (e) => {
if (e.key === 'a') {
// 只设置标记,不插入字符
setShouldDoSomething(true);
}
};
handleBeforeInput = (e) => {
// 统一在这里处理字符插入
insertText(e.data);
};
原则:
-
keydown:处理快捷键、特殊按键(Enter、Backspace、Escape) -
beforeinput:统一处理字符插入 -
compositionstart/end:管理输入法状态
2. preventDefault() 的作用范围
e.preventDefault(); // 只阻止当前事件的默认行为
// 不会阻止的(在 Safari 中文模式下):
// - compositionstart/end
// - beforeinput(composition 后重新发起)
// - input
如果要阻止字符插入,应该在 beforeinput 阶段调用 preventDefault(),而不是 keydown。
3. 使用 ref 记录辅助数据
// ✅ 使用 ref(不触发重渲染)
const mentionTriggerIndexRef = useRef<number | null>(null);
mentionTriggerIndexRef.current = start;
// ❌ 不要用 state(会触发重渲染,性能差)
const [mentionTriggerIndex, setMentionTriggerIndex] = useState<number | null>(null);
选择原则:
- useState:需要触发 UI 更新的数据(如搜索关键词、选中索引)
- useRef:辅助计算的数据(如触发位置、缓存值)
4. 浏览器兼容性测试的重要性
这个 Bug 提醒我们:
- ✅ 不要假设所有浏览器行为一致
- ✅ 核心功能必须在 Safari、Chrome、Firefox 中测试
- ✅ 特别注意 Safari + 中文输入法 这种组合
浏览器事件机制对比
标准输入事件流
flowchart TD
A[用户按键] --> B[keydown]
B --> C{是否 composing?}
C -->|否 英文输入| D[beforeinput]
D --> E[input]
E --> F[keyup]
C -->|是 中文输入| G[compositionstart]
G --> H[compositionupdate]
H --> I[compositionend]
I --> J[beforeinput]
J --> K[input]
K --> L[keyup]
style D fill:#c8e6c9
style J fill:#fff9c4
preventDefault() 在不同浏览器的表现
| 浏览器 | keydown preventDefault() | 是否阻止后续 beforeinput? |
|---|---|---|
| Chrome | ✅ 阻止所有后续事件 | ✅ 是 |
| Firefox | ✅ 阻止所有后续事件 | ✅ 是 |
| Safari(英文) | ✅ 阻止所有后续事件 | ✅ 是 |
| Safari(中文) | ⚠️ 仅阻止 keydown 阶段 | ❌ 否(composition 后仍触发) |
为什么中文需要合成?一个有趣的对比
字符数量的差异
英文字母: 26 个
+ 大写: 26 个
+ 数字: 10 个
+ 符号: ~20 个
= 总共约 80 个字符
标准键盘: ~104 个按键
✅ 键盘够用 → 可以直接映射
常用汉字: 3,500 个
GB2312: 6,763 个
Unicode: 20,000+ 个
标准键盘: ~104 个按键
❌ 键盘远远不够 → 必须用合成方案
不同语言的输入方式
英文(直接映射):
按键 A → 字符 A
按键 @ → 字符 @
中文拼音(音码):
输入: z h o n g g u o (8 个按键)
显示: 拼音候选 → 中国、钟国、忠国...
选择: 按空格或数字
输出: 中国 (2 个汉字)
日文(两层合成):
输入: a r i g a t o u
第一层: ありがとう (平假名)
转换键: 按空格
第二层: 有難う (汉字+假名)
输出: 有難う
韩文(字母拼合):
输入: ㄱ + ㅏ + ㅁ (3 个字母)
拼合: ㄱ → 가 → 감
输出: 감 (1 个音节块)
为什么 @ 也要走合成流程?
这是工程妥协,而非必然设计:
- 状态机简化 - 统一处理所有按键,代码更简单
-
标点歧义 - 某些标点有全角/半角之分(
,vs,) - 词库扩展 - 现代输入法支持 emoji 和符号候选
- 历史兼容 - 早期设计被沿用至今
如果输入法对 @ 特殊处理,会导致:
用户输入: n i h a o @
如果 @ 直接插入:
nihao → (合成中)
@ → (直接插入) ❌ 破坏合成状态!
用户无法继续输入
统一走合成:
nihao → (合成中)
用户确认 → (插入"你好",结束)
@ → (新合成,立即结束) ✅ 状态一致
总结
研究完这个 Bug,我的理解是:
问题本质:
- Safari 将 composition 流程视为独立事件链
-
compositionend后重新发起beforeinput - 忽略了之前在
keydown阶段的preventDefault()
修复原则:
- ❌ 不在
keydown中插入普通字符 - ✅ 在
beforeinput统一处理字符插入 - ✅
keydown仅用于特殊按键(Enter、Backspace、Escape) - ✅ 使用
ref记录辅助数据,避免不必要的重渲染
深层收获:
- 理解了为什么中文需要"合成":键盘按键数 << 汉字数量
- 理解了为什么
@也走合成流程:输入法的统一状态管理 - 意识到浏览器兼容性测试的重要性:Safari + 中文输入法 是个特殊组合
实用建议:
- 核心功能必须在 Safari 上测试,特别是输入相关的
- 不要假设 preventDefault() 能阻止所有后续事件
- 事件处理职责分离:keydown 处理特殊键,beforeinput 处理字符插入
- 使用 ref 存储不需要触发渲染的辅助数据
下次遇到类似的输入问题,你会知道:
- 先看事件触发顺序(打印日志)
- 检查是否在 keydown 阶段插入了字符
- 在 Safari + 中文输入法下测试
- composition 流程可能带来意外的事件触发
如果你的项目也有 @ 提及、# 话题这类功能,建议现在就去 Safari 上测一测。数据显示,Safari 在中国的市场份额约 20%(主要是 iOS 用户),别让这 20% 的用户遇到诡异的重复输入问题。
参考资料
W3C 标准文档
- UI Events Specification - Composition Events - composition 事件规范
- Input Events Level 2 - beforeinput event - beforeinput 事件规范
MDN 文档
- CompositionEvent - composition 事件详解
- InputEvent - input 事件详解
- Event.preventDefault() - preventDefault 的作用范围
浏览器差异
- WebKit Bugzilla - Safari 已知问题
- Chromium Issue Tracker - Chrome 事件处理实现
相关文章
- IME (Input Method Editor) 原理 - 输入法编辑器工作原理
今日苹果 App Store 前端源码泄露,赶紧 fork 一份看看
新闻
今日苹果 App Store 前端源码泄露,仓库地址:github.com/rxliuli/app…
仅仅过去了十几个小时,就已经 fork 上千份,star 过千了 😂
泄露原因
所以它是怎么泄露的呢?
因为苹果忘记在 App Store 网站的生产环境中禁用 sourcemap 了 😂
然后就被存档上传了一份,哈哈哈哈!
这份代码仓库里有:
- 完整的 Svelte/TypeScript 源代码
- 状态管理逻辑
- UI组件
- API集成代码
- 路由配置
稍后让我替大家细看一下这份代码~
🔍 深度解析:Vue 编译器中的 validateBrowserExpression 表达式校验机制
一、背景与概念说明
在 Vue 3 的编译阶段中,模板(template)需要被解析成 JavaScript 表达式。例如:
<div>{{ user.name }}</div>
会被编译为:
_createElementVNode("div", null, _toDisplayString(user.name))
然而,模板中的表达式必须是合法的 JavaScript 语法,同时不能包含某些保留关键字(如 for, while, class 等)。
因此,Vue 编译器需要一个安全机制去检测表达式是否合法——这正是 validateBrowserExpression 函数的职责。
二、源码概览
import type { SimpleExpressionNode } from './ast'
import type { TransformContext } from './transform'
import { ErrorCodes, createCompilerError } from './errors'
// 1️⃣ 定义不允许出现在表达式中的关键字
const prohibitedKeywordRE = new RegExp(
'\b' +
(
'arguments,await,break,case,catch,class,const,continue,debugger,default,' +
'delete,do,else,export,extends,finally,for,function,if,import,let,new,' +
'return,super,switch,throw,try,var,void,while,with,yield'
)
.split(',')
.join('\b|\b') +
'\b',
)
// 2️⃣ 定义用于剔除字符串字面量的正则(防止误匹配)
const stripStringRE =
/'(?:[^'\]|\.)*'|"(?:[^"\]|\.)*"|`(?:[^`\]|\.)*${|}(?:[^`\]|\.)*`|`(?:[^`\]|\.)*`/g
/**
* 3️⃣ 表达式验证函数
* 主要在浏览器端运行时编译器中调用
*/
export function validateBrowserExpression(
node: SimpleExpressionNode,
context: TransformContext,
asParams = false,
asRawStatements = false,
): void {
const exp = node.content
// ① 空表达式情况(例如 v-if="")由上层指令处理
if (!exp.trim()) {
return
}
try {
// ② 构造一个 Function 来检测表达式语法是否合法
new Function(
asRawStatements
? ` ${exp} `
: `return ${asParams ? `(${exp}) => {}` : `(${exp})`}`,
)
} catch (e: any) {
// ③ 捕获语法错误并进一步检查是否包含关键字
let message = e.message
const keywordMatch = exp
.replace(stripStringRE, '')
.match(prohibitedKeywordRE)
if (keywordMatch) {
message = `avoid using JavaScript keyword as property name: "${keywordMatch[0]}"`
}
// ④ 通过上下文的 onError 报告错误
context.onError(
createCompilerError(
ErrorCodes.X_INVALID_EXPRESSION,
node.loc,
undefined,
message,
),
)
}
}
三、原理解析
1️⃣ 关键逻辑:用 new Function() 检测表达式是否合法
new Function(`return (${exp})`)
这一技巧利用了 JavaScript 引擎本身的语法检查能力。
- 如果表达式语法错误,会直接抛出
SyntaxError。 - 如果语法合法,则不会报错,说明可安全用于运行时求值。
例如:
new Function('return (user.name)') // ✅ 通过
new Function('return (if)') // ❌ SyntaxError: Unexpected token 'if'
2️⃣ 防止关键字误用
Vue 不希望用户写出类似:
<div>{{ class }}</div>
虽然这是合法的 JS 标识符(在模板上下文中可能被误解析),但会与 JS 关键字冲突。
因此,使用正则 prohibitedKeywordRE 检测关键字出现。
注意这里的关键点:
- 先使用
stripStringRE去掉字符串字面量,防止"return"这种字符串触发误报。 - 然后再匹配关键字。
3️⃣ 错误汇报机制
通过 context.onError 统一抛出编译阶段错误:
context.onError(
createCompilerError(
ErrorCodes.X_INVALID_EXPRESSION,
node.loc,
undefined,
message,
)
)
这会被编译器统一捕获并转化为编译日志或提示信息。
四、对比分析
| 特性 | validateBrowserExpression |
Vue 服务器端编译器 (SSR) | Babel 等工具 |
|---|---|---|---|
| 检查方式 | 运行时 new Function()
|
静态 AST 解析 | 语法树静态分析 |
| 运行环境 | 浏览器 | Node.js | 通用 |
| 目的 | 快速语法检测 + 安全关键字过滤 | 静态优化 + 安全执行 | 完整语言解析 |
| 性能 | 快速、轻量 | 相对较重 | 较慢但最精确 |
五、实践示例
✅ 合法表达式
<div>{{ user.age + 1 }}</div>
验证过程:
exp = "user.age + 1"-
new Function("return (user.age + 1)")✅ 无异常 - 校验通过。
❌ 非法表达式(语法错误)
<div>{{ if user.age }}</div>
验证过程:
- 抛出
SyntaxError: Unexpected identifier - 捕获错误 → 报告
X_INVALID_EXPRESSION。
⚠️ 关键字误用
<div>{{ class }}</div>
验证过程:
-
语法层面
new Function不报错(因为class是保留字) -
但关键字匹配命中 → 提示:
avoid using JavaScript keyword as property name: "class"
六、拓展思考
-
安全性:
new Function()在编译器中使用是安全的,因为它只执行语法检查,不执行结果。但若在运行时执行用户输入,则会有安全风险。 -
替代方案:
在更严格的环境中,可以使用 AST 解析器(如@babel/parser)进行安全检测。 -
兼容性:
某些浏览器中对new Function()的语法报错信息不同,因此 Vue 使用自定义错误代码 (ErrorCodes.X_INVALID_EXPRESSION) 统一处理。
七、潜在问题与优化方向
| 问题点 | 说明 | 可能优化 |
|---|---|---|
| 错误定位不精确 | 只能指出哪一条表达式出错,不能指出字符位置 | 可结合 AST 报错精确行列 |
| 关键字正则维护复杂 | 新的 JS 关键字需手动更新 | 可自动生成关键字列表 |
| 性能瓶颈 | 大量表达式时多次构造 Function 对象 |
可在编译时缓存校验结果 |
八、总结
validateBrowserExpression 是 Vue 编译器的核心安全防线之一,它通过:
-
new Function()检查表达式语法; - 正则匹配禁止关键字;
- 报告编译错误;
实现了轻量、快速且安全的模板表达式验证。
这一实现方案在运行时编译环境中兼顾了性能与安全性,为 Vue 模板的动态编译提供了强有力的保障。
本文部分内容借助 AI 辅助生成,并由作者整理审核。
深入解析:Vue 编译器核心工具函数源码(compiler-core/utils.ts)
一、概念与背景
在 Vue 3 的模板编译流程中,compiler-core 是整个编译链的心脏模块之一。
它负责将模板语法 (<template>...</template>) 转化为虚拟 DOM 渲染函数(render)。
而其中的 utils.ts 文件,提供了一系列“编译辅助工具函数”,用于:
-
表达式判断与解析(如
isMemberExpression,isFnExpression) -
节点属性分析与注入(如
findProp,injectProp) -
位置信息与错误处理(如
advancePositionWithMutation,assert) -
作用域与上下文检测(如
hasScopeRef)
这些工具是编译器在“语义判断”与“代码生成”阶段的中间层逻辑支撑。
二、核心原理分解
1. 静态表达式判断:isStaticExp
export const isStaticExp = (p: JSChildNode): p is SimpleExpressionNode =>
p.type === NodeTypes.SIMPLE_EXPRESSION && p.isStatic
原理:
判断一个 AST 节点是否为“简单静态表达式”(即内容在编译期可确定的常量)。
注释说明:
-
NodeTypes.SIMPLE_EXPRESSION→ 表示{{ message }}或v-bind:foo="bar"中的bar。 -
isStatic→ 编译器在解析阶段标记的属性,用于区分“动态 vs 静态”节点。
2. 核心组件识别:isCoreComponent
export function isCoreComponent(tag: string): symbol | void {
switch (tag) {
case 'Teleport':
case 'teleport':
return TELEPORT
case 'Suspense':
case 'suspense':
return SUSPENSE
case 'KeepAlive':
case 'keep-alive':
return KEEP_ALIVE
case 'BaseTransition':
case 'base-transition':
return BASE_TRANSITION
}
}
原理:
将内置组件(Teleport、Suspense、KeepAlive、BaseTransition)映射为编译时符号。
这些符号用于生成渲染函数时调用特定的 runtime helper。
3. 表达式词法分析:isMemberExpressionBrowser
export const isMemberExpressionBrowser = (exp: ExpressionNode): boolean => {
const path = getExpSource(exp)
.trim()
.replace(whitespaceRE, s => s.trim())
// 状态机初始化
let state = MemberExpLexState.inMemberExp
let stateStack: MemberExpLexState[] = []
let currentOpenBracketCount = 0
let currentOpenParensCount = 0
let currentStringType: "'" | '"' | '`' | null = null
for (let i = 0; i < path.length; i++) {
const char = path.charAt(i)
switch (state) {
case MemberExpLexState.inMemberExp:
if (char === '[') {
stateStack.push(state)
state = MemberExpLexState.inBrackets
currentOpenBracketCount++
} else if (char === '(') {
stateStack.push(state)
state = MemberExpLexState.inParens
currentOpenParensCount++
} else if (
!(i === 0 ? validFirstIdentCharRE : validIdentCharRE).test(char)
) {
return false
}
break
case MemberExpLexState.inBrackets:
if (char === `'` || char === `"` || char === '`') {
stateStack.push(state)
state = MemberExpLexState.inString
currentStringType = char
} else if (char === `[`) {
currentOpenBracketCount++
} else if (char === `]`) {
if (!--currentOpenBracketCount) {
state = stateStack.pop()!
}
}
break
...
}
}
return !currentOpenBracketCount && !currentOpenParensCount
}
原理:
这段代码实现了一个简易 状态机词法分析器,判断字符串是否是合法的成员表达式(如 foo.bar, foo['x'])。
核心状态:
-
inMemberExp: 主路径部分 -
inBrackets: 方括号访问 -
inParens: 括号访问 -
inString: 字符串字面量内部
示例:
- ✅ 合法 →
user.name,list[index].value - ❌ 非法 →
a(),1user,foo..bar
4. 属性与指令查找:findDir / findProp
这两个函数是 Vue 编译阶段的“节点属性查询器”。
export function findDir(
node: ElementNode,
name: string | RegExp,
allowEmpty: boolean = false,
): DirectiveNode | undefined {
for (let i = 0; i < node.props.length; i++) {
const p = node.props[i]
if (
p.type === NodeTypes.DIRECTIVE &&
(allowEmpty || p.exp) &&
(isString(name) ? p.name === name : name.test(p.name))
) {
return p
}
}
}
作用:
快速定位某个指令(如 v-if、v-model)节点。
细节:
-
allowEmpty:是否允许无表达式的指令(如v-on)。 -
name:支持字符串或正则,用于匹配指令名称。
5. 属性注入机制:injectProp
export function injectProp(
node: VNodeCall | RenderSlotCall,
prop: Property,
context: TransformContext,
): void {
...
if (props == null || isString(props)) {
propsWithInjection = createObjectExpression([prop])
} else if (props.type === NodeTypes.JS_CALL_EXPRESSION) {
...
} else if (props.type === NodeTypes.JS_OBJECT_EXPRESSION) {
...
} else {
propsWithInjection = createCallExpression(context.helper(MERGE_PROPS), [
createObjectExpression([prop]),
props,
])
}
...
}
原理与用途:
在 AST 生成阶段注入新的属性(例如添加 key、ref 等虚拟节点属性)。
逻辑说明:
- 若当前无
props→ 创建一个新对象表达式{ [prop]: value }。 - 若存在
mergeProps(...)→ 在现有参数前追加新属性。 - 若存在
toHandlers(...)→ 用MERGE_PROPS合并。
示例:
<div v-for="i in list" :key="i"></div>
编译后,injectProp 确保 key 存在于最终 createVNode 调用参数中。
三、实践示例
假设我们在模板中写下:
<div v-if="show" class="red" :id="user.id"></div>
编译器在处理时会:
-
用
findDir(node, 'if')定位v-if指令; -
用
findProp(node, 'class')与findProp(node, 'id', true)读取属性; -
在生成渲染函数时,通过
injectProp插入key; -
生成的最终代码大致为:
createVNode("div", { class: "red", id: user.id, key: 0 })
四、拓展与优化
-
词法解析性能:
isMemberExpressionBrowser采用手写状态机而非 AST 解析器,主要是为了性能考虑(编译阶段非常频繁)。 -
SSR 与浏览器分支:
isMemberExpressionNode在 Node 环境下用 Babel 解析,以保证 TypeScript 支持。 -
作用域检测:
hasScopeRef用于确定某表达式是否引用了当前上下文变量,在v-for、v-slot等语义分析中极为关键。
五、潜在问题与思考
- 浏览器兼容性问题:正则与 Unicode 字符匹配范围较广,某些极端字符可能导致错误识别。
-
递归注入风险:
injectProp的嵌套调用路径较深,若处理嵌套normalizeProps结构,可能产生意外覆盖。 -
性能平衡:
parseExpression(Babel 调用)比手写解析更安全但更慢,因此 Vue 在浏览器环境默认使用isMemberExpressionBrowser。
六、总结
本文剖析了 Vue 编译器中 utils.ts 的关键逻辑,包括:
- 静态判断与词法分析;
- 编译指令查找与属性注入;
- 作用域与上下文引用检测;
- 多层工具函数在编译管线中的协作关系。
这些函数虽小,却构成了 Vue 编译器高性能与高鲁棒性的基础。
本文部分内容借助 AI 辅助生成,并由作者整理审核。