阅读视图

发现新文章,点击刷新页面。

CSS水平垂直居中的9种方法:原理、优缺点与差异对比

在前端开发中,实现元素的水平垂直居中是布局中的核心技巧之一,本文将系统性地讲解9种主流方法,涵盖传统方案与现代最佳实践,并通过代码示例、优缺点分析和场景对比。


一、Flexbox布局

原理

通过将父容器设置为Flex容器,并利用justify-contentalign-items属性控制主轴和交叉轴的对齐方式。

.parent {
  display: flex;
  justify-content: center; /* 水平居中 */
  align-items: center;     /* 垂直居中 */
}

优点

  • 代码简洁:一行代码实现双向居中。
  • 无需子元素尺寸:支持动态内容和响应式布局。
  • 兼容性强:现代浏览器广泛支持(IE10+)。

缺点

  • IE兼容性限制:IE9及以下不支持。
  • 影响子元素布局:父容器变为Flex容器后,所有子元素默认参与Flex布局。

适用场景

  • 现代项目(兼容IE10+)
  • 需要动态内容居中的弹窗、按钮组等。

二、Grid布局

原理

利用CSS Grid的place-items属性,一次性控制水平和垂直对齐。

.parent {
  display: grid;
  place-items: center; /* 水平垂直居中 */
}

优点

  • 语法极简:单行代码实现双向居中。
  • 二维布局能力:天然支持复杂网格布局。
  • 无需子元素尺寸

缺点

  • 兼容性较差:IE不支持,部分旧版浏览器需前缀。
  • 语义化争议:过度使用Grid可能导致布局层级混乱。

适用场景

  • 现代浏览器项目(如Chrome、Firefox、Edge)
  • 需要二维布局的卡片、仪表盘等。

三、绝对定位 + transform(经典方案)

原理

通过将子元素定位到父容器中心后,利用transform: translate(-50%, -50%)调整自身中心点。

.parent {
  position: relative;
}
.child {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

优点

  • 无需子元素尺寸:支持任意宽高的元素。
  • 兼容性良好:IE9+支持(需加前缀)。
  • 性能优化:现代浏览器渲染效率高。

缺点

  • 脱离文档流:可能影响父容器布局。
  • transform副作用:可能干扰其他CSS属性(如z-index)。

适用场景

  • 需要绝对定位的弹窗、图标提示框。
  • 兼容IE9+的项目。

四、绝对定位 + margin: auto

原理

设置子元素四边为0并固定宽高,通过margin: auto自动计算外边距。

.parent {
  position: relative;
}
.child {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  width: 200px;
  height: 100px;
  margin: auto;
}

优点

  • 兼容性极佳:IE8+支持。
  • 逻辑清晰:通过margin: auto实现自动居中。

缺点

  • 必须固定宽高:子元素需明确尺寸。
  • 响应式局限:不适用于动态内容。

适用场景

  • 固定尺寸的模态框、广告位。

五、绝对定位 + 负边距

原理

先将子元素左上角定位到父容器中心,再通过负边距反向移动一半尺寸。

.child {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 200px;
  height: 100px;
  margin-left: -100px; /* 宽度一半 */
  margin-top: -50px;   /* 高度一半 */
}

优点

  • 兼容性好:IE6+支持。
  • 直观易懂:通过负边距调整位置。

缺点

  • 依赖固定尺寸:必须精确知道子元素宽高。
  • 维护成本高:尺寸变化需手动调整边距。

适用场景

  • 传统项目或需要兼容IE6+的场景。

六、line-height + text-align(文本居中)

原理

通过设置父容器的line-height等于高度,并配合text-align: center实现单行文本居中。

.parent {
  height: 200px;
  line-height: 200px;
  text-align: center;
}
.child {
  display: inline-block;
  line-height: normal; /* 重置子元素行高 */
}

优点

  • 简单高效:适合单行文本场景。
  • 兼容性极佳:IE6+支持。

缺点

  • 仅限文本:不适用于块级元素或多行文本。
  • 父容器必须固定高度

适用场景

  • 导航菜单项、按钮文字居中。

七、table-cell + vertical-align(传统方案)

原理

将父容器模拟为表格单元格,并通过vertical-align: middle实现垂直居中。

.parent {
  display: table-cell;
  vertical-align: middle;
  text-align: center;
}
.child {
  display: inline-block;
}

优点

  • 兼容性好:IE8+支持。
  • 无需子元素尺寸

缺点

  • 语义不准确:父容器本质是块级元素。
  • 层级限制:需要多层嵌套结构。

适用场景

  • 传统项目或需要兼容IE8+的场景。

八、writing-mode(特殊场景)

原理

通过改变文字方向(如vertical-lr),再结合text-aligntransform实现居中。

.parent {
  writing-mode: vertical-lr;
  text-align: center;
}
.child {
  writing-mode: horizontal-tb;
  display: inline-block;
}

优点

  • 创意排版:适合特殊文字方向需求。
  • 无需子元素尺寸

缺点

  • 语义混乱:影响内容的正常阅读方向。
  • 兼容性差:部分浏览器支持不一致。

适用场景

  • 日文、中文竖排排版等特殊需求。

九、inline-block + 伪元素(行内块居中)

原理

通过伪元素创建占位符,结合vertical-align: middle实现垂直居中。

.parent::before {
  content: '';
  display: inline-block;
  height: 100%;
  vertical-align: middle;
}
.child {
  display: inline-block;
  vertical-align: middle;
}

优点

  • 兼容性好:IE8+支持。
  • 适用于行内元素

缺点

  • 代码复杂:需要额外伪元素。
  • 依赖font-size: 0:可能引入空格问题。

适用场景

  • 图标与文字垂直对齐、表单控件居中。

总结对比表格

方法 是否需要尺寸 兼容性 代码复杂度 推荐度 适用场景
Flexbox ❌ 否 ⭐⭐⭐⭐ (IE10+) ⭐⭐ ⭐⭐⭐⭐⭐ 现代项目、动态内容
Grid ❌ 否 ⭐⭐⭐ (IE不支持) ⭐⭐⭐⭐⭐ 现代浏览器、网格布局
绝对定位+transform ❌ 否 ⭐⭐⭐⭐ (IE9+) ⭐⭐ ⭐⭐⭐⭐ 兼容IE9+的绝对定位元素
绝对定位+margin auto ✅ 是 ⭐⭐⭐⭐⭐ (IE8+) ⭐⭐ ⭐⭐⭐ 固定尺寸元素
绝对定位+负边距 ✅ 是 ⭐⭐⭐⭐⭐ (IE6+) ⭐⭐ ⭐⭐ 传统项目
line-height+text-align ✅ 是 ⭐⭐⭐⭐⭐ (IE6+) ⭐⭐ 单行文本
table-cell+vertical-align ❌ 否 ⭐⭐⭐⭐⭐ (IE8+) ⭐⭐ 传统项目
writing-mode ❌ 否 ⭐⭐⭐ ⭐⭐⭐ 特殊排版
inline-block+伪元素 ❌ 否 ⭐⭐⭐⭐⭐ (IE8+) ⭐⭐⭐ 行内元素对齐

建议

  1. 现代项目首选Flexbox:代码简洁、功能强大,兼容IE10+。
  2. 兼容老旧浏览器时:使用absolute + transformtable-cell方案。
  3. 响应式设计:优先使用Flexbox或Grid布局。
  4. 文本居中line-heighttext-align是经典组合。
  5. 避免滥用writing-mode:除非有特殊排版需求。

uni-app支付宝端彻底禁掉下拉刷新效果

前情

uni-app是我比较喜欢的跨平台框架,它能开发小程序/H5/APP(安卓/iOS),重要的是对前端开发友好,自带的IDE让开发体验也挺棒的,公司项目就是主推uni-app,解决了公司一套代码发布快抖微支四端的需求

坑位

按我以往开发快抖微小程序的开发经验,我只要在page.json关闭下拉刷新,这样就可以关掉页面的下拉刷新同时页面不会有向下拉拽效果

"pages": [
    ...
    {
      "path" : "/pages/OrderList/OrderList",
      "style" :
      {
        "navigationBarTitleText" : "订单列表",
            "enablePullDownRefresh": false,
      }
    }
    ...
]

但是实际上支付宝端还是有下拉效果,只是不会触发onPullDownRefresh生命钩子,其实此时已经关掉了下拉刷新,只是这效果容易让人误解,详见下图:

20250812_205131.gif

解决方案

方案1:通过pages.json配置关掉支付宝小程序的允许向下拉拽

选通过"enablePullDownRefresh": false禁掉下拉刷新效果,我们再针对支付宝做特殊配置,通过"allowsBounceVertical": "NO"禁用支付宝的向下拉拽效果,此处使用了uni-app的条件编译来实现对支付宝的特有配置,关键配置如下:

"pages": [
    ...
    {
      "path" : "/pages/OrderList/OrderList",
      "style" :
      {
        "navigationBarTitleText" : "订单列表",
            "enablePullDownRefresh": false,
            // #ifdef MP-ALIPAY
            "allowsBounceVertical": "NO"
            // #endif
      }
    }
    ...
]

注:allowsBounceVertical 配置不是true和false,它对应的是YES(开启下拉)和NO(关闭下拉)

方案2:通过API关掉支付宝小程序的允许向下拉拽

下拉刷新效果通过pages.json下的"enablePullDownRefresh": false禁用,至于向下拉拽效果支付宝不但提供了通过pages.json配置关掉它,同时也提供了API来控制它,此处同样使用了uni-app的条件编译来实现对支付宝的单独调用一些特有的API,关键代码如下:

"pages": [
    ...
    {
      "path" : "/pages/OrderList/OrderList",
      "style" :
      {
        "navigationBarTitleText" : "订单列表",
            "enablePullDownRefresh": false
      }
    }
    ...
]
// #ifdef MP-ALIPAY
my.setCanPullDown({
  canPullDown: true,
})
// #endif

思考

此文主要是讲解怎么禁用下拉刷新,举一反三,如果你页面要实现下拉刷新效果,只需要增加如下配置即可,因为支付宝小程序的allowsBounceVertical 默认为YES

"pages": [
    ...
    {
      "path" : "/pages/OrderList/OrderList",
      "style" :
      {
        "navigationBarTitleText" : "订单列表",
            "enablePullDownRefresh": false
      }
    }
    ...
]

uni-app是多端框架,但是它并没有抹平所有端的效果,对于遇到一端可以一端不行的问题,我们可以去对应小程序官网文挡查阅特有的配置,再使用条件编译来手动抹平各端的差异,uni-app的条件编辑就是解决各端差异的最有效手段,我感觉这是uni-app向跨端开发者提供的杀手锏

从 0 到 1 带你打造一个工业级 TypeScript 状态机

在前端开发的江湖里,状态管理是每个侠客都必须修炼的内功心法。当组件逻辑日渐复杂,isLoading, isError, isSuccess, isSubmitting 这些布尔类型的“状态”变量开始纠缠不清时,我们的代码就如同走火入魔,充满了不可预知的行为和难以修复的 Bug。

此时,一门古老而强大的武学——有限状态机 (Finite State Machine, FSM)——便能助我们理清思绪,让状态的流转如行云流水般清晰、可控。

本文将不仅仅是介绍状态机,而是带你从零开始,亲手用 TypeScript 锻造一个工业级的、可扩展的、类型安全的状态机。我们将深入其设计的每一个细节,理解其背后的设计模式、数据结构选择与思想权衡。

为什么是 TypeScript 状态机?答案是:确定性

在探讨实现之前,我们必须明确状态机的核心价值。它由四大要素构成:

  • 状态 (State):系统在任何时刻所处的、唯一的、离散的条件。例如,数据请求的生命周期可以是 idle | loading | success | error
  • 事件 (Event):触发状态从一个到另一个的外部输入或动作。
  • 转换 (Transition):一个规则,定义了在特定状态下,响应某个事件后,应该进入哪一个新状态
  • 动作 (Action):在发生转换时执行的副作用(Side Effect)。

将这四大要素与 TypeScript 结合,会产生惊人的化学反应。TypeScript 的核心优势——类型系统——能让“非法的状态无处遁形”。

告别布尔值地狱:

// ❌ 混乱的布尔值,可能出现 isSuccess 和 isError 同时为 true 的非法状态
interface State {
  isLoading: boolean;
  isSuccess: boolean;
  isError: boolean;
  data: any;
  error: Error | null;
}

// ✅ 使用 TypeScript 的联合类型,状态在任何时刻都必然是四者之一
type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success', data: any }
  | { status: 'error', error: Error };

通过这种方式,我们利用编译器强制保证了状态的原子性和互斥性,这是构建健壮系统(Robustness)的第一步。

知己知彼:主流 TypeScript 状态机库选型指南

在决定自己造轮子之前,了解社区中已有的优秀轮子是必修课。这能帮助我们理解不同库的设计哲学和适用场景。

核心特性 最适用场景 推荐技术栈
tyfsm 极度轻量 (<1KB),强类型(使用歧视联合),零依赖 UI 状态、网络请求等简单状态管理 React, Vue, Angular
ts-fsm 支持异步转换,语法类似 XState,功能较全面 I/O 密集型操作(如 API 队列) NestJS 后端服务, Node.js
wstate 内置 React Hooks,提供状态机工厂模式 多实例的复杂组件状态管理 React, Next.js
Stately.js (XState) 声明式 DSL,可视化状态图,功能最强大 复杂的、多层次的业务流程,跨框架逻辑 大型企业级应用

选型建议:如果你的需求简单,tyfsm 是绝佳选择。如果你的应用(尤其是后端)有大量异步流程,ts-fsm 值得考虑。如果你是 React 重度用户,wstate 提供了很好的集成。而对于需要可视化、能清晰表达整个业务逻辑的复杂系统,XState 是当之无愧的王者。

那我们为什么还要自己实现?因为理解内部原理,才能更好地运用外部工具。我们的目标是构建一个集众家之长、设计思想清晰的“教学级”工业实现。

庖丁解牛:从核心设计到实现

现在,让我们卷起袖子,一步步解构 StateMachine 的锻造过程。我们将聚焦于关键代码,理解其背后的设计决策。

第一步:定义契约 (Interfaces & Types)

在动工之前,我们先用 TypeScript 定义好整个系统的“蓝图”。这是类型驱动开发的基石。

// 定义基本类型
export type State = string | number;
export type StateMachineContext = Record<string, any>;

// 状态机的公开 API
export interface StateMachine<S extends State, C extends StateMachineContext> {
  get state(): S;
  transitionTo(state: S, context?: C): Promise<void> | void;
  // ... 其他方法
}

// 状态机的配置对象
export interface StateMachineConfig<S extends State, C extends StateMachineContext> {
  /** 初始化状态 */
  initialState: S;
  /** 状态转换规则配置 */
  transitions: {
    from: S;
    to: S | S[];
    action?: (from: S, to: S, context?: C) => void | Promise<void>;
  }[];
  /** 自定义状态验证器(可选) */
  validator?: (from: S, to: S) => boolean;
  /** 为每个状态定义可选的进入/离开钩子 */
  stateHooks?: {
    [key in S]?: {
      onEnter?: (state: S, context?: C) => void | Promise<void>;
      onLeave?: (state: S, context?: C) => void | Promise<void>;
    };
  };
  /** 错误处理 */
  errorHandler?: {
    handle: (error: Error) => void;
  };
}

设计细节

  • 我们使用泛型 <S, C> 贯穿始终,这保证了从配置到运行时,状态 (S) 和上下文 (C) 的类型都是连贯和安全的。
  • transitions.to 支持单个状态或状态数组 S | S[],这为设计提供了灵活性,一个状态可以合法地转换到多个目标。

状态机四要素的落地

  1. 状态 (States):我们通过泛型 S extends string | number 来定义,允许用户使用 enum 或字符串/数字字面量联合类型,充分利用 TypeScript 的类型检查。

  2. 转换 (Transitions):用户通过一个声明式的 transitions 数组来配置规则。

transitions: [
    { from: 'idle', to: 'loading' },
    { from: 'loading', to: ['success', 'error'] } // 支持多目标状态
]

数据结构选择:在内部,我们并没有在每次转换时都去遍历这个数组。为了性能,我们在构造函数中将它转换成了一个 Map<S, Set<S>> 结构,即 _transitionTable选择原因Map 的键查找时间复杂度接近 O(1),Sethas() 方法也是 O(1)。这意味着验证一个转换是否合法的操作,其性能与转换规则的数量无关,效率极高。

  1. 动作 (Actions):我们提供了两种类型的动作,满足不同粒度的需求。
    • 转换动作 (action): 绑定在具体的 from -> to 转换上,用于执行该转换独有的副作用。
    • 状态钩子 (onEnter/onLeave): 绑定在某个状态上。无论从哪个状态进入或离开,都会触发,适合执行通用逻辑(如进入 loading 就显示 Spinner)。

第二步:构建基石 (StateMachineBase)

StateMachineBase 是我们所有状态机的抽象基类,它封装了最核心、最通用的逻辑。

export abstract class StateMachineBase<S, C> implements StateMachine<S, C> {
  protected _state: S;
  protected _transitionTable: Map<S, Set<S>>;
  // ... 其他属性

  constructor(config: StateMachineConfig<S, C>) {
    this._state = config.initialState;
    // ... 初始化 Maps
    this._buildTransitionTable(config.transitions);
    this._buildStateHooks(config);
  }

  // **数据结构选择**:性能的关键
  private _buildTransitionTable(transitions) {
    // ...
    transitions.forEach(config => {
      // ...
      toStates.forEach(toState => {
        this._transitionTable.get(from)!.add(toState);
        // ...
      });
    });
  }

  // 转换验证逻辑
  protected validateTransition(nextState: S): void {
    const allowed = this._transitionTable.get(this.state);
    if (!allowed || !allowed.has(nextState)) {
      throw new InvalidTransitionError(this.state, nextState, Array.from(allowed || []));
    }
  }
  // ...
}

实现细节与注意事项

  • _buildTransitionTable 是构造函数中的核心。它将用户友好的配置数组预处理成高效的 Map<State, Set<State>> 结构。这一步的预处理,让运行时的 validateTransition 检查速度极快 (接近 O(1)),这是一个典型的以空间换时间的优化策略。
  • 错误处理:我们没有简单地 throw new Error(),而是定义了 InvalidTransitionError 等自定义错误类型。这允许调用者通过 instanceof 进行精确的、类型安全的错误处理,而不是依赖脆弱的错误消息文本。这是构建健壮 API 的关键一环。

第三步:同步 vs. 异步,分而治之的艺术

这是我们设计中的一个关键决策。为什么不创建一个能同时处理同步和异步的“万能” transitionTo 方法呢?答案是:避免复杂性和不必要的性能开销

  • async/await 具有“传染性”。一个 async 函数会迫使其调用链上的所有函数都返回 Promise。如果将 transitionTo 默认设为 async,那么即使用户的所有 action 和钩子都是同步的,他也必须用 await 来调用,这既不符合直觉,也带来了微小的 Promise 开销。

因此,我们提供了两个独立的实现:

  • SyncStateMachine: 纯粹的同步执行,非常适合 UI 状态管理等即时响应场景。
  • AsyncStateMachine: 专为异步操作设计,transitionTo 返回 Promise
// AsyncStateMachine 的核心健壮性设计
export class AsyncStateMachine<S, C> extends StateMachineBase<S, C> {
  private isTransitioning = false;

  async transitionTo(state: S, context?: C): Promise<void> {
    if (this.isTransitioning) {
      throw new ConcurrentTransitionError(this.state, state);
    }
    this.isTransitioning = true;
    try {
      // ... 异步转换流程
    } finally {
      this.isTransitioning = false;
    }
  }
}

实现注意事项

  • AsyncStateMachine 中的 isTransitioning 标志至关重要。它能有效防止在前一个异步转换(如 API 请求)完成前,用户又触发了另一次转换,从而避免了竞态条件 (Race Condition)
  • try...finally 结构是绝对必要的。它确保了无论转换流程成功与否(例如,某个异步 action 中抛出异常),isTransitioning 标志最终都会被重置为 false,从而避免状态机被永久“锁死”。

通过 createStateMachine({ async: true }) 工厂函数,我们将选择权交给了用户,让他们根据实际场景选择最合适的引擎。

第四步:设计思想升华——微内核与插件化架构

我们的核心设计哲学是**“微内核”架构**,它基于开闭原则:对扩展开放,对修改关闭。

  • 内核 (StateMachineBase):只负责最纯粹的状态转换逻辑。
  • 扩展 (Plugins):所有其他功能,如日志、历史记录、持久化等,都作为独立的插件存在,通过监听内核暴露的 onChange 事件来工作。

这种设计带来了极高的可维护性 (Maintainability)可扩展性 (Extensibility)

我们的插件系统建立在两种经典的设计模式之上:

  1. 观察者模式 (Observer Pattern)StateMachine 内核是被观察者 (Subject),通过 onChange 暴露订阅接口。插件都是观察者 (Observer)
  2. 模板方法模式 (Template Method Pattern):我们抽象了一个 StateMachinePlugin 基类,它定义了插件的生命周期骨架(attach/detach 方法),并将具体实现(onAttach, onDetach, onStateChange)留给子类。

从零到壹,打造你的第一个插件 (LoggerPlugin)

让我们亲手实现一个简单的 LoggerPlugin,它会在每次状态转换时,向控制台打印详细的日志。

插件蓝图:StateMachinePlugin 基类

export abstract class StateMachinePlugin<S, C, Config extends object = {}> {
  // ... 封装 attach/detach 逻辑
  /**
    * (必需) 在插件附加到状态机时被调用。
    * 用于执行初始化逻辑,例如恢复持久化状态或记录初始状态。
    * 可以是同步或异步的。
    */
  protected abstract onAttach(): Promise<void> | void;
  /**
    * (必需) 在每次状态机状态变化时被调用。
    * 这是插件实现其核心功能的地方。
    */
  protected abstract onStateChange(from: S, to: S, context?: C): void;
  /**
    * (可选) 在插件从状态机分离时被调用。
    * 用于执行任何必要的清理工作。
    */
   protected onDetach(): void {
        // 默认无操作
   }
}

现在,我们来继承这个基类,实现 LoggerPlugin

// state-machine.logger-plugin.ts

import { StateMachinePlugin } from './state-machine.plugin-base';
import { State, StateMachineContext, StateMachine } from './state-machine.core';

// 1. (可选) 为插件定义配置接口
interface LoggerPluginConfig {
  prefix?: string;
}

// 2. 继承基类
export class LoggerPlugin<
  S extends State,
  C extends StateMachineContext
> extends StateMachinePlugin<S, C, LoggerPluginConfig> {

  private readonly prefix: string;

  // 3. 实现构造函数 (如果需要处理配置)
  constructor(machine: StateMachine<S, C>, config?: LoggerPluginConfig) {
    super(machine, config);
    this.prefix = this.config?.prefix || '[FSM Logger]';
  }

  // 4. 实现 onAttach 方法 (插件附加时执行)
  protected onAttach(): void {
    console.log(`${this.prefix} Attached. Initial state: "${String(this.machine.state)}"`);
  }

  // 5. 实现 onStateChange 方法 (核心逻辑)
  protected onStateChange(from: S, to: S, context?: C): void {
    // 使用 console.group 来美化输出
    console.groupCollapsed(`${this.prefix} State Transition: ${String(from)}${String(to)}`);
    console.log(`Timestamp: ${new Date().toISOString()}`);
    console.log(`From State:`, from);
    console.log(`To State:`, to);
    if (context) {
      console.log('Context:', context);
    }
    console.groupEnd();
  }

  // 6. (可选) 实现 onDetach 方法 (插件分离时执行)
  protected onDetach(): void {
    console.log(`${this.prefix} Detached.`);
  }
}

如何使用我们的新插件?

const machine = createStateMachine({
  initialState: 'idle',
  transitions: [{ from: 'idle', to: 'loading' }],
});

// 实例化插件
const logger = new LoggerPlugin(machine, { prefix: '[MyDataFetcher]' });

// 附加插件,启动监听
await logger.attach();

// 触发一次转换
machine.transitionTo('loading', { trigger: 'user_click' });

你会在控制台看到这样的输出,清晰明了:

[MyDataFetcher] Attached. Initial state: "idle"
▼ [MyDataFetcher] State Transition: idle → loading
    Timestamp: 2025-08-26T...
    From State: idle
    To State: loading
    Context: { trigger: 'user_click' }

就这样,我们从零到一实现了一个功能完整、可配置的插件,而无需触碰状态机核心的任何代码。这就是开闭原则 (Open/Closed Principle) 的完美体现:对扩展开放,对修改关闭

深度剖析:action vs. hooks 和事件的隐式设计

actionhooks 的职责精准划分

在我们设计的状态机中,action(转换动作)和 hooks(状态钩子 onEnter/onLeave)都是用来执行副作用的,但它们的设计意图、粒度和生命周期完全不同。理解它们的差异是精通这个状态机库的关键。

特性 action (转换动作) hooks (onEnter/onLeave 状态钩子)
绑定对象 一个具体的 “转换” (Transition) 一个具体的 “状态” (State)
触发时机 在从 A 状态 转换到 B 状态的过程中触发 进入 (enter)离开 (leave) A 状态时触发
粒度 精细 (Fine-grained) 粗略 (Coarse-grained)
语境 “当这件事发生时,做……” “当处于这个状态时,做……”
一对一/多对一 通常是一对一的关系(一个转换对应一个动作) 通常是多对一的关系(多个转换可能进入同一个状态)

1. action (转换动作):描述“因果”

action 的核心是与一个特定的转换绑定。它回答的问题是:“当状态从 A 变为 B 这个具体事件发生时,我应该执行什么副作用?”

代码示例

transitions: [
  // 这个 action 只在从 'idle' 到 'loading' 时触发
  { from: 'idle', to: 'loading', action: fetchUserData },
  // 这个 action 只在从 'editing' 到 'saving' 时触发
  { from: 'editing', to: 'saving', action: saveDocument },
  // 从 'loading' 到 'idle' 没有 action
  { from: 'loading', to: 'idle' },
]

核心使用场景

  • 执行一次性、与转换强相关的任务:最典型的就是 API 调用。fetchUserData 这个动作的起因,正是“用户触发了数据加载”这个转换。它不应该在任何其他时候被调用。
  • 传递转换特定的数据action 可以接收 context,这个 context 往往包含了触发这次转换的特定信息,例如 saveDocument(from, to, { content: '...' })
  • 描述业务流程中的“动词”:支付、保存、发送、取消……这些都是典型的转换动作。

2. hooks (onEnter/onLeave):描述“状态”的固有行为

hooks 的核心是与一个特定的状态绑定。它回答的问题是:“每当系统进入离开 X 这个状态时,应该发生什么?”它不关心你是从哪个状态过来的,也不关心你要去哪个状态。

代码示例

stateHooks: {
  loading: {
    // 无论从 idle 还是 retrying 进入 loading,都会显示 Spinner
    onEnter: () => showSpinner(),
    // 无论从 loading 去往 success 还是 error,都会隐藏 Spinner
    onLeave: () => hideSpinner(),
  },
  error: {
    // 每次进入 error 状态,就记录一条日志
    onEnter: (state, context) => logError(context.error),
  }
}

核心使用场景

  • 管理与状态生命周期绑定的 UI: 这是最常见、最强大的用途。显示/隐藏加载指示器、禁用/启用按钮、播放/停止动画等,都应该放在 onEnter/onLeave 钩子中。这使得 UI 逻辑与状态完全同步,代码高度内聚。
  • 资源管理 (Setup/Teardown):进入某个状态时建立连接或订阅 (onEnter),离开时断开连接或取消订阅 (onLeave)。例如,进入 live-chat 状态时建立 WebSocket 连接,离开时关闭它。
  • 状态的“副作用初始化”: 进入某个状态时,需要启动一个定时器或监听某个事件。离开时,则必须清理掉,防止内存泄漏。

一个比喻来帮助理解

  • action“买票” 这个动作,你只有在决定 “从北京站” 前往 “上海站” 这个具体的行程时,才会执行“买票”这个动作。你不会在从“天津站”到“南京站”的行程中执行这张票的购买动作。
  • hooks“在上海” 的行为。onEnter: '上海站' 就是 “到达上海站后,打开手机导航,开始游览”。你不管是从北京来的,还是从南京来的,只要你到了上海站,你都会做这件事。onLeave: '上海站' 就是 “离开上海站前,买点当地特产,发个朋友圈告别”。你不管下一站是去杭州还是回北京,只要你准备离开上海站,你就会做这件事。

隐式的“事件”:一种更符合前端直觉的设计

这里来解释一下:为什么我们的实现中,没有明确的、作为一等公民的“事件 (Events)”?

在经典的状态机理论(例如 UML 状态图)中,事件通常是显式的,状态机通过一个 send('EVENT_NAME')dispatch({ type: 'EVENT_TYPE' }) 的方法来接收事件。状态机会根据当前状态和接收到的事件类型来决定下一个状态。

为什么我们的实现中,没有明确的 send('EVENT_NAME')而我们的实现,采用了一种更直接、更符合前端函数调用习惯的“目标状态驱动 (State-driven)”模型。

// 我们的模型
machine.transitionTo('loading');

// 传统模型
machine.send('FETCH');

而我们的实现,采用了一种更直接、更符合前端函数调用习惯的“目标状态驱动 (State-driven)”模型。

// 我们的目标状态驱动模型
const machine = createStateMachine({
  initialState: 'idle',
  transitions: [
    { from: 'idle', to: 'loading' }
  ],
});
machine.transitionTo('loading');

我们为什么选择这种模型?

  1. 心智模型更简单:对于许多前端开发者来说,“我想要让组件进入 loading 状态”比“我需要发送一个 FETCH 事件来让组件进入 loading 状态”要更加直接。开发者思考的是状态本身,而不是触发状态的抽象事件。这降低了学习和使用的门槛。

  2. 事件是隐式存在的:虽然没有 send('EVENT'),但“事件”并没有消失,它只是被隐式地包含在了 transitionTo 的调用中

    • 当你调用 machine.transitionTo('loading') 时,这个调用本身就可以被理解为一个匿名的、意图为“转换到 loading”的事件。
    • context 对象进一步扮演了事件载荷 (payload) 的角色。machine.transitionTo('error', { error: new Error('...') }) 就等同于 send({ type: 'REJECT', payload: new Error('...') })
  3. 减少样板代码:在事件驱动模型中,你需要为每个事件命名,并在配置中显式地将事件映射到转换。在我们的模型中,这个映射被简化了:转换规则 from -> to 本身就定义了所有合法的“事件”(即所有合法的 transitionTo 调用)。

  4. 与现代前端框架的编程范式更契合:在 React 或 Vue 中,我们通常通过调用一个函数来改变状态(如 setState('loading')),而不是分发一个事件对象。我们的 transitionTo API 与这种范式无缝对接。

我们的状态机通过将“事件”设计为对 transitionTo 的调用,在保留了状态机核心确定性的前提下,提供了一个更简洁、更符合现代前端开发直觉的 API。这是一种设计上的权衡 (Trade-off),它牺牲了一部分理论上的纯粹性,换来了更高的开发效率和更低的心智负担,对于绝大多数前端应用场景来说,这是一个非常明智的选择。

总结与展望

从一个简单的状态管理需求出发,我们利用 TypeScript 的类型系统构建了一个安全的基础,通过精巧的数据结构设计保证了性能,通过分离同步与异步实现兼顾了不同场景,最终通过一个优雅的插件化架构赋予了它无限的生命力。

我们打造的不仅仅是一个状态机,更是一个软件设计的范例。它体现了单一职责、开闭原则、依赖倒置等核心原则。

未来可以探索的方向

  • 层级与并行状态机:支持更复杂的嵌套状态,实现类似 XState 的功能。
  • 可视化工具:创建一个可以读取状态机配置并自动生成可视化状态图的工具。
  • 框架深度集成:为 React, Vue, Angular 提供官方的 Hooks 或 Wrapper,进一步简化使用。

希望这篇文章能为你提供启发,让你在面对复杂的状态逻辑时,能够自信地亮出“状态机”这把利剑,斩断乱麻,让代码重归清晰与稳定。

最后的挽留:深入浅出HTML5 beforeunload事件

一、什么是beforeunload事件?

在网页开发中,有一种“隐形守护者”,它能在用户即将关闭页面或刷新时,悄无声息地伸出援手——这就是beforeunload事件

定义
beforeunload 是 HTML5 提供的一个窗口事件,当用户尝试关闭浏览器标签页、刷新页面或导航到其他页面时触发。它的核心作用是:在用户离开页面前,执行关键操作或弹出确认对话框,防止数据丢失或用户误操作。

触发时机

  • 用户点击关闭按钮(×)
  • 用户刷新页面(F5 或 Ctrl+R)
  • 用户通过地址栏输入新网址
  • 用户通过 JavaScript 调用 window.location.hrefwindow.close()

二、beforeunload的核心属性与方法

要驾驭 beforeunload 事件,需要了解它的“武器库”:

1. event.returnValue

  • 作用:设置确认对话框的提示信息(部分浏览器忽略此值,仅作为兼容性处理)。
  • 语法
    event.returnValue = "您有未保存的更改,确定离开吗?";
    

2. event.preventDefault()

  • 作用:阻止默认行为(即页面关闭或刷新),强制弹出确认对话框。
  • 注意:并非所有浏览器都支持此方法,需结合 event.returnValue 使用。

3. 兼容性处理

由于浏览器差异,需采用“双保险”策略:

window.addEventListener('beforeunload', function (e) {
  if (hasUnsavedData()) {
    e.preventDefault(); // 标准方法
    e.returnValue = ''; // 兼容旧版浏览器
    return '您有未保存的更改,确定离开吗?'; // 早期浏览器会显示此文本
  }
});

三、beforeunload的使用技巧

1. 精准判断“未保存数据”

beforeunload 的核心价值在于“防数据丢失”。你需要一个函数(如 hasUnsavedData())来判断用户是否进行了未保存的操作。例如:

  • 表单字段被修改但未提交
  • 文件上传未完成
  • 编辑器内容未保存

示例代码:

function hasUnsavedData() {
  const form = document.getElementById('my-form');
  return form.isDirty; // 假设表单有一个 isDirty 状态
}

2. 避免滥用

beforeunload 是一把双刃剑。滥用会导致用户体验恶化,例如:

  • 禁止用于广告弹窗:用户可能认为你的网站“卡死”了。
  • 不要在无关场景触发:比如用户只是滚动页面,却弹出离开提示。

3. 移动端优化

  • 性能问题:移动端浏览器对 beforeunload 的支持较弱,需进行兼容性测试。
  • 提示信息限制:移动端浏览器通常不显示自定义消息,仅显示默认文案(如“页面有未保存的更改”)。

四、beforeunload的典型应用场景

1. 表单数据保护

用户填写了复杂的表单,但未保存就关闭页面?beforeunload 可以弹出提示,避免数据丢失。

2. 文件上传中断警告

在文件上传过程中,用户可能因网络问题或误操作关闭页面。beforeunload 可提醒用户上传进度,并建议重新尝试。

3. SPA(单页应用)路由守卫

在 SPA 中,用户通过前端路由切换页面时,可以通过 beforeunload 实现类似“离开当前页面确认”的功能。

4. 数据缓存与回滚

在触发 beforeunload 时,可以将未保存的数据临时存储到 localStorage 或服务器,方便用户下次恢复。


五、beforeunload的注意事项

1. 浏览器限制

  • 默认文案不可控:现代浏览器(如 Chrome、Firefox)已弃用自定义提示消息,仅显示默认文案(如“页面有未保存的更改”)。
  • 异步操作陷阱:如果页面正在进行异步请求(如 AJAX),beforeunload 可能不会触发。

2. 性能影响

  • 阻塞缓存:绑定 beforeunload 会阻止浏览器使用页面缓存(如 Firefox 的 Back-Forward 缓存),导致返回页面时重新加载。
  • 慎用 alert/confirm/prompt:在 beforeunload 中调用这些方法可能导致行为异常。

3. 测试技巧

  • 手动测试:直接关闭标签页或刷新页面。
  • 开发者工具:在控制台输入 window.dispatchEvent(new Event('beforeunload')) 模拟事件。
  • 自动化测试:使用 Puppeteer 或 Selenium 模拟用户操作。

六、代码实战:一个完整的示例

// 监听 beforeunload 事件
window.addEventListener('beforeunload', function (e) {
  // 判断是否有未保存的数据
  if (hasUnsavedData()) {
    // 标准方法:阻止默认行为并设置提示
    e.preventDefault();
    e.returnValue = ''; // 兼容性写法
    return '您有未保存的更改,确定离开吗?'; // 部分浏览器会显示此文本
  }
});

// 模拟 hasUnsavedData 函数
function hasUnsavedData() {
  const form = document.getElementById('my-form');
  // 实际场景中需根据表单状态判断
  return form.isDirty; // 假设表单有一个 isDirty 属性
}

七、总结:beforeunload的“黄金法则”

  1. 只在必要场景使用:表单编辑、文件上传、敏感操作等。
  2. 提示信息简洁明了:避免技术术语,用用户语言表达。
  3. 尊重用户体验:不要干扰用户正常操作,避免滥用。
  4. 兼容性优先:为不同浏览器设计“双保险”策略。

结语
beforeunload 事件如同网页的“最后防线”,它能在用户即将离开时,守护数据、挽回操作。然而,它的力量也需要开发者谨慎使用,才能在技术与用户体验之间找到完美平衡。下次当你需要“挽留”用户时,不妨试试这个隐藏的利器!

记录:离线包实现桥接

写在前面

  1. 使用 WebViewJavascriptBridge 框架,将WebViewJavascriptBridge对象由原生端注入到WebView中。
  2. 前端检验 window.WebViewJavascriptBridge 是否存在
  3. 通过特定机制(事件监听/iframe) 等待桥接准备就绪
  4. 使用原生注入的 WebViewJavascriptBridge 对象提供的方法:
    • callHandler: 调用原生功能
    • registerHandler: 注册供原生调用的方法

jsBridge编写

新建一个jsBridge文件

const u = navigator.userAgent
const isAndroid = u.indexOf('Android') > -1 || u.indexOf('Adr') > -1

/* eslint-disable */
function setupWebViewJavascriptBridge(callback) {
  // 第一次调用这个方法的时候,为false
  if (window.WebViewJavascriptBridge) {
    return callback(WebViewJavascriptBridge)
  }
  if (isAndroid) {
    document.addEventListener('WebViewJavascriptBridgeReady', function () {
      callback(WebViewJavascriptBridge)
    }, false)
  } else {
    // 第一次调用的时候,也是false
    if (window.WVJBCallbacks) {
      return window.WVJBCallbacks.push(callback)
    }
    // 把callback对象赋值给对象。
    window.WVJBCallbacks = [callback]
    // 这段代码的意思就是执行加载WebViewJavascriptBridge_JS.js中代码的作用
    var WVJBIframe = document.createElement('iframe')
    WVJBIframe.style.display = 'none'
    WVJBIframe.src = 'https://__bridge_loaded__'
    document.documentElement.appendChild(WVJBIframe)
    setTimeout(function () {
      document.documentElement.removeChild(WVJBIframe)
    }, 0)
  }
}

/* Android 版本 WebViewJavascriptBridge 的设计要求:
通过调用 init() 方法,建立 JS 端与 Android 原生端的双向通信通道

iOS 版本的 WebViewJavascriptBridge 在桥接对象注入时自动完成初始化,
通过 iframe 机制触发加载后,桥接自动就绪 
*/
if (isAndroid) {
  setupWebViewJavascriptBridge(function (bridge) {
    bridge.init()
  })
}
export default {
  callhandler(name, data, callback) {
    setupWebViewJavascriptBridge(function (bridge) {
      bridge.callHandler(name, data, callback)
    })
  },
  registerhandler(name, callback) {
    setupWebViewJavascriptBridge(function (bridge) {
      bridge.registerHandler(name, function (data, responseCallback) {
        callback(data, responseCallback)
      })
    })
  }
}

Android

Android使用 addEventListener 的原因:

  • Android WebView 提供了标准的事件机制
  • 原生端可以在适当时候触发 JavaScript 事件
  • 通过 document.addEventListener 监听自定义事件是一种标准做法

Android原生端在初始化完成后主动触发 WebViewJavascriptBridgeReady 事件通知 JS 端

iOS

iOS 使用 iframe 的原因:

  • UIWebView(较老的 iOS WebView)没有像 Android 那样的直接事件触发机制
  • 但 WebView 会拦截特定 URL 请求,这是可以利用的特性

使用URL Scheme 拦截机制:

var WVJBIframe = document.createElement('iframe') 
WVJBIframe.style.display = 'none' 
WVJBIframe.src = 'https://__bridge_loaded__' 
document.documentElement.appendChild(WVJBIframe)

创建一个隐藏的 iframe 并设置特殊 URL,iOS 原生端通过拦截这个 URL 请求来知道需要初始化桥接。

使用

index.js

  import jsBridge from './jsBridge'
  
  /**
   * methods downloadImage  下载图片
   * params list {Array} base64列表parmas = {list: [base64]} base64不要前面的信息 img/png
   *        title {string}
   * */
    downloadImage  (params, responseCallback) {
        jsBridge.callhandler('downloadImage', params, (data) => {
          responseCallback(JSON.parse(data))
        })
    },
  
    /**
    * methods checkSmsCode 校验验证码
    * params businessCode  {string} (modifyDrawPasswordDynamic     銀行卡修改取款密碼
    * params strategyCode {string} (ebil_ebilling写死)
    * */
    checkSmsCode (params, responseCallback) {
        jsBridge.callhandler('checkSmsCode', params, (data) => {
          let obj = JSON.parse(data)
          if (obj.authStatus === 'pass') {
            responseCallback(JSON.parse(data))
          } else {
            Toast.info('驗證碼錯誤_SMS code Error')
          }
        })
   },
   
  /**
   * methods 校验账户的交易密码
   * params {} 无需传参
   * */
  getCheckPassword (responseCallback) {
    jsBridge.callhandler('getCheckPassword', {}, data => {
      if (data) {
        responseCallback(JSON.parse(data))
      }
    })
  },

前端页面:

    getImg () {
      this.$bridge.downloadImage({ list: this.url }, () => {
        // console.log(res)
      })
    },
    nextPage () {
      this.$bridge.checkSmsCode({ businessCode: 'creditAutoAply', strategyCode: 'credit_aply_sms_code' }, res => {
        this.$bridge.getCheckPassword(data => {
          if (data.authStatus) {
            // 保存文件信息
            this.$remote(SAVE_FILE_INFO, params).then(res => {
              ....
            })
          }
        })
      })
    }

总结

  • Android: 利用标准事件机制,原生主动通知 Web
  • iOS: 利用 URL 拦截机制,Web 主动触发原生初始化

用的好好的vue.config.js代理,突然报308, 怎么回事?🤔

背景

上周有个vue项目, package.json中显示的vue-cli-service版本是 "@vue/cli-service": "~4.5.0",本地开发代理配置突然不能使用了, 报308永久重定向错误

image.png

查看了一下vue.config.js中的代理转发配置,没发现明显问题,

  devServer: {
    proxy: {
      '/api': {
        target: 'https://test.xxx.com',
        changeOrigin: true,
        pathRewrite: {
          '^/api': '',
        },
      },
    },
  },

又看了一下公共网络请求方法设置的基础路径, 没发现问题

const instance = axios.create({
  // ...
  baseURL:process.env.NODE_ENV == 'development' ? '/api' : env.VUE_APP_BASE_URL,
})

最后又查看了一下api定义处接口的url, 也看不出来问题。

  // 店铺详情
  storeInfo: (id) => {
    return get(`/项目名/api-v2/ticketStore/noAuth/store/${id}`)
  },

当时开发任务时间紧张,只能先迂回过去, 根据api文档定义,盲写业务逻辑, 写完之后,部署到线上环境去调试。今天相对空闲一些, 想查找一下引发问题的原因,毕竟本地开发,不可能不使用接口代理转发功能,问题迟早都要解决,绕不过去。

问题排查

以我过去排查疑难问题的经验, 一个比较有效的做法,就是把执行流程中的每个步骤的详细信息打印出来,问题一般就会水落石出。按照这个思路, 我给代理转发配置中添加了许多执行步骤日志, 看看转发的目标url是否正确,是从哪个环节开始不正常了。

在请求转发事件中,打印请求转发目标url

onProxyReq: (proxyReq, req, res) => {
    console.log('=== 代理请求详情 ===')
    console.log('URL:',`${proxyReq.protocol}//${proxyReq.getHeader('host')}${proxyReq.path}`)
}

在请求转发响应事件中, 打印响应数据

onProxyRes: (proxyRes, req, res) => {
    console.log('=== 代理响应详情 ===')
    console.log('状态码:', proxyRes.statusCode)

    proxyRes.on('data', (chunk) => {
      body += chunk.toString()
    })
    
    proxyRes.on('end', () => {
      console.log('=== 错误详细信息 ===')
      console.log(body)
    })
},

重启服务,刷新了一下页面, 终端控制台输出如下:

=== 代理请求详情 ===
URL: https://test.xxx.com/xxx/api-v2/ticketStore/noAuth/store/578518674433958631?t=1756195546614
 
=== 代理响应详情 ===
状态码: 308
=== 错误详细信息 ===
<html>
<head><title>308 Permanent Redirect</title></head>
<body>

打印出来的目标url没问题, 但是响应不对。308和常见的301/302 重定向 类似, 表示目标地址已经迁移, 区别在于:308 要求客户端在重定向时必须保持原有的 HTTP 方法和请求体(比如 POST 还是 POST,不能变成 GET), 308 响应里通常会带一个 Location 头,指向新的地址, 那么这个新的地址是什么呢? 打印出来看看

vue-cli-service比较坑爹, 不会像vite一样, 每次修改了配置文件,重新启动服务,每次修改了vue.config.js的配置,都得手动重启服务,刷新页面。

onProxyRes: (proxyRes, req, res) => {
  let body = ''

  console.log('=== 代理响应详情 ===')
  console.log('状态码:', proxyRes.statusCode)

  // 打印所有响应头
  console.log('响应头:', proxyRes.headers)

  // 单独打印 Location
  if (proxyRes.headers.location) {
    console.log('Location:', proxyRes.headers.location)
  }
}

打印出来的内容如下:很奇怪,这不是没转发之前的接口地址吗, 为什么要原路返回? 此刻心态平稳,头脑清晰的我发现, 协议头发生了变化, 为什么发出去的时候协议是http, 返回的响应中变成了https。

=== 代理响应详情 ===
状态码: 308
响应头: {
  server: 'nginx/1.19.0',
  date: 'Tue, 26 Aug 2025 08:22:09 GMT',
  'content-type': 'text/html',
  'content-length': '171',
  connection: 'close',
  location: 'https://localhost/xxx/api-v2/ticketStore/noAuth/store/578518674433958631?t=1756196529903',
  'strict-transport-security': 'max-age=15724800; includeSubDomains',
  'access-control-allow-origin': '*',
  'access-control-allow-credentials': 'true',
  'access-control-allow-methods': 'PUT, GET, POST, OPTIONS,DELETE',
  'access-control-allow-headers': 'DNT,web-token,app-token,Authorization,Accept,Origin,Keep-Alive,User-Agent,X-Mx-ReqToken,X-Data-Type,X-Auth-Token,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,token,Cookie'
}
Location: https://localhost/xxx/api-v2/ticketStore/noAuth/store/578518674433958631?t=1756196529903

查了一下为什么协议会从 http 变成 https的原因, 常见原因有:

  1. 后端应用 / 框架本身做了强制跳转
    比如 Spring Boot、Django、Rails 等框架里,可以开启“强制 HTTPS”,请求 HTTP 就自动 301/308 跳转到 HTTPS。

  2. 反向代理(Nginx/Traefik/Ingress)配置了 HTTPS 跳转
    在代理里通常会有类似:

    if ($scheme = http) {
        return 308 https://$host$request_uri;
    }
    

    或者

    return 308 https://$host$request_uri;
    

    这会把所有 http 请求强制跳到 https。

  3. 应用识别到 X-Forwarded-Proto: http 和安全策略
    有的服务会检测请求头,如果发现是 http 协议,就直接重定向到 https。 (比如某些 API 网关、Kubernetes Ingress 默认行为)

  4. 浏览器/客户端 HSTS 策略
    如果你之前访问过 https://localhost 并且服务端设置了 HSTS 头,浏览器可能会强制所有后续请求走 HTTPS(不过你这里是服务端返回 308,更可能是前两种原因)。

逐条看了一下, 第三种情况的可能性最大。我先让运维查了一下最近Kubernetes Ingress有没有改动, 运维说最近半年都没有修改过,那么就得查请求头了,把请求头打印出来看一下

 onProxyReq: (proxyReq, req, res) => {
    // 追加打印
    console.log('Headers:', proxyReq.getHeaders())
 }

果然打印出来的请求头中有'x-forwarded-proto': 'http'

=== 代理请求详情 ===
// ...
Headers: [Object: null prototype] {
  'x-forwarded-host': 'localhost:8080',
  'x-forwarded-proto': 'http',
  'x-forwarded-port': '8080',
  'x-forwarded-for': '127.0.0.1',

为什么 x-forwarded-proto: http 会触发 308 重定向?

1. 后端/网关判断请求协议

  • 大部分后端框架、API 网关、Ingress Controller(nginx-ingress、Traefik 等)都会根据 X-Forwarded-Proto 来识别“原始请求协议”。
  • 如果它看到 x-forwarded-proto: http,而站点要求强制 HTTPS,就会返回 301/302/308 跳转到 https://

2. 典型 Nginx Ingress 配置
Nginx ingress 默认就有一个选项 force-ssl-redirect: true。 它的逻辑就是:

    if ($http_x_forwarded_proto = "http") {
        return 308 https://$host$request_uri;
    }

所以一旦代理传了 x-forwarded-proto: http,Ingress 就会强制跳转。至此,问题已经水落石出。

修复问题

既然Nginx Ingress遇到x-forwarded-proto: http,就会执行308重定向, 那么只需在vue-service-cli代理配置中, 每次转发请求时,移除x-forwarded-proto: http请求头设置就可以了。

  devServer: {
    proxy: {
      '/api': {
        // 添加这句
        xfwd: false,
      }
    }
  }

果然,提交之后,打印的请求头中所有以x-forwarded-开头的请求头都看不见了,代理响应也正常了。可是,为什么突然变成这样了,在没改项目配置的情况下。是不是@vue/cli-service的版本最近有升级,查看了一下依赖链,发现与半年前相比,并无改变。

@vue/cli-service@4.5.19
   ↓
webpack-dev-server@3.11.3 (依赖 webpack@4.47.0)
   ↓
http-proxy-middleware@0.19.1
   ↓
http-proxy@1.18.1
   ↓
follow-redirects@1.0.0

我将项目的git版本进行了回退, 回退到半年前, 添加了请求头打印, 发现也会输出x-forwarded-相关的请求头, 浏览器上显示请求响应代码是308, 所以这个问题不是前端的改动引起的,如果后端的话可信的话,可能是浏览器的安全策略升级导致的。

最后

我又找了一个vite项目对比了一下,发现vite.config.ts配置的server.proxy, 不会引发308重定向问题,打印了一下请求头,没有输出x-forwarded-xxx, 那就奇怪了。难道vite的代理请求使用的不是http-proxy, 查看了vite的官方源码,发现使用的代理工具果然不同, 是http-proxy-3,它是对经典 http-proxy 的 TypeScript 重写版本。目标是解决原版 http-proxy 中的 socket 泄漏、安全漏洞和老旧 API。已用于生产环境。看了一下 http-proxy最新的版本是v1.8.1, 5年之前发布的, 现在还使用它的话,本地代理转发默认的配置会引发308重定向问题, 难怪vite不使用它了。通过对这个问题的排查,让我觉得,开发工具得与时俱进,不定期升级才行,否则就会出现莫名其妙的幺蛾子。

React 18 核心新特性解析

React 18 核心新特性解析

一、自动批处理(Auto Batching)⭐⭐⭐⭐⭐

批处理是指 React 将多个状态更新,聚合到一次 render 中执行,以提升性能。但在  React 18 之前,React 只会在事件回调中使用批处理,而在 Promise、setTimeout、原生事件等场景下,是不能使用批处理的。而在 React 18 中,所有的状态更新,都会自动使用批处理,不关心场景。

// React 17(仅事件处理器内批处理) const handleClick = () => { setCount(c => c + 1); // 触发渲染 setFlag(f => !f); // 触发渲染 } // 最终只1次渲染 // 非事件场景(如Promise、setTimeout) fetchData().then(() => { setCount(c => c + 1); // 立即渲染 setFlag(f => !f); // 再次渲染 }); // React 18(全场景自动批处理) fetchData().then(() => { setCount(c => c + 1); setFlag(f => !f); }); // 合并为单次渲染 setTimeout(() => { setCount(c => c + 1); setFlag(f => !f); // React 只会 re-render 一次,这就是批处理 }, 1000);

强制立即渲染(应急方案): 如果你在某种场景下不想使用批处理,你可以通过 flushSync来强制同步执行(比如:你需要在状态更新后,立刻读取新 DOM 上的数据等。)

import { flushSync } from 'react-dom'; function handleClick() { flushSync(() => { setCounter(c => c + 1); }); // React 更新一次 DOM flushSync(() => { setFlag(f => !f); }); // React 更新一次 DOM }

二、并发模式(Concurrent Mode)⭐⭐⭐⭐⭐

CM 本身并不是一个功能,而是一个底层设计,它能 保持界面响应性同时执行耗时操作

在以前,React 在状态变更后,会开始准备虚拟 DOM,然后渲染真实 DOM,整个流程是串行的。一旦开始触发更新,只能等流程完全结束,期间是无法中断的。

 

 

在 CM 模式下,React 在执行过程中,每执行一个 Fiber,都会看看有没有更高优先级的更新,如果有,则当前低优先级的的更新会被暂停,待高优先级任务执行完之后,再继续执行或重新执行。

 

 

不过对于普通开发者来说,我们一般是不会感知到 CM 的存在的,在升级到 React 18 之后,我们的项目不会有任何变化。

 

我们需要关注的是基于 CM 实现的上层功能,比如 Suspense、Transitions、streaming server rendering(流式服务端渲染), 等等。

 

核心API:startTransition

React 的状态更新可以分为两类:

  • 紧急更新(Urgent updates):比如打字、点击、拖动等,需要立即响应的行为,如果不立即响应会给人很卡,或者出问题了的感觉
  • 过渡更新(Transition updates):将 UI 从一个视图过渡到另一个视图。不需要即时响应,有些延迟是可以接受的。
  • 举个例子:比如你在家看电影,这时候快递送达在敲你的门,startTransition 出来之前你会先看完整部电影再去拿快递,startTransition 之后识别了拿快递为高优先级,电影为低优先级,这时候你会暂定电影的播放,开门拿快递,然后回来继续看电影。

 

React 并不能自动识别哪些更新是优先级更高的,CM 只是提供了可中断的能力,也就是说默认情况下,所有的更新都是紧急更新。

例如键盘输入事件:

import { useState, startTransition } from 'react'; function SearchBox() { const [keywords, setKeywords] = useState(''); const [searchResults, setSearchResults] = useState([]); const handleChange = (e) => { setKeywords(e.target.value); // 即时更新输入框 startTransition(() => { // 延迟处理搜索 performHeavySearch(e.target.value).then(results => { setSearchResults(results); }); }); }; return ( <> <input value={keywords} onChange={handleChange} /> <SearchResults results={searchResults} /> </> ); }

毕达哥拉斯树

下方 Gif 图中可以看出,当我们把树的节点拉满,然后操作倾斜树,此时页面的真实 dom 会跟随滑动条的数值每一次的更新实时渲染,滑动条跟树都在抢渲染线程,你会感觉到滑动条拖动非常卡顿,体验感差。

开启 startTransition 后,滑动条的数值更新被标记为 高优先级 DOM,被实时渲染,树的渲染为低优先级 DOM,你会发现滑动条的拖动变得流畅,不再卡顿,而树渲染的卡顿被认为是可以接受的。

 

三、新 Hooks ⭐⭐⭐⭐

useId:跨平台唯一ID生成

支持同一个组件在客户端和服务端生成相同的唯一的 ID,避免 hydration 的不兼容。原理是每个 id 代表该组件在组件树中的层级结构。

function Checkbox() { const id = useId(); return ( <> <label htmlFor={id}>Accept</label> <input id={id} type="checkbox" /> </> ); }
 useDeferredValue:延迟更新非关键内容

useDeferredValue 可以让一个 state 延迟生效,只有当前没有紧急更新时,该值才会变为最新值。useDeferredValue 和 startTransition 一样,都是标记了一次非紧急更新。

上边 startTransition 的例子,就可以用 useDeferredValue来实现。

 

const [treeLeanInput, setTreeLeanInput] = useState(0); const deferredValue = useDeferredValue(treeLeanInput); function changeTreeLean(event) { const value = Number(event.target.value); setTreeLeanInput(value) } return ( <> <input type="range" value={treeLeanInput} onChange={changeTreeLean} /> <Pythagoras lean={deferredValue} /> </> )

 

四、根节点API升级 ⭐⭐⭐

// React 17 import ReactDOM from 'react-dom'; ReactDOM.render(<App />, document.getElementById('root')); // React 18 import { createRoot } from 'react-dom/client'; const root = createRoot(document.getElementById('root')); root.render(<App />);

 

 

 

 

React 19 核心新特性解析

一、服务器组件 Server Component - 主要更新

Server Components 是 React 19 中最大的变化之一,它提供了一种在服务器端渲染组件的新方法,并提供了更快、更高效的用户体验。

 

  • 缩短初始页面加载时间: 通过在服务器端渲染组件,React 19 减少了发送到客户端的 JavaScript 数量(只需返回渲染后的结果),从而加快了初始加载时间。在将页面发送到客户端之前,还可以在服务器上从数据库中对数据进行提取。
  • 增强的代码可移植性: 服务器组件允许开发人员编同时可以在服务器和客户端上运行的组件,从而减少重复,提高可维护性,并更轻松地在代码库中共享逻辑。
  • 更好的 SEO: 组件的服务器端渲染可确保发送到客户端的 HTML 已填充内容,从而使搜索引擎更容易抓取和索引网站。

 

// Users.server.jsx // Server Component: Fetches data and returns JSX export default async function Users() { const res = await fetch("https://api.example.com/users"); const users = await res.json(); return ( <div> <h1>Users</h1> {users.map((user) => ( <div key={user.id}> <h2>{user.name}</h2> <p>{user.role}</p> </div> ))} </div> ); }

 

React 通过服务器端渲染 (SSR) 进行了改进,它将第一个渲染移动到服务器。提供给用户的 HTML 不再为空,它提高了用户看到初始 UI 的速度。但是,仍需要获取数据才能显示实际内容。

我们第一次可以在 UI 呈现并显示给用户之前获取数据,提供给用户的 HTML 在第一次渲染时完全填充了实际内容,无需获取其他数据或第二次渲染。

二、Actions(异步操作的革命性改进)⭐⭐⭐⭐⭐

异步数据更新一直是 React 应用中的难点之一。React 19 引入了 Actions,通过支持异步函数来管理数据变更、加载状态、错误处理和乐观更新(optimistic updates),使复杂逻辑的处理变得更加简单。

 

自动管理 Pending 状态: 使用 useActionState 和 useFormStatus 等新钩子轻松处理表单的加载状态。

内置乐观更新支持: 通过 useOptimistic 实现实时数据更新。

更智能的错误处理: 集成错误边界,简化错误回退逻辑。

 

useActionState 管理异步函数状态

useActionState 是 React 19 新增的一个 Hook,用来管理异步函数,自动维护了 data、action、pending 等状态。

经过 useActionState 改造的代码如下:

// React 18 - 手动管理状态 function Form() { const [isPending, setIsPending] = useState(false); async function handleSubmit() { setIsPending(true); await fetch('/api'); setIsPending(false); } return ( <form onSubmit={handleSubmit}> <button disabled={isPending}> {isPending ? 'Submitting...' : 'Submit'} </button> </form> ); } // React 19 - 自动化状态管理 function Form() { // 接受一个异步请求函数,返回 [data、action、pending] const [error, handleSubmit, isPending] = useActionState( async () => { // 自动处理pending状态 await fetch('/api'); } ); return ( <form action={handleSubmit}> <button disabled={isPending}> {isPending ? 'Submitting...' : 'Submit'} </button> </form> ); }

useOptimistic 乐观更新

乐观更新是一种常见的体验优化手段,在发送异步请求之前,我们默认请求是成功的,让用户立即看到成功后的状态。

官方示例: 提交表单更新 name,可以立即将新的 name 更新到 UI 中。请求成功则 UI 不变,请求失败则 UI 回滚。

 

function ChangeName() { const [name, setName] = useState(""); // 定义乐观更新的状态 const [optimisticName, setOptimisticName] = useOptimistic(name); const submitAction = async (formData) => { const newName = formData.get("name"); // 请求之前,先把状态更新到 optimisticLike setOptimisticName(newName); try { await updateName(newName); // 成功之后,更新最终状态 setName(newName); } catch (e) { console.error(e); } }; return ( <form action={submitAction}> <p>Your name is: {optimisticName}</p> <p> <label>Change Name:</label> <input type="text" name="name" disabled={name !== optimisticName} /> </p> </form> ); }

useFormStatus 获取表单状态

useFormStatus 是 React 19 新增的一个 Hook,主要用来快捷读取到最近的父级 form 表单的数据,其实就是类似 Context 的封装。

 

import { useFormStatus } from "react-dom"; import action from './actions'; function Submit() { const status = useFormStatus(); return <button disabled={status.pending}>Submit</button> } export default function App() { return ( <form action={action}> <Submit /> </form> ); } const { pending, data, method, action } = useFormStatus(); useFormStatus 能拿到父级最近的 form 的状态: pending:是否正在提交中 data:表单正在提交的数据,如果 form 没有被提交,则为 null method:form 的 method 属性,get 或 post action:form 的 action 属性,如果 action 不是函数,则为 null useFormStatus 使用场景较窄,绝大部分开发者不会用到。

三、use Hook(异步处理新范式)⭐⭐⭐⭐⭐

use 是 React 19 新增的一个特性,支持处理 Promise 和 Context。

 

假如我们要实现这样一个需求:请求接口数据,请求过程中,显示 loading,请求成功,展示数据。

 

use 的使用有一些注意事项:

  • 需要在组件或 Hook 内部使用
  • use 可以在条件语句(比如 if)或者循环(比如 for)里面调用

 

以前我们可能会这样写代码:

// React 18 - useEffect方案 function ReactUseDemo() { const [data, setData] = useState(""); const [loading, setLoading] = useState(false); useEffect(() => { setLoading(true); getList() .then((res) => { setData(res); setLoading(false); }) .catch(() => { setLoading(false); }); }, []); if (loading) return <div>Loading...</div>; return <div>{data}</div>; } // React 19 - use Hook方案 export default function ReactUseDemo() { return ( <Suspense fallback={<div>Loading...</div>}> <ChildCompont /> </Suspense> ); } function ChildCompont() { const data = use(getData()); return <div>{data}</div>; }

 

四、Ref作为Prop(API统一)⭐⭐⭐⭐

在之前,父组件传递 ref 给子组件,子组件如果要消费,则必须通过 forwardRef 来消费。

// React 18 之前 function RefDemo() { const inputRef = useRef(null); const focusInput = () => { inputRef.current.focus(); }; return ( <div> <Input ref={inputRef} /> <button onClick={focusInput}>Focus</button> </div> ); } const Input = forwardRef((props, ref) => { return <input ref={ref} />; }); // React 19 export const Input = ({ ref }) => { return <input ref={ref} />; };

 

 

五、预加载资源 Preloading resources  ⭐⭐⭐

React 19 添加了几个新的 API,通过加载和预加载脚本、样式表和字体等资源来提高页面加载性能和用户体验。

这段 React 代码将生成以下 HTML 输出。请注意,链接和脚本的优先级和排序是按它们应该加载的时间,而不是根据它们在 React 中的使用顺序。

// React code import { prefetchDNS, preconnect, preload, preinit } from "react-dom"; function MyComponent() { preinit("https://.../path/to/some/script.js", { as: "script" }); preload("https://.../path/to/some/font.woff", { as: "font" }); preload("https://.../path/to/some/stylesheet.css", { as: "style" }); prefetchDNS("https://..."); preconnect("https://..."); }

六、文档元数据支持 Document Metadata ⭐⭐

React 19 将原生提升和渲染 title、link 和 meta 标签,甚至可以从嵌套组件中。不再需要第三方解决方案来管理这些标签。

// React 18 - 第三方库方案 import { Helmet } from 'react-helmet'; function ProductPage() { return ( <> <Helmet> <title>商品详情页</title> <meta name="description" content="商品详细信息页面" /> </Helmet> {/* ... */} </> ); } // React 19 - 原生支持 function ProductPage() { return ( <> <title>商品详情页</title> <meta name="description" content="商品详细信息页面" /> {/* ... */} </> ); }

完结撒花🎉

作者:陶帅星(汽车之家前端工程师)

第四章:幕后英雄 —— Background Scripts (Service Worker)

第四章:幕后英雄 —— Background Scripts (Service Worker)

本章目标:理解 Manifest V3 中 Service Worker 的工作模式,学会监听浏览器事件,并为我们的扩展建立一个持久的后台逻辑处理中心。


为什么需要一个“幕后英雄”?

让我们回顾一下我们目前的两个组件:

  • Popup:一个华丽的“驾驶舱”。它的生命周期极其短暂,用户点击图标时出现,点击别处时就彻底消失,里面的一切都会被重置。它只适合处理临时的、与用户直接交互的任务。
  • Content Script:一支支“外派无人机”。它们与特定网页共存亡,当网页关闭时,它们也就随之消失。它们擅长执行针对特定页面的任务,但无法获得全局视野,也无法在没有网页打开时工作。

现在,请思考几个问题:

  1. 当用户第一次安装我们的扩展时,我们想弹出一个欢迎页面或者设置一些默认选项,这个逻辑应该由谁来执行?Popup 和 Content Script 显然都不合适。
  2. 我们想在用户创建了一个新的标签页或者更新了某个标签页的网址时,立刻得到通知并执行某些操作,这个“监听”任务应该由谁来负责?
  3. 如果我们的扩展需要**定期(比如每小时)**从服务器获取一些数据,这个定时任务应该放在哪里?
  4. 当 Popup 需要获取所有标签页的信息时,它向谁去请求?当 Content Script 收集到页面数据后,它又该把数据汇报给谁?谁来做这个**“总调度”**?

所有这些问题的答案,都指向了同一个组件——Background Script(后台脚本)。

在 Manifest V2 的时代,这个后台脚本是一个可以一直存活在后台的页面(Background Page)。但在 Manifest V3 中,为了极大地提升性能和降低资源消耗,Google 将其升级为了一个更现代、更高效的模式——Service Worker

你可以把 Service Worker 想象成我们扩展的**“全天候待命的事件处理中心”。它平时处于“休眠”状态,不占用任何系统资源。但一旦有它所关心的事件**发生(比如扩展被安装、用户点击了某个菜单、闹钟响起),浏览器就会瞬间唤醒它,让它处理这个事件。处理完毕后,如果短时间内没有新的事件,它会再次进入休眠。

它就是那个在幕后默默守护着一切,不求闻达,却又不可或缺的英雄。


4.1 认识 MV3 的新核心:Service Worker

在深入实践之前,我们必须花一点时间来理解 Service Worker 的几个关键特性,因为它的工作模式与我们之前接触的任何 JavaScript 环境都大相径庭。

1. 事件驱动 (Event-driven)

这是你必须刻在脑子里的第一条准则。Service Worker 的所有代码,都应该被包裹在事件监听器中。

你不能在 Service Worker 的顶层作用域(全局作用域)编写像 setInterval 或者 fetch 这样的持续性或异步任务。因为当 Service Worker 执行完顶层代码并进入休眠后,这些任务就会被强行终止。

正确的做法是,把你的逻辑注册为对特定事件的响应。

错误的做法 ❌:

// background.js (顶层作用域)
let counter = 0;
setInterval(() => {
  counter++;
  console.log("计时器:", counter); // 这个计时器会在Service Worker休眠后被杀死
}, 1000);

正确的做法 ✅:

// background.js
// 监听扩展首次安装事件
chrome.runtime.onInstalled.addListener(() => {
  // 只有当 onInstalled 事件发生时,这里的代码才会被执行
  console.log("扩展已安装!");
  chrome.storage.local.set({ enabled: true }); 
});

// 监听闹钟事件 (用于替代 setInterval)
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'my-periodic-task') {
    console.log("闹钟响了,执行定时任务!");
  }
});

在正确的例子中,代码只有在特定的事件(onInstalled, onAlarm)被触发时才会运行。

2. 非持久性 (Non-persistent)

Service Worker 是一个“召之即来,挥之即去”的角色。浏览器为了节省资源,会非常“积极”地让它休眠。

  • 唤醒:当一个它监听的事件发生时,它会被唤醒。
  • 运行:它有大约 30 秒的时间来处理这个事件。如果你的异步操作(比如一个 fetch 请求)超过 30 秒还没完成,它可能会被强行终止。
  • 休眠:事件处理完毕后,如果短时间内没有新事件,它就会再次进入休眠状态。

这个特性带来的直接后果是:你绝对不能依赖 Service Worker 中的全局变量来存储状态!

错误的做法 ❌:

// background.js
let userSettings = null; // 这是一个全局变量

chrome.runtime.onInstalled.addListener(() => {
  userSettings = { theme: 'dark' }; // 在安装时设置
  console.log("设置已初始化:", userSettings);
});

chrome.action.onClicked.addListener(() => {
  // 当用户点击工具栏图标时...
  // 这里的 userSettings 很有可能已经是 null 了!
  // 因为从安装到用户点击,Service Worker 可能已经休眠并重启了无数次,
  // 全局变量早就被重置了。
  if (userSettings && userSettings.theme === 'dark') { 
    console.log("执行暗色主题逻辑");
  }
});

正确的做法 ✅: 使用 chrome.storage API。这是一个专为扩展设计的、持久化的存储方案。它独立于 Service Worker 的生命周期。

// background.js
chrome.runtime.onInstalled.addListener(() => {
  // 将状态存储在持久化的 chrome.storage 中
  chrome.storage.local.set({ theme: 'dark' });
  console.log("设置已存储到 storage");
});

chrome.action.onClicked.addListener(async () => {
  // 每次需要状态时,都从 storage 中异步获取
  const data = await chrome.storage.local.get('theme');
  const userSettings = { theme: data.theme };

  if (userSettings.theme === 'dark') {
    console.log("成功获取到状态,执行暗色主题逻辑");
  }
});

在正确的例子中,我们将状态保存在了 chrome.storage 这个“外部保险箱”里。无论 Service Worker 重启多少次,保险箱里的东西都不会丢。

3. 如何调试 Service Worker

这是另一个核心技巧。既然 Service Worker 在后台运行,没有界面,那我们去哪里看它的 console.log 和错误呢?

答案就在我们的老朋友——扩展管理页面 (chrome://extensions)。

  1. 找到你的扩展卡片。
  2. 在 Manifest V3 中,你会看到一个名为 “Service Worker”“查看视图:Service Worker” 的蓝色链接。
  3. 点击这个链接!

一个专门用于调试你的 Service Worker 的开发者工具窗口就会被打开。你可以在它的 Console 面板看到所有后台日志,在 Sources 面板打断点调试,在 Application 面板查看 Storage 的状态。

重要提示:这个调试窗口只要开着,浏览器就会为了方便你调试,而强制让你的 Service Worker 保持唤醒状态。这在开发时非常方便,但要记住,在测试真实休眠行为时,需要把这个窗口关掉。


4.2 项目实战:搭建我们的“指挥中心”

理论学习结束,让我们撸起袖子,为我们的“智能标签页管家”正式启用它的“大脑”。

第一步:创建 Background Script 文件

content.js 一样,我们把后台脚本也放在 scripts 文件夹里。

my-first-extension/scripts/ 目录下,创建一个新文件,命名为 background.js

📂 my-first-extension/
└── 📂 scripts/
    ├── 📄 background.js  <-- 新建这个文件
    └── 📄 content.js

第二步:在 manifest.json 中注册 Service Worker

我们需要再次修改我们的“营业执照”,告诉浏览器:“嘿,我有一个后台 Service Worker,它的文件在这里,请你帮我管理它。”

打开 manifest.json,在顶级作用域添加一个新的字段:"background"

{
  "manifest_version": 3,
  "name": "我的第一个扩展",
  "version": "1.0.0",
  ...
  "action": { ... },

  "background": {
    "service_worker": "scripts/background.js"
  },

  "content_scripts": [ ... ]
}

这个配置非常简单:

  • "background": 声明后台脚本配置的对象。
  • "service_worker": "scripts/background.js": 指定我们的 Service Worker 脚本的路径。路径同样是相对于扩展根目录的。

在 MV3 中,后台脚本必须通过这种方式指定,而且只能有一个

第三步:编写我们的第一个后台逻辑

现在,让我们在 background.js 里写入一些初始的、有代表性的事件监听逻辑。

打开 scripts/background.js,输入以下代码:

// scripts/background.js

// --- 1. 监听扩展安装和更新事件 ---
chrome.runtime.onInstalled.addListener((details) => {
    // 打印事件对象,方便调试
    console.log("onInstalled event details:", details);

    // 判断事件类型
    if (details.reason === "install") {
        console.log("感谢安装!这是您的第一次使用。");
        // 第一次安装时,可以执行一些初始化操作
        // 比如,设置默认的存储值
        chrome.storage.local.set({
            isExtensionEnabled: true,
            blockedSites: ["www.example.com"]
        });
        
        // 第一次安装后,可以打开一个欢迎页面或者教程页面
        // chrome.tabs.create({
        //     url: "welcome.html" // 我们需要先创建这个页面
        // });

    } else if (details.reason === "update") {
        const previousVersion = details.previousVersion;
        console.log(`扩展已从版本 ${previousVersion} 更新!`);
        // 在这里可以处理版本更新的逻辑,比如迁移旧数据
    }
});


// --- 2. 监听工具栏图标点击事件 ---
// 注意:如果 manifest.json 中定义了 popup 页面,
// 这个 onClicked 事件将不会被触发,因为点击的默认行为是打开 popup。
// 我们在这里写出来,是为了演示这个重要的 API。
chrome.action.onClicked.addListener((tab) => {
    console.log("工具栏图标被点击了!");
    console.log("当前标签页信息:", tab);
    // 在没有popup时,可以用来执行一些快捷操作
});


// --- 3. 监听标签页更新事件 ---
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
    // changeInfo 对象包含了变化的具体信息,比如 status 或 url
    // status 通常会经历 "loading" -> "complete" 的变化
    if (changeInfo.status === 'complete' && tab.url) {
        console.log(`标签页 ${tabId} 已加载完成, URL: ${tab.url}`);

        // 在这里,我们可以根据 tab.url 执行一些逻辑
        // 比如,如果 URL 匹配了某个规则,就禁用我们的 browserAction
        if (tab.url.includes("google.com")) {
            // chrome.action.disable(tabId);
            // console.log(`在 Google 页面禁用了图标`);
        } else {
            // chrome.action.enable(tabId);
        }
    }
});


// --- 4. 监听一个简单的消息 (为后续章节做准备) ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    console.log("收到了来自 Content Script 或 Popup 的消息:", message);
    console.log("消息发送方信息:", sender);
    
    if (message.greeting === "hello from content script") {
        // 收到消息后,可以做一些处理,然后回复
        sendResponse({ farewell: "你好,Content Script,我已经收到你的消息了。" });
    }
    // `return true;` 在需要异步发送响应时使用,我们后面会详细讲
});

console.log("Background Service Worker 已启动并正在监听事件。");

这是一段信息量巨大的代码,但别怕,它清晰地展示了 Service Worker 的核心工作模式——“监听-响应”。让我们逐一分析这些“哨兵”:

  1. chrome.runtime.onInstalled:

    • 何时触发:在扩展被新安装、更新到一个新版本,或者浏览器更新时触发。
    • 作用:这是执行一次性初始化操作的绝佳位置。比如设置默认值到 chrome.storage,或者像注释里写的,打开一个欢迎页面。
    • details.reason: 这个参数非常有用,它可以告诉你这次事件是 "install"(新安装)还是 "update"(更新),让你能执行不同的逻辑。
  2. chrome.action.onClicked:

    • 何时触发:当用户点击工具栏上的扩展图标时。
    • 重要前提只有在 manifest.jsonaction 字段里没有定义 default_popup 的情况下,这个事件才会生效! 如果定义了 popup,点击的默认行为就是打开 popup,这个事件监听器就会被忽略。
    • 作用:对于那些不需要复杂 UI,只需要一个快捷开关的扩展来说,这是核心交互。比如一个一键切换网页黑白模式的扩展。
  3. chrome.tabs.onUpdated:

    • 何时触发:当一个标签页被更新时。这个事件会触发多次,比如 URL 改变、加载状态改变(从 loadingcomplete)。
    • 作用:这是一个非常强大的“嗅探器”。你可以实时监控用户的浏览行为。
    • changeInfo.status === 'complete': 我们通常会加上这个判断,确保只在页面加载完成时才执行逻辑,避免不必要的重复操作。
    • 在注释的代码中,我们演示了如何根据 URL 动态地禁用 (disable)启用 (enable) 我们的工具栏图标,让它在某些网站上变成灰色不可点击状态。这是一种非常常见的交互优化。
  4. chrome.runtime.onMessage:

    • 何时触发:当扩展的其他部分(如 Content Script 或 Popup)使用 chrome.runtime.sendMessage 发送消息时。
    • 作用:这是我们扩展内部通信的核心枢纽。它就像是“指挥中心”的总接线员。我们会在下一章详细讲解它。现在,我们先把它放在这里,让它准备好接收信号。

第四步:申请权限

你可能已经注意到了,在上面的代码里,我们用到了 chrome.storagechrome.tabs 这些 API。默认情况下,我们的扩展是没有权限使用这些强大功能的。

如果我们不申请权限就直接使用,浏览器会在 Service Worker 的控制台里无情地报错:“Uncaught TypeError: Cannot read properties of undefined (reading 'local')” 或者 “Access to 'tabs' is not allowed”。

所以,我们必须再次更新我们的“营业执照” manifest.json,明确地告诉浏览器:“我需要使用‘存储’和‘标签页’这两项特权!”

manifest.json 的顶级作用域,添加一个新的字段:"permissions"

{
  "manifest_version": 3,
  ...
  "background": {
    "service_worker": "scripts/background.js"
  },

  "permissions": [
    "storage",
    "tabs"
  ],

  "content_scripts": [ ... ]
}
  • "permissions": 它的值是一个数组,里面列出了你扩展需要的所有权限。
    • "storage": 允许我们使用 chrome.storage API。
    • "tabs": 允许我们使用 chrome.tabs API(比如 onUpdated, create, query 等)。

权限申请原则:按需、最少。 永远只申请你功能所必需的权限。申请过多的权限不仅可能在应用商店审核时遇到麻烦,也会引起用户的警惕。

第五步:部署与验证

现在,我们已经万事俱备了。

  1. 保存所有修改过的文件 (manifest.json, background.js)。
  2. 回到 chrome://extensions 页面,刷新我们的扩展。
    • 第一次刷新(模拟安装):因为你添加了 background 字段,这相当于一次重大更新。刷新后,你应该能看到你的扩展卡片上多出了那个蓝色的 “Service Worker” 链接。
  3. 点击“Service Worker”链接,打开后台的开发者工具。
    • Console 面板,你应该能看到 onInstalled 事件被触发时打印的日志,比如 “扩展已从版本 xxx 更新!”。
    • 你还能看到最下面那句:“Background Service Worker 已启动并正在监听事件。”
  4. 验证 onUpdated 事件
    • 保持后台开发者工具窗口打开。
    • 在你的浏览器里随便打开一些新的标签页,或者刷新现有的标签页。
    • 观察后台的 Console,你会看到 onUpdated 事件被频繁触发,并在页面加载完成时打印出 “标签页 xxx 已加载完成, URL: ...” 的信息。
  5. 验证 storage 初始化
    • 在后台开发者工具窗口中,切换到 Application 面板。
    • 在左侧的 Storage -> Local Storage 下,虽然你看不到 chrome.storage.local 的直接内容,但你可以通过在 Console 中执行 chrome.storage.local.get(console.log) 来查看。你应该能看到我们初始化的 { isExtensionEnabled: true, blockedSites: [...] }

到此为止,我们已经成功地为我们的扩展植入了“大脑”。它现在是一个拥有后台逻辑、能够响应浏览器级事件、并为未来通信做好了准备的、更加完整的应用程序了。


本章总结与展望

在这一章,我们攻克了 Manifest V3 中最核心、最具变革性的一个概念:

  1. 理解了 Service Worker:我们掌握了它“事件驱动”、“非持久性”的核心工作模式,并知道了不能依赖全局变量,而应使用 chrome.storage 来持久化状态。
  2. 学会了调试后台:我们知道了如何通过 chrome://extensions 页面打开 Service Worker 的专属开发者工具。
  3. 实践了事件监听:我们亲手编写了对扩展安装、标签页更新等关键浏览器事件的监听器,让我们的扩展具备了后台处理能力。
  4. 掌握了权限申请:我们学会了在 manifest.json 中使用 permissions 字段,为我们的扩展申请必要的操作权限。

我们的“智能标签页管家”现在拥有了:

  • 一个漂亮的交互界面 (Popup)
  • 一支强大的勘探部队 (Content Scripts)
  • 一个可靠的指挥中心 (Background Script)

所有的部件都已就位。但是,它们之间还是一座座“孤岛”。指挥中心无法向勘探部队下达指令,勘探部队也无法将情报传回,交互界面更是无法从指挥中心获取全局数据。

在下一章,我们将要搭建连接这些孤岛的“跨海大桥”——我们将深入学习扩展内部的消息通信机制 (Messaging)。

那将是真正激动人心的时刻,我们将把所有独立的模块连接成一个有机的、协同工作的整体。我们的“智能标签页管家”将真正开始变得“智能”。

el-select使用filter-method实现自定义过滤

最近有个任务要用el-select来自定义过滤,发现有很多细节,网上搜的很多不够严谨,情况考虑不全,此处给出完整解决方案

实现DEMO

  • filter-method input里输入时才会触发,和input上的change事件一样,它只会传一个参数:检索的文字。要结合各种情况,自己过滤出optionsFilter给option赋值

  • 容易遗漏的情况:当输入一段和下面选项不匹配的文字时,此时过滤的结果列表是空,这时点击空白处,检索文字会自动清空,过滤结果保持是空,会影响下一次搜索,所以需要配合@focus来重置过滤结果

<template>
  <div>
    <el-select v-model="seledMan"
               clearable
               filterable
               size="small"
               :filter-method="filterMan"
               placeholder="请选择"
               @change="searchFirstList"
               @clear="filterMan"
               @focus="filterMan()">
      <el-option v-for="item in optionsFilter"
                 :key="item.value"
                 :label="item.label"
                 :value="item.value">
      </el-option>
    </el-select>
  </div>
</template>
<script>
export default {
  data() {
    return {
      optionsOrigin: [
        { value: 'zhangsan', label: '张三' },
        { value: 'lisi', label: '李四' },
        { value: 'wangwu', label: '王五' },
        { value: 'zhaoliu', label: '赵六' },
      ],
      optionsFilter: [],

      seledMan: '',
    }
  },
  methods: {
    filterMan(query) {
      console.log('触发过滤')
      if (!query) {
        this.optionsFilter = this.optionsOrigin
        return
      }
      const queryStr = query.toLowerCase()
      const filterList = this.optionsOrigin.filter((option) => {
        const nameZh = option.label,
          namePY = option.value.toLowerCase()
        return nameZh.includes(queryStr) || namePY.includes(queryStr)
      })
      this.optionsFilter = filterList
    },

    searchFirstList() {
      //选择人之后,重置页面为第一页,触发检索
      console.log('selDone', this.seledMan)
    },
  },
  created() {
    // 也可以是接口获取optionsOrigin后,同时赋给optionsFilter
    this.optionsFilter = [...this.optionsOrigin]
  },
  mounted() {},
  computed: {},
}
</script>
<style lang="scss" scoped>
</style>

前端SEO处理

1. SEO 元素

Title

  • 重要性:页面的标题对搜索引擎排名具有很高的权重。

  • 最佳实践

    • 突出页面的重点内容。
    • 重要关键词尽量靠前出现,且出现次数不超过两次。
    • 每个页面的标题要独特,不同于其他页面。

Description

  • 重要性:描述性文本为搜索引擎和用户提供页面内容的概括。

  • 最佳实践

    • 高度概括页面内容,吸引用户点击。
    • 长度适中,通常在 150-160 个字符之间,避免关键词堆砌。
    • 每个页面的描述要有所不同,确保相关性。

Keywords

  • 重要性:虽然现代搜索引擎对关键词的重视程度降低,但依然有其参考价值。

  • 最佳实践

    • 列举出重要关键词,确保与页面内容相关。
    • 注意避免过度使用,保持自然流畅。

2. 语义化的 HTML 代码

  • 重要性:语义化 HTML 代码有助于搜索引擎理解页面内容的结构和重要性。

  • 最佳实践

    • 使用适当的 HTML 标签(如 <header><article><nav> 等)来组织内容。
    • 确保代码符合 W3C 标准,提升可访问性。

3. HTML 代码顺序

  • 抓取顺序:搜索引擎抓取 HTML 内容的顺序是从上到下。

  • 最佳实践

    • 重要内容应放在 HTML 代码的前面,确保被优先抓取。
    • 避免使用 JavaScript 输出重要内容,因为爬虫不会执行 JavaScript。

4. 减少 IFrame 使用

  • 问题:搜索引擎通常无法抓取 IFrame 中的内容。

  • 最佳实践

    • 尽量减少使用 IFrame,尤其是用于加载关键内容。

5. 图片的 alt 属性

  • 重要性:非装饰性图片的 alt 属性不仅有助于 SEO,还有助于提高网站的无障碍性。

  • 最佳实践

    • 为每个非装饰性图片添加 descriptive 的 alt 文本,描述图像内容或其功能。

6. 网站速度

  • 重要性:页面加载速度是搜索引擎排名的重要指标。

  • 最佳实践

    • 优化资源(如图像压缩、CSS 和 JS 合并)。
    • 使用缓存和内容分发网络(CDN)来加快加载速度。

总结

通过合理设置 title、description 和 keywords,使用语义化 HTML 代码,优化内容结构,并关注网站速度图片描述,可以显著提升网页的 SEO 效果。这些做法不仅能帮助搜索引擎更好地理解和抓取网页内容,还能提升用户体验,增加网站的可见性和流量。

什么是单点登录,如何实现

核心概念

不同域名下的多个子系统共享登录状态

关键原理

### 🔄postMessage + LocalStorage

这种方式的实现原理是登录主系统后,将token通过postMessage发送给子系统,子系统进行message的监听

// 主系统登录成功逻辑  
const token = "xxxx";  
const domains = ["https://sub1.com", "https://sub2.com"];  
domains.forEach(domain => {  
  const iframe = document.createElement("iframe");  
  iframe.style.display = "none";  
  iframe.src = `${domain}/sso-sync.html`;  
  document.body.appendChild(iframe);  
  iframe.onload = () => iframe.contentWindow.postMessage(token, domain);  
});
//子系统sso-sync.html页面
<!-- sub1.com/sso-sync.html -->  
<script>  
  window.addEventListener("message", e => {  
    if (e.origin === "https://main.com") localStorage.setItem("token", e.data);  
  });  
</script>

该种方式,有一个我一直没理解的就是,需要在主系统的iframe中打开子系统的一个专门的页面用来接收主系统的postMessage,总感觉不是很顺

🔒 反向代理统一入口方案

通过nginx来配置代理,是一个很巧妙的方式,打开逻辑就是将不同域名执行同一个域名的不同的子路径,在浏览器端仍然是一个路径下,实现登录状态的共享

server {
  listen 80;
  server_name gateway.com; # 统一入口域名
  location /app1 { proxy_pass http://app1.com; }
  location /app2 { proxy_pass http://app2.com; }
}
  • 所有系统访问入口为 gateway.com/app1gateway.com/app2,浏览器视为同源。
  • 系统间跳转使用相对路径(如 window.location.href = '/app2/home'),避免跨域检测

🧩 CORS + 集中式认证中心方案

适用于独立域名系统且需精细控制跨域权限的场景。具体的实现就是将登录的逻辑交给一个独立的域名下-认证中心,所有的子系统在未登录的情况下,重定向到认证中心进行登录,并在登录后跳回原子系统,并携带token 在后续的子系统登录时采用同样逻辑,跳转到认证中心,因为认证中心已经登录过,所以可以直接回传回第二个子系统,实现一次登录,不同系统的状态共享

  1. 认证中心设计
    • 独立部署于 auth-center.com,提供登录页和令牌签发接口。
    • 响应头配置:
Access-Control-Allow-Origin: https://app1.com
Access-Control-Allow-Credentials: true
  1. 子系统登录跳转
    • 未登录用户访问 app1.com时,重定向至:
window.location.href = 'https://auth-center.com/login?redirect_uri=' + encodeURIComponent('https://app1.com/callback');
  1. 令牌传递与验证
    • 认证中心回调 app1.com/callback?token=xxxx,前端存储令牌至内存或 SessionStorage
    • 切换至 app2.com时,重复上述流程,认证中心通过 Cookie自动识别已登录用户,直接签发令牌

图表

🔄postMessage + LocalStorage

graph TD
    A[用户登录主系统] --> B[主系统生成Token]
    B --> C[创建隐藏iframe加载子系统页面]
    C --> D[iframe加载完成后发送postMessage]
    D --> E[子系统监听message事件]
    E --> F[接收Token存入LocalStorage]
    F --> G[子系统建立本地会话]

CORS + 集中式认证中心方案

graph LR
    A[用户访问子系统A] --> B{本地有Token?}
    B -->|无| C[重定向至认证中心]
    B -->|有| D[验证Token有效性]
    C --> E[用户在认证中心登录]
    E --> F[认证中心生成Token]
    F --> G[重定向回子系统A并附带Token]
    G --> H[子系统A验证Token]
    H --> I[建立本地会话]
    I --> J[用户访问子系统B]
    J --> K{本地有Token?}
    K -->|无| C
    K -->|有| D

通过兵马俑排列讲清Flex、Grid布局

前言:

公元前246年

秦始皇横扫六合,一统中华,自觉功高盖世,遂召李斯

秦始皇:“朕一统天下,死后理应有幽冥大军护我左右,应当以活人同殉”

李斯:“不可,以活人殉,显暴戾,若以陶土代之,既可彰陛下天威,又可安民心”

秦始皇:“善,朕欲陶士每行四尊,每列三具,列阵整肃齐列”

李斯心想:‘你这老登,死这为难我,废了,脑袋保不住了’

恍惚之际,神光显于李斯周身,一英俊潇洒,酷似吴彦祖的男人现于李斯身侧

男人说道:“我来助你成千秋伟业”

屏幕前的各位已经穿越到了公元前246年的西安,化身吴彦祖,一起来帮助李斯吧

一、全兵士情况

flex布局是实现兵马俑排列的较为复杂的方式,当兵马俑内全部是兵士时,和grid布局差异不大

Flex布局

布局效果

image.png

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        .container {
            display: flex;
            justify-content: space-between;
            flex-wrap: wrap;
        }

        .box {
            height: 200px;
            width: 400px;
            background-color: orange;
            margin-right: 20px;
            margin-bottom: 30px;
        }

        .box:nth-child(4n) {
            margin-right: 0;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
</div>
</body>
</html>

Grid布局

布局效果

image.png

1、grid-template-columns: repeat(4, 1fr);
这里的fr,代表占用一个格子,repeat(4,1fr)表示一行四个盒子,每个盒子占用一个格子,也可以写成grid-template-columns: 1fr 1fr 1fr 1fr;这种形式,里面的属性也可以换成px,纵向同理
2、gap是行列间距

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        .container {
            display: grid;
            grid-template-columns: repeat(4, 1fr);
            gap: 10px;
        }

        .box {
            height: 200px;
            background-color: orange;
        }

    </style>
</head>
<body>
<div class="container">
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
</div>
</body>
</html>

二、第一行中间加入一辆2*2的战车

秦始皇突发奇想,要求李斯在原本十二兵士前排中心插入一辆2*2的战车,发挥你们的能力救救李斯

这种情况下不建议使用flex布局,过于复杂,这里只展示gird布局方式

布局效果

image.png

gird-column:2/4:表示从第2列开始,占据2个格子
grid-row: 1/3:表示从第1行开始,占据2个格子

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        .container {
            display: grid;
            grid-template-columns: repeat(4, 1fr);
            gap: 10px;

        }

        .box {
            height: 200px;
            background-color: orange;
        }

        .box1 {
            grid-column: 2/4;
            grid-row: 1/3;
            background-color: pink;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="box"></div>
    <div class="box"></div>
    <div class="box1"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box"></div>
</div>
</body>
</html>

三、自适应布局

秦始皇又要求李斯,能够使得兵士根据墓道大小自动改变行列排列,李斯此刻正在三角洲大量购买全家福,企图保住他的浮木,请帮助他

自适应布局仍然推荐使用Grid,导航栏的话可以使用Flex

grid-template-columns: repeat(auto-fill, minmax(255px, 1fr)); 这种写法可以用来做响应式布局,auto-fil主轴上指定的宽度或者重复次数是最大可能的正整数,minmax最小值255px、最大值1fr代表剩余空间的比例。

李斯此刻恍然大悟,默默收起了全家福以备下次使用,请各位彦祖跟李斯一起完成兵马俑坑道的建设吧

避免 node_modules 修改被覆盖:用 patch-package 轻松搞定

在前端开发中,我们偶尔会遇到需要修改 node_modules 依赖包代码的场景 —— 比如调整组件样式、修复第三方库 bug 等。但这里藏着一个棘手问题:每次执行npm installyarn install重新安装依赖时,之前手动修改的代码都会被无情覆盖,辛苦调整的内容瞬间归零。

想要解决这个 “修改易丢失” 的痛点,patch-package工具堪称最优解。它能将我们对 node_modules 的修改转化为可复用的补丁文件,并且在依赖安装完成后自动执行补丁,让自定义修改稳定保留,无需每次安装后重复手动调整。

一、patch-package 使用步骤

1. 安装依赖工具

首先在项目中安装patch-package,作为开发依赖使用,执行以下命令即可:

bash

# 使用npm安装
npm install patch-package --save-dev

# 若使用yarn,执行此命令
yarn add patch-package --dev

2. 手动修改目标依赖代码

找到 node_modules 中需要调整的文件(例如某 UI 组件的源码文件node_modules/uv-ui/components/button/button.vue),按照业务需求直接修改代码 —— 无论是调整逻辑、修改样式还是修复 bug,都可以像修改本地文件一样操作。

3. 生成专属补丁文件

修改完成后,执行命令生成补丁(注意将命令中的 “xxx” 替换为你实际修改的依赖包名称,比如 “uv-ui”):

bash

npx patch-package xxx

执行成功后,项目根目录会自动创建一个patches文件夹,里面会生成以 “包名 + 版本号.patch” 命名的文件(例如@climblee+uv-ui+1.1.20.patch),这个文件完整记录了你的修改内容,相当于给依赖包打了一个 “专属补丁”。

4. 配置自动应用补丁

为了让每次安装依赖后都能自动触发补丁,需要在package.jsonscripts字段中添加postinstall命令:

json

{
  "scripts": {
    "postinstall": "patch-package"
  }
}

这里无需手动执行postinstall,因为 npm 或 yarn 会在npm install/yarn install命令执行完毕后,自动运行这个脚本,将patches文件夹中的补丁应用到新安装的依赖中,确保修改不丢失。

二、注意事项

  1. 锁定依赖版本:如果后续升级了被修改的依赖包(比如从 1.1.20 升级到 1.1.21),之前生成的补丁会失效,因为新版本的代码结构可能已变化。建议在package.json中锁定该依赖的版本,避免意外升级导致补丁失效。
  2. 控制修改范围:若对依赖包的修改内容过多(比如重构核心逻辑、新增大量功能),不建议使用patch-package。这种情况下,更推荐 fork 原依赖包到自己的仓库,在仓库中维护自定义版本,再通过 npm/yarn 安装自己仓库的依赖包,这样更便于长期维护。

五分钟集成一个高颜值甘特图?这个国产开源库让你告别繁琐!

想在Web项目中快速集成一个功能强大、交互流畅的甘特图?别再手动canvas雕花了,或许这个宝藏开源库能让你事半功倍。

前言:甘特图开发的痛

作为前端开发者,在接到“做一个项目进度管理后台”的需求时,最头疼的模块之一就是甘特图。自己从零实现?

  • 要处理复杂的时间线坐标计算
  • 要实现任务条的拖拽交互
  • 要画任务间的依赖关系线
  • 还要考虑性能优化数据动态更新

一套组合拳下来,头发又少了几根。而市面上的商业组件库往往价格不菲。今天,就给大家安利一个我偶然发现的国产开源甘特图解决方案——mzgantt,亲测后感觉非常惊艳,迫不及待分享给大家。

一、 mzgantt:一眼心动的亮点

在决定使用一个开源库前,我们最关心的是什么?功能、颜值、文档和易用性。mzgantt 在这几点上做得相当出色:

  • 🎨 现代美观的UI设计:视觉效果清爽,交互反馈细腻,脱离“开源=粗糙”的刻板印象。

  • ⚡ 开箱即用的丰富功能

    • 任务拖拽、进度调整
    • 依赖关系与关键路径线
    • 多级树形结构(支持折叠/展开)
    • 视图缩放(日、周、月)
  • 📖 中文文档 & 详细API:这对国内开发者来说是巨大的福音,学习成本和集成成本极低。

  • 🛠 多种集成方式:支持CDN直接引入和NPM包安装,完美适配不同技术栈的项目。

二、 5分钟快速上手实战

光说不练假把式,下面我们通过一个最简单的例子,让你在本地立刻跑起来一个功能完整的甘特图。

1. 引入库(两种方式任选)

方式一:CDN引入(最适合快速体验)
在你的HTML文件中直接引入构建好的CSS和JS文件。

html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>mzgantt Quick Start</title>
    <!-- 1. 引入样式 -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/mzgantt@latest/dist/mzgantt.min.css">
</head>
<body>
    <!-- 2. 准备一个容器 -->
    <div id="ganttEl" style="width: 1000px; height: 600px; margin: 20px auto; border: 1px solid #eee;"></div>

    <!-- 3. 在body底部引入JS并初始化 -->
    <script src="https://cdn.jsdelivr.net/npm/mzgantt@latest/dist/mzgantt.min.js"></script>
    <script>
        // 等下我们的初始化代码就写在这里
    </script>
</body>
</html>

方式二:NPM安装(推荐用于生产项目)
如果你使用的是Vue、React等现代化前端框架,可以通过NPM安装。

bash

npm install mzgantt

然后在你的组件中引入:

javascript

import Gantt from 'mzgantt';
import 'mzgantt/dist/mzgantt.css'; // 别忘了引入样式!

// 你的组件逻辑...

2. 初始化与数据加载

在上面的HTML的<script>标签内(或你的框架组件生命周期中),添加以下代码:

javascript

document.addEventListener('DOMContentLoaded', function() {
  // 初始化,绑定到容器元素
  const gantt = new Gantt('#ganttEl');
  
  // 准备演示数据
  const demoData = [
    {
      id: 'task-1',
      name: '项目启动会',
      start: '2023-11-01',
      end: '2023-11-01',
      progress: 100
    },
    {
      id: 'task-2',
      name: '需求分析',
      start: '2023-11-02',
      end: '2023-11-05',
      progress: 100
    },
    {
      id: 'task-3',
      name: 'UI/UX设计',
      start: '2023-11-06',
      end: '2023-11-10',
      progress: 80,
      dependencies: 'task-2' // 此任务依赖于id为'task-2'的任务
    },
    {
      id: 'task-4',
      name: '前端开发',
      start: '2023-11-09', // 可以和设计阶段重叠
      end: '2023-11-20',
      progress: 50,
      dependencies: 'task-3'
    }
  ];
  
  // 加载数据
  gantt.load(demoData);
});

3. 查看效果!

现在,用浏览器打开你的HTML文件。恭喜你!一个交互式的甘特图已经成功渲染。你可以:

  • 拖拽任务条来调整日期。
  • 看到任务3和任务4之间的依赖关系箭头线
  • 看到每个任务进度条的可视化展示。

整个过程,你没有写任何绘图和交互逻辑,只是声明了数据,功能完整的甘特图就跃然屏上。

三、 总结与后续探索

通过这个简单的例子,我们可以看到mzgantt极大地简化了甘特图的集成复杂度。它通过数据驱动的方式,让开发者可以更专注于业务逻辑而非视图实现。

下一步你可以:

  • 前往官方文档探索视图配置(viewMode)自定义列事件监听等高级功能。
  • 在GitHub上查看项目源码,学习其实现思路。
  • 思考如何将其与你现有的Vue/React项目集成(通常使用包装组件或自定义Hook的方式)。

评论区聊聊:
你在项目中遇到过甘特图的需求吗?之前是怎么解决的?觉得这个库怎么样?欢迎一起讨论!

相关标签:  #前端 #JavaScript #开源 #可视化 #甘特图 #项目管理

Java中wait()和sleep()方法到底差在哪?通俗解释

Java中wait()和sleep()方法到底差在哪?通俗解释

🎬 引子:一个让人抓狂的面试题

上周面试时,面试官问了个看似简单的问题:"wait()sleep() 有什么区别?"

我心想,这还不简单?一个是等待,一个是睡眠呗!结果刚开口就被打断:"别急着回答,你真的理解它们的本质区别吗?"

这一问,把我问懵了。平时用得挺多,但细想起来,确实说不出个所以然来。

🔍 探索:从表面现象开始

回家后,我决定好好研究一下。先从最直观的使用场景入手:

场景一:多线程等待

想象一下,你在餐厅排队等位。sleep() 就像你戴着耳机玩手机,完全沉浸在自己的世界里,即使服务员叫你的号码,你也听不见。

wait() 就不同了,你虽然在等待,但耳朵一直竖着听服务员的招呼。

// sleep() - 自顾自地睡,谁也叫不醒
public void useRestroom() {
    System.out.println("占用洗手间...");
    Thread.sleep(5000); // 雷打不动睡5秒
    System.out.println("使用完毕");
}

// wait() - 可以被唤醒的等待
synchronized(lockObject) {
    while(!condition) {
        lockObject.wait(); // 等待条件满足
    }
    // 继续执行
}

💥 踩坑瞬间:锁的秘密

在实际测试中,我遇到了一个奇怪的现象。同样是让线程暂停,为什么 wait() 需要放在 synchronized 块里,而 sleep() 却可以随便用?

更诡异的是,当我用 wait() 时,其他线程竟然可以访问同一个对象!而用 sleep() 时,其他线程只能干等着。

这时我才意识到,问题的核心不在于"等待"和"睡眠",而在于锁的处理方式

🎯 解决:揭开真相的那一刻

经过深入研究,终于搞明白了它们的本质区别:

核心差异对比表

维度 wait() sleep()
锁的处理 释放对象锁 不释放任何锁
调用位置 必须在synchronized中 任何地方都可以
唤醒方式 notify()/notifyAll() 时间到自动醒
所属类 Object类方法 Thread类静态方法
异常处理 InterruptedException InterruptedException

关键代码对比

// wait() - 会释放锁,让其他线程有机会执行
synchronized(sharedResource) {
    while(!ready) {
        sharedResource.wait(); // 释放锁,其他线程可以进入
    }
    // 被唤醒后重新获得锁
}

// sleep() - 死死抱着锁不放
synchronized(sharedResource) {
    Thread.sleep(1000); // 睡觉时仍持有锁,其他线程干瞪眼
    // 醒来后继续持有锁
}

🧠 经验启示:何时用谁?

wait() 适用场景

  • 生产者消费者模式:队列满了生产者等待,队列空了消费者等待
  • 线程协作:需要等待某个条件成立
  • 资源竞争:多个线程需要协调访问共享资源

sleep() 适用场景

  • 定时任务:每隔一段时间执行某个操作
  • 限流控制:控制操作频率
  • 模拟耗时操作:测试时模拟网络延迟

🎯 总结:一句话记住核心

wait() 是礼貌的等待 - 会让出资源给别人用;sleep() 是霸道的睡觉 - 占着茅坑不拉屎。

现在想想,那个面试官问得确实有水平。表面上问的是两个方法的区别,实际上考查的是对Java多线程同步机制的理解深度。

看来,技术这东西,表面功夫不够,还得深入底层原理才能真正掌握啊!

本文转自渣哥zha-ge.cn/java/9

鸿蒙应用开发深度解析:从基础列表到瀑布流,全面掌握界面布局艺术

鸿蒙应用开发深度解析:从基础列表到瀑布流,全面掌握界面布局艺术

引言

随着鸿蒙生态的蓬勃发展,HarmonyOS Next(鸿蒙Next)作为纯血的鸿蒙系统,其应用开发也迎来了全新的机遇与挑战。应用界面是用户感知产品的第一触点,而信息的高效、优雅呈现则离不开强大的布局组件。在鸿蒙应用开发中,ListArcListGrid 和 WaterFlow 是构建复杂列表页面的四大核心利器。本文将深入剖析这四种组件的特性、使用场景及实现细节,助你轻松驾驭鸿蒙界面开发。


一、 核心列表与网格组件概览

在深入每个组件之前,我们先通过一个表格快速了解它们的核心特性和适用场景:

组件名称 核心特性 最佳适用场景 所属API版本
List 线性垂直/水平滚动,性能优化,项复用 通讯录、消息列表、设置项等常规线性列表 ArkUI API 7+
ArcList 沿圆弧方向排列和滚动,支持3D旋转效果 智能手表、智慧屏等圆形或曲面设备 ArkUI API 8+
Grid 二维网格布局,同时支持行与列方向的滚动 应用市场、相册、功能入口等网格状界面 ArkUI API 7+
WaterFlow 交错式网格布局,项高度可动态变化 图片社交、电商、新闻资讯等瀑布流浏览 ArkUI API 9+

选择正确的组件是构建高效、美观界面的第一步。


二、 创建列表 (List)

List 是最高频使用的滚动列表组件,它沿垂直或水平方向线性排列子组件,并自动处理滚动和性能优化(如组件复用)。

2.1 基础用法

一个最简单的 List 包含一个 List 容器和多个 ListItem 子组件。

typescript

// ListExample.ets
@Entry
@Component
struct ListExample {
  private data: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

  build() {
    List({ space: 20 }) { // space 设置列表项之间的间距
      ForEach(this.data, (item: number) => {
        ListItem() {
          // 每个列表项的内容
          Text(`列表项 ${item}`)
            .fontSize(20)
            .height(60)
            .width('100%')
            .textAlign(TextAlign.Center)
            .backgroundColor(0xF5DEB3)
            .borderRadius(10)
        }
      }, (item: number) => item.toString())
    }
    .width('100%')
    .height('100%')
    .backgroundColor(0xF0F8FF)
  }
}

2.2 高级特性与最佳实践

  • 数据量大时使用 LazyForEach:当列表数据源非常大时,应使用 LazyForEach 来按需创建列表项,极大提升性能。
  • 列表项点击事件:在 ListItem 的子组件上添加 onClick 事件。
  • 列表方向:通过 listDirection 属性设置滚动方向,Axis.Vertical(默认,垂直)或 Axis.Horizontal(水平)。

typescript

List({ space: 10, initialIndex: 0 }) {
  LazyForEach(this.dataSource, (item: MyDataModel) => {
    ListItem() {
      MyListItemComponent({ item: item })
    }
    .onClick(() => {
      // 处理点击事件
      router.pushUrl(...);
    })
  }, (item: MyDataModel) => item.id.toString())
}
.listDirection(Axis.Vertical) // 设置滚动方向

三、 弧形列表 (ArcList) - 圆形屏幕的绝配

ArcList 是专为圆形屏幕设备(如智能手表)设计的特色组件。它让列表项沿着圆弧弯曲排列,并支持3D旋转的视觉效果,极大地提升了圆形屏幕的交互体验和美感。

3.1 核心概念与属性

  • alignType:列表项的对齐方式,通常使用 ArcAlignType.CENTER(居中)。
  • radius:圆弧的半径。合理设置半径可以控制列表的弯曲程度。
  • scroller:与 ScrollController 关联,用于控制列表的滚动位置。

3.2 代码示例

typescript

// ArcListExample.ets
@Entry
@Component
struct ArcListExample {
  private scroller: ScrollController = new ScrollController()
  private data: string[] = ['跑步', '骑行', '游泳', '登山', '瑜伽', '健身']

  build() {
    Column() {
      // 弧形列表
      ArcList({ scroller: this.scroller, alignType: ArcAlignType.CENTER }) {
        ForEach(this.data, (item: string, index?: number) => {
          ListItem() {
            // 每个弧形列表项
            Text(item)
              .fontSize(16)
              .fontColor(Color.White)
              .textAlign(TextAlign.Center)
              .width(80)
              .height(80)
              .backgroundColor(0x6A5ACD)
              .borderRadius(40) // 设置为圆形,更契合弧形布局
          }
        }, (item: string) => item)
      }
      .radius(180) // 设置圆弧半径
      .height(200)
      .width('100%')

      // 一个简单的控制按钮
      Button('滚动到末尾')
        .onClick(() => {
          this.scroller.scrollToEdge(ScrollEdge.End) // 使用scroller控制滚动
        })
        .margin(20)
    }
    .width('100%')
    .height('100%')
  }
}

效果描述:上述代码会在屏幕上方创建一个弯曲的弧形列表,列表项是圆形按钮。点击下方的按钮,列表会平滑地滚动到末尾。在实际的智能手表上,用户通过旋转表冠来滚动列表的体验非常流畅和自然。


四、 创建网格 (Grid/GridItem)

当你的内容需要以二维矩阵形式展现时,Grid 组件是不二之选。它由 Grid 容器和 GridItem 子组件构成。

4.1 定义网格布局

Grid 的核心是通过 columnsTemplate 和 rowsTemplate 来定义网格的列和行结构。

  • columnsTemplate: '1fr 1fr 1fr':表示3列,每列等宽(1fr 是自适应单位)。
  • rowsTemplate: '1fr 1fr':表示2行,每行等高。

4.2 代码示例:创建一个3x2的网格

typescript

// GridExample.ets
@Entry
@Component
struct GridExample {
  build() {
    Grid() {
      ForEach(new Array(6), (item: undefined, index: number) => {
        GridItem() {
          Column() {
            Image($r('app.media.icon' + (index + 1))) // 假设有6张图片资源
              .width(60)
              .height(60)
              .objectFit(ImageFit.Contain)
            Text('应用 ' + (index + 1))
              .margin({ top: 8 })
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .backgroundColor(0xFFFFFF)
          .borderRadius(12)
        }
      })
    }
    .columnsTemplate('1fr 1fr 1fr') // 3列等宽
    .rowsTemplate('1fr 1fr')        // 2行等高
    .columnsGap(16)                 // 列间距
    .rowsGap(16)                   // 行间距
    .width('100%')
    .height(300)
    .backgroundColor(0xDCDCDC)
    .padding(20)
  }
}

五、 创建瀑布流 (WaterFlow)

瀑布流布局是现代应用(如Pinterest、淘宝)的常见设计,其特点是宽度固定、高度不固定的项交错排列,充分利用垂直空间,非常适合展示图片、卡片等异构内容。

5.1 核心概念

  • 灵活性:每个 WaterFlowItem 可以有自己的高度,布局由内容决定。
  • 性能:与 List 一样,WaterFlow 支持懒加载和组件复用,即使海量数据也能保持流畅。
  • 列数:通过 columnsTemplate 设置瀑布流的列数,如 '1fr 1fr' 表示两列。

5.2 代码示例:创建一个图片瀑布流

假设我们有一组图片数据,每张图片的高度不同。

typescript

// WaterFlowExample.ets
@Entry
@Component
struct WaterFlowExample {
  // 模拟数据源,包含图片资源和随机高度
  @State imageData: { src: Resource, height: number }[] = [    { src: $r('app.media.pic1'), height: Math.floor(Math.random() * 200) + 200 },    { src: $r('app.media.pic2'), height: Math.floor(Math.random() * 200) + 200 },    // ... 更多数据  ]

  build() {
    WaterFlow() {
      LazyForEach(this.imageData, (item: { src: Resource, height: number }) => {
        WaterFlowItem() {
          // 每个瀑布流项的内容
          Image(item.src)
            .width('100%')
            .height(item.height) // 关键:每个项的高度不同,形成瀑布流效果
            .objectFit(ImageFit.Cover)
            .borderRadius(10)
        }
      })
    }
    .columnsTemplate('1fr 1fr') // 设置为2列瀑布流
    .columnsGap(10)
    .rowsGap(10)
    .width('100%')
    .height('100%')
    .padding(10)
  }
}

效果描述:运行后,你会看到一个两列的图片流,每张图片以其自身的高度显示,上下错落有致地排列,随着滚动不断加载新图片,形成经典的“瀑布”视觉效果。


总结与选择

在鸿蒙应用开发中,选择合适的布局组件至关重要:

  1. 追求效率的线性列表:毫不犹豫地选择 List,它是性能最优、最通用的选择。
  2. 为圆形而生:为智能手表等设备开发时,使用 ArcList 来提供原生且炫酷的圆形交互体验。
  3. 规整的网格布局:当内容需要被整齐地分类展示(如应用图标、功能菜单)时,Grid 提供了最强大的二维布局能力。
  4. 动态与视觉吸引力:展示高度不一的图片、卡片、商品时,WaterFlow(瀑布流)能创造出充满活力且节省空间的视觉效果。

鸿蒙的ArkUI框架通过这些组件,为开发者提供了从简单到复杂、从平面到立体的全方位布局解决方案。掌握它们,你就能轻松应对绝大多数界面开发需求,打造出既流畅又美观的鸿蒙原生应用。

希望这篇详尽的指南能对你的开发工作有所帮助!如果有任何疑问,欢迎在评论区留言讨论。

掌控右键宇宙!HTML5 contextmenu事件的终极使用指南,支持自定义右键菜单

在网页开发中,右键点击一个元素时,默认的上下文菜单(Context Menu)会像魔法一样弹出。然而,这种“魔法”并非不可控——HTML5中的contextmenu事件,正是开发者用来掌控这股力量的“幕后指挥官”。通过它,我们可以自定义右键菜单的内容、行为和样式,甚至将其变成一个功能强大的交互入口。本文将带你全面掌握contextmenu事件的精髓,从基础概念到实战技巧,一网打尽!


一、什么是contextmenu事件?

定义与本质

contextmenu是HTML5引入的一种鼠标事件,当用户尝试打开上下文菜单时触发。这个事件的目标是用户右键点击的元素,但它的作用远不止于此——通过监听并处理这个事件,开发者可以完全取代浏览器默认的右键菜单,构建自定义的交互逻辑。

触发条件

  • 右键点击:最常见的触发方式。
  • Shift + 单击:在某些浏览器(如Firefox)中,这种组合键也会触发contextmenu事件。
  • 触摸设备:通过长按或特定手势模拟右键点击(需额外处理)。

二、contextmenu事件的常见属性与方法

1. 事件对象属性

contextmenu事件继承自PointerEvent(早期版本为MouseEvent),包含丰富的属性,以下是最常用的几个:

属性 描述
clientX / clientY 鼠标点击的坐标位置(相对于视口)。
target 触发事件的元素(即用户右键点击的元素)。
pointerId 指针的唯一标识符(用于多点触控场景)。
pressure 压力值(0到1之间,适用于手写笔设备)。
tiltX / tiltY 指针倾斜角度(单位为度)。

2. 关键方法

  • 阻止默认行为

    event.preventDefault();
    

    在兼容DOM的浏览器中使用preventDefault(),在IE中需设置event.returnValue = false

  • 事件监听

    element.addEventListener("contextmenu", (e) => {
      // 自定义逻辑
    });
    

三、使用技巧:从基础到高阶

1. 动态生成右键菜单

通过动态创建DOM元素,可以实现菜单的实时更新。例如:

document.addEventListener("contextmenu", (e) => {
  e.preventDefault();
  const menu = document.createElement("div");
  menu.style.position = "fixed";
  menu.style.left = `${e.clientX}px`;
  menu.style.top = `${e.clientY}px`;
  menu.innerHTML = `<div>复制</div><div>粘贴</div>`;
  document.body.appendChild(menu);
  menu.addEventListener("mouseleave", () => menu.remove());
});

技巧点

  • 使用mouseleave事件自动清理菜单,避免内存泄漏。
  • 菜单项的样式可以通过CSS灵活控制,例如添加悬停效果或阴影。

2. 事件委托与动态绑定

当菜单项较多或需要动态生成时,使用事件委托能显著提升性能:

document.addEventListener("click", (e) => {
  if (e.target.classList.contains("menu-item")) {
    handleMenuClick(e.target.textContent);
  }
});

3. 响应式设计

在移动端或不同屏幕尺寸下,右键菜单的位置可能超出视口范围。通过计算坐标边界,确保菜单始终可见:

const x = Math.min(e.clientX, window.innerWidth - 200);
const y = Math.min(e.clientY, window.innerHeight - 100);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;

4. 第三方库的使用

对于复杂需求,可借助成熟的库(如jQuery ContextMenu),快速实现功能:

$.contextMenu({
  selector: "#myElement",
  items: {
    copy: { name: "复制", callback: () => alert("复制成功!") },
    paste: { name: "粘贴", callback: () => alert("粘贴成功!") }
  }
});

四、应用场景:右键菜单的“超能力”

1. 富文本编辑器

在富文本编辑器中,右键菜单可以集成“加粗”、“斜体”、“插入链接”等操作,提升用户体验。

2. 数据可视化工具

在图表或数据表格中,右键点击单元格可弹出“导出为CSV”、“查看详情”等选项,增强交互性。

3. 游戏开发

在网页游戏中,右键菜单可作为快捷操作面板(如“使用道具”、“切换武器”),简化操作流程。

4. 自定义文件管理器

通过右键菜单实现“新建文件夹”、“重命名”、“删除”等功能,打造桌面级体验。


五、注意事项:避开“坑”才能飞得更高

1. 跨浏览器兼容性

  • Firefox的特殊处理:在Firefox中,如果用户按住Shift键右键点击,会直接显示默认菜单,不会触发contextmenu事件。
  • 移动端适配:需要额外处理长按事件或手势识别。

2. 内存泄漏风险

动态生成的菜单元素需在不再需要时及时移除,避免占用过多内存。

3. 避免无限递归

在WPF等框架中,强制打开菜单(如Popup.IsOpen = true)可能再次触发contextmenu事件,导致递归调用。需通过标志位或逻辑判断避免。

4. 用户体验优先

  • 简洁性:菜单项不宜过多,避免信息过载。
  • 一致性:保持菜单样式与整体设计风格统一。
  • 响应速度:菜单的出现延迟应控制在200ms以内,确保流畅体验。

六、结语:右键宇宙的无限可能

contextmenu事件不仅是技术实现的工具,更是提升用户体验的“魔法棒”。通过灵活运用这一事件,开发者可以将右键点击变成一个功能强大的交互入口,从简单的复制粘贴到复杂的数据操作,甚至是游戏中的快捷指令。掌握它,你就能在网页开发的“右键宇宙”中自由翱翔!

bun + vite7 的结合,孕育的 Robot Admin 【靓仔出道】(二十)终章

Vue应用功能扩展完整指南 - Robot Admin架构深度解析

阅读时间: 9 分钟
难度等级: 高级
适用场景: Vue 3 + TypeScript项目架构设计

前言

Robot Admin 应用程序在设计时充分考虑了可扩展性,为开发者提供了多种添加新功能和自定义系统的方式。无论您需要添加新的 UI 组件、创建可复用逻辑,还是与外部 API 集成,该框架都提供了清晰的扩展功能模式和约定。

核心扩展点概览

应用程序提供了几个关键的扩展点,您可以利用这些扩展点来添加新功能:

  • 🔌 插件: 与 Vue 应用程序集成的全局功能
  • 🎣 钩子: 可在组件中组合的可复用逻辑
  • 🧩 组合式函数: 领域特定的组合函数
  • 📝 指令: 自定义 DOM 行为
  • 🌐 API 模块: 后端服务集成

让我们详细探讨这些扩展点。


1. 创建自定义插件

插件是扩展应用程序全局功能最强大的方式。它们可以访问 Vue 应用实例,并可以注册全局组件、指令、提供服务或修改应用配置。

1.1 插件结构

插件就是一个接收 Vue 应用实例的函数,可选择返回一个包含额外设置逻辑的对象。以下是基本结构:

// src/plugins/my-plugin.ts
import type { App } from 'vue'

export function setupMyPlugin(app: App) {
  // 注册全局组件
  // app.component('MyComponent', MyComponent)
  
  // 提供全局服务
  // app.provide('myService', new MyService())
  
  // 添加全局属性
  // app.config.globalProperties.$myUtil = myUtil
  
  console.log('My plugin has been installed!')
}

1.2 注册插件

要使您的插件在整个应用程序中可用,您需要从插件索引文件中导出它,并在主应用程序入口中注册:

// src/plugins/index.ts
export * from './my-plugin'
// src/main.ts
import { setupMyPlugin } from '@/plugins'

async function bootstrap() {
  const app = createApp(App)
  
  // 注册您的插件
  setupMyPlugin(app)
  
  // ... 其他设置
  app.mount('#app')
}

💡 命名约定: 插件函数始终遵循 setup[PluginName] 的命名约定,以保持与现有代码库的一致性。


2. 构建可复用钩子

钩子是封装可复用逻辑的组合式函数。它们非常适合在组件之间共享有状态逻辑,而无需传统混入的开销。

2.1 钩子结构

一个好的钩子遵循清晰的模式:管理状态、提供计算值,并暴露交互方法。让我们看看钩子的标准结构:

// src/hooks/useMyFeature/index.ts
import { ref, computed } from 'vue'

export interface UseMyFeatureOptions {
  initialValue?: string
  onSuccess?: (result: any) => void
  onError?: (error: Error) => void
}

export function useMyFeature(options: UseMyFeatureOptions = {}) {
  // 状态管理
  const state = ref({
    loading: false,
    data: options.initialValue || '',
    error: null as Error | null
  })

  // 计算值
  const isLoading = computed(() => state.value.loading)
  const hasError = computed(() => state.value.error !== null)

  // 方法
  const execute = async () => {
    state.value.loading = true
    try {
      const result = await doSomething(state.value.data)
      options.onSuccess?.(result)
      return result
    } catch (error) {
      state.value.error = error as Error
      options.onError?.(error as Error)
      throw error
    } finally {
      state.value.loading = false
    }
  }

  const reset = () => {
    state.value.data = options.initialValue || ''
    state.value.error = null
  }

  return {
    state,
    isLoading,
    hasError,
    execute,
    reset
  }
}

2.2 在组件中使用钩子

创建钩子后,在组件中使用它非常简单:

<template>
  <div>
    <button @click="execute" :disabled="isLoading">
      {{ isLoading ? 'Processing...' : 'Execute' }}
    </button>
    <div v-if="hasError" class="error">
      {{ state.error?.message }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { useMyFeature } from '@/hooks/useMyFeature'

const { state, isLoading, hasError, execute } = useMyFeature({
  initialValue: 'default value',
  onSuccess: (result) => console.log('Success:', result)
})
</script>

3. 开发组合式函数

组合式函数是领域特定的组合函数,为复杂功能提供更高级别的抽象。它们与钩子的不同之处在于,它们通常更专业化,并处理更复杂的业务逻辑。

3.1 组合式函数结构

查看强大组合式函数的构建模式:

// src/composables/MyDomain/useMyFeature.ts
import { ref, reactive, computed } from 'vue'
import type { MyFeatureConfig, MyFeatureState } from '@/types/modules/my-feature'

const DEFAULT_CONFIG: MyFeatureConfig = {
  maxItems: 10,
  autoSave: false,
  enableValidation: true
}

export const useMyFeature = (config: Partial<MyFeatureConfig> = {}) => {
  // 合并配置与默认值
  const finalConfig = { ...DEFAULT_CONFIG, ...config }
  
  // 响应式状态
  const state = reactive<MyFeatureState>({
    items: [],
    selectedItem: null,
    isLoading: false,
    isInitialized: false
  })

  // 计算属性
  const itemCount = computed(() => state.items.length)
  const canAddMore = computed(() => itemCount.value < finalConfig.maxItems)

  // 方法
  const addItem = (item: any) => {
    if (!canAddMore.value) return
    state.items.push(item)
  }

  const removeItem = (index: number) => {
    state.items.splice(index, 1)
  }

  const clearItems = () => {
    state.items = []
  }

  return {
    state,
    config: finalConfig,
    itemCount,
    canAddMore,
    addItem,
    removeItem,
    clearItems
  }
}

4. 创建自定义指令

指令允许您创建可复用的 DOM 行为,这些行为可以直接应用于模板中的元素。它们非常适合添加低级 DOM 操作或与第三方库集成。

4.1 指令结构

应用程序使用指令的自动注册系统。以下是创建自定义指令的方法:

// src/directives/modules/my-directive.ts
import type { Directive, DirectiveBinding } from 'vue'

export interface MyDirectiveBinding extends DirectiveBinding {
  value?: string | MyDirectiveOptions
}

export interface MyDirectiveOptions {
  color?: string
  duration?: number
  onHover?: () => void
}

function parseOptions(value: string | MyDirectiveOptions | undefined): MyDirectiveOptions {
  if (!value) return { color: 'blue', duration: 300 }
  if (typeof value === 'string') return { color: value, duration: 300 }
  return { color: 'blue', duration: 300, ...value }
}

const MyDirective: Directive = {
  mounted(el: HTMLElement, binding: MyDirectiveBinding) {
    const options = parseOptions(binding.value)
    
    const handler = () => {
      el.style.backgroundColor = options.color
      options.onHover?.()
    }
    
    el.addEventListener('mouseenter', handler)
    el.addEventListener('mouseleave', () => {
      el.style.backgroundColor = ''
    })
    
    // 存储处理器以便清理
    el._myDirectiveHandler = handler
  },
  
  unmounted(el: HTMLElement) {
    if (el._myDirectiveHandler) {
      el.removeEventListener('mouseenter', el._myDirectiveHandler)
    }
  }
}

export default MyDirective

4.2 使用自定义指令

创建后,您的指令会自动注册,可以在模板中使用:

<template>
  <div v-my-directive="'red'">Hover over me!</div>
  
  <!-- 带选项 -->
  <div v-my-directive="{ color: 'green', duration: 500 }">
    Advanced usage
  </div>
</template>

5. 添加 API 模块

API 模块为与后端服务通信提供了清晰的接口。它们封装 HTTP 请求,并为组件提供类型安全的方法。

5.1 API 模块结构

遵循标准模式,以下是创建新 API 模块的方法:

// src/api/my-feature.ts
import { postData, getData, putData, deleteData } from '@/axios/request'
import type { 
  GetMyFeatureListResponse,
  PostMyFeatureData,
  PutMyFeatureByIdData
} from './generated'

/**
 * 获取我的功能项列表
 */
export const getMyFeatureListApi = () =>
  getData<GetMyFeatureListResponse>('/my-feature/list')

/**
 * 创建新的我的功能项
 */
export const createMyFeatureApi = (data: PostMyFeatureData) =>
  postData<PostMyFeatureData>('/my-feature', data)

/**
 * 更新现有的我的功能项
 */
export const updateMyFeatureApi = (id: number, data: PutMyFeatureByIdData) =>
  putData<PutMyFeatureByIdData>(`/my-feature/${id}`, data)

/**
 * 删除我的功能项
 */
export const deleteMyFeatureApi = (id: number) =>
  deleteData(`/my-feature/${id}`)

5.2 在组件中使用 API 模块

API 模块设计为可直接在组件或组合式函数中使用:

<template>
  <div>
    <button @click="loadData" :disabled="loading">Load Data</button>
    <div v-if="error" class="text-red-500">{{ error }}</div>
    <ul v-if="items.length" class="mt-4">
      <li v-for="item in items" :key="item.id" class="py-2">
        {{ item.name }}
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { getMyFeatureListApi } from '@/api/my-feature'

const items = ref([])
const loading = ref(false)
const error = ref('')

const loadData = async () => {
  loading.value = true
  error.value = ''
  try {
    const response = await getMyFeatureListApi()
    items.value = response.data
  } catch (err) {
    error.value = 'Failed to load data'
    console.error(err)
  } finally {
    loading.value = false
  }
}
</script>

6. 扩展架构流程图

以下是这些扩展点在应用程序中的协作方式:

graph TD
    A[Vue 应用启动] --> B[加载插件]
    B --> C[注册全局组件]
    B --> D[注册指令]
    B --> E[提供服务]
    
    F[组件渲染] --> G[使用钩子]
    F --> H[使用组合式函数]
    F --> I[调用API模块]
    F --> J[应用指令]
    
    G --> K[响应式状态]
    H --> L[业务逻辑]
    I --> M[数据请求]
    J --> N[DOM操作]
    
    K --> O[UI更新]
    L --> O
    M --> O
    N --> O

7. 扩展功能的最佳实践

在扩展 Robot Admin 应用程序时,请牢记这些最佳实践:

✅ 开发规范

  1. 遵循命名约定

    • 钩子:use[FeatureName]
    • 插件:setup[PluginName]
    • 组合式函数:use[DomainName][Action]
  2. 类型化一切

    • 利用 TypeScript 提供类型安全和更好的开发体验
    • 为所有接口和选项定义类型
  3. 保持小而专注

    • 每个扩展应该有单一职责并做好它
    • 避免创建过于复杂的多功能扩展

✅ 用户体验

  1. 提供良好的默认值

    • 使您的扩展易于使用,具有合理的默认配置
    • 支持渐进式配置复杂性
  2. 优雅地处理错误

    • 始终包含错误处理并提供有意义的反馈
    • 使用合适的错误边界和降级策略

✅ 团队协作

  1. 记录您的扩展

    • 为其他开发者提供清晰的文档和示例
    • 包含 JSDoc 注释和使用示例
  2. 测试您的扩展

    • 编写单元测试以确保您的扩展按预期工作
    • 考虑边缘情况和错误场景

⚠️ 重要提醒: 在创建新扩展之前,请检查代码库中是否已存在类似功能。重用现有模式和组件有助于保持整个应用程序的一致性。


8. 扩展点对比表

扩展类型 适用场景 复杂度 复用性 性能影响
插件 全局功能、第三方集成 中等
钩子 组件逻辑复用 简单 极低
组合式函数 业务逻辑封装 中等
指令 DOM操作、UI行为 中等 中等
API模块 数据请求、服务集成 简单 中等 取决于请求

9. 实战案例推荐

为了更好地理解这些扩展模式,建议您:

  1. 探索现有代码

    • 查看 src/plugins/src/hooks/src/composables/
    • 研究 src/directives/src/api/ 中的实现
  2. 从简单开始

    • 先尝试创建简单的钩子
    • 逐步过渡到更复杂的组合式函数
  3. 参考最佳实践

    • 学习现有代码的模式
    • 将这些模式调整到您的特定需求

总结

Robot Admin 应用程序提供了一套强大的扩展点,允许您以清晰、可维护的方式自定义和扩展功能。通过遵循本指南中概述的模式和约定,您可以创建与现有代码库无缝集成的强大扩展。

这套扩展体系不仅提供了技术上的灵活性,更重要的是为团队协作和代码维护奠定了坚实的基础。无论是新功能开发还是现有功能改进,都能在这个架构下得到优雅的实现。


推荐阅读顺序: 建议先从钩子开始实践,再逐步学习组合式函数和插件系统。学习的最佳方法是研究现有代码并将这些模式调整到您的特定需求。

如果你在实践中遇到问题或有更好的扩展思路,欢迎在评论区分享讨论!

期待共建!

如果这套组件系统对你的开发工作有所启发或帮助,请不要吝啬你的 Star!每一个 ⭐ 都是对我最大的鼓励和支持。

👉 点击这里 Star 支持项目 (🧧行大运摸大票💰)

🔗 探索更多资源

📋 资源类型 🔗 链接 📝 说明
🎯 在线预览 robotadmin.cn 体验完整功能演示
📚 详细文档 tzagileteam.com 深入了解实现细节
💻 源码仓库 https:/github.com/ChenyCHENYU/Robot_Admin 获取完整源代码

10.TypeScript tsconfig.json 配置文件详解

10.TypeScript tsconfig.json 配置文件详解

1. 什么是 tsconfig.json

tsconfig.json 是 TypeScript 项目的配置文件,用于指定编译项目所需的根文件和编译器选项。当运行 tsc 命令时,TypeScript 编译器会自动查找并使用该配置文件。

2. 生成 tsconfig.json

可以通过以下命令生成默认的 tsconfig.json 文件:

npx tsc --init

该命令会在项目根目录下生成一个带有大量注释的 tsconfig.json 文件,其中包含了所有可用的编译器选项及其默认值。

3. 常见字段含义

3.1. 文件包含选项

  • files: 指定要编译的文件列表。如果指定了 files,则只有列出的文件及其依赖会被编译。
  • include: 指定要包含在编译过程中的文件或目录。支持 glob 模式匹配。
  • exclude: 指定要排除在编译过程之外的文件或目录。默认排除 node_modulesbower_componentsjspm_packages<outDir> 目录。

3.2. 编译选项 (compilerOptions)

  • target: 指定编译后的 JavaScript 版本。常用值包括 ES5ES6/ES2015ES2016ES2017ES2018ES2019ES2020ESNext
  • module: 指定生成的模块代码。常用值包括 CommonJS(Node.js 默认)、AMDUMDSystemES6/ES2015ES2020ESNextNone
  • lib: 指定编译过程中需要包含的库文件。例如 ES5ES6DOMDOM.Iterable 等。
  • outDir: 指定编译后 JavaScript 文件的输出目录。
  • rootDir: 指定 TypeScript 源文件的根目录。用于控制输出目录结构。
  • allowJs: 允许编译 JavaScript 文件。
  • checkJs: 报告 JavaScript 文件中的错误。需要 allowJstrue
  • jsx: 指定 JSX 代码生成方式。常用值包括 preservereact-nativereact
  • declaration: 生成相应的 .d.ts 声明文件。
  • sourceMap: 生成相应的 .map 文件,用于调试。
  • outFile: 将所有输出打包到一个文件中。仅在 moduleAMDSystem 时可用。
  • removeComments: 删除所有注释。
  • noEmit: 不生成输出文件。
  • importHelpers: 从 tslib 导入辅助工具函数,以减少代码重复。
  • downlevelIteration: 当 targetES5ES3 时,提供更完整的迭代器支持。
  • strict: 启用所有严格的类型检查选项。这是一个总开关,启用后会自动启用以下所有 strict 相关选项。
  • noImplicitAny: 禁止隐式的 any 类型。
  • strictNullChecks: 启用严格的 null 检查。
  • strictFunctionTypes: 启用严格的函数类型检查。
  • strictBindCallApply: 启用严格的 bindcallapply 方法。
  • strictPropertyInitialization: 启用类属性初始化的严格检查。
  • noImplicitThis: 禁止 this 的隐式类型为 any
  • alwaysStrict: 以严格模式解析并为每个源文件生成 "use strict" 语句。
  • noUnusedLocals: 报告未使用的局部变量错误。
  • noUnusedParameters: 报告未使用的函数参数错误。
  • noImplicitReturns: 报告函数所有代码路径都未明确返回值的错误。
  • noFallthroughCasesInSwitch: 报告 switch 语句中 fallthrough 的错误。
  • moduleResolution: 指定模块解析策略。常用值包括 node(Node.js)和 classic(TypeScript 1.6 之前)。
  • baseUrl: 解析非相对模块名的基准目录。
  • paths: 设置模块名到基于 baseUrl 的路径映射。
  • rootDirs: 虚拟地将多个目录合并成一个源目录。
  • typeRoots: 指定要包含的类型定义包的根目录。
  • types: 指定要包含的类型定义包名称列表。
  • allowSyntheticDefaultImports: 允许从没有设置默认导出的模块中默认导入。
  • esModuleInterop: 启用 CommonJS 和 ES 模块之间的互操作性。会自动设置 allowSyntheticDefaultImportstrue
  • skipLibCheck: 跳过声明文件的类型检查。
  • forceConsistentCasingInFileNames: 禁止对同一文件使用大小写不一致的引用。

4. 常用配置示例

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "CommonJS",
    "lib": ["ES2015", "DOM"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

这个配置示例定义了一个典型的 TypeScript 项目设置,它将 src 目录下的 TypeScript 文件编译为 ES2015 目标的 JavaScript 代码,并输出到 dist 目录。同时启用了严格的类型检查和模块互操作性支持。

❌