阅读视图

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

屎山代码拆不动?微前端来救场:一个应用变“乐高城堡”

前言

想象你有一座巨大的乐高城堡,一开始几个人拼得很开心。后来城堡越拼越大,几百人同时在上面加砖,有人碰倒了塔楼,有人改错了城墙,整个城堡摇摇欲坠。你想拆成几个独立的小城堡,又怕它们之间连不起来。

这就是巨石前端的困境。微前端就是解决方案:把大应用拆成多个小应用(子应用),每个小应用独立开发、独立部署,最后在浏览器里组合成一个完整页面。就像乐高套装里的每个小模块,可以单独拼好,再插到一起。

一、什么时候需要微前端?

  • 项目太大,编译部署一次要10分钟。
  • 团队太多,几十人改同一个仓库,Git冲突到崩溃。
  • 想渐进式升级技术栈(比如老项目用AngularJS,新模块用React)。
  • 不同团队负责不同业务板块,希望独立发布互不干扰。

如果你的项目只有三五个人,别用微前端——杀鸡不用牛刀。

二、微前端三大核心问题

微前端要解决三个问题:

  1. 怎么加载子应用?(路由分发)
  2. 怎么隔离子应用?(JS沙箱、样式隔离)
  3. 怎么通信?(全局状态、事件总线)

三、常见实现方式

1. 路由分发式(Nginx反向代理)

不同路径对应不同子应用,比如/app1 → 应用1,/app2 → 应用2。父页面通过iframe或服务端路由组合。

  • 简单,但切换应用会刷新页面。
  • 不适合需要无缝组合的场景。

2. iframe:最土的“隔离神器”

iframe天然隔离JS和CSS,但缺点明显:通信麻烦、SEO差、弹窗无法覆盖、全局状态不共享。

3. single-spa:微前端的“老大哥”

一个框架,帮你管理子应用的加载、挂载、卸载。你需要自己写如何加载子应用(比如动态script加载),以及子应用暴露的生命周期(bootstrap、mount、unmount)。

  • 灵活,但需要较多配置。
  • 适合自己造轮子。

4. qiankun:蚂蚁开箱即用的方案

基于single-spa,内置了JS沙箱、样式隔离、HTML Entry(自动加载子应用的HTML、JS、CSS)。你只需要改几行代码,就能把一个普通应用变成微前端子应用。

  • 推荐大部分项目用qiankun。
  • 支持Vue、React、Angular等。

5. Webpack 5 Module Federation:去中心化的“共享冰箱”

不需要主应用,任意两个应用可以互相暴露和使用模块。运行时动态加载对方代码,像从冰箱里拿菜一样。

  • 非常适合多个独立部署的微前端应用。
  • 需要Webpack 5支持。

四、qiankun 实战:三步把React应用变成子应用

假设你有一个主应用(基座),一个子应用(React)。

主应用(基座)注册子应用

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'reactApp',
    entry: '//localhost:3001', // 子应用启动的地址
    container: '#subapp-container',
    activeRule: '/react',
  },
]);
start();

子应用(React)改造

src/index.js里暴露生命周期:

function render(props) {
  ReactDOM.render(<App />, document.getElementById('root'));
}

if (!window.__POWERED_BY_QIANKUN__) {
  render(); // 独立运行时直接渲染
}

export async function bootstrap() {}
export async function mount(props) {
  render(props);
}
export async function unmount() {
  ReactDOM.unmountComponentAtNode(document.getElementById('root'));
}

再改webpack配置,让打包成umd格式:

output: {
  library: `${name}-[name]`,
  libraryTarget: 'umd',
  globalObject: 'window',
}

搞定!子应用独立运行时正常访问,被qiankun加载时也能完美嵌入。

五、JS沙箱:防止子应用污染全局

qiankun提供了两种沙箱:

  • SnapshotSandbox:记录恢复window属性变化(兼容IE)。
  • ProxySandbox:用ES6 Proxy代理对window的读写,每个子应用有自己的fakeWindow。

这样子应用里修改windowdocument都不会影响全局。

六、样式隔离:你的样式别弄脏我的衣服

qiankun默认使用shadowDOM(需要子应用支持),也可以通过配置strictStyleIsolation开启。或者简单约定:子应用所有样式加namespace

七、应用间通信:传递“小纸条”

  • 通过props传递:主应用mount子应用时,可以传入通信函数。
  • 全局状态管理:用qiankuninitGlobalState
  • 自定义事件window.dispatchEvent(但注意沙箱可能隔离window)。

八、常见坑点与建议

  1. 重复依赖:多个子应用都打包了React,体积大。解决方案:用externals或Module Federation共享。
  2. 子应用间路由跳转:用history.pushState前判断是否在微前端环境,调用主应用的路由实例。
  3. 公共样式:主应用提供全局样式,子应用只写局部样式。
  4. 性能:预加载子应用,或使用loadable组件按需加载。

九、Module Federation:不用主应用的“分布式”微前端

如果你的项目没有明确的主应用,每个应用都可以暴露模块给其他应用,用Webpack 5的ModuleFederationPlugin

// 应用A暴露组件
new ModuleFederationPlugin({
  name: 'appA',
  filename: 'remoteEntry.js',
  exposes: {
    './Button': './src/Button',
  },
});

// 应用B消费
new ModuleFederationPlugin({
  name: 'appB',
  remotes: {
    appA: 'appA@http://localhost:3001/remoteEntry.js',
  },
});
// 在B里异步加载:import('appA/Button')

这样两个应用独立部署,运行时动态加载对方组件,超级灵活。

十、总结:微前端不是银弹,但能救急

  • 微前端适合超大项目、多团队、技术栈升级
  • 简单场景用qiankun,复杂场景用Module Federation
  • 注意JS沙箱、样式隔离、通信成本。
  • 如果项目只有几十个页面,别折腾,用组件化就够了。

微前端就像乐高积木:拆开是独立小玩具,拼起来是宏伟城堡。用得好,团队效率翻倍;用不好,调试到你怀疑人生。


如果你觉得今天的“乐高城堡”够形象,点个赞让更多人看到。明天我们将聊聊前端设计模式——单例、观察者、工厂、策略,那些让你代码更优雅的套路。我们明天见!

学习 Redux Toolkit :从 Context 误区到 createSlice 实践

本文说明:本文是基于 Redux Toolkit 官方文档及其 maintainer 发布的博文做的整理,双语对照以防止与原文有歧义。文末有完整的原文链接可供详细学习。

希望这份整理对你有帮助。

一、开篇:Context 不是状态管理系统

“Should I use Context or should I use Redux?” “我应该用上下文还是用 Redux?”

And they seem to think that Context itself is a state management system. It’s not. 他们似乎认为 Context 本身就是一个状态管理系统。 其实不是

It’s a dependency injection mechanism, and you can put whatever value you want in Context, and most often you are the one managing that state in a React component, with the useState hook or the useReducer hook. And you’re the one deciding where the state lives, handling how to update it, and then putting the value into Context for distribution. 它是一种依赖注入机制,你可以在上下文中输入任何你想要的值,通常你会在 React 组件中管理该状态,使用 useState 钩子或 useReducer 钩子。你负责决定状态的所在位置,处理如何更新,然后把这个值放进 Context 进行分发。

So yeah, useReducer plus useContext together kind of make up a state management system. And that one is more equivalent to what Redux does with React, but Context by itself is not a state management system. 所以,是的,useReducer 加上 useContext 一起构成了一个状态管理系统。这个更类似于 Redux 对 React 的处理,但 Context 本身并不是一个状态管理系统 。

既然 Context 本身不是状态管理方案,那么 Redux Toolkit 提供了怎样的替代方案?我们先从它的 API 全景看起。

二、Redux Toolkit 工具箱里有什么?

Redux Toolkit 包含以下 API

  • configureStore(): 包装 createStore 以提供简化的配置选项和良好的默认值。它可以自动组合您的切片 reducer,添加您提供的任何 Redux 中间件,默认情况下包含 redux-thunk,并启用 Redux DevTools Extension 的使用。
  • createReducer(): 允许您提供操作类型到 case reducer 函数的查找表,而不是编写 switch 语句。此外,它会自动使用 immer,让您可以使用正常的可变代码编写更简单的不可变更新,例如 state.todos[3].completed = true
  • createAction(): 为给定的操作类型字符串生成一个操作创建器函数。
  • createSlice(): 接受 reducer 函数对象、切片名称和初始状态值,并自动生成具有相应操作创建器和操作类型的切片 reducer。
  • combineSlices(): 将多个切片组合成一个 reducer,并允许在初始化后“延迟加载”切片。
  • createAsyncThunk: 接受一个动作类型字符串和一个返回 Promise 的函数,并生成一个 thunk,根据该 Promise 分发 pending/fulfilled/rejected 动作类型。
  • createEntityAdapter: 生成一组可重用的 reducer 和 selector,用于管理存储中的规范化数据。
  • 来自 Reselect 库的 createSelector 实用程序,为了方便使用而重新导出。

注意到上面多次提到 Immer 了吗?它正是 RTK 让你能“直接修改 state”的秘密武器。

三、Immer:为什么你能直接“修改”state?

Immer(德语为:always)是一个小型包,可让您以更方便的方式使用不可变状态。

Immer 简化了不可变数据结构的处理

Immer 可以在需要使用不可变数据结构的任何上下文中使用。例如与 React state、React 或 Redux reducers 或者 configuration management 结合使用。不可变的数据结构允许(高效)的变化检测:如果对对象的引用没有改变,那么对象本身也没有改变。此外,它使克隆对象相对便宜:数据树的未更改部分不需要复制,并且在内存中与相同状态的旧版本共享

一般来说,这些好处可以通过确保您永远不会更改对象、数组或映射的任何属性来实现,而是始终创建一个更改后的副本。在实践中,这可能会导致代码编写起来非常麻烦,并且很容易意外违反这些约束。 Immer 将通过解决以下痛点来帮助您遵循不可变数据范式:

  1. Immer 将检测到意外 mutations 并抛出错误。
  2. Immer 将不再需要创建对不可变对象进行深度更新时所需的典型样板代码:如果没有 Immer,则需要在每个级别手动制作对象副本。通常通过使用大量 ... 展开操作。使用 Immer 时,会对 draft 对象进行更改,该对象会记录更改并负责创建必要的副本,而不会影响原始对象。
  3. 使用 Immer 时,您无需学习专用 API 或数据结构即可从范例中受益。使用 Immer,您将使用纯 JavaScript 数据结构,并使用众所周知的安全地可变 JavaScript API。

代码对比:不使用 Immer vs 使用 Immer

不使用 Immer

如果没有 Immer,我们将不得不小心地浅拷贝每层受我们更改影响的 state 结构

const nextState = baseState.slice() // 浅拷贝数组
nextState[1] = {
    // 替换第一层元素
    ...nextState[1], // 浅拷贝第一层元素
    done: true // 期望的更新
}
// 因为 nextState 是新拷贝的, 所以使用 push 方法是安全的,
// 但是在未来的任意时间做相同的事情会违反不变性原则并且导致 bug!
nextState.push({title: "Tweet about it"})

使用 Immer

使用 Immer,这个过程更加简单。我们可以利用 produce 函数,它将我们要更改的 state 作为第一个参数,对于第二个参数,我们传递一个名为 recipe 的函数,该函数传递一个 draft 参数,我们可以对其应用直接的 mutations。一旦 recipe 执行完成,这些 mutations 被记录并用于产生下一个状态。 produce 将负责所有必要的复制,并通过冻结数据来防止未来的意外修改。

import {produce} from "immer"

const nextState = produce(baseState, draft => {
    draft[1].done = true
    draft.push({title: "Tweet about it"})
})

Immer 核心要点总结

简单说应该就是state是不可以变的,但immer提供了state这种不可变数据的更改?

  1. 最终结果仍遵循不可变原则
    • 原始 state 不会被修改
    • 修改后产生一个全新的对象
    • 未变化的部分共享引用(结构共享)
  2. 但编写体验是“可变”的
    • 你直接对 draft 赋值:draft[1].done = true
    • 你直接 push:draft.push(...)
    • 看起来就像修改了原对象

Immer 工作原理图示

基本思想是,使用 Immer,您会将所有更改应用到临时 draft,它是 currentState 的代理。一旦你完成了所有的 mutations,Immer 将根据对 draft statemutations 生成 nextState。这意味着您可以通过简单地修改数据来与数据交互,同时保留不可变数据的所有好处。

immer-hd.png

理解了 Immer 的原理,我们来看 RTK 中最核心的抽象——Slice(切片)。

四、Slice:Redux 开发的模块化核心

一、什么是 Slice? 在 Redux Toolkit 中,Slice(切片) 是核心概念之一,用于简化 Redux 开发流程。 核心特性

  • 自动生成 Action:无需手动定义 ACTION_TYPE 常量和 action creator 函数

  • 不可变更新:内部集成 Immer 库,允许直接"修改"状态,同时保证生成新的不可变对象

  • 模块化管理:将应用状态拆分为独立模块(用户模块、酒店模块、审核模块等)

    本 Slice 专门负责管理酒店审核模块的状态逻辑,包括列表数据、筛选条件、加载状态及审核操作结果。

Slice 处理的是同步更新,那异步请求呢?RTK 专门提供了 createAsyncThunk。

五、createAsyncThunk:异步请求的标准方案

概述

一个接受 Redux action 类型字符串和回调函数的函数,该回调函数应返回一个 promise。它根据您传入的动作类型前缀生成 promise 生命周期动作类型,并返回一个 thunk 动作创建者,该创建者将运行 promise 回调并根据返回的 promise 分派生命周期动作。

本节概述了处理异步请求生命周期的标准推荐方法。

它不会生成任何 reducer 函数,因为它不知道您要获取什么数据、如何跟踪加载状态,以及如何处理返回的数据。您应该编写自己的 reducer 逻辑来处理这些操作,并使用适合您应用程序的加载状态和处理逻辑。

掌握了这些核心概念,你已经可以开始使用 RTK 了。更多细节可以参考以下资源。

六、延伸学习

createAsyncThunk | Redux Toolkit 中文

createSlice | Redux Toolkit 中文

什么时候(以及什么时候不该)入手 Redux --- When (and when not) to reach for Redux

Angular 基础知识点全汇总(附实战示例 | 新手友好)

前言

Angular 是由 Google 维护的企业级前端框架,基于 TypeScript 开发,内置路由、表单、HTTP、依赖注入等全套解决方案,适合中大型后台管理系统、企业级应用开发。本文整理了 Angular 从入门到实战的全套基础知识点,每个知识点搭配可直接运行的代码示例,新手也能快速上手!

适用版本:Angular 14+ / 18+(长期支持版)阅读对象:前端新手、Vue/React 转 Angular 开发者

1. Angular 核心概述

核心特点

  • 完整的企业级框架(全家桶,无需额外集成第三方库)
  • 强类型:基于 TypeScript 开发
  • 内置依赖注入 (DI)、路由、表单、HTTP 客户端
  • 单向数据流 + 可选双向绑定
  • 适合大型团队、长期维护的项目

核心组成

  • 模块 (Module) :组织应用的最小单元
  • 组件 (Component) :页面的最小单元
  • 服务 (Service) :公共逻辑封装
  • 指令 / 管道 / 路由:扩展功能

2. 环境搭建与项目创建

2.1 安装 Angular CLI

bash 运行

# 全局安装 Angular 脚手架
npm install -g @angular/cli

# 验证安装
ng version

2.2 创建新项目

bash 运行

# 创建项目(支持路由、SCSS、严格模式)
ng new angular-demo --routing --style=scss --strict

# 进入项目
cd angular-demo

# 启动项目(默认端口4200)
ng serve --open

2.3 常用 CLI 命令

bash 运行

# 创建组件
ng generate component components/home
# 简写
ng g c components/home

# 创建服务
ng g s services/http

# 创建路由模块
ng g m app-routing --module=app --flat

3. 核心概念:模块 (Module)

定义

模块是 Angular 应用的组织结构,一个应用至少有一个根模块 AppModule

核心作用

  • 声明组件、指令、管道
  • 导入依赖模块
  • 提供服务
  • 启动应用

示例:根模块 app.module.ts

typescript 运行

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
// 路由模块
import { AppRoutingModule } from './app-routing.module';
// 根组件
import { AppComponent } from './app.component';
// 自定义组件
import { HomeComponent } from './components/home/home.component';

@NgModule({
  // 声明:当前模块的组件/指令/管道
  declarations: [
    AppComponent,
    HomeComponent
  ],
  // 导入:依赖的其他模块
  imports: [
    BrowserModule,
    AppRoutingModule
  ],
  // 提供:全局服务
  providers: [],
  // 启动:根组件
  bootstrap: [AppComponent]
})
export class AppModule { }

4. 组件 (Component) 基础

定义

组件是 Angular 应用的页面最小单元,由 HTML 模板 + TS 逻辑 + CSS 样式 组成。

组件结构

  1. xxx.component.ts:逻辑 / 数据
  2. xxx.component.html:模板
  3. xxx.component.scss:样式
  4. xxx.component.spec.ts:测试文件

示例:自定义组件

typescript 运行

// home.component.ts
import { Component } from '@angular/core';

@Component({
  // 组件选择器(HTML标签)
  selector: 'app-home',
  // 模板路径
  templateUrl: './home.component.html',
  // 样式路径
  styleUrls: ['./home.component.scss']
})
export class HomeComponent {
  // 组件数据
  title = 'Angular 入门组件';
  // 方法
  sayHello() {
    alert('Hello Angular!');
  }
}

html 预览

<!-- home.component.html -->
<div class="home">
  <h2>{{ title }}</h2>
  <button (click)="sayHello()">点击我</button>
</div>

5. 模板基础语法

5.1 插值表达式

作用:渲染组件中的变量

html 预览

<h1>{{ 变量名 }}</h1>
<p>{{ 1 + 1 }}</p>
<p>{{ name.toUpperCase() }}</p>

5.2 属性绑定

作用:给 HTML 标签动态绑定属性

html 预览

<!-- 原生属性 -->
<img [src]="imgUrl" alt="图片">
<!-- 类名绑定 -->
<div [class.active]="isActive">激活状态</div>
<!-- 样式绑定 -->
<div [style.color]="textColor">文字颜色</div>

6. 数据绑定(单向 / 双向)

6.1 单向绑定

  1. 组件 → 模板[]
  2. 模板 → 组件()

6.2 双向绑定(核心)

依赖 FormsModule,用于表单数据同步

html 预览

<input [(ngModel)]="username" placeholder="请输入用户名">
<p>你输入的用户名:{{ username }}</p>

使用前提:在 app.module.ts 导入模块

typescript 运行

import { FormsModule } from '@angular/forms';

imports: [BrowserModule, FormsModule]

7. Angular 内置指令

分为 结构指令(修改 DOM 结构)和 属性指令(修改 DOM 样式 / 属性)

7.1 结构指令

1. *ngIf 条件渲染

html 预览

<div *ngIf="isShow">显示内容</div>
<div *ngIf="!isShow">隐藏内容</div>

2. *ngFor 列表渲染

html 预览

<ul>
  <li *ngFor="let item of list; let i = index">
    索引:{{ i }},内容:{{ item.name }}
  </li>
</ul>

3. *ngSwitch 多条件判断

html 预览

<div [ngSwitch]="status">
  <p *ngSwitchCase="1">待支付</p>
  <p *ngSwitchCase="2">已支付</p>
  <p *ngSwitchDefault>未知状态</p>
</div>

7.2 属性指令

1. ngClass 动态类名

html 预览

<div [ngClass]="{ active: isActive, disabled: isDisabled }">
  动态样式
</div>

2. ngStyle 动态样式

html 预览

<div [ngStyle]="{ color: 'red', fontSize: '20px' }">
  内联样式
</div>

8. 事件绑定与用户交互

8.1 基础事件绑定

html 预览

<!-- 点击事件 -->
<button (click)="handleClick()">点击</button>
<!-- 输入事件 -->
<input (input)="handleInput($event)" />
<!-- 表单提交 -->
<form (ngSubmit)="handleSubmit()"></form>

8.2 事件对象

typescript 运行

handleInput(e: Event) {
  const value = (e.target as HTMLInputElement).value;
  console.log(value);
}

9. 组件通讯(核心)

9.1 父组件 → 子组件(@Input)

子组件

typescript 运行

import { Component, Input } from '@angular/core';

@Component({ selector: 'app-child' })
export class ChildComponent {
  // 接收父组件数据
  @Input() msg = '';
}

父组件模板

html 预览

<app-child [msg]="父组件传递的数据"></app-child>

9.2 子组件 → 父组件(@Output + EventEmitter)

子组件

typescript 运行

import { Component, Output, EventEmitter } from '@angular/core';

export class ChildComponent {
  @Output() sendMsg = new EventEmitter<string>();
  
  sendToParent() {
    this.sendMsg.emit('子组件传递的消息');
  }
}

父组件

html 预览

<app-child (sendMsg)="getMsg($event)"></app-child>

typescript 运行

getMsg(msg: string) {
  console.log(msg);
}

9.3 兄弟组件通讯

步骤 1:创建消息服务

bash 运行

ng g s services/message

typescript 运行

// message.service.ts
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class MessageService {
  // 订阅主体
  private msgSubject = new Subject<any>();

  // 发送消息
  sendMessage(data: any) {
    this.msgSubject.next(data);
  }

  // 接收消息
  getMessage() {
    return this.msgSubject.asObservable();
  }
}

步骤 2:兄弟组件 A(发送方)

typescript 运行

// brother-a.component.ts
import { MessageService } from '../../services/message.service';
constructor(private msgService: MessageService) {}

sendBrotherMsg() {
  this.msgService.sendMessage('来自兄弟A的消息');
}

html 预览

<button (click)="sendBrotherMsg()">发送给兄弟B</button>

步骤 3:兄弟组件 B(接收方)

typescript 运行

// brother-b.component.ts
import { MessageService } from '../../services/message.service';
import { Subscription } from 'rxjs';

msg = '';
sub!: Subscription;

constructor(private msgService: MessageService) {}

ngOnInit() {
  // 订阅消息
  this.sub = this.msgService.getMessage().subscribe(data => {
    this.msg = data;
  });
}

// 销毁时取消订阅(防内存泄漏)
ngOnDestroy() {
  this.sub.unsubscribe();
}

html 预览

<p>接收兄弟消息:{{ msg }}</p>

步骤 4:父组件承载两个兄弟组件

html 预览

<!-- parent.component.html -->
<app-brother-a></app-brother-a>
<app-brother-b></app-brother-b>

10. 服务与依赖注入 (Service)

typescript

运行

// data.service.ts
@Injectable({ providedIn: 'root' })
export class DataService {
  user = { name: 'Angular' };
  getUser() { return this.user; }
}

组件使用

typescript

运行

constructor(private dataService: DataService) {}
ngOnInit() {
  console.log(this.dataService.getUser());
}

11. 路由 (Routing) 基础

typescript

运行

// app-routing.module.ts
const routes: Routes = [
  { path: '', redirectTo: 'home', pathMatch: 'full' },
  { path: 'home', component: HomeComponent },
  { path: '**', component: HomeComponent }
];

html

预览

<router-outlet></router-outlet>
<a routerLink="/home">首页</a>

10. 服务与依赖注入 (Service)

定义

服务用于封装公共逻辑(HTTP 请求、工具函数、全局状态),实现业务解耦。

10.1 创建服务

bash 运行

ng g s services/data

10.2 服务示例

typescript 运行

// data.service.ts
import { Injectable } from '@angular/core';

// 注入根组件(全局单例)
@Injectable({ providedIn: 'root' })
export class DataService {
  // 公共数据
  userInfo = { name: 'Angular用户' };
  
  // 公共方法
  getUserInfo() {
    return this.userInfo;
  }
}

10.3 组件使用服务(依赖注入)

typescript 运行

import { DataService } from '../../services/data.service';

export class HomeComponent {
  // 依赖注入服务
  constructor(private dataService: DataService) {}
  
  ngOnInit() {
    // 使用服务
    const user = this.dataService.getUserInfo();
    console.log(user);
  }
}

11. 路由 (Routing) 基础

11.1 路由配置

typescript 运行

// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './components/home/home.component';
import { AboutComponent } from './components/about/about.component';

const routes: Routes = [
  // 默认路由
  { path: '', redirectTo: '/home', pathMatch: 'full' },
  { path: 'home', component: HomeComponent },
  { path: 'about', component: AboutComponent },
  // 404路由
  { path: '**', redirectTo: '/home' }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

11.2 路由出口 + 路由跳转

html 预览

<!-- 路由出口(渲染组件) -->
<router-outlet></router-outlet>

<!-- 声明式跳转 -->
<a routerLink="/home">首页</a>
<a routerLink="/about">关于</a>

<!-- 编程式跳转 -->
<button (click)="toAbout()">跳转到关于页</button>

typescript 运行

// 编程式导航
import { Router } from '@angular/router';
constructor(private router: Router) {}
toAbout() {
  this.router.navigate(['/about']);
}

12. 表单开发(模板驱动 / 响应式)

12.1 模板驱动表单(简单表单)

html 预览

<form #myForm="ngForm" (ngSubmit)="onSubmit(myForm)">
  <input name="username" ngModel required placeholder="用户名">
  <button type="submit">提交</button>
</form>

12.2 响应式表单(复杂表单,推荐)

导入模块

typescript 运行

import { ReactiveFormsModule } from '@angular/forms';

使用

typescript 运行

import { FormBuilder, FormGroup, Validators } from '@angular/forms';

export class LoginComponent {
  loginForm: FormGroup;
  
  constructor(private fb: FormBuilder) {
    // 初始化表单
    this.loginForm = this.fb.group({
      username: ['', [Validators.required, Validators.minLength(2)]],
      password: ['', [Validators.required]]
    });
  }
  
  onSubmit() {
    console.log(this.loginForm.value);
  }
}

html 预览

<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
  <input formControlName="username">
  <input formControlName="password">
  <button type="submit" [disabled]="!loginForm.valid">提交</button>
</form>

13. HTTP 网络请求

13.1 导入模块

typescript 运行

import { HttpClientModule } from '@angular/common/http';

13.2 封装 HTTP 服务

typescript 运行

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class HttpService {
  constructor(private http: HttpClient) {}
  
  // GET请求
  getList(): Observable<any> {
    return this.http.get('https://api.example.com/list');
  }
  
  // POST请求
  addData(data: any): Observable<any> {
    return this.http.post('https://api.example.com/add', data);
  }
}

13.3 组件使用

typescript 运行

this.httpService.getList().subscribe({
  next: (res) => console.log(res),
  error: (err) => console.error(err)
});

14. 管道 (Pipe)

14.1 内置管道

html 预览

<!-- 日期管道 -->
<p>{{ now | date:'yyyy-MM-dd' }}</p>
<!-- 大小写管道 -->
<p>{{ name | uppercase }}</p>
<!-- 小数管道 -->
<p>{{ num | number:'1.2-2' }}</p>
<!-- JSON管道 -->
<p>{{ obj | json }}</p>

14.2 自定义管道

示例 1:性别转换管道

bash 运行

ng g p pipes/sex-transform

typescript 运行

// sex-transform.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'sexTransform' })
export class SexTransformPipe implements PipeTransform {
  // value:传入值,args:额外参数
  transform(value: number): string {
    switch (value) {
      case 1: return '男';
      case 2: return '女';
      default: return '未知';
    }
  }
}

使用方式

html 预览

<p>{{ 1 | sexTransform }}</p> <!-- 输出:男 -->
<p>{{ 2 | sexTransform }}</p> <!-- 输出:女 -->

示例 2:数组过滤管道

typescript 运行

// filter.pipe.ts
@Pipe({ name: 'filterList' })
export class FilterListPipe implements PipeTransform {
  transform(list: any[], key: string, keyword: string): any[] {
    if (!keyword) return list;
    return list.filter(item => item[key].includes(keyword));
  }
}

html 预览

<li *ngFor="let item of list | filterList: 'name': keyword">{{item.name}}</li>

15. 组件生命周期钩子

15.1 Angular 共有 8 个生命周期钩子,按执行顺序排列,包含创建 / 更新 / 销毁全流程

typescript 运行

import {
  Component,
  OnChanges,
  OnInit,
  DoCheck,
  AfterContentInit,
  AfterContentChecked,
  AfterViewInit,
  AfterViewChecked,
  OnDestroy,
  Input
} from '@angular/core';

@Component({
  selector: 'app-life-cycle',
  template: `<p>{{ msg }}</p>`
})
export class LifeCycleComponent implements
  OnChanges,
  OnInit,
  DoCheck,
  AfterContentInit,
  AfterContentChecked,
  AfterViewInit,
  AfterViewChecked,
  OnDestroy {

  @Input() msg = '测试';

  // 1. 输入属性(@Input)改变时触发
  ngOnChanges(): void {
    console.log('1. ngOnChanges - 属性改变');
  }

  // 2. 组件初始化(最常用,请求数据)
  ngOnInit(): void {
    console.log('2. ngOnInit - 组件初始化完成');
  }

  // 3. 脏值检测(每次变更检测触发)
  ngDoCheck(): void {
    console.log('3. ngDoCheck - 变更检测');
  }

  // 4. 内容投影初始化完成
  ngAfterContentInit(): void {
    console.log('4. ngAfterContentInit - 内容投影初始化');
  }

  // 5. 内容投影变更检测
  ngAfterContentChecked(): void {
    console.log('5. ngAfterContentChecked - 内容检测');
  }

  // 6. 视图初始化完成(操作DOM)
  ngAfterViewInit(): void {
    console.log('6. ngAfterViewInit - 视图渲染完成');
  }

  // 7. 视图变更检测
  ngAfterViewChecked(): void {
    console.log('7. ngAfterViewChecked - 视图检测');
  }

  // 8. 组件销毁(清理定时器/订阅)
  ngOnDestroy(): void {
    console.log('8. ngOnDestroy - 组件销毁');
  }
}

15.2 执行顺序

  1. ngOnChanges → 输入属性变化
  2. ngOnInit → 初始化
  3. ngDoCheck → 每次渲染检查
  4. ngAfterContentInit → 内容投影
  5. ngAfterContentChecked → 内容检查
  6. ngAfterViewInit → DOM 渲染完成
  7. ngAfterViewChecked → 视图检查
  8. ngOnDestroy → 组件销毁

15.3 核心使用场景

  • ngOnInit发起网络请求、初始化数据
  • ngOnChanges:监听父组件传值变化
  • ngAfterViewInit:操作 DOM 元素
  • ngOnDestroy清除定时器、取消订阅、防内存泄漏

16. 常用装饰器总结

表格

装饰器 作用 位置
@Component 定义组件 组件类
@NgModule 定义模块 模块类
@Injectable 定义服务 服务类
@Input 父传子 子组件属性
@Output 子传父 子组件事件
@ViewChild 获取 DOM / 子组件 组件属性

17. 项目打包与部署

17.1 生产打包

bash 运行

ng build --prod
# 或
ng build --configuration production

打包产物:dist/ 目录

17.2 部署

dist 目录静态文件部署到 Nginx、Apache、GitHub Pages 等平台。


18. 总结

本文覆盖了 Angular 入门所有核心基础知识点,包含:

  • 环境搭建、模块 / 组件基础
  • 模板语法、数据绑定、内置指令
  • 组件通讯、服务注入、路由、表单、HTTP
  • 生命周期、管道、打包部署

Angular 作为企业级框架,学习曲线略陡,但规范统一、生态完善、长期维护,非常适合中大型项目开发。掌握以上知识点,即可独立开发 Angular 中小型应用!

总篇:iframe沙盒存储隔离:从紧急补丁到企业级防御体系的完整指南

在微前端与第三方组件集成的浪潮下,iframe 沙盒环境中的 SessionStorage 安全问题,正从一个隐秘的"技术细节"演变为可能引发数据泄露、权限逃逸的"阿喀琉斯之踵"。本系列文章将为你完整呈现:我们如何从一次真实攻防演练中发现致命漏洞,到构建一套经过生产验证的三层纵深防御体系的全过程。 在这里插入图片描述


缘起:一次攻防演练暴露的"沙盒幻象"

在一次内部红蓝对抗中,一个看似平常的设定引发了我们的警觉:一个嵌入在同源 iframe 中、完全受控的第三方图表组件,竟能悄无声息地读取并篡改主应用存储在 sessionStorage 中的用户令牌(authToken)、管理员权限(userRole)及核心业务数据。

核心漏洞

浏览器同源策略保护的是"源"而非"上下文"。当 iframe 的 sandbox 属性包含 allow-same-origin 时,它与主应用被视为同一源,从而共享同一份 sessionStorage 物理存储

这不是浏览器的 Bug,而是其安全模型的一个特性——却也成了攻击者眼中的"特性":

// 恶意代码可轻易在 iframe 内执行
const stolenToken = sessionStorage.getItem('authToken');  // 窃取令牌
sessionStorage.setItem('userRole', 'super_admin');        // 权限提升

我们意识到:这不仅是代码冲突,更是严重的安全漏洞。任何一个被嵌入的第三方组件(即使来源可信),一旦被 XSS 攻击或自身存在恶意代码,都可能成为突破"沙盒"的跳板。


破局:构建纵深防御的思维演进

面对这一问题,简单的"禁用某个属性"或"期望对方整改"并不可靠。我们需要的是一套可自主掌控、可持续演进的技术方案。解决思路经历了三次关键进化:

层级 策略 核心手段 定位
L1 快速止血 通信层修复 移除 allow-same-origin,通过严格的 postMessage 替代直接存储访问 紧急响应,治标不治本
L2 核心隔离 代理层隔离 运行时拦截:动态代理 sessionStorage API,自动添加命名空间前缀(如 ns_app1_ 性价比最高的方案
L3 体系防御 监控层防护 存储访问代理层 + 运行时行为监控 + 安全策略执行层,支持异常检测、自动阻断、灰度发布 企业级基础设施

系列导航:你将在这三篇文章中获得什么

本系列分为上、中、下三篇,由浅入深,带你走完从认知漏洞到建立堡垒的完整路径。

📘 上篇(总篇):《iframe沙盒存储隔离:从紧急补丁到企业级防御体系的完整指南》

(首篇发布)

  • 核心价值:建立完整认知,提供可立即执行的紧急修复方案
  • 你会学到
    • 同源策略与存储共享机制的底层原理
    • allow-same-origin + allow-scripts 组合的致命风险
    • L1 快速止血方案:postMessage 通信改造的最佳实践
    • 如何评估现有系统的暴露面与风险等级
  • 适合读者:所有使用 iframe 的前端开发者、技术经理、安全工程师

🔰 中篇:《手把手拦截——iframe 沙盒 SessionStorage 隔离的轻量级实践》

(第二篇发布)

  • 核心价值:给你一套"开箱即用"的代码,立即解决数据污染问题
  • 你会学到
    • Monkey Patch(猴子补丁)技术:优雅劫持 iframe 内的存储 API
    • 完整的 Vue/React 示例代码(前缀隔离、安全的 clear 方法改造)
    • 嵌套 iframe、Storage 事件监听等边界情况的处理
  • 适合读者:一线前端工程师、团队技术骨干,寻求快速有效解决方案的实践者

🛡️ 下篇:《从漏洞到堡垒——构建企业级 iframe 存储安全纵深防御体系》

(第三篇发布)

  • 核心价值:呈现可应对复杂攻击、支撑大型工程的安全架构蓝本
  • 你会学到
    • 基于 Proxy 与 MutationObserver 的健壮代理实现(防绕过)
    • 生产级部署:灰度发布、监控指标、性能测试与回滚方案
    • 与 W3C Storage Access API 的对比与融合路径
    • 开源安全框架的设计思路
  • 适合读者:前端架构师、技术负责人、安全工程师,关注高可用、高安全、可演进架构的决策者

为什么你需要关注这个系列?

  1. 问题普遍性:只要你使用了同源 iframe 嵌入(微前端、第三方 SDK、多团队协作),就可能面临此风险
  2. 方案完整性:从"救火"的 50 行代码,到"防火"的系统工程,提供不同阶段的解决方案
  3. 实战参考性:所有方案均源于真实攻防演练与生产环境迭代,包含踩坑记录与决策权衡
  4. 视野前瞻性:不止于解决当下问题,更探讨与 Web 标准接轨的未来演进路径

安全不是可选项,而是现代 Web 应用的默认值。 对 iframe 沙盒存储漏洞的忽视,可能让精心构建的应用防线从内部被攻破。

本系列文章正是为你厘清风险、提供武器、建立防线的实战指南。敬请期待后续的深度解析。


[下篇预告]:,我们将直接切入实战,剖析漏洞原理,并附上一段可直接复制使用的代码,让你能在半小时内为你的 iframe 应用穿上第一件"隔离衣"。


Vue2 与 Vue3 超全基础知识点汇总

本文涵盖Vue2/Vue3 所有核心基础 API,每个知识点都配完整可运行代码 + 逐行注释,0 基础也能看懂。全文从入门到实战,对比两代 Vue 差异。

1. Vue 基础认知

1.1 核心定义

Vue 是一套用于构建用户界面的渐进式 JavaScript 框架,核心是数据驱动视图,数据变→视图自动变,无需手动操作 DOM。

1.2 Vue2 vs Vue3 核心区别

对比项 Vue2 Vue3
响应式原理 Object.defineProperty ES6 Proxy(无监听缺陷)
代码风格 选项式 API(Options API) 组合式 API(Composition API)+ 兼容选项式
TS 支持 差,需手动配置 原生 TS,类型推导完美
构建工具 Vue-CLI(Webpack) Vite(极速启动)
体积 全量打包,体积大 按需引入,Tree-Shaking 优化
性能 普通 渲染性能提升 55%,内存减少 33%

2. 项目创建与目录结构

2.1 Vue2 项目创建

bash 运行

# 1. 全局安装Vue脚手架
npm install -g @vue/cli

# 2. 创建项目
vue create vue2-project

# 3. 运行项目
cd vue2-project
npm run serve

2.2 Vue3 项目创建(推荐 Vite)

bash 运行

# 1. Vite创建Vue3项目
npm create vite@latest vue3-project -- --template vue

# 2. 安装依赖
cd vue3-project
npm install

# 3. 运行项目
npm run dev

3. 入口文件 main.js 完整写法

3.1 Vue2 入口文件

javascript 运行

// 引入Vue核心库
import Vue from 'vue'
// 引入根组件
import App from './App.vue'
// 关闭生产环境提示
Vue.config.productionTip = false

// 创建Vue实例,挂载根组件到#app
new Vue({
  // 渲染函数
  render: h => h(App)
}).$mount('#app') // 挂载到public/index.html的#app节点

3.2 Vue3 入口文件

javascript 运行

// 引入createApp创建应用实例
import { createApp } from 'vue'
// 引入根组件
import App from './App.vue'
// 引入样式
import './style.css'

// 1. 创建应用实例
const app = createApp(App)
// 2. 挂载到DOM节点
app.mount('#app')

// 拓展:全局配置(Vue3无全局污染)
// app.config.globalProperties.$msg = '全局变量'

4. 模板核心语法(全指令详解)

Vue 模板语法基于 HTML,所有指令以 v- 开头,数据变视图自动更新

4.1 文本插值 {{}}

<!-- Vue2 和 Vue3 模板插值用法完全一致 -->
<template>
  <div>
    <!-- 直接渲染变量 -->
    <h1>{{ msg }}</h1>
    <!-- 渲染表达式 -->
    <p>{{ num + 1 }}</p>
    <p>{{ isShow ? '显示' : '隐藏' }}</p>
  </div>
</template>

<!-- Vue2 -->
<script>
export default {
  data() {
    return {
      msg: 'Hello Vue2',
      num: 10,
      isShow: true
    }
  }
}
</script>

<!-- Vue3 -->
<script setup>
import { ref } from 'vue'
const msg = ref('Hello Vue3')
const num = ref(10)
const isShow = ref(true)
</script>

4.2 v-html 渲染 HTML 标签

{{}} 会转义标签,v-html 可解析原生 HTML

<template>
  <div v-html="htmlStr"></div>
</template>

<!-- Vue2 -->
<script>
export default {
  data() {
    return { htmlStr: '<span style="color:red">红色文字</span>' }
  }
}
</script>

<!-- Vue3 -->
<script setup>
import { ref } from 'vue'
const htmlStr = ref('<span style="color:red">红色文字</span>')
</script>

⚠️ 安全警告:仅在信任的内容上使用 v-html,防止 XSS 攻击!

4.3 v-bind 绑定属性(简写:)

动态绑定标签属性(src、class、style、disabled 等)

<template>
  <!-- 完整写法 -->
  <img v-bind:src="imgUrl" />
  <!-- 简写: (最常用) -->
  <img :src="imgUrl" />
  <!-- 绑定class -->
  <div :class="{ active: isActive }">class绑定</div>
</template>

<!-- Vue2 -->
<script>
export default {
  data() {
    return {
      imgUrl: 'https://xxx.jpg',
      isActive: true
    }
  }
}
</script>

4.4 v-on 绑定事件(简写 @)

绑定点击、输入、鼠标等事件

<template>
  <!-- 完整写法 -->
  <button v-on:click="handleClick">点击</button>
  <!-- 简写@ (最常用) -->
  <button @click="handleClick">点击</button>
  <!-- 传参 -->
  <button @click="handleClickParams(10)">传参点击</button>
</template>

<!-- Vue2 -->
<script>
export default {
  methods: {
    handleClick() { alert('Vue2点击事件') },
    handleClickParams(num) { alert('参数:' + num) }
  }
}
</script>

4.5 v-model 双向绑定(表单专用)

表单元素(input/textarea/select)数据双向同步

vue2

<template>
  <input v-model="inputVal" placeholder="请输入" />
  <p>输入内容:{{ inputVal }}</p>
</template>

<!-- Vue2 -->
<script>
export default {
  data() { return { inputVal: '' } }
}
</script>

vue3 统一语法,支持多 v-model,废弃 .sync

<!-- 父组件 -->
<Child v-model:msg="msg" v-model:age="age" />

<!-- 子组件 -->
<script setup>
const props = defineProps(['msg', 'age'])
const emit = defineEmits(['update:msg', 'update:age'])
</script>

4.6 v-for 列表渲染(必须加 key)

循环渲染数组 / 对象,key 必须唯一,不能用 index

<template>
  <ul>
    <li v-for="item in list" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

<!-- Vue2 -->
<script>
export default {
  data() {
    return { list: [{id:1,name:'苹果'},{id:2,name:'香蕉'}] }
  }
}
</script>

4.7 v-if /v-else/v-else-if 条件渲染

控制元素创建 / 销毁,切换开销大

<template>
  <div v-if="score >= 90">优秀</div>
  <div v-else-if="score >= 60">及格</div>
  <div v-else">不及格</div>
</template>

4.8 v-show 条件显示

控制元素显示 / 隐藏(display:none),切换开销小

<template>
  <div v-show="isShow">v-show显示内容</div>
</template>

4.9 其他指令

  • v-once:只渲染一次,后续数据更新不重新渲染
  • v-pre:跳过编译,直接显示原始内容
  • v-cloak:解决插值表达式闪烁问题

5. 响应式数据 API 全解(最核心)

响应式:数据改变 → 视图自动刷新,无需操作 DOM

5.1 Vue2 响应式 API

5.1.1 data 定义响应式数据

data 必须是函数,返回对象,防止组件复用数据污染

<script>
export default {
  // data是函数,返回对象
  data() {
    return {
      // 基本数据类型
      msg: 'Vue2',
      num: 0,
      // 引用数据类型
      user: { name: '张三', age: 18 },
      list: [1,2,3]
    }
  }
}
</script>

5.1.2 Vue2 响应式缺陷 & 修复 API

Vue2 用 Object.defineProperty无法监听数组下标修改、对象新增属性

<script>
export default {
  data() { return { user: {}, list: [1,2] } },
  mounted() {
    // 1. 对象新增属性 → 视图不更新
    this.user.name = '张三' // 无效
    // 修复:this.$set
    this.$set(this.user, 'name', '张三')

    // 2. 数组下标修改 → 视图不更新
    this.list[0] = 100 // 无效
    // 修复:this.$set
    this.$set(this.list, 0, 100)

    // 3. 删除对象属性 → 视图不更新
    // 修复:this.$delete
    this.$delete(this.user, 'name')
  }
}
</script>

5.2 Vue3 响应式 API(组合式)

Vue3 用 Proxy,无任何响应式缺陷,所有操作都能监听

5.2.1 ref 定义基本数据类型

用于:字符串、数字、布尔、null、undefined

<script setup>
// 1. 引入ref
import { ref } from 'vue'

// 2. 定义响应式数据
const msg = ref('Hello Vue3') // 字符串
const num = ref(0) // 数字
const isShow = ref(true) // 布尔

// 3. JS中修改值必须加 .value
const changeMsg = () => {
  msg.value = '修改后的Vue3'
  num.value++
}
</script>

<template>
  <!-- 模板中自动解包,无需.value -->
  <p>{{ msg }}</p>
  <p>{{ num }}</p>
  <button @click="changeMsg">修改数据</button>
</template>

5.2.2 reactive 定义引用数据类型

用于:对象、数组、Map、Set

<script setup>
import { reactive } from 'vue'

// 定义对象
const user = reactive({ name: '李四', age: 20 })
// 定义数组
const list = reactive(['苹果', '香蕉'])

// 修改数据:直接修改,无需.value
const updateUser = () => {
  user.age = 21 // 直接改
  list[0] = '葡萄' // 直接改数组下标,无缺陷
}
</script>

5.2.3 toRefs 解构 reactive 数据

reactive 解构后会丢失响应式,用 toRefs 修复

<script setup>
import { reactive, toRefs } from 'vue'
const user = reactive({ name: '王五', age: 25 })

// 错误:解构后无响应式
// const { name, age } = user

// 正确:toRefs 保持响应式
const { name, age } = toRefs(user)

// 修改数据
const changeAge = () => {
  age.value = 26
}
</script>

5.2.4 toRef 单独抽取一个属性

<script setup>
import { reactive, toRef } from 'vue'
const user = reactive({ name: '赵六' })
// 抽取单个属性
const name = toRef(user, 'name')
</script>

5.2.5 其他响应式 API

  • unref:如果是 ref 返回.value,否则返回本身
  • shallowRef:浅响应式,只监听.value 修改
  • shallowReactive:浅响应式,只监听第一层属性

6. 方法(methods)与事件处理

6.1 Vue2 定义方法

所有方法放在 methods 选项中

<template>
  <button @click="add">点击+1</button>
  <p>数字:{{ num }}</p>
</template>

<script>
export default {
  data() { return { num: 0 } },
  // 方法存放位置
  methods: {
    add() {
      // 通过this访问data数据
      this.num++
    }
  }
}
</script>

6.2 Vue3 定义方法

直接在 <script setup> 中定义函数,模板直接用

<template>
  <button @click="add">点击+1</button>
  <p>数字:{{ num }}</p>
</template>

<script setup>
import { ref } from 'vue'
const num = ref(0)

// 直接定义函数
const add = () => {
  num.value++
}
</script>

6.3 事件修饰符

<!-- 阻止冒泡 -->
<button @click.stop="handle">.stop</button>
<!-- 阻止默认行为 -->
<a @click.prevent="handle">.prevent</a>
<!-- 只触发一次 -->
<button @click.once="handle">.once</button>

7. 生命周期钩子完整对比 + 使用

生命周期:Vue 实例从创建→挂载→更新→销毁的全过程

7.1 完整生命周期对应表

Vue2 钩子 Vue3 钩子 执行时机
beforeCreate setup 创建前(无 this)
created setup 创建后(可访问数据)
beforeMount onBeforeMount 挂载前
mounted onMounted 挂载完成(操作 DOM、发请求)
beforeUpdate onBeforeUpdate 更新前
updated onUpdated 更新完成
beforeDestroy onBeforeUnmount 销毁前
destroyed onUnmounted 销毁完成

7.2 Vue2 生命周期使用

<script>
export default {
  data() { return { msg: 'Vue2生命周期' } },
  // 挂载完成,最常用
  mounted() {
    console.log('DOM渲染完成,可发请求')
  },
  // 销毁前
  beforeDestroy() {
    console.log('组件销毁,清除定时器')
  }
}
</script>

7.3 Vue3 生命周期使用

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const msg = ref('Vue3生命周期')

// 挂载完成
onMounted(() => {
  console.log('DOM渲染完成')
})

// 组件销毁
onUnmounted(() => {
  console.log('组件销毁')
})
</script>

8. 计算属性 computed(缓存特性)

8.1 作用

处理复杂逻辑,有缓存,依赖数据不变时不会重新计算,比 methods 性能高

8.2 Vue2 computed

<template>
  <p>全名:{{ fullName }}</p>
</template>

<script>
export default {
  data() {
    return { firstName: '张', lastName: '三' }
  },
  // 计算属性
  computed: {
    // 只读计算属性
    fullName() {
      return this.firstName + this.lastName
    },
    // 读写计算属性(get+set)
    fullName2: {
      get() { return this.firstName + this.lastName },
      set(val) {
        const arr = val.split(' ')
        this.firstName = arr[0]
        this.lastName = arr[1]
      }
    }
  }
}
</script>

8.3 Vue3 computed

<script setup>
import { ref, computed } from 'vue'
const firstName = ref('李')
const lastName = ref('四')

// 只读计算属性
const fullName = computed(() => {
  return firstName.value + lastName.value
})

// 读写计算属性
const fullName2 = computed({
  get() { return firstName.value + lastName.value },
  set(val) {
    const arr = val.split(' ')
    firstName.value = arr[0]
    lastName.value = arr[1]
  }
})
</script>

9. 侦听器 watch /watchEffect(监听数据变化)

9.1 watch 监听指定数据(Vue2)

<script>
export default {
  data() { return { num: 0, user: { age: 18 } } },
  watch: {
    // 监听基本类型
    num(newVal, oldVal) {
      console.log('新值:', newVal, '旧值:', oldVal)
    },
    // 深度监听对象(必须加deep:true)
    user: {
      handler(newVal) { console.log('user变化:', newVal) },
      deep: true,
      immediate: true // 立即执行一次
    }
  }
}
</script>

9.2 watch 监听指定数据(Vue3)

<script setup>
import { ref, reactive, watch } from 'vue'
const num = ref(0)
const user = reactive({ age: 18 })

// 监听基本类型
watch(num, (newVal, oldVal) => {
  console.log(newVal, oldVal)
})

// 深度监听对象
watch(user, (newVal) => {
  console.log(newVal)
}, { deep: true, immediate: true })
</script>

9.3 watchEffect 自动监听(Vue3 专属)

无需指定依赖,自动收集,代码更简洁

<script setup>
import { ref, watchEffect } from 'vue'
const num = ref(0)

// 只要num变化,自动执行
watchEffect(() => {
  console.log('num变化:', num.value)
})
</script>

10. 组件基础定义与使用

组件:可复用的 Vue 实例,一个组件就是一个.vue 文件

10.1 Vue2 组件使用

<!-- 父组件 App.vue -->
<template>
  <div>
    <!-- 使用子组件 -->
    <Child />
  </div>
</template>

<script>
// 1. 引入子组件
import Child from './components/Child.vue'
export default {
  // 2. 注册组件
  components: { Child }
}
</script>

<!-- 子组件 Child.vue -->
<template>
  <div>我是子组件</div>
</template>

10.2 Vue3 组件使用

<script setup>引入即注册,无需手动注册

<!-- 父组件 -->
<template>
  <Child />
</template>

<script setup>
// 引入直接用,无需注册
import Child from './components/Child.vue'
</script>

11. 组件通信 8 种方式(全场景)

11.1 父传子 props(最常用)

Vue2 父传子

<!-- 父组件 -->
<Child :msg="父组件数据" :user="user" />
<script>
import Child from './Child.vue'
export default {
  components: { Child },
  data() { return { msg: '父组件传给子组件', user: { name: '父' } } }
}
</script>

<!-- 子组件 -->
<script>
export default {
  // 接收父组件数据
  props: {
    msg: String,
    user: Object
  }
}
</script>

Vue3 父传子

<!-- 父组件 -->
<Child :msg="父组件数据" />
<script setup>
import Child from './Child.vue'
const msg = ref('父组件数据')
</script>

<!-- 子组件 -->
<script setup>
import { defineProps } from 'vue'
// 定义props,接收数据
const props = defineProps({
  msg: {
    type: String,
    default: ''
  }
})
// 模板中直接用{{ msg }}
</script>

11.2 子传父 $emit /defineEmits

Vue2 子传父

<!-- 子组件 -->
<button @click="sendToParent">发送给父组件</button>
<script>
export default {
  methods: {
    sendToParent() {
      // 触发自定义事件,传参
      this.$emit('sendMsg', '子组件数据')
    }
  }
}
</script>

<!-- 父组件 -->
<Child @sendMsg="handleReceive" />
<script>
export default {
  methods: {
    handleReceive(val) { console.log('接收子组件数据:', val) }
  }
}
</script>

Vue3 子传父

<!-- 子组件 -->
<script setup>
import { defineEmits } from 'vue'
// 定义自定义事件
const emit = defineEmits(['sendMsg'])
const send = () => {
  emit('sendMsg', 'Vue3子组件数据')
}
</script>

<!-- 父组件 -->
<Child @sendMsg="handleReceive" />

11.3 其他通信方式

1、兄弟组件通信:Vue2 → EventBus;Vue3 → mitt / Pinia

2、跨级组件通信:provide /inject

适用场景:多层嵌套组件(如祖父→父→孙→曾孙),无需逐层透传props,由祖先组件提供数据,后代组件直接注入使用。


Vue2 vs Vue3 核心差异

特性 Vue2 Vue3
响应式 默认非响应式,需手动用computed/ref包装 原生支持响应式,直接传递ref/reactive即可
API 位置 选项式 API(provide/inject选项) 组合式 API(setup中用provide/inject函数)
TS 支持 强(类型自动推导)

Vue2 示例

javascript 运行

// 祖先组件(App.vue)
export default {
  provide() {
    return {
      // 传递响应式数据,必须用computed包装
      appName: computed(() => this.appName),
      userInfo: { name: '张三', age: 30 }
    }
  },
  data() {
    return {
      appName: 'Vue2 应用'
    }
  }
}

// 后代组件(任意层级,如孙组件)
export default {
  inject: ['appName', 'userInfo'],
  mounted() {
    console.log('注入的appName:', this.appName.value) // computed需.value
    console.log('注入的userInfo:', this.userInfo)
  }
}

Vue3 示例(组合式 API + <script setup>

<!-- 祖先组件 App.vue -->
<script setup>
import { provide, ref, reactive } from 'vue'

// 响应式数据
const appName = ref('Vue3 应用')
const userInfo = reactive({ name: '张三', age: 30 })

// 提供数据,直接传递响应式变量
provide('appName', appName)
provide('userInfo', userInfo)
</script>

<!-- 后代组件(任意层级) -->
<script setup>
import { inject } from 'vue'

// 注入数据,第二个参数为默认值(可选)
const appName = inject('appName', '默认应用名')
const userInfo = inject('userInfo')

console.log('注入的appName:', appName.value) // ref需.value
console.log('注入的userInfo:', userInfo)
</script>

3、ref / $refs:父组件获取子组件实例

适用场景:父组件需要直接调用子组件的方法、访问子组件的 data,或操作子组件的 DOM 元素。


Vue2 vs Vue3 核心差异

表格

特性 Vue2 Vue3
获取实例 this.$refs.child 选项式同 Vue2;组合式需在onMounted后通过ref获取
<script setup>支持 无此语法 子组件必须用defineExpose显式暴露属性 / 方法
DOM 访问 this.$refs.dom 同 Vue2,组合式需ref绑定

Vue2 示例

<!-- 子组件 Child.vue -->
<template>
  <div>子组件</div>
</template>
<script>
export default {
  data() {
    return {
      childMsg: '我是子组件数据'
    }
  },
  methods: {
    childMethod() {
      console.log('子组件方法被调用')
      return '子组件返回值'
    }
  }
}
</script>

<!-- 父组件 Parent.vue -->
<template>
  <Child ref="childComp" />
  <div ref="domBox">父组件DOM</div>
</template>
<script>
import Child from './Child.vue'
export default {
  components: { Child },
  mounted() {
    // 获取子组件实例
    const childInstance = this.$refs.childComp
    console.log('子组件数据:', childInstance.childMsg)
    // 调用子组件方法
    const res = childInstance.childMethod()
    console.log('子组件方法返回值:', res)

    // 获取DOM元素
    const dom = this.$refs.domBox
    console.log('DOM元素:', dom)
  }
}
</script>

Vue3 示例(<script setup>

<!-- 子组件 Child.vue -->
<template>
  <div>子组件</div>
</template>
<script setup>
import { ref } from 'vue'
const childMsg = ref('我是子组件数据')
const childMethod = () => {
  console.log('子组件方法被调用')
  return '子组件返回值'
}

// 【关键】<script setup>默认闭包,必须显式暴露给父组件!
defineExpose({
  childMsg,
  childMethod
})
</script>

<!-- 父组件 Parent.vue -->
<template>
  <Child ref="childComp" />
  <div ref="domBox">父组件DOM</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import Child from './Child.vue'

// 声明ref,绑定到子组件/DOM
const childComp = ref(null)
const domBox = ref(null)

onMounted(() => {
  // 必须在onMounted后才能获取到实例/DOM!
  console.log('子组件实例:', childComp.value)
  console.log('子组件数据:', childComp.value.childMsg)
  const res = childComp.value.childMethod()
  console.log('子组件方法返回值:', res)

  // 获取DOM
  console.log('DOM元素:', domBox.value)
})
</script>

4、全局状态管理:Vuex(Vue2) / Pinia(Vue3)

适用场景:中大型项目,任意关系组件共享全局状态,需要统一管理状态读写、异步操作。


Vue2 方案:Vuex(Vue2 官方状态管理)

Vuex 核心概念:State(状态)、Mutations(同步修改)、Actions(异步操作)、Getters(计算属性)、Modules(模块化)。

javascript 运行

// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    count: 0,
    userInfo: null
  },
  mutations: {
    // 同步修改state(唯一修改state的方式)
    increment(state, payload = 1) {
      state.count += payload
    },
    setUserInfo(state, user) {
      state.userInfo = user
    }
  },
  actions: {
    // 异步操作,提交mutation
    async fetchUserInfo({ commit }) {
      const res = await fetch('/api/user')
      const user = await res.json()
      commit('setUserInfo', user)
    }
  },
  getters: {
    doubleCount: state => state.count * 2
  }
})

// 组件中使用
export default {
  computed: {
    count() {
      return this.$store.state.count
    },
    doubleCount() {
      return this.$store.getters.doubleCount
    }
  },
  methods: {
    addCount() {
      this.$store.commit('increment', 2) // 提交mutation
    },
    getUserInfo() {
      this.$store.dispatch('fetchUserInfo') // 触发action
    }
  }
}

Vue3 方案:Pinia(Vue3 官方推荐,替代 Vuex)

Pinia 是 Vue3 的下一代状态管理,相比 Vuex:

  • 去掉Mutations,直接在actions中修改状态(同步 / 异步都支持)
  • 自动模块化,无需手动注册
  • 完美支持 TS,组合式 API 友好
  • 体积仅~1KB,DevTools 支持更好

javascript 运行

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    userInfo: null
  }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    // 同步/异步直接写,无需mutation
    increment(payload = 1) {
      this.count += payload
    },
    async fetchUserInfo() {
      const res = await fetch('/api/user')
      const user = await res.json()
      this.userInfo = user
    }
  }
})

// 组件中使用(<script setup>)
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

const counterStore = useCounterStore()
// 解构保持响应式(必须用storeToRefs)
const { count, doubleCount, userInfo } = storeToRefs(counterStore)

// 直接调用action
const addCount = () => counterStore.increment(2)
const getUserInfo = () => counterStore.fetchUserInfo()
</script>

5、parent/parent / children:父子直接访问(不推荐)


12. 插槽 Slot(默认 / 具名 / 作用域)

插槽:父组件向子组件传递 HTML 结构

12.1 默认插槽

<!-- 子组件 -->
<slot>默认内容</slot>
<!-- 父组件 -->
<Child>我是插入的内容</Child>

12.2 具名插槽(多个插槽)

Vue2

<!-- 子组件 -->
<slot name="header"></slot>
<!-- 父组件 -->
<template slot="header">头部内容</template>

Vue3(v-slot 简写 #)

<!-- 子组件 -->
<slot name="header"></slot>
<!-- 父组件 -->
<template #header>头部内容</template>

12.3 作用域插槽(子传数据给插槽)

<!-- 子组件 -->
<slot :user="user"></slot>
<script setup>
const user = reactive({ name: '插槽数据' })
</script>

<!-- 父组件 -->
<template #default="scope">
  {{ scope.user.name }}
</template>

13. 自定义指令 Directive

13.1 Vue2 自定义指令

javascript 运行

// 全局指令
Vue.directive('focus', {
  inserted(el) { el.focus() }
})

// 局部指令
export default {
  directives: {
    focus: { inserted(el) { el.focus() } }
  }
}

13.2 Vue3 自定义指令

javascript 运行

// 全局指令
app.directive('focus', {
  mounted(el) { el.focus() }
})

// 局部指令
<script setup>
const vFocus = { mounted: (el) => el.focus() }
</script>
<template> <input v-focus /> </template>

14. 过滤器 Filter(Vue2 有 / Vue3 废弃)

Vue2 过滤器

<template>
  <p>{{ msg | reverse }}</p>
</template>
<script>
export default {
  data() { return { msg: 'abc' } },
  filters: {
    reverse(val) { return val.split('').reverse().join('') }
  }
}
</script>

Vue3 替代方案

计算属性函数替代过滤器

<script setup>
import { ref, computed } from 'vue'
const msg = ref('abc')
const reverseMsg = computed(() => msg.value.split('').reverse().join(''))
</script>

15. 混入 Mixin(Vue2 有 / Vue3 废弃)

Vue2 用于复用代码,Vue3 用组合式函数替代

// mixin.js
export default {
  data() { return { mixinMsg: 'mixin数据' } },
  methods: { mixinFun() { console.log('mixin方法') } }
}

// 组件使用
import myMixin from './mixin.js'
export default { mixins: [myMixin] }

16. 路由 Vue-Router(完整配置 + 使用)

16.1 Vue2 + Vue-Router@3

javascript 运行

// router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
Vue.use(VueRouter)

const routes = [
  { path: '/', component: Home }
]
const router = new VueRouter({ routes })
export default router

// main.js 引入
import router from './router'
new Vue({ router }).$mount('#app')

16.2 Vue3 + Vue-Router@4

javascript 运行

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
const routes = [
  { path: '/', component: Home }
]
const router = createRouter({
  history: createWebHistory(),
  routes
})
export default router

// 组件使用
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter() // 跳转
const route = useRoute() // 获取参数
// 跳转
const goHome = () => router.push('/')
</script>

17. Vue3 新增高级 API

17.1 Teleport 传送门

将组件渲染到指定 DOM,常用于弹窗

<teleport to="body">
  <div class="modal">弹窗内容</div>
</teleport>

17.2 Suspense 异步组件

<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    <div>加载中...</div>
  </template>
</Suspense>

17.3 defineComponent 类型推导

<script setup>
import { defineComponent } from 'vue'
const MyComponent = defineComponent({/*...*/})
</script>

18. Vue2 废弃 API 汇总

  1. $on / $off / $once(EventBus 废弃)
  2. filter 过滤器
  3. mixin 混入
  4. $children / $destroy
  5. 旧版插槽语法 slot / slot-scope
  6. .sync 修饰符

19. 新手常见问题与注意事项

  1. Vue3 ref 修改变量必须加 .value,模板中不用
  2. v-for 必须加唯一 key,不要用 index
  3. v-if 和 v-for 不要同标签使用
  4. Vue2 对象 / 数组修改用 $set,Vue3 直接修改
  5. 计算属性有缓存,methods 无缓存
  6. 组件名要大驼峰,避免和 HTML 标签冲突

文末总结

  1. Vue2 以选项式 API为主,适合小型项目,响应式有缺陷;
  2. Vue3 以组合式 API为主,适合中大型项目,响应式完美,TS 友好;
  3. 新手优先学 Vue3 + Vite + Pinia,这是未来主流技术栈。

前端渲染:从 CSR、SSR 到同构与手写 Vite+React SSR 实践

在现代全栈开发的日常中,尤其是当我们着手构建大型 Web 应用或负责 C 端核心业务时,总会不可避免地撞上一座大山:首屏加载性能与 SEO 优化

无论是早期的 jQuery 时代,还是如今由 React、Vue 主导的组件化时代,前端圈一直在围绕一个核心问题进行技术迭代:网页到底是谁组装的? 是用户的浏览器,还是远端的服务器?

本文将由浅入深,带你彻底厘清 CSR、SSR 的前世今生,探讨现代框架的“同构渲染”黑魔法,并最终从零手写一个基于 Vite + Express + React 的 SSR 服务,让你不仅知其然,更知其所以然。

一、 渲染模式的演进:CSR 与 SSR 的较量

要理解现代架构,我们必须先看懂历史。

1. CSR (Client Side Render) 客户端渲染

在单页应用(SPA)横行的今天,CSR 是我们最熟悉的模式。它的本质是:服务器先返回一个“空壳” HTML,所有的页面渲染、路由跳转、状态管理都在用户的浏览器端由 JavaScript 完成。

工作流程(四步走):

  1. 请求 HTML: 用户输入网址,浏览器向服务器发起请求。
  2. 返回空壳: 服务器仅返回一个极简的 HTML(通常只有一个 <div id="root"></div>)和一个打包好的庞大 JavaScript 文件(如 bundle.js)。
  3. JS 解析(白屏期): 浏览器开始下载并执行 JS 文件。在这个过程中,用户只能看到大白屏或骨架屏。
  4. 数据请求与组装: JS 运行时向后端 API 请求数据,拿到 JSON 数据后,在本地动态生成 DOM 元素并插入页面,画面最终呈现。

优缺点剖析:

  • 优势(体验与成本): 服务器压力极小,只负责吐静态资源和 API 数据。“炒菜组装”的算力消耗全部转移给了用户的设备。一旦首屏加载完成,后续交互(如路由切换、弹窗)均在本地计算,体验极其丝滑,媲美原生 APP。
  • 致命劣势(性能与 SEO): 必须等待 JS 下载并执行完毕才能看到内容,网络稍差就会导致严重的首屏白屏。更致命的是,SEO(搜索引擎优化)极差。由于爬虫大多不会去执行复杂的 JS 代码,它们抓取到的永远只是那个没有实质内容的“空壳 HTML”,导致网站毫无自然流量。

适用场景:

后台管理系统、SaaS 工具、重交互的 Web 应用(如在线文档、可视化大屏)。这些内部系统无需考虑 SEO,开发体验和操作流畅度才是第一要务。

2. SSR (Server Side Render) 服务端渲染

为了解决 CSR 的痛点,业界把目光重新投向了服务器。利用 Node.js 环境能够运行 JS 的特性,在服务器端提前把 React/Vue 组件和数据拼接好,直接生成完整的 HTML 字符串返回给浏览器。

工作流程:

  1. 发起请求: 用户访问页面。
  2. 服务端组装(核心): 服务器接收请求后,在后端直接调用接口拉取数据,然后将数据和 React 模板拼接,生成包含完整内容的 HTML。
  3. 返回成品: 将这套完整的 HTML 发送给浏览器。
  4. 直接展示: 浏览器拿到的是现成的 HTML DOM 树,直接渲染展示,用户瞬间就能看到满屏内容。

优缺点剖析:

  • 优势(性能与流量): 首屏加载极快,彻底告别白屏。SEO 完美,搜索引擎爬虫能直接抓取到丰富的页面文本,极其利于收录和排名。
  • 劣势(成本与复杂度): 服务器压力剧增。每个用户的每次访问都需要服务器去实时“炒菜”,高并发场景下极易成为性能瓶颈。此外,开发复杂度变高,需要处理 Node.js 环境与浏览器环境的差异(例如在 useEffect 触发前,服务端是没有 windowdocument 对象的)。

适用场景:

官网、新闻门户网站、电商商品详情页等高度依赖搜索引擎引流的 C 端页面。

二、 现代架构的答案:同构渲染 (Isomorphic Rendering)

非黑即白的时代已经过去。小孩子才做选择,现代前端全都要。为了结合 CSR 的极佳交互体验和 SSR 的首屏/SEO 优势,诞生了诸如 Next.js 和 Nuxt.js 这样的现代框架。它们的核心机制就是同构渲染

同构渲染的口诀很简单:首次访问 SSR + 后续交互 CSR

  1. SSR 阶段(极速首屏): 当你第一次打开网页时,服务器立刻将组装好的、带有完整内容的 HTML 返回。屏幕瞬间出现画面,爬虫也非常满意。
  2. 下载脚本: 浏览器在展示静态画面的同时,后台开始默默下载包含交互逻辑的 JS 文件。
  3. Hydration(水合/注水): JS 加载完成后,会在浏览器里重新执行一遍,并静默地“附着”到刚才那个静态的 HTML 页面上。它会对比当前的 DOM 树,不重建 DOM,只是给原本静态的按钮绑上事件,给表单注入状态。这个过程就像给干瘪的海绵注入水分,让它变得鲜活可交互。
  4. CSR 接管: “注水”完成后,页面彻底变成 SPA。后续点击链接只会请求数据,不再请求完整 HTML,体验恢复到极致丝滑。

三、 深水区实战:基于 Vite + Express 手写 SSR

纸上得来终觉浅,绝知此事要躬行。接下来,我们将抛开 Next.js 的封装,从底层原理出发,手写一个包含水合机制的 React SSR 应用。

1. 扫清工程化障碍:路径与 Vite 的角色

在编写 Node 服务端代码前,必须厘清两个概念:

A. 路径处理:path.resolve() vs path.join()

在处理静态资源时经常踩坑:

  • path.join():简单的字符串路径拼接。把 / 看作普通字符,结果可能是相对路径。
  • path.resolve():将路径解析为绝对路径。它会从右向左解析,把 / 看作根目录并丢弃左侧路径。若参数不足以构成绝对路径,则默认拼接当前工作目录(CWD)。

注意:在 ESM 中,不支持 CommonJS 的 __dirname,可以使用 path.resolve()(不传参时即为 CWD)来替代。

B. Vite 在 SSR 中的角色:包工头

在普通的 CSR 中,浏览器负责解析 JS。但在 SSR 中,Node 不认识 .jsx 语法。我们需要通过 createViteServer 将 Vite 以中间件的形式嵌入到 Express 中,让 Vite 接管编译工作。

Vite 提供的三大绝招:

  1. vite.middlewares:共享中间件,处理静态资源和热更新(HMR)。
  2. vite.transformIndexHtml:HTML 转换,将 HMR 客户端脚本注入原始模板。
  3. vite.ssrLoadModuleSSR 的灵魂 API。突破 Node 限制,在后台瞬时编译 React 组件,返回 Node 可直接运行的 ESM 模块对象。

2. 实战代码拆解

我们的目标是实现一套完整的同构渲染架构。

步骤一:HTML 骨架与挂载点 (index.html)

准备一个包含占位符的 HTML 文件。注意这里的 `` 标记,服务器等下会将渲染好的 React 组件代码替换到这里。同时引入客户端入口脚本。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SSR 实战</title>
</head>
<body>
    <div id="root"><!--app-html--></div>
    <script type="module" src="/src/entry-client.jsx"></script>
</body>
</html>

步骤二:编写 React 组件 (App.jsx)

写一个极简的组件。加入 onClick 事件是为了验证后续的**水合(Hydration)**是否成功。如果只是单纯的静态 HTML 替换,点击是不会弹窗的。

export default function App() {
    return <h1 onClick={() => alert('水合成功!Hello Vite SSR')}>Hello Vite SSR</h1>
}

步骤三:双端入口设计

同构应用需要两个入口:一个给服务器执行,一个给浏览器执行。

服务端入口 (entry-server.jsx):

职责:将 React 组件渲染成纯粹的 HTML 字符串。不涉及任何 DOM 操作,不执行生命周期(如 useEffect)和事件绑定。

import React from 'react';
// react-dom/server 提供将组件渲染为 HTML 字符串的能力
import { renderToString } from 'react-dom/server';
import App from './App';

export function render() {
    console.log('Server is rendering the App...');
    return renderToString(<App />);
}

客户端入口 (entry-client.jsx):

职责:Hydration(水合)。浏览器接收到 HTML 后,在此处把事件监听等前端逻辑“粘”上去。

console.log('Client script is running...');
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from './App';

// 水合渲染:让服务器端的 HTML 字符串变成可交互的页面
// React 在前端再执行一次,与现有 DOM 对比,注入事件和逻辑
hydrateRoot(document.getElementById('root'), <App />);

步骤四:编写 Express 核心调度服务 (server.js)

这是整个架构的枢纽。Express 扮演 Web 服务器(洋葱模型式的中间件处理请求),Vite 扮演实时编译器。

import fs from 'fs';
import path from 'path';
import express from 'express';
import { createServer as createViteServer } from 'vite';

// 获取当前目录的绝对路径
const __dirname = path.resolve();
const app = express();

async function start() {
    console.log('SSR Server starting...');
    
    // 1. 以中间件模式创建 Vite 服务器
    const vite = await createViteServer({
        server: { middlewareMode: true }, // 关键:告诉 Vite 不要自己启动 HTTP 服务
        appType: 'custom'                 // 告诉 Vite 页面 HTML 的渲染由 Express 接管
    });
    
    // 2. 将 Vite 作为中间件注入到 Express
    // 处理静态资源、热更新逻辑
    app.use(vite.middlewares);

    // 3. 拦截所有请求,手写 SSR 逻辑
    app.use(async (req, res) => {
        try {
            // A. 同步读取原始的 index.html 模板
            let template = fs.readFileSync(
                path.resolve(__dirname, 'index.html'),
                'utf-8'
            );

            // B. 让 Vite 接管 HTML 转换
            // 这一步至关重要,它会注入 Vite 的 HMR 热更新脚本
            template = await vite.transformIndexHtml(req.url, template);

            // C. 加载服务器端入口文件
            // vite.ssrLoadModule 突破 Node 限制,动态编译 jsx 并返回模块对象
            const { render } = await vite.ssrLoadModule('/src/entry-server.jsx')
            
            // D. 在服务端执行 render,将 React 组件渲染成完整的 HTML 字符串
            const html = await render();
            
            // E. 将渲染出的 HTML 字符串替换到模板的占位符中
            template = template.replace('<!--app-html-->', html);
            
            // F. 将组装好的完整 HTML 返回给浏览器
            res.status(200).set({'Content-Type': 'text/html'}).end(template);
        } catch (error) {
            // 捕获编译错误,通过 Vite 修复堆栈追踪
            vite.ssrFixStacktrace(error);
            res.status(500).end(error.message);
        }
    })
}

app.listen(3000, () => {
    console.log('Server is running on port 3000');
});

start();

四、 总结

通过上述实践,我们走通了现代前端渲染的闭环:

  1. Express 接收到用户的 URL 请求。
  2. 读取本地 index.html,并通过 Vite 中间件 注入热更新代码。
  3. Vite 在后端即时编译 entry-server.jsx,调用 renderToString,快速炒出一盘“没有灵魂”(无交互)的完整 HTML 页面,发给浏览器。这就是 SSR 阶段,爬虫狂喜,用户秒看首屏。
  4. 浏览器解析 HTML,遇到 <script src="/src/entry-client.jsx"> 开始下载前端逻辑。
  5. 前端逻辑加载完毕,调用 hydrateRoot 进行水合,页面瞬间拥有了灵魂,点击事件生效。自此,页面由 CSR 接管

理解了这些底层 API 的流转,再去审视像 Next.js 这种高度封装的生产级 SSR 框架时,便能做到知根知底。现代全栈不仅仅是 API 搬运,深入掌控应用的生命周期和渲染管线,才是构建高性能 Web 系统的基石。

前端性能优化:从"术"到"道"的完整修炼指南

前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完

---# 前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完

---# 前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完

---# 前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完


欢迎交流讨论,共同提升前端工程化水平。更多文章

别再被 `npx` 骗了:Debug 纪实 —— 为什么总是找不到文件?

做全栈开发,最让人抓狂的往往不是复杂的业务逻辑,而是各种匪夷所思的 “环境玄学”

  • “为什么教学视频里敲 npx xxx 秒开,我一敲就报错?”
  • “为什么我昨天在这台电脑上敲就没事,今天怎么突然就不行了?”
  • “按照控制台弹出的方案重试了 3 次,为什么一行能在 Windows 跑通的都没有?”

今天,我们就以开发用到的 Inngest CLI 为例(同样适用于 Prisma, esbuild, sharp 等工具),彻底扒开前端包管理器的底层黑盒,讲透这个恶心无数开发者的 Binary not found 现象。


💥 案发现场

当你在本地输入 npx inngest-cli@latest dev 时,满心欢喜地等待面板启动,结果迎面砸来这样一段报错:

Error: Inngest CLI binary not found.
This happened because install scripts were skipped.
To fix this, use the method most appropriate for your setup:
  NPM_CONFIG_CACHE=$(mktemp -d) npx --ignore-scripts=false inngest-cli@latest
  ...

你尝试复制了报错提示里的命令,然后发现它在 Windows 的 PowerShell 里连语法都不对! 这是为什么?


🕵️‍♂️ 剥开黑盒探寻本质:为什么找不到肉身?

这个报错并不是说你断网了没装上包,而是说你装下来的包**“少了灵魂”**。

1. 挂羊头卖狗肉的 NPM 包装戏法

现代的开发工具链(如 Inngest、esbuild、Prisma 等)由于对性能有极致要求,它们底层的引擎绝大多数是用 Go、C++ 或 Rust 写的。 为了能兼容前端庞大的 npm 生态,开发者通常会在 npm 仓库里发一个纯粹由 JS 构成的 “空壳子”

它的真实运作机制是: 触发安装 -> 下载 JS 空壳 -> 触发 postinstall 钩子脚本 -> 脚本自动从 Github Releases 拉取对应系统(Win/Mac/Linux)的 .exe 可执行文件。

一旦这个 postinstall 脚本因为任何原因(网络超时、没有权限)没有跑成功,你的包里就只剩下一个没用的 JS 空壳。这就叫 Binary not found

2. 拦路虎:pnpm v10 的“安全铁腕”

你可能会问:“我的网络有魔法代理,为什么还会失败?” 真相隐藏在你的包管理器里。如果你升级到了 pnpm v10,由于它引入了极其严格的“受信任依赖”机制,默认会悄悄拦截一切第三方包在后台执行构建脚本(postinstall)的行为

你的命令行里大概会有这样一行一闪而过的高大上的警告:

Ignored build scripts: inngest-cli@1.16.1. Run "pnpm approve-builds" to pick ...

是的,是 pnpm 觉得这个包不安全,亲手把下载 .exe 的途径给掐断了。

3. NPX 的“就近连坐”病毒(解释时灵时不灵)

这是最魔幻的一点:为什么昨天能行,今天装完反而坏了?

  • 当你没安装时(昨天):运行 npx 时,它去自己干净的全局临时目录下载了一个包,刚好没受困于安全拦截,顺利拿到了二进制文件,成功运行。
  • 当你在本地项目里安装了它但被拦截时(今天):你的项目 node_modules 里多了一个“没有二进制文件的空壳包”。
  • 致命的偷懒机制:当你再次敲击 npx inngest-cli 时,npx 会自作聪明地优先使用本地项目中已有的坏包,而不是去全局深究。

这就造成了:只要你的项目里混进了一个“太监版”的依赖,无论你敲多少次全局 npx,它都会被就近传染,当场暴毙。


🛠️ 解法:做防弹的工程底座

搞懂了原理,我们就绝不能像“脚本小子”一样,每次报错就去删除 %LOCALAPPDATA%\npm-cache\_npx 缓存。在正规的全栈商业级项目中,所有基建都必须是绝对受控且确定的。

彻底杜绝玄学的标准动作:将隐式全局依赖,转变为显式本地依赖。

Step 1: 签署白名单 (pnpm.onlyBuiltDependencies)

不要让 pnpm 盲猜,直接在你的 package.json 中明确发给 Inngest 发“放行条”:

{
  "pnpm": {
    "onlyBuiltDependencies": [
      "inngest-cli",
      "prisma",
      "esbuild",
      "sharp"
    ]
  }
}

🔥 Tips: 另一个快捷写法是在终端执行 pnpm approve-builds --save-bundle,它会自动把被拦截的包扫进信任名单。

Step 2: 固化到开发依赖

将不靠谱的 npx 游击战术转编为正规军:

# 保证当前终端顺畅访问 Github 的前提下
pnpm add -D inngest-cli

这时候你再看日志,必定能看到真正的 .exe 安稳落地。

Step 3: 固定项目启动快捷键

打开 package.jsonscripts

"scripts": {
  "dev:inngest": "inngest dev"
}

以后只需优雅地执行 pnpm run dev:inngest,把复杂的事情彻底封装在项目内部。不管换谁接手、换什么电脑拉下代码,都不再需要承受你昨天吃过的苦!


🎯 总结与认知升级

全栈开发往往就是在和这些看似无聊的“基建脏活”抗争。当你能够把“这破电脑怎么又抽风了”,转变为“哦,这显然是 pnpm 包提取钩子被跳过导致的本地模块污染”,你的水平就已经跟初级搬砖工拉开了真正的身位。

下次如果有人对你说“这机器跑不起来,但我本地没问题”,记得用这套理论降维打击他。👨‍💻

前端架构演进:基于AST的常量模块自动化迁移实践

前端架构演进:基于AST的常量模块自动化迁移实践

从“硬编码”到“全自动”:一次常量模块重构的工程化探索

在这里插入图片描述

一、背景与痛点

在许多中大型前端项目中,常量管理常常是一个被忽视但又十分重要的环节。随着业务迭代,常量定义方式可能发生变化,历史代码中也可能沉淀出各种“不规范”的模式。

在我们的项目中,常量定义最初采用了一种集中式导出方式:

// src/constants/Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
  // ... 数十个常量
}

而在业务代码中,这些常量通过一个“万能”的 @/locales 模块统一导入,并以 Constants_expert.default.STATUS_PENDING 的形式使用:

// 旧代码片段
import { Constants_expert } from '@/locales';

if (status === Constants_expert.default.STATUS_APPROVED) { ... }

这种模式存在几个严重问题:

  1. Tree Shaking 失效export default 对象导致整个常量对象被打包,无法按需剔除。
  2. 命名空间冗余:每次使用都要写 .default,代码冗长且容易出错。
  3. 模块职责混乱@/locales 本应是国际化模块,却承担了常量聚合的职责。
  4. 可维护性差:新增常量文件需要手动修改 @/locales 的导出,极易遗漏。

为了彻底解决这些问题,我们决定进行两项重构:

  • 常量文件:将 export default { ... } 拆解为多个 export const,实现具名导出。
  • 业务代码:将所有 Constants_xxx.default.PROP 替换为直接使用 PROP,并添加对应的具名导入。

项目涉及 30+ 个常量文件200+ 个业务文件,手工修改不仅耗时,而且极易出错。于是,我们开发了两个基于 AST(抽象语法树) 的自动化迁移脚本,实现了零人工干预的平滑过渡。

本文将从技术实现、难点攻克、工程化落地三个维度,深度剖析这次自动化重构的全过程。


二、整体方案设计

整个迁移流程分为两个独立的阶段,必须严格按顺序执行

graph LR
    A[常量文件] -->|transform-const.js| B[具名导出常量]
    C[业务代码] -->|transform-project.js| D[直接引用+具名导入]
    B -.->|提供导出变量列表| D
  • 第一阶段:扫描 src/constants/*.ts,将每个文件中的 export default 对象转换为多个 export const 语句。
  • 第二阶段:扫描 src/views 下的所有 .vue.ts.js 文件,识别旧的导入模式,分析实际使用的常量,删除旧导入,生成新的具名导入,并替换代码中的引用。

两个脚本均支持 --dry-run 预览模式,并在修改前自动创建 .bak 备份文件,确保操作可逆。


三、第一阶段:常量文件格式转换(transform-const.js)

3.1 核心目标

将这样的代码:

// Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
}

转换为:

export const STATUS_PENDING = 0;
export const STATUS_APPROVED = 1;

同时保留所有注释(文件头注释、属性上方注释等)。

3.2 AST 操作流程

我们使用 Babel 全家桶完成这次转换:

  • @babel/parser:将源码解析为 AST
  • @babel/traverse:遍历和修改 AST 节点
  • @babel/types:构建新的 AST 节点
  • @babel/generator:将 AST 还原为代码

核心步骤:

  1. 解析源码,指定 sourceType: 'module'plugins: ['typescript'] 以支持 TS 语法。
  2. 遍历 AST,找到 ExportDefaultDeclaration 节点,并判断其声明是否为 ObjectExpression
  3. 移除该默认导出节点
  4. 遍历对象的每个属性,对每个属性构建一个 ExportNamedDeclaration 节点,内部包裹 VariableDeclaration 类型为 const
  5. 保留注释:将原属性的 leadingCommentstrailingComments 赋值给新节点。
  6. 重新生成代码,并写回原文件。

关键代码片段:

traverse(ast, {
  ExportDefaultDeclaration(path) {
    if (t.isObjectExpression(path.node.declaration)) {
      defaultExportObject = path.node.declaration;
      path.remove(); // 移除整个 export default
    }
  },
});

defaultExportObject.properties.forEach((prop) => {
  const propName = prop.key.name;
  const propValue = prop.value;
  const exportDecl = t.exportNamedDeclaration(
    t.variableDeclaration('const', [
      t.variableDeclarator(t.identifier(propName), propValue),
    ])
  );
  // 保留注释
  if (prop.leadingComments) exportDecl.leadingComments = prop.leadingComments;
  exportConstNodes.push(exportDecl);
});

3.3 易错点与防御

  • 非对象默认导出:某些常量文件可能已经是 export const 格式,或者导出一个函数。脚本会检测并跳过,避免破坏已有代码。
  • 属性名非标识符:如果对象的键是字符串字面量(如 "my-const": 123),则无法转换为合法的变量名,脚本会给出警告并跳过该属性。
  • 文件备份:转换前自动创建 .bak 文件,防止误操作导致代码丢失。

四、第二阶段:业务代码引用迁移(transform-project.js)

这是整个方案中最复杂的部分,需要同时处理 JavaScript/TypeScriptVue SFC 文件,并且要保证转换后的代码语法正确、依赖完整。

4.1 动态发现常量文件

第一阶段完成后,src/constants 下的每个 .ts 文件都导出了一批具名常量。我们需要知道每个常量文件导出了哪些变量名,以便在第二阶段验证引用的有效性。

function loadAllConstantFiles() {
  const constantFiles = glob.sync(path.join(CONSTANTS_DIR, '*.ts'), { absolute: true });
  const constantMap = new Map(); // key: 文件名(如 Constants_expert), value: { filePath, exportedNames }

  for (const filePath of constantFiles) {
    const ast = parser.parse(fs.readFileSync(filePath, 'utf-8'), { plugins: ['typescript'] });
    const exportedNames = new Set();
    traverse(ast, {
      ExportNamedDeclaration(path) {
        if (t.isVariableDeclaration(path.node.declaration) && path.node.declaration.kind === 'const') {
          path.node.declaration.declarations.forEach(d => {
            if (t.isIdentifier(d.id)) exportedNames.add(d.id.name);
          });
        }
      },
    });
    constantMap.set(path.basename(filePath, '.ts'), { filePath, exportedNames });
  }
  return constantMap;
}

这样我们就获得了所有常量文件的“导出变量白名单”。

4.2 识别旧的导入模式

在业务代码中,旧的导入语句通常长这样:

import { Constants_expert, Constants_supplier_portrait } from '@/locales';

我们需要找到这些导入,并记录每个本地标识符对应的常量集合名(例如 Constants_expert 对应 Constants_expert 集合)。

使用 AST 遍历 ImportDeclaration,匹配 source.value === '@/locales',然后遍历 specifiers,只处理 ImportSpecifier 类型:

traverse(ast, {
  ImportDeclaration(path) {
    if (path.node.source.value === OLD_IMPORT_SOURCE) {
      path.node.specifiers.forEach(spec => {
        if (t.isImportSpecifier(spec)) {
          const importedName = spec.imported.name;
          const localName = spec.local.name;
          if (constantMap.has(importedName)) {
            oldLocalToConstantMap.set(localName, importedName);
            shouldRemove = true;
          }
        }
      });
      if (shouldRemove) path.remove(); // 删除整条导入语句
    }
  },
});

4.3 替换成员访问表达式

旧的引用方式有两种常见形态:

  • Constants_expert.default.STATUS_PENDING
  • Constants_expert.STATUS_PENDING(某些早期代码省略了 .default

我们需要将它们统一替换为 STATUS_PENDING,并记录下该常量名被使用了。

通过 AST 遍历 MemberExpression,找到根标识符,判断是否在 oldLocalToConstantMap 中,然后解析属性链,提取出最终属性名:

traverse(ast, {
  MemberExpression(path) {
    const root = findRootIdentifier(path.node);
    if (!root) return;
    const localName = root.name;
    if (!oldLocalToConstantMap.has(localName)) return;

    const constantSetName = oldLocalToConstantMap.get(localName);
    const chain = getPropertyChain(path.node);
    let propName = null;
    if (chain.length >= 3 && chain[1] === 'default') {
      propName = chain[2];
    } else if (chain.length >= 2) {
      propName = chain[1];
    }

    if (propName && constantMap.get(constantSetName).exportedNames.has(propName)) {
      // 记录需要导入的变量
      neededImports.get(constantSetName).add(propName);
      // 替换整个节点为一个简单的标识符
      path.replaceWith(t.identifier(propName));
    }
  },
});

4.4 Vue SFC 的特殊处理

Vue 单文件组件包含 <template><script><script setup> 等多个块,需要分别处理。

Script 块:将块内的代码提取出来,调用上述的 transformScript 函数,得到新的代码和需要的导入变量。注意一个 SFC 可能同时存在 <script><script setup>,需要分别处理并合并导入变量。

Template 块:模板中也可能直接使用 Constants_expert.default.STATUS_PENDING 表达式。由于模板不是完整的 JavaScript,用 AST 解析成本较高,我们采用正则替换的方式。

但正则替换有几个坑:

  • 常量名可能包含正则元字符(如 +.),需要转义。
  • 需要同时匹配 .default 和没有 .default 的情况。
  • 替换后要记录使用了哪些变量,以便生成导入。

我们构建动态正则:

const safeName = escapeRegExp(constName);
const regexWithDefault = new RegExp(`\\b${safeName}\\.default\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');
const regexWithoutDefault = new RegExp(`\\b${safeName}\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');

匹配后,将 Constants_expert.default.STATUS 替换为 STATUS,并将 STATUS 加入 neededImports。

4.5 生成新的导入语句

经过上述分析,我们得到了每个常量集合需要导入的具名变量列表。但这里有一个隐蔽的坑:不同常量文件可能导出同名的变量(例如 Constants_expertConstants_supplier 都导出了 STATUS),如果直接生成 import { STATUS } from ... 两次,会产生语法错误。

因此,我们必须先检测冲突:

const varToConstMap = new Map();
for (const [constName, vars] of neededImportsTotal) {
  for (const v of vars) {
    if (varToConstMap.has(v) && varToConstMap.get(v) !== constName) {
      throw new Error(`变量名冲突: "${v}" 同时出现在 "${varToConstMap.get(v)}" 和 "${constName}" 中,请手动重命名其中一个导出变量`);
    }
    varToConstMap.set(v, constName);
  }
}

如果没有冲突,再生成导入语句。导入路径需要将绝对路径转换为 @/ 开头的别名:

const srcDir = path.join(rootDir, 'src');
let importPath = constantFilePath.replace(srcDir, '@/').replace(/\.ts$/, '');
importPath = importPath.replace(/\\/g, '/');

最后,将导入语句插入到文件顶部(如果有 script 块则插入到第一个 script 块的开始位置)。


五、技术难点与解决方案

5.1 路径别名动态转换

最初我们使用 path.relative 然后替换 ../@/,但当文件深度超过两层时,会出现 @/../../constants/xxx 的错误路径。解决方案:基于项目根目录的 src 进行绝对路径替换,直接构造 @/constants/xxx,简单可靠。

5.2 多个 <script> 块的替换位置

Vue SFC 可能同时存在 <script><script setup>,它们的起始和结束偏移量不同。我们需要记录每个块的 loc.start.offsetloc.end.offset,分别替换。并且由于替换后文件长度会变化,必须从后往前依次替换,避免位置偏移错误。

5.3 模板正则的精确匹配

模板中可能包含字符串字面量,例如:

<div :title="'Constants_expert.default.STATUS'"></div>

我们不应该替换引号内的内容。由于 Vue 模板语法的复杂性,完全避免误判需要解析模板 AST,成本过高。我们采用了一个折中方案:只替换独立表达式中的匹配,通过正则的单词边界 \b 来减少误判。在实际项目中,常量名很少出现在字符串内部,因此风险可控。

5.4 保留代码格式与注释

AST 转换后重新生成的代码会丢失原格式(空行、缩进等)。为了最小化 diff,我们使用了 generate{ retainLines: true, comments: true } 选项,尽可能保留原始行号和注释位置。对于 template 的正则替换,我们只替换匹配部分,其余原样保留。


六、工程化落地与自动化流程

为了确保迁移过程平滑、可回滚,我们设计了一套完整的执行流程:

# 1. 全量备份(使用 git 分支)
git checkout -b feature/migrate-constants

# 2. 执行常量文件转换(dry-run 预览)
node scripts/transform-const.js --dry-run
node scripts/transform-const.js

# 3. 执行项目引用迁移(dry-run 预览)
node scripts/transform-project.js --dry-run
node scripts/transform-project.js

# 4. 运行类型检查、单元测试,确保无报错
npm run type-check
npm run test

# 5. 提交变更
git add .
git commit -m "refactor: migrate constants to named exports"

两个脚本都内置了 --dry-run 模式和自动 .bak 备份。即便转换出现问题,也可以快速恢复:

# 恢复所有备份文件
find src -name "*.bak" | while read bak; do mv "$bak" "${bak%.bak}"; done

七、成果与思考

通过这两个脚本,我们在 10 分钟内完成了原本需要 2 人天 的手工重构工作,且零失误。转换后的代码:

  • Tree Shaking 友好:打包体积减少约 15%(未使用的常量被自动剔除)。
  • 可读性提升:代码中直接使用 STATUS_PENDING 而非冗长的 Constants_expert.default.STATUS_PENDING
  • 维护成本降低:新增常量文件无需任何额外配置,脚本自动发现。

更重要的是,这次实践让我们深刻体会到 AST 驱动重构 的巨大威力。无论是代码格式化、框架升级,还是架构调整,只要存在“模式化的代码变换”,都可以借助 AST 工具实现自动化。

未来拓展方向

  • 支持更复杂的引用模式:如 Constants_expert['default'].STATUSConstants_expert[someVar].STATUS,这些可以通过增强 MemberExpression 的递归分析来支持。
  • 集成到 CI 流水线:当常量文件结构发生变化时,自动触发迁移脚本,确保代码库始终保持统一风格。
  • 可视化迁移报告:输出每个文件转换前后的 diff,以及冲突变量列表,便于人工审核。

八、总结

本文详细介绍了如何利用 Babel AST 和 Vue 编译器,完成一次大型常量模块的重构迁移。从最初的痛点分析,到两个阶段脚本的设计,再到各种技术坑点的解决方案,我们不仅解决了实际问题,也沉淀了一套可复用的自动化重构方法论。

如果你也面临类似的“技术债务”清理任务,不妨尝试用 AST 武装自己——让机器去处理那些重复、枯燥的代码变换,把人解放出来做更有创造性的工作。

欢迎交流讨论,共同提升前端工程化水平。更多文章

探索Vite深入 Rollup 分块插件:从零实现一个智能分包工具

探索Vite深入 Rollup 分块插件:从零实现一个智能分包工具

告别正则匹配的硬编码,用规则引擎优雅管理代码分割

引言

在 Rollup 打包配置中,manualChunks 是最强大也最容易被误用的选项之一。社区常见的做法是写一堆 if (id.includes('node_modules')) 或正则表达式,把第三方库一股脑打入 vendor 块。这种方案在项目初期看似简单,但随着迭代,很容易出现:

  • chunk 体积失控:一个 vendor 文件动辄几 MB。
  • 缓存失效频繁:任何依赖更新都会导致整个 vendor 重新下载。
  • 代码复用不佳:被多个入口共享的公共模块无法独立拆分。

为了解决这些问题,我们开发了 rollup.plugin.robin-build 插件(纯 JS/TS 版本,下文简称“本插件”)。它提供了一套声明式的分块规则配置,支持路径匹配、引用次数阈值、优先级排序等高级特性,让代码分割变得可预测、可维护。

插件概览

本插件导出两个主要部分:

  1. output 对象:标准的 Rollup 输出配置,定义了文件命名与分类规则。
  2. createSplitChunks 函数:接收用户配置,返回一个符合 manualChunks 签名 (moduleId, { getModuleInfo }) => string | void 的函数。

插件本身不依赖任何外部库,仅使用 Node.js 内置模块 path。其核心思路是:用户以对象形式定义多个“规则组”,每个规则组包含匹配条件(路径字符串或正则)、目标 chunk 名称、优先级以及最小引用次数。插件在构建时遍历每个模块,按优先级匹配规则,决定模块归属的 chunk。

第一部分:输出配置(output)

export const output = {
    entryFileNames: 'js/robin-[hash].js',
    hashCharacters: 'hex', // 减少字符集,见下图1
    experimentalMinChunkSize: 20 * 1024,
    chunkFileNames: (chunkInfo) => {
        if(chunkInfo.name && chunkInfo.name.startsWith('vendor-')){
            return 'js/[name]-[hash].js'
        }
        return 'js/chunk-[hash].js'
    },
    assetFileNames: (info) => { ... }
}

在这里插入图片描述

1.1 entryFileNames 与 hash 配置

  • entryFileNames:入口 chunk 的文件名模板。这里使用 app-[hash].js,并放入 js/ 目录。
  • hashCharacters: 'hex':指定 hash 编码方式为十六进制(Rollup 5.0+ 支持)。
  • experimentalMinChunkSize:设置最小 chunk 大小(20KB),Rollup 会尝试合并小于此阈值的 chunk,减少 HTTP 请求数量。

1.2 chunkFileNames 动态命名

chunkFileNames 可以是函数,接收 chunkInfo 对象。插件判断如果 chunk 名称以 vendor- 开头(通常是通过规则生成的 vendor 块),则保留原名称,例如 vendor-react-[hash].js;否则统一命名为 chunk-[hash].js

这样做的好处是:vendor 块名称可读性高,便于调试和 CDN 缓存策略区分。

1.3 assetFileNames 按扩展名分类

assetFileNames 根据文件扩展名将静态资源归类到不同子目录:

扩展名类型 输出目录
.css asset/css/
.wasm asset/wasm/
.json, .map asset/data/
.txt, .xml, .pdf asset/docs/
图片格式 asset/img/
音视频格式 asset/media/
字体格式 asset/fonts/
其他 asset/other/

这种细粒度分类对于大型项目尤其重要:运维可以针对不同资源类型设置不同的 CDN 缓存头(例如图片缓存一年,JSON 缓存五分钟)。

第二部分:核心分块引擎 createSplitChunks

createSplitChunks 是整个插件的灵魂。它接收一个配置对象,返回 manualChunks 函数。我们先看它的完整实现:

export const createSplitChunks = (config = {}) => {
    if(!isObject(config)) return null

    const list = []
    Object.keys(config).forEach((key) => {
        const test = config[`${key}`].test

        if(!(isRegExp(test) || isString(test))) {
            throw new Error('test 必须为正则表达式或字符串')
        }

        if (isString(test) && !path.isAbsolute(test)) {
            throw new Error(`test 路径必须为绝对路径,实际获取到的是: ${test}`)
        }

        if (isRegExp(test) && test.global) {
            throw new Error('正则表达式测试不得使用 /g 标志')
        }

        list.push({
            ...config[key],
            chunk_name: `${key.startsWith('vendor') ? key : `vendor-${key}`}`,
            type: isRegExp(test) ? 'regexp' : 'string'
        })
    })
    list.sort((a, b) => (b.priority || 0) - (a.priority || 0))

    return (disk_path, { getModuleInfo }) => {
        const moduleInfo = getModuleInfo(disk_path)

        const target = list.find(item=> {
            if(item['minChunks'] && moduleInfo){
                const static_count = moduleInfo['importers'] ? moduleInfo['importers'].length : 0
                const dynamic_count = moduleInfo['dynamicImporters'] ? moduleInfo['dynamicImporters'].length : 0
                const total = static_count + dynamic_count
                if (total < item['minChunks']) return false
            }
            if(item.type === 'regexp') return item.test.test(disk_path)
            return disk_path.startsWith(item.test)
        })

        if(target && isNull(target.name)) return null

        if(target) return target.name || target.chunk_name

        return null
    }
}

2.1 配置解析与校验

插件期望 config 是一个对象,其每个 key 代表一个规则组的名称,value 必须包含 test 字段(字符串绝对路径或正则表达式)。此外还可以包含:

  • name:自定义 chunk 名称(如果未提供,会自动生成 vendor-${key})。
  • priority:优先级(数字越大越先匹配)。
  • minChunks:最小引用次数,只有模块被引用的总次数 ≥ 该值时才匹配。

首先进行严格的类型校验:

  • 使用 toString.call 来判断数据类型(因为 typeof null === 'object',需要区分)。
  • 对于字符串类型的 test,要求必须是绝对路径(通过 path.isAbsolute 验证)。这确保了匹配的确定性,避免相对路径在不同工作目录下产生歧义。
  • 对于正则表达式,禁止使用 g 全局标志,因为 test 方法在全局标志下会有状态残留,导致不可预期的行为。

2.2 构建规则列表与优先级排序

解析后的每个规则对象会被扩展两个内部字段:

  • chunk_name:自动生成的备用名称(如 vendor-react)。
  • type:标记匹配方式('regexp''string')。

然后将规则数组按 priority 降序排序。没有指定优先级的规则默认为 0。排序保证了高优先级规则先被匹配,避免低优先级规则“抢走”本应归属高优先级规则的模块。

2.3 manualChunks 回调逻辑

manualChunks 接收两个参数:disk_path(模块在磁盘上的绝对路径)和上下文对象 { getModuleInfo }getModuleInfo 可以获取模块的依赖关系信息。

对于每个模块,插件会:

  1. 获取模块的引用信息moduleInfo.importers(静态导入该模块的模块列表)和 dynamicImporters(动态导入该模块的模块列表)。两者的长度之和就是该模块被其他模块引用的总次数 total
  2. 遍历规则列表:按照优先级顺序查找第一个匹配的规则。
    • 如果规则定义了 minChunks,则检查 total >= minChunks,不满足则跳过该规则。
    • 根据规则类型,用 test 匹配 disk_path
  3. 决定 chunk 名称
    • 如果匹配到的规则中 name 字段为 null,则返回 null(表示不强制放入任何特定 chunk,由 Rollup 默认处理)。
    • 否则返回 name 或自动生成的 chunk_name
  4. 未匹配任何规则则返回 null,让 Rollup 按照默认算法处理(通常是基于模块共享度自动拆分)。

2.4 设计亮点

优先级机制解决规则冲突

当多个规则都能匹配同一个模块时,优先级决定了最终归属。例如:

{
  "vue-vendor": {
    test: /[\\/]node_modules[\\/](vue|vue-router|vue-i18n)[\\/]/,
    priority: 10
  },
  "node-vendor": {
    test: /[\\/]node_modules[\\/]/,
    priority: 0
  }
}

vuevue-i18nvue-router 会进入 vue-vendor 块,而其他 npm 包则进入 node-vendor。如果没有优先级,node-vendor 可能会先匹配,导致 Vue 也被打入通用 vendor。

minChunks 避免过度拆分

一个模块如果被很多地方引用(例如工具函数 debounce),独立成 chunk 是有益的;但如果只被一个入口使用,则应该合并到该入口的 chunk 中,减少 HTTP 请求。minChunks 参数允许开发者设置阈值,只有达到引用次数的模块才独立打包。

路径匹配的两种模式
  • 字符串绝对路径:适用于明确知道模块所在目录的场景,例如 '/app/shared/utils'
  • 正则表达式:更灵活,可以匹配 node_modules 中的特定包名,例如 /node_modules\/lodash-es/
自定义 chunk 名称与 null 返回值

允许规则返回 null 可以让某些模块“逃逸”出规则体系,由 Rollup 默认算法处理。这在使用第三方插件或有特殊分块需求时非常有用。

第三部分:类型判断辅助函数

插件开头定义了几个类型判断函数:没有引入loadsh-es,个人感觉没有必要,所以简化写一下

const toString = Object.prototype.toString
const isObject = (data) => toString.call(data) === '[object Object]'
const isNull = (data) => toString.call(data) === '[object Null]'
const isRegExp = (data) => toString.call(data) === '[object RegExp]'
const isString = (data) => toString.call(data) === '[object String]'

为什么不直接用 typeofinstanceof

  • typeof null === 'object',无法区分 null 和普通对象。
  • 在 Rollup 插件环境中,moduleInfo 等对象可能来自不同的上下文,instanceof 可能失效。而 Object.prototype.toString 返回的是内部 [[Class]] 属性,跨框架可靠。

第四部分:使用示例

4.1 基础配置

// rollup.config.js
import { createSplitChunks, output } from 'rollup-plugin-robin-build';

export default {
  input: 'src/main.js',
  output: {
    dir: 'dist',
    ...output,
    manualChunks: createSplitChunks({
      // 规则1:vue 全家桶单独打包
      vue: {
        test: /[\\/]node_modules[\\/](vue|vue-router|vue-i18n)[\\/]/,
        priority: 10,
        minChunks: 1
      },
      // 规则2:antd 组件库单独打包
      antd: {
        test: /[\\/]node_modules[\\/]antd[\\/]/,
        priority: 9
      },
      // 规则3:其他 node_modules 打入 vendor
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        priority: 0
      },
      // 规则4:src/utils 下的公共工具,引用次数 >=3 时独立
      utils: {
        test: path.resolve(__dirname, 'src/utils'),
        minChunks: 3,
        name: 'shared-utils'
      }
    })
  }
}

4.2 配合动态导入

Rollup 能识别动态导入(import()),getModuleInfo 中的 dynamicImporters 会记录哪些模块动态引入了当前模块。因此,minChunks 同样适用于动态导入的场景。

4.3 高级:跳过某些模块

如果某个模块我们不想受任何规则影响,可以在规则中设置 name: null

{
  exclude: {
    test: /[\\/]src[\\/]legacy[\\/]/,
    name: null,   // 让 Rollup 默认处理
    priority: 100
  }
}

第五部分:性能考量

5.1 时间复杂度

每个模块都会遍历规则列表(最坏 O(m·n)),其中 m 为模块数,n 为规则数。对于大型项目(几千个模块,几十条规则),遍历开销仍然可控。但如果规则数量膨胀到上百条,可以考虑将正则表达式编译一次并缓存结果,或使用 Trie 树优化字符串前缀匹配。

5.2 使用 getModuleInfo 的开销

getModuleInfo 是 Rollup 内部维护的模块图查询函数,调用开销极小。我们只在需要检查 minChunks 时才调用,且仅访问 importersdynamicImporters 属性,性能影响可忽略。

5.3 避免重复计算

插件没有做缓存,因为 manualChunks 函数在构建过程中会被多次调用(每个模块一次)。如果规则中包含复杂的自定义函数,可以考虑在外层 memoize。不过本插件完全基于配置,没有用户自定义函数,所以不需要缓存。

第六部分:与其他方案的对比

方案 优点 缺点
原生 manualChunks + 硬编码 简单直接 规则硬编码,难以维护;无法基于引用次数动态拆分
SplitChunksPlugin (Webpack) 功能强大,支持 cacheGroups 配置复杂,且 Webpack 与 Rollup 生态不同
本插件 声明式配置,支持优先级、minChunks,轻量无依赖 需要手动编写规则;无法像 Webpack 那样自动提取公共模块

本插件更适合那些希望将分块规则集中管理、且对 Rollup 生态有强依赖的项目。配合 experimentalMinChunkSize 和 Rollup 自带的 Tree Shaking,可以获得接近 Webpack 的代码分割体验。

第七部分:扩展建议

虽然当前插件已经满足大多数场景,但还可以进一步增强:

  • 支持函数形式的 test:允许用户传入 (id) => boolean,实现更灵活的匹配逻辑。
  • 支持异步规则:例如根据模块内容的大小或依赖关系动态决策。
  • 提供内置预设:例如 preset: 'vue' 自动配置 Vue 相关的分块规则。
  • 集成 bundle 分析器:生成分块报告,帮助用户调整规则。

结语

代码分割是前端性能优化的核心环节之一,但往往被忽视或粗暴处理。通过本插件,我们可以用声明式的规则引擎精细控制每个模块的去向,实现:

  • 更合理的缓存策略:稳定依赖单独 chunk,业务代码频繁更新不影响第三方库缓存。
  • 更快的首屏加载:避免一次性加载巨大的 vendor 文件。
  • 更清晰的构建产物:每个 chunk 有明确的命名和用途。

希望这篇文章能帮助你理解 manualChunks 的高级用法,并启发你构建属于自己的分块工具。如果你对插件有任何疑问或改进建议,欢迎在评论区交流。

对比

vue 全家桶单独打包 我在项目中走CDN啦! 默认分包 在这里插入图片描述 只用插件分包 在这里插入图片描述 很明显有很大的差别!

SwiftUI路由管理架构揭秘:从混乱到优雅的蜕变

引言

想象一下:当你打开一个 App,点击不同标签页,切换页面时,所有导航状态都能完美保持;当你从详情页返回时,TabBar 能智能地重新出现;当你需要传递数据时,类型安全的导航能让你告别字符串硬编码的烦恼。这一切,都离不开一个优秀的路由管理架构。

在现代 iOS 应用开发中,路由管理常常被视为"基础设施"而被忽视,但其重要性却不亚于任何核心功能。一个设计良好的路由系统,不仅能让代码结构更清晰,还能显著提升用户体验。今天,我将带大家深入剖析我项目中的路由管理架构,分享从设计到实现的全过程,希望能为你的项目带来启发。

路由架构概览

我项目的路由管理基于 SwiftUI 的 NavigationStackNavigationPath,采用了集中式的路由管理方案。核心组件包括:

  • Router 类:全局导航路由器,管理所有 Tab 的导航路径
  • MainTab 枚举:定义应用的标签页结构
  • MainContainerView:主容器视图,负责整合标签页和导航逻辑
  • App 启动注入:在应用启动时将 Router 注入到环境中

路由的启动注入

EviApp.swift 中,我们通过 @StateObject 创建 Router 实例,并通过 environmentObject 将其注入到应用环境中:

import SwiftUI

@main
struct EviApp: App {
    // 把 AppDelegate 接进来,系统会照常调用 didFinishLaunchingWithOptions 等
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    // 全局弹框管理器
    @StateObject private var overlay = GlobalOverlayManager.shared
    // 全局导航路由器
    @StateObject private var router = Router()
    
    var body: some Scene {
        WindowGroup {
            MainContainerView()
                .environmentObject(overlay)
                .environmentObject(router)
        }
    }
}

这样,在应用的任何视图中,都可以通过 @EnvironmentObject 来访问 Router 实例,实现全局路由管理。

核心组件分析

1. Router 类:路由管理的核心

import SwiftUI

/// 全局导航路由器,管理所有Tab的导航路径
class Router: ObservableObject {
    
    // 当前选中的Tab
    @Published var selectedTab: MainTab = .home
    
    // 为每个tab单独存储NavigationPath
    @Published var homePath = NavigationPath()
    @Published var hotPath = NavigationPath()
    @Published var creationPath = NavigationPath()
    @Published var stylePath = NavigationPath()
    @Published var profilePath = NavigationPath()
    
    // MARK: - 获取导航路径
    
    /// 获取指定tab的导航路径
    func getNavigationPath(for tab: MainTab) -> NavigationPath {
        switch tab {
        case .home: return homePath
        case .hot: return hotPath
        case .creation: return creationPath
        case .style: return stylePath
        case .profile: return profilePath
        }
    }
    
    /// 获取指定tab的导航路径绑定
    func getNavigationPathBinding(for tab: MainTab) -> Binding<NavigationPath> {
        switch tab {
        case .home: return binding(for: \.homePath)
        case .hot: return binding(for: \.hotPath)
        case .creation: return binding(for: \.creationPath)
        case .style: return binding(for: \.stylePath)
        case .profile: return binding(for: \.profilePath)
        }
    }
    
    // MARK: - 清空导航路径
    
    /// 清空指定tab的导航路径
    func clearPath(for tab: MainTab) {
        switch tab {
        case .home: clear(\.homePath)
        case .hot: clear(\.hotPath)
        case .creation: clear(\.creationPath)
        case .style: clear(\.stylePath)
        case .profile: clear(\.profilePath)
        }
    }
    
    /// 清空所有导航路径
    func clearAllPaths() {
        clear(\.homePath)
        clear(\.hotPath)
        clear(\.creationPath)
        clear(\.stylePath)
        clear(\.profilePath)
    }
    
    // MARK: - 当前Tab操作
    
    /// 获取当前选中Tab的导航路径
    func getCurrentNavigationPath() -> NavigationPath {
        return getNavigationPath(for: selectedTab)
    }
    
    /// 获取当前选中Tab的导航路径绑定
    func getCurrentNavigationPathBinding() -> Binding<NavigationPath> {
        return getNavigationPathBinding(for: selectedTab)
    }
    
    /// 清空当前选中Tab的导航路径
    func clearCurrentPath() {
        clearPath(for: selectedTab)
    }
    
    // MARK: - 私有辅助方法
    
    /// 创建导航路径的绑定
    private func binding(for keyPath: ReferenceWritableKeyPath<Router, NavigationPath>) -> Binding<NavigationPath> {
        Binding {
            self[keyPath: keyPath]
        } set: {
            self[keyPath: keyPath] = $0
        }
    }
    
    /// 清空指定的导航路径
    private func clear(_ keyPath: ReferenceWritableKeyPath<Router, NavigationPath>) {
        self[keyPath: keyPath].removeLast(self[keyPath: keyPath].count)
    }
}

设计亮点

  • 集中管理:所有路由逻辑集中在一个类中,便于统一管理
  • Tab 隔离:为每个标签页维护独立的导航路径,确保切换标签时不会影响其他标签的导航状态
  • 响应式设计:使用 @Published 修饰符,实现路由状态的自动更新
  • 便捷方法:提供了丰富的方法来操作导航路径,如获取路径、清空路径等

2. MainTab 枚举:标签页定义

import SwiftUI

/// 主标签栏枚举
enum MainTab {
    case home
    case hot
    case creation
    case style
    case profile
}

extension MainTab {
    
    /// 根据选中状态返回对应的图标名称
    func iconName(isSelected: Bool) -> String {
        switch self {
        case .home:
            return isSelected ? "tabbar_home_sel" : "tabbar_home_nor"
        case .hot:
            return isSelected ? "tabbar_hot_sel" : "tabbar_hot_nor"
        case .creation:
            return "tabbar_add"
        case .style:
            return isSelected ? "tabbar_style_sel" : "tabbar_style_nor"
        case .profile:
            return isSelected ? "tabbar_me_sel" : "tabbar_me_nor"
        }
    }
}

设计亮点

  • 类型安全:使用枚举定义标签页,避免了字符串硬编码
  • 扩展功能:通过扩展为枚举添加了获取图标名称的功能,使代码更整洁

3. MainContainerView:路由的实际应用

import SwiftUI

/// 主容器视图,包含悬浮TabBar
struct MainContainerView: View {
    
    // 获取指定tab的导航路径
    private func getNavigationPath(for tab: MainTab) -> NavigationPath {
        return router.getNavigationPath(for: tab)
    }
    
    /// 创建带有NavigationStack的标签页视图
    private func tabView(_ tab: MainTab) -> some View {
        NavigationStack(path: router.getNavigationPathBinding(for: tab)) {
            switch tab {
            case .home:
                HomeView()
            case .hot:
                HotHomeView()
            case .creation:
                CreationHomeView()
            case .style:
                StyleHomeView()
            case .profile:
                ProfileHomeView()
            }
        }
        .tag(tab)
    }
    
    @StateObject private var appConfigManager = AppConfigManager.shared
    
    @EnvironmentObject private var overlay: GlobalOverlayManager
    @EnvironmentObject private var router: Router
    
    var body: some View {
        if appConfigManager.appConfig != nil {
            ZStack {
                
                // 真正负责页面生命周期的容器
                TabView(selection: $router.selectedTab) {
                    tabView(.home)
                    tabView(.hot)
                    tabView(.creation)
                    tabView(.style)
                    tabView(.profile)
                }
                
                // 你的悬浮TabBar,根据当前选中标签的导航路径长度控制显示
                if isTabBarVisible {
                    VStack {
                        Spacer()
                        FloatingTabBar(selectedTab: $router.selectedTab)
                            .padding(.horizontal, 16)
                            .padding(.bottom, 20)
                    }
                }
                
                // 全局弹框显示
                if let current = overlay.current {
                    
                    // 遮罩
                    Color.black.opacity(0.4)
                        .ignoresSafeArea()
                        .onTapGesture {
                            overlay.dismiss()
                        }
                    
                    switch current {
                    case .login:
                        LoginOverlayView(onClose: {
                            overlay.dismiss()
                        })
                        .transition(.flipFromBottom)
                    }
                }
                
            }
            .animation(.easeInOut(duration: 0.25), value: overlay.current)
        } else {
            // 显示空View
            EmptyView()
                .background(ThemeManager.Background.global)
        }
    }
    
    var isTabBarVisible: Bool {
        return getNavigationPath(for: router.selectedTab).count == 0
    }
}

设计亮点

  • NavigationStack 集成:为每个标签页创建独立的 NavigationStack
  • TabBar 智能显示:根据当前导航路径长度控制 TabBar 的显示/隐藏
  • 环境对象注入:使用 @EnvironmentObject 注入 Router,实现全局访问
  • 动画效果:添加了平滑的过渡动画,提升用户体验

路由管理的实现细节

1. 路径管理机制

路由系统的核心是 NavigationPath 的管理。NavigationPath 是 SwiftUI 4.0+ 引入的类型,它是一个类型擦除的容器,可以存储任意类型的导航目的地。

在我们的实现中:

  • 每个标签页都有自己的 NavigationPath 实例
  • 通过 getNavigationPathBinding 方法获取路径的绑定,用于 NavigationStack
  • 提供了 clearPathclearAllPaths 方法来清空导航路径

2. 标签页切换逻辑

当用户切换标签页时:

  1. router.selectedTab 的值会更新
  2. TabView 会根据新的 selectedTab 显示对应的标签页
  3. 由于每个标签页有独立的 NavigationPath,切换标签不会影响其他标签的导航状态

3. 导航路径的实际使用

在具体的视图中,可以通过以下方式使用路由:

// 在视图中注入 Router
@EnvironmentObject private var router: Router

// 使用全局路由管理进行导航
let currentPath = router.getCurrentNavigationPathBinding()
// 向当前路径添加新页面
currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(material))

// 清空当前标签页的导航路径
router.clearCurrentPath()

4. 导航目的地定义

项目使用 AppNavigationDestination 枚举来定义导航目的地:

import Foundation
import SwiftUI

/// 导航目标枚举
enum AppNavigationDestination: Hashable {
    case accountLogin
    case materialDetail(MaterialListDTOElement)
}

这种方式的优势:

  • 类型安全:使用枚举定义导航目的地,避免了字符串硬编码
  • 参数传递:可以在导航时传递相关数据,如 materialDetail 中的 MaterialListDTOElement
  • 可扩展性:可以轻松添加新的导航目的地

5. NavigationStack 中处理导航目的地

在使用 NavigationStack 时,需要处理导航目的地的显示逻辑。通常在根视图中添加 navigationDestination 修饰符:

NavigationStack(path: router.getNavigationPathBinding(for: tab)) {
    HomeView()
        .navigationDestination(for: AppNavigationDestination.self) { destination in
            switch destination {
            case .accountLogin:
                AccountLoginView()
            case .materialDetail(let material):
                MaterialDetailView(material: material)
            }
        }
}

这样,当我们通过 currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(material)) 导航时,NavigationStack 会自动显示对应的目标视图。

6. 完整导航流程示例

下面是一个完整的导航流程示例,展示从触发导航到显示目标页面的全过程:

// 1. 在视图中注入 Router
@EnvironmentObject private var router: Router

// 2. 定义导航触发事件
Button("查看素材详情") {
    // 3. 获取当前路径绑定
    let currentPath = router.getCurrentNavigationPathBinding()
    // 4. 向路径添加导航目的地
    currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(selectedMaterial))
}

// 5. 在根视图中处理导航目的地
NavigationStack(path: router.getNavigationPathBinding(for: .home)) {
    HomeView()
        .navigationDestination(for: AppNavigationDestination.self) { destination in
            switch destination {
            case .materialDetail(let material):
                MaterialDetailView(material: material)
            default:
                EmptyView()
            }
        }
}

// 6. 从详情页返回
Button("返回") {
    // 清空当前路径,返回根视图
    router.clearCurrentPath()
}

7. 导航路径与 TabBar 显示的关联

MainContainerView 中,通过 isTabBarVisible 计算属性控制 TabBar 的显示:

var isTabBarVisible: Bool {
    return getNavigationPath(for: router.selectedTab).count == 0
}

当导航路径为空时(即处于标签页的根视图),显示 TabBar;当导航路径不为空时(即进入了子页面),隐藏 TabBar,为用户提供更大的内容显示区域。

优势与最佳实践

优势

  1. 清晰的职责分离:路由逻辑与 UI 逻辑分离,使代码更易于维护
  2. 类型安全:使用枚举和类型化的导航路径,减少运行时错误
  3. 状态管理:集中管理路由状态,避免状态分散
  4. 灵活性:可以轻松添加新的标签页和导航目的地
  5. 用户体验:标签页切换时保持各自的导航状态,提升用户体验

最佳实践

  1. 统一的路由入口:所有导航操作都通过 Router 进行,避免直接操作 NavigationPath
  2. 合理的路径清理:在适当的时机清理导航路径,避免内存占用过高
  3. 导航目的地的类型定义:为导航目的地创建明确的类型,提高代码可读性
  4. 错误处理:添加适当的错误处理,确保导航操作的稳定性
  5. 测试:为路由逻辑编写单元测试,确保其正确性

代码优化建议

  1. 导航目的地类型化

    // 建议为每个标签页创建导航目的地枚举
    enum HomeDestination {
        case detail(id: String)
        case search
    }
    
    // 然后在导航时使用
    router.homePath.append(HomeDestination.detail(id: "123"))
    
  2. 添加导航日志

    // 添加导航日志,便于调试和分析用户行为
    func appendToPath(_ value: some Hashable, for tab: MainTab) {
        let path = getNavigationPathBinding(for: tab)
        path.wrappedValue.append(value)
        print("Navigate to \(value) in tab \(tab)")
    }
    
  3. 导航路径持久化

    // 可以考虑在应用进入后台时保存导航状态,在应用启动时恢复
    func saveNavigationState() {
        // 保存导航状态到 UserDefaults 或其他存储
    }
    
    func restoreNavigationState() {
        // 从存储中恢复导航状态
    }
    
  4. 添加路由拦截器

    // 可以添加路由拦截器,用于处理登录验证等场景
    func appendToPath(_ value: some Hashable, for tab: MainTab) {
        if needsAuthentication(for: value) {
            // 显示登录界面
            overlay.present(.login)
        } else {
            let path = getNavigationPathBinding(for: tab)
            path.wrappedValue.append(value)
        }
    }
    

总结

通过以上分析,我们可以看到,一个良好的路由管理架构对于 iOS 应用的重要性。我项目中的路由架构采用了集中式管理、Tab 隔离、响应式设计等原则,通过 Router 类、MainTab 枚举和 MainContainerView 的配合,实现了清晰、灵活、用户友好的导航体验。

这种路由架构不仅适用于当前项目,也可以作为其他 SwiftUI 项目的参考。通过不断优化和扩展,可以构建更加完善的路由系统,为用户提供更加流畅的应用体验。

希望这篇文章能够帮助大家更好地理解和实现 iOS 项目中的路由管理架构。如果你有任何问题或建议,欢迎在评论区留言讨论!

❌