阅读视图

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

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 项目中的路由管理架构。如果你有任何问题或建议,欢迎在评论区留言讨论!

React 架构进阶:自定义 Hooks 的高级设计模式与最佳实践

在 React 16.8 引入 Hooks 之后,我们告别了 Class 组件中复杂的生命周期和高阶组件(HOC)的嵌套地狱。然而,随着业务复杂度的提升,简单的 useState 和 useEffect 组合往往导致组件内部逻辑臃肿,难以维护。

很多开发者停留在“把逻辑抽离成函数”的初级阶段,却忽略了自定义 Hooks(Custom Hooks)本质上是逻辑复用的设计模式。本文将深入探讨自定义 Hooks 的高级设计模式,如何通过合理的抽象提升代码的可读性、可测试性和复用性。

一、为什么我们需要高级设计模式?

在初级实践中,我们常看到这样的代码:

// ❌ 反模式:逻辑泄露与耦合
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch(`/api/user/${userId}`).then(setUser).finally(() => setLoading(false));
  }, [userId]);

  // ... 还有获取用户帖子、获取用户关注列表的逻辑混在一起
  return loading ? <Spinner /> : <div>{user.name}</div>;
}

这种写法的问题在于:

  1. UI 与逻辑耦合:组件既负责渲染,又负责数据获取。
  2. 难以测试:很难在不渲染 UI 的情况下测试数据获取逻辑。
  3. 无法复用:如果在另一个页面也需要获取用户信息,代码只能复制粘贴。

通过自定义 Hooks,我们可以将“关注点分离(Separation of Concerns)”。

二、核心设计模式详解

2.1 容器模式(Container Pattern)的 Hooks 化

这是最经典的模式,将数据获取和状态管理逻辑剥离,组件只负责展示。

// ✅ useUser.ts - 专注数据逻辑
export function useUser(userId) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    let cancelled = false;
    
    async function fetchUser() {
      try {
        const response = await fetch(`/api/user/${userId}`);
        if (!cancelled) {
          setState({ data: await response.json(), loading: false, error: null });
        }
      } catch (err) {
        if (!cancelled) setState({ data: null, loading: false, error: err });
      }
    }

    fetchUser();
    return () => { cancelled = true; }; // 清理副作用
  }, [userId]);

  return state;
}

// ✅ UserProfile.tsx - 专注 UI 展示
function UserProfile({ userId }) {
  const { data: user, loading, error } = useUser(userId);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <div>{user.name}</div>;
}

优势:UI 组件变得极其纯净,逻辑 Hook 可以独立进行单元测试。

2.2 状态机模式(State Machine Pattern)

对于复杂的交互流程(如表单提交、多步骤向导、播放器控制),简单的布尔值状态(isLoadingisSuccessisError)容易导致状态冲突。此时应引入有限状态机思想。

// ✅ useAsyncAction.ts - 管理复杂状态流转
function useAsyncAction(asyncFunction) {
  const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case 'START': return { status: 'loading', data: null, error: null };
      case 'SUCCESS': return { status: 'success', data: action.payload, error: null };
      case 'FAILURE': return { status: 'failure', data: null, error: action.payload };
      case 'RESET': return { status: 'idle', data: null, error: null };
      default: return state;
    }
  }, { status: 'idle', data: null, error: null });

  const execute = useCallback(async (...args) => {
    dispatch({ type: 'START' });
    try {
      const result = await asyncFunction(...args);
      dispatch({ type: 'SUCCESS', payload: result });
    } catch (err) {
      dispatch({ type: 'FAILURE', payload: err });
    }
  }, [asyncFunction]);

  return { ...state, execute };
}

应用场景:登录注册流程、文件上传、复杂的表单验证。它保证了状态流转的确定性,避免了“既 loading 又 error”的非法状态。

2.3 组合模式(Composition Pattern)

Hooks 最大的威力在于组合。我们可以像搭积木一样,将多个小 Hooks 组合成一个功能强大的大 Hook。

// 基础 Hook:处理本地存储
function useLocalStorage(key, initialValue) {
  // ... 实现略
  return [value, setValue];
}

// 基础 Hook:处理窗口大小
function useWindowSize() {
  // ... 实现略
  return { width, height };
}

// ✅ 组合 Hook:响应式主题管理器
function useResponsiveTheme() {
  const [theme, setTheme] = useLocalStorage('app-theme', 'light');
  const { width } = useWindowSize();

  // 自动逻辑:屏幕小于 768px 强制使用移动端样式,但保留用户主题偏好
  const isMobile = width < 768;
  const effectiveTheme = isMobile ? 'mobile-optimized' : theme;

  useEffect(() => {
    document.body.className = effectiveTheme;
  }, [effectiveTheme]);

  return { theme, setTheme, isMobile };
}

核心价值:降低了单个 Hook 的认知负荷,每个 Hook 只做一件事,并通过组合产生新的行为。

2.4 观察者模式与订阅机制

在处理全局事件或非 React 源的数据(如 WebSocket、第三方 SDK)时,可以使用观察者模式。

// ✅ useWebSocket.ts
function useWebSocket(url) {
  const [message, setMessage] = useState(null);

  useEffect(() => {
    const ws = new WebSocket(url);
    
    ws.onmessage = (event) => {
      setMessage(JSON.parse(event.data));
    };

    ws.onerror = (error) => {
      console.error('WS Error', error);
    };

    // 清理连接
    return () => {
      ws.close();
    };
  }, [url]);

  const sendMessage = useCallback((data) => {
    // 发送逻辑
  }, []);

  return { message, sendMessage };
}

三、避坑指南:自定义 Hooks 的常见陷阱

3.1 条件调用 Hooks

错误示范

function useConditionalHook(condition) {
  if (condition) {
    useEffect(() => { ... }); // ❌ 违反 Rules of Hooks
  }
}

修正:Hooks 必须在顶层调用。如果需要根据条件执行逻辑,请将条件判断写在 Hook 内部,而不是包裹 Hook 本身。

3.2 过度抽象

不要为了复用而复用。如果一个逻辑只在当前组件使用,或者不同组件的使用差异极大,强行提取 Hook 反而会增加认知负担。 “三次法则” 是一个不错的经验:当同一段逻辑出现第三次时,再考虑提取。

3.3 依赖项数组的陷阱

在自定义 Hook 中返回回调函数时,务必注意闭包陷阱。

// ❌ 容易捕获旧状态的回调
function useCounter() {
  const [count, setCount] = useState(0);
  const logCount = () => {
    console.log(count); // 可能永远是初始值或旧值
  };
  return { count, logCount };
}

// ✅ 使用 ref 或将其放入 useEffect/useCallback 依赖中
function useCounter() {
  const [count, setCount] = useState(0);
  
  const logCount = useCallback(() => {
    console.log(count); 
  }, [count]); // 确保依赖最新 count
  
  return { count, logCount };
}

四、实战案例:构建一个通用的 useFetch

结合上述模式,我们来构建一个生产级别的 useFetch

import { useEffect, useReducer, useCallback } from 'react';

// 定义状态类型
const initialState = {
  data: null,
  loading: false,
  error: null,
};

function reducer(state, action) {
  switch (action.type) {
    case 'REQUEST': return { ...state, loading: true, error: null };
    case 'SUCCESS': return { loading: false, data: action.payload, error: null };
    case 'FAILURE': return { loading: false, data: null, error: action.payload };
    case 'RESET': return initialState;
    default: return state;
  }
}

export function useFetch(url, options = {}) {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { manual = false } = options; // 是否手动触发

  const execute = useCallback(async (overrideUrl) => {
    const targetUrl = overrideUrl || url;
    if (!targetUrl) return;

    dispatch({ type: 'REQUEST' });
    try {
      const response = await fetch(targetUrl);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      const data = await response.json();
      dispatch({ type: 'SUCCESS', payload: data });
    } catch (err) {
      dispatch({ type: 'FAILURE', payload: err.message });
    }
  }, [url]);

  useEffect(() => {
    if (!manual) {
      execute();
    }
  }, [execute, manual]);

  return { ...state, refetch: execute, reset: () => dispatch({ type: 'RESET' }) };
}

使用示例

function UserList() {
  const { data, loading, error, refetch } = useFetch('/api/users');

  if (loading) return <div>加载中...</div>;
  if (error) return <div>出错了: {error} <button onClick={refetch}>重试</button></div>;

  return (
    <ul>
      {data.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

五、总结

自定义 Hooks 不仅仅是代码复用的工具,更是 React 组件架构的核心支柱。

  1. 逻辑解耦:让 UI 组件回归纯粹的表现层。
  2. 状态治理:利用 Reducer 和状态机管理复杂交互。
  3. 能力组合:通过小 Hook 的堆叠构建复杂功能。
  4. 测试友好:逻辑与视图分离使得单元测试变得简单高效。

掌握这些高级模式,你将能够编写出更健壮、更易维护的 React 应用,真正发挥 Hooks 体系的威力。


后续思考题:

  • 如何在自定义 Hook 中处理服务端渲染(SSR)时的 Hydration 问题?
  • 自定义 Hooks 能否完全替代 Redux/MobX 等全局状态管理库?边界在哪里?

欢迎在评论区分享你在项目中封装过的最得意的自定义 Hook!

Modern.js 3.0 发布:聚焦 Web 框架,拥抱生态发展

前言

Modern.js 2.0 发布 至今,已过去三年时间,感谢社区开发者们对 Modern.js 的使用和信任。Modern.js 一直保持稳定的迭代,累计发布了超过 100 个版本。

在字节内部,Modern.js 已成为 Web 开发的核心框架。在全公司活跃的 Web 项目中,使用占比已从 2025 年初的 40% 增长至目前接近 70%。

这三年中,我们不断扩充新特性,持续进行代码重构与优化,也收到了非常多的反馈,这些经验成为了 3.0 版本改进的重要参考。经过慎重考虑,我们决定发布 Modern.js 3.0,对框架进行一次全面的升级。

Modern.js 2.0 到 3.0 的演变

从 Modern.js 2.0 到 3.0,有两个核心转变:

更聚焦,专注于 Web 框架

  • Modern.js 2.0:包含 Modern.js App、Modern.js Module、Modern.js Doc
  • Modern.js 3.0:只代表 Modern.js App,Modern.js Module 和 Modern.js Doc 已孵化为 RslibRspress

更开放,积极面向社区工具

  • Modern.js 2.0:内置各类工具、框架独特的 API 设计
  • Modern.js 3.0:强化插件体系,完善接入能力,推荐社区优质方案

Modern.js 3.0 新特性

React Server Component

TL;DR:Modern.js 3.0 集成 React Server Component,支持 CSR 和 SSR 项目,并支持渐进式迁移。

什么是 React Server Component

React Server Components(服务端组件)是一种新的组件类型,它允许组件逻辑完全在服务端执行,并直接将渲染后的 UI 流式传输到客户端。与传统的客户端组件相比,服务端组件带来了以下特性:

特性 说明
零客户端包体积 组件代码不包含在客户端 JS Bundle 中,仅在服务端执行,加快首屏加载与渲染速度
更高的内聚性 组件可直接连接数据库、调用内部 API、读取本地文件,提高开发效率
渐进增强 可与客户端组件无缝混合使用,按需下放交互逻辑到客户端,在保持高性能的同时,支持复杂交互体验

需要明确的是,RSC 和 SSR 是截然不同的概念

  • RSC:描述的是组件类型,即组件在哪里执行(服务端 vs 客户端)
  • SSR:描述的是渲染模式,即 HTML 在哪里生成(服务端 vs 客户端)

两者可以组合使用:Server Component 可以在 SSR 项目下使用,也可以在 CSR 项目下使用。在 Modern.js 3.0 中,我们同时支持这两种模式,开发者可以根据需求选择。

开箱即用

在 Modern.js 3.0 中,只需在配置中启用 RSC 能力:

modern.config.ts

export default defineConfig({
  server: {
    rsc: true,
  }
});

配置启用后,所有的路由组件都会默认成为 Server Component。项目中可能存在无法在服务端运行的组件,你可以先为这些组件添加 'use client' 标记,以保持原有行为,再逐步迁移。

RSC 效果演示视频:lf3-static.bytednsdoc.com/obj/eden-cn…

Modern.js 3.0 的 RSC 特性

Modern.js 一直选择 React Router 作为路由解决方案。去年,React Router v7 宣布支持 React Server Component,这为 Modern.js 提供了在 SPA 应用下实现 RSC 的基础。

相比于社区其他框架,Modern.js 对 RSC 做了几点优化:

  • 使用 Rspack 最新的 RSC 插件构建,显著提升 RSC 项目构建速度;并进一步优化了产物体积。
  • 不同于社区主流框架只支持 RSC + SSR,Modern.js 3.0 的 RSC 同样支持 CSR 项目
  • 在路由跳转时,框架会自动将多个 Data Loader 和 Server Component 的请求合并为单个请求,并流式返回,提升页面性能
  • 在嵌套路由场景下,路由组件类型不受父路由组件类型的影响,开发者可以从任意路由层级开始采用 Server Component

渐进式迁移

基于灵活的组件边界控制能力,Modern.js 3.0 提供了渐进式的迁移方式。Modern.js 3.0 允许基于路由组件维度的 Server Component 迁移,无需迁移整条组件树链路。

更多 React Server Component 的详细内容,可以参考:React Server Component


拥抱 Rspack

TL;DR:Modern.js 3.0 移除了对 webpack 的支持,全面拥抱 Rspack,并升级到最新的 Rspack & Rsbuild 2.0。

在 2023 年,我们开源了 Rspack,并在 Modern.js 中支持将 Rspack 作为可选的打包工具。在字节内部,超过 60% 的 Modern.js 项目已经切换到 Rspack 构建。

经过两年多发展,Rspack 在社区中的月下载量已超过 1000 万次,成长为行业内被广泛使用的打包工具;同时,Modern.js 的 Rspack 构建模式也得到持续完善。

Rspack 下载量

在 Modern.js 3.0 中,我们决定移除对 webpack 的支持,从而使 Modern.js 变得更加轻量和高效,并能更充分地利用 Rspack 的新特性。

更顺畅的开发体验

Modern.js 3.0 在移除 webpack 后,能够更好地遵循 Rspack 最佳实践,在构建性能、安装速度等方面均有提升:

底层依赖升级

Modern.js 3.0 将底层依赖的 Rspack 和 Rsbuild 升级至 2.0 版本,并基于新版本优化了默认构建配置,使整体行为更加一致。

参考以下文档了解底层行为变化:

更快的构建速度

Modern.js 通过 Rspack 的多项特性来减少构建耗时:

  • 默认启用 Barrel 文件优化:构建组件库速度提升 20%
  • 默认启用持久化缓存:非首次构建的速度提升 50%+

更快的安装速度

移除 webpack 相关依赖后,Modern.js 3.0 的构建依赖数量和体积均明显减少:

  • npm 依赖数量减少 40%
  • 安装体积减少 31 MB

更小的构建产物

Modern.js 现在默认启用 Rspack 的多项产物优化策略,能够比 webpack 生成更小的产物体积,例如:

增强 Tree shaking

增强了 tree shaking 分析能力,可以处理更多动态导入语法,例如解构赋值:

// 参数中的解构访问
import('./module').then(({ value }) => {
  console.log(value);
});

// 函数体内的解构访问
import('./module').then((mod) => {
  const { value } = mod;
  console.log(value);
});

常量内联

对常量进行跨模块内联,有助于压缩工具进行更准确的静态分析,从而消除无用的代码分支:

// constants.js
export const ENABLED = true;

// index.js
import { ENABLED } from './constants';
if (ENABLED) {
  doSomething();
} else {
  doSomethingElse();
}

// 构建产物 - 无用分支被消除
doSomething();

全链路可扩展

TL;DR:Modern.js 3.0 正式开放完整插件体系,提供运行时、服务端插件,同时支持灵活处理应用入口。

Modern.js 2.0 提供了 CLI 插件与内测版本的运行时插件,允许开发者对项目进行扩展。但在实践过程中,我们发现现有的能力不足以支撑复杂的业务场景。

Modern.js 3.0 提供了更灵活的定制能力,允许为应用编写全流程的插件,帮助团队统一业务逻辑、减少重复代码:

  • CLI 插件:在构建阶段扩展功能,如添加命令、修改配置
  • Runtime 插件:在渲染阶段扩展功能,如数据预取、组件封装
  • Server 插件:在服务端扩展功能,如添加中间件、修改请求响应

运行时插件

运行时插件在 CSR 与 SSR 过程中都会运行,新版本提供了两个核心钩子:

  • onBeforeRender:在渲染前执行逻辑,可用于数据预取、注入全局数据
  • wrapRoot:封装根组件,添加全局 Provider、布局组件等

你可以在 src/modern.runtime.ts 中注册插件,相比在入口手动引入高阶组件,运行时插件可插拔、易更新,在多入口场景下无需重复引入:

src/modern.runtime.tsx

import { defineRuntimeConfig } from "@modern-js/runtime";

export default defineRuntimeConfig({
  plugins: [
    {
      name: "my-runtime-plugin",
      setup: (api) => {
        api.onBeforeRender((context) => {
          context.globalData = { theme: "dark" };
        });
        api.wrapRoot((App) => (props) => <App {...props} />);
      },
    },
  ],
});

更多 Runtime 插件使用方式,请查看文档:Runtime 插件

服务端中间件

在实践过程中我们发现,部分项目需要扩展 Web Server,例如鉴权、数据预取、降级处理、动态 HTML 脚本注入等。

在 Modern.js 3.0 中,我们使用 Hono 重构了 Web Server,并正式开放了服务端中间件与插件的能力。开发者可以使用 Hono 的中间件来完成需求:

server/modern.server.ts

import { defineServerConfig, type MiddlewareHandler } from "@modern-js/server-runtime";

const timingMiddleware: MiddlewareHandler = async (c, next) => {
  const start = Date.now();
  await next();
  const duration = Date.now() - start;
  c.header('X-Response-Time', `${duration}ms`);
};

const htmlMiddleware: MiddlewareHandler = async (c, next) => {
  await next();
  const html = await c.res.text();
  const modified = html.replace(
    "<head>",
    '<head><meta name="generator" content="Modern.js">'
  );
  c.res = c.body(modified, { status: c.res.status, headers: c.res.headers });
};

export default defineServerConfig({
  middlewares: [timingMiddleware],
  renderMiddlewares: [htmlMiddleware],
});

更多服务端插件使用方式,可以查看文档:自定义 Web Server

自定义入口

在 Modern.js 3.0 中,我们重构了自定义入口,相比于旧版 API 更加清晰灵活:

src/entry.tsx

import { createRoot } from '@modern-js/runtime/react';
import { render } from '@modern-js/runtime/browser';

const ModernRoot = createRoot();

async function beforeRender() {
  // 渲染前的异步操作,如初始化 SDK、获取用户信息等
}

beforeRender().then(() => {
  render(<ModernRoot />);
});

更多入口使用方式,请查看文档:入口


路由优化

TL;DR:Modern.js 3.0 内置 React Router v7,提供配置式路由能力与 AI 友好的调试方式。

内置 React Router v7

在 Modern.js 3.0 中,我们统一升级到 React Router v7,并废弃了对 v5 和 v6 的内置支持。这一决策基于以下考虑:

版本演进与稳定性

React Router v6 是一个重要的过渡版本,它引入了许多新特性(如数据加载、错误边界等)。而 v7 在保持 v6 API 兼容性的基础上,进一步优化了性能、稳定性和开发体验。随着 React Router 团队将 Remix 定位为独立框架,React Router 核心库可能会在 v7 版本上长期维护,使其成为更可靠的选择。

升级路径

  • 从 v6 升级:React Router v7 对 v6 开发者来说是无破坏性变更的升级。在 Modern.js 2.0 中,我们已提供了 React Router v7 插件支持,你可以通过插件方式渐进式升级,验证兼容性后再迁移到 Modern.js 3.0。
  • 从 v5 升级:v5 到 v7 存在较大的 API 变化,建议参考 React Router 官方迁移指南 进行迁移。

配置式路由

在 Modern.js 中,我们推荐使用约定式路由来组织代码。但在实际业务中,开发者偶尔遇到以下场景:

  • 多路径指向同一组件
  • 灵活的路由控制
  • 条件性路由
  • 遗留项目迁移

因此,Modern.js 3.0 提供了完整的配置式路由支持,可以与约定式路由一起使用,或两者分别单独使用。

src/modern.routes.ts

import { defineRoutes } from "@modern-js/runtime/config-routes";

export default defineRoutes(({ route, layout, page }) => {
  return [
    route("home.tsx", "/"),
    route("about.tsx", "about"),
    route("blog.tsx", "blog/:id"),
  ];
});

更多配置式路由使用方式,请查看文档:配置式路由

路由调试

运行 npx modern routes 命令即可在 dist/routes-inspect.json 文件中生成完整的路由结构分析报告。

报告中会显示每个路由的路径、组件文件、数据加载器、错误边界、Loading 组件等完整信息,帮助开发者快速了解项目的路由配置,快速定位和排查路由相关问题。结构化的 JSON 格式也便于 AI agent 理解和分析路由结构,提升 AI 辅助开发的效率。

具体使用方式,请查看文档:路由调试


服务端渲染

TL;DR:Modern.js 3.0 重做了 SSG 能力,提供了灵活的缓存能力,对降级策略也进行了进一步的完善。

静态站点生成(SSG)

在 Modern.js 2.0 中,我们提供了静态站点生成的能力。这个能力非常适合用在可以静态渲染的页面中,能极大的提升页面首屏性能。

在新版本中,我们对 SSG 进行了重新设计:

  • 数据获取使用 Data Loader,与非 SSG 场景保持一致
  • 简化了 API,降低理解成本
  • 与约定式路由更好地结合

在新版本中,你可以通过 data loader 进行数据获取,与非 SSG 场景保持一致。然后在 ssg.routes 配置中即可直接指定要渲染的路由:

modern.config.ts

export default defineConfig({
  output: {
    ssg: {
      routes: ['/blog'],
    },
  },
});

routes/blog/page.data.ts

export const loader = async () => {
  const articles = await fetchArticles();
  return { articles };
};

更多 SSG 的使用方式,请查看文档:SSG

缓存机制

Modern.js 3.0 中提供了不同维度的缓存机制,帮助项目提升首屏性能。所有缓存均支持灵活配置,比如可以支持类似 HTTP 的 stale-while-revalidate 策略:

渲染缓存

支持将 SSR 结果进行整页的缓存,在 server/cache.ts 中配置:

server/cache.ts

import type { CacheOption } from '@modern-js/server-runtime';

export const cacheOption: CacheOption = {
  maxAge: 500, // ms
  staleWhileRevalidate: 1000, // ms
};

使用渲染缓存,请查看文档:渲染缓存

数据缓存

我们在新版本中提供了 cache 函数,相比渲染缓存它提供了更精细的数据粒度控制。当多个数据请求依赖同一份数据时,cache 可以避免重复请求:

server/loader.ts

import { cache } from "@modern-js/runtime/cache";
import { fetchUserData, fetchUserProjects, fetchUserTeam } from "./api";

// 缓存用户数据,避免重复请求
const getUser = cache(fetchUserData);

const getProjects = async () => {
  const user = await getUser("test-user");
  return fetchUserProjects(user.id);
};

const getTeam = async () => {
  const user = await getUser("test-user"); // 复用缓存,不会重复请求
  return fetchUserTeam(user.id);
};

export const loader = async () => {
  // getProjects 和 getTeam 都依赖 getUser,但 getUser 只会执行一次
  const [projects, team] = await Promise.all([getProjects(), getTeam()]);
  return { projects, team };
};

更多数据缓存的使用方式,请查看文档:数据缓存

灵活的降级策略

在实践过程中,我们沉淀了多维度的降级策略:

类型 触发方式 降级行为 使用场景
异常降级 Data Loader 执行报错 触发 ErrorBoundary 数据请求异常兜底
组件渲染报错 服务端渲染异常 降级到 CSR,复用已有数据渲染 服务端渲染异常兜底
业务降级 Loader 抛出 throw Response 触发 ErrorBoundary,返回对应 HTTP 状态码 404、权限校验等业务场景
配置 Client Loader 配置 Client Loader 绕过 SSR,直接请求数据源 需要在客户端直接获取数据的场景
强制降级 Query 参数 ?__csr=true 跳过 SSR,返回 CSR 页面 调试、临时降级
强制降级 请求头 x-modern-ssr-fallback 跳过 SSR,返回 CSR 页面 网关层控制降级

轻量 BFF

TL;DR:Modern.js 3.0 基于 Hono 重构了 Web Server,提供基于 Hono 的一体化函数,同时支持跨项目调用。

Hono 一体化函数

在 Modern.js 3.0 中,我们使用 Hono 作为 BFF 的运行时框架,开发者可以基于 Hono 生态扩展 BFF Server,享受 Hono 轻量、高性能的优势。

通过 useHonoContext 可以获取完整的 Hono 上下文,访问请求信息、设置响应头等:

api/lambda/user.ts

import { useHonoContext } from '@modern-js/server-runtime';

export const get = async () => {
  const c = useHonoContext();
  const token = c.req.header('Authorization');
  c.header('X-Custom-Header', 'modern-js');
  const id = c.req.query('id');

  return { userId: id, authenticated: !!token };
};

跨项目调用

在过去,Modern.js BFF 只能在当前项目中使用,而我们陆续收到开发者反馈,希望能够在不同项目中使用。这多数情况是由于开发者的迁移成本、运维成本造成的,相比于抽出原有代码再部署一个,显然复用已有服务更加合理。

为了保证开发者能得到与当前项目一体化调用类似的体验,我们提供了跨项目调用的能力。

更多 BFF 的使用方式,请查看文档:BFF


Module Federation 深度集成

TL;DR:Modern.js 3.0 与 Module Federation 2.0 深度集成,支持 MF SSR 和应用级别模块导出。

MF SSR

Modern.js 3.0 支持在 SSR 应用中使用 Module Federation,组合使用模块联邦和服务端渲染能力,为用户提供更好的首屏性能体验。

modern.config.ts

export default defineConfig({
  server: {
    ssr: {
      mode: 'stream',
    },
  },
});

配合 Module Federation 的数据获取能力,每个远程模块都可以定义自己的数据获取逻辑:

src/components/Button.data.ts

export const fetchData = async () => {
  return {
    data: `Server time: ${new Date().toISOString()}`,
  };
};

src/components/Button.tsx

export const Button = (props: { mfData: { data: string } }) => {
  return <button>{props.mfData?.data}</button>;
};

应用级别模块

不同于传统的组件级别共享,Modern.js 3.0 支持导出应用级别模块——具备完整路由能力、可以像独立应用一样运行的模块。这是微前端场景中的重要能力。

生产者导出应用

src/export-App.tsx

import '@modern-js/runtime/registry/index';
import { render } from '@modern-js/runtime/browser';
import { createRoot } from '@modern-js/runtime/react';
import { createBridgeComponent } from '@module-federation/modern-js/react';

const ModernRoot = createRoot();
export const provider = createBridgeComponent({
  rootComponent: ModernRoot,
  render: (Component, dom) => render(Component, dom),
});

export default provider;

消费者加载应用

src/routes/remote/$.tsx

import { createRemoteAppComponent } from '@module-federation/modern-js/react';
import { loadRemote } from '@module-federation/modern-js/runtime';

const RemoteApp = createRemoteAppComponent({
  loader: () => loadRemote('remote/app'),
  fallback: ({ error }) => <div>Error: {error.message}</div>,
  loading: <div>Loading...</div>,
});

export default RemoteApp;

通过通配路由 $.tsx,所有访问 /remote/* 的请求都会进入远程应用,远程应用内部的路由也能正常工作。

更多 Module Federation 的使用方式,请查看文档:Module Federation


技术栈更新

TL;DR:Modern.js 3.0 升级 React 19,最低支持 Node.js 20。

React 19

Modern.js 3.0 新项目默认使用 React 19,最低支持 React 18。

如果你的项目仍在使用 React 16 或 React 17,请先参考 React 19 官方升级指南 完成版本升级。

Node.js 20

随着 Node.js 不断推进版本演进,Node.js 18 已经 EOL。在 Modern.js 3.0 中,推荐使用 Node.js 22 LTS,不再保证对 Node.js 18 的支持。

Storybook Rsbuild

在 Modern.js 3.0 中,我们基于 Storybook Rsbuild 实现了使用 Storybook 构建 Modern.js 应用。

通过 Storybook Addon,我们将 Modern.js 配置转换合并为 Rsbuild 配置,并通过 Storybook Rsbuild 驱动构建,让 Storybook 调试与开发命令保持配置对齐。

更多 Storybook 使用方式,请查看文档:使用 Storybook

使用 Biome

随着社区技术不断发展,更快、更简洁的工具链已经成熟。在 Modern.js 3.0 中,新项目默认使用 Biome 作为代码检查和格式化工具。


从 Modern.js 2.0 升级到 3.0

主要变更

升级 Modern.js 3.0 意味着拥抱更轻量、更标准的现代化开发范式。通过全面对齐 Rspack 与 React 19 等主流生态,彻底解决历史包袱带来的维护痛点,显著提升构建与运行性能。

未来,我们也会基于 Modern.js 3.0 提供更多的 AI 集成与最佳实践,配合灵活的全栈插件系统,让开发者能以极低的学习成本复用社区经验,实现开发效率的质变与应用架构的现代化升级。

更多改进与变更,请查看文档:升级指南

反馈和社区

最后,再次感谢每一位给予我们反馈和支持的开发者,我们将继续与大家保持沟通,在相互支持中共同成长。

如果你在使用过程中遇到问题,欢迎通过以下方式反馈:

❌