普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月19日首页

《swiftUI进阶 第9章SwiftUI 状态管理完全指南》

作者 90后晨仔
2026年4月19日 00:49

概述

状态管理是 SwiftUI 应用的核心。本章将系统介绍从 iOS 13 到 iOS 17+ 的所有状态管理技术,包括传统的 ObservableObject 系列和现代的 @Observable 宏,帮助你根据项目需求选择最合适的方案。


第一部分:基础状态管理(iOS 13+)

1. @State:本地视图状态

@State 用于管理视图内部的简单状态,当值改变时自动刷新 UI。

struct CounterView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") { count += 1 }
        }
    }
}

要点

  • 标记为 private,仅当前视图使用
  • 适合 IntStringBool 等简单类型
  • 当状态变化时,SwiftUI 重新计算 body

2. @Binding:父子视图双向绑定

@Binding 创建对现有状态的引用,允许子视图修改父视图的状态。

struct ParentView: View {
    @State private var isOn = false
    
    var body: some View {
        ToggleView(isOn: $isOn)  // 传递绑定
    }
}

struct ToggleView: View {
    @Binding var isOn: Bool
    
    var body: some View {
        Toggle("开关", isOn: $isOn)
    }
}

第二部分:传统响应式状态管理(iOS 13+)

3. ObservableObject 协议与 @Published

ObservableObject 用于创建可观察的类,@Published 标记需要通知视图的属性。

import Combine

class UserViewModel: ObservableObject {
    @Published var name = "张三"
    @Published var age = 25
    
    func updateName(_ newName: String) {
        name = newName
    }
}

4. @StateObject vs @ObservedObject

特性 @StateObject @ObservedObject
生命周期 视图创建时初始化一次 随视图重建而重建
所有权 拥有对象 仅观察外部对象
适用场景 视图的主要数据源 从父视图传入的对象
struct ContentView: View {
    @StateObject private var viewModel = UserViewModel()  // 拥有
    
    var body: some View {
        ChildView(viewModel: viewModel)  // 传递
    }
}

struct ChildView: View {
    @ObservedObject var viewModel: UserViewModel  // 观察
    
    var body: some View {
        Text(viewModel.name)
    }
}

5. @EnvironmentObject:全局共享状态

通过环境在任意层级共享对象,避免逐层传递。

class AppState: ObservableObject {
    @Published var isLoggedIn = false
}

@main
struct MyApp: App {
    @StateObject private var appState = AppState()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appState)
        }
    }
}

struct ProfileView: View {
    @EnvironmentObject var appState: AppState
    
    var body: some View {
        Text(appState.isLoggedIn ? "已登录" : "未登录")
    }
}

6. @Environment:系统环境值

访问系统提供的环境值,如颜色方案、尺寸类等。

struct ThemeAwareView: View {
    @Environment(\.colorScheme) var colorScheme
    @Environment(\.horizontalSizeClass) var horizontalSizeClass
    
    var body: some View {
        Text("当前模式: \(colorScheme == .dark ? "深色" : "浅色")")
    }
}

7. @AppStorage:持久化存储

使用 UserDefaults 自动持久化简单数据。

struct SettingsView: View {
    @AppStorage("username") var username = ""
    @AppStorage("isDarkMode") var isDarkMode = false
    
    var body: some View {
        TextField("用户名", text: $username)
        Toggle("深色模式", isOn: $isDarkMode)
    }
}

8. @SceneStorage:场景持久化

在场景(如多窗口)中保持状态,窗口关闭后自动清除。

struct DocumentView: View {
    @SceneStorage("scrollPosition") var scrollPosition: Double = 0
    
    var body: some View {
        ScrollView {
            // 内容
        }
    }
}

第三部分:现代状态管理(iOS 17+)

9. @Observable 宏

iOS 17 引入 @Observable 宏,简化了可观察对象的创建,无需 ObservableObject@Published

import SwiftUI

@Observable
class UserModel {
    var name = "张三"
    var age = 25
    var email = "zhangsan@example.com"
}

struct ContentView: View {
    @State private var userModel = UserModel()
    
    var body: some View {
        VStack {
            Text("姓名: \(userModel.name)")
            TextField("修改姓名", text: $userModel.name)  // 直接使用 $ 绑定
        }
    }
}

优势

  • 语法更简洁,无需协议和属性包装器
  • 所有属性默认可观察
  • 性能更优(直接访问)

10. @Bindable 双向绑定

当需要将 @Observable 对象的属性传递给需要绑定的子视图时,使用 @Bindable

@Observable
class Settings {
    var isDarkMode = false
}

struct ParentView: View {
    @State private var settings = Settings()
    
    var body: some View {
        ChildView(settings: settings)  // 直接传递
    }
}

struct ChildView: View {
    @Bindable var settings: Settings  // 添加 @Bindable
    
    var body: some View {
        Toggle("深色模式", isOn: $settings.isDarkMode)  // 可绑定
    }
}

11. 使用 @Environment 与 @Observable 结合

现代方式也可以将可观察对象放入环境。

@Observable
class AppState {
    var isLoggedIn = false
    var userName = ""
}

@main
struct MyApp: App {
    @State private var appState = AppState()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)  // 注入环境
        }
    }
}

struct ProfileView: View {
    @Environment(AppState.self) private var appState  // 读取环境
    
    var body: some View {
        Text(appState.isLoggedIn ? "欢迎 \(appState.userName)" : "未登录")
    }
}

第四部分:最佳实践与迁移指南

选择合适的状态管理工具

场景 推荐方式(iOS 13-16) 推荐方式(iOS 17+)
单个视图内部状态 @State @State
父子视图共享 @Binding @Binding
复杂业务逻辑 @StateObject + ObservableObject @State + @Observable
全局共享状态 @EnvironmentObject @Environment + @Observable
持久化简单数据 @AppStorage @AppStorage
场景临时状态 @SceneStorage @SceneStorage

从 ObservableObject 迁移到 @Observable

迁移步骤

  1. class SomeModel: ObservableObject 改为 @Observable class SomeModel
  2. 移除所有 @Published 包装器
  3. @StateObject 改为 @State(如果对象是视图拥有的)
  4. @ObservedObject 改为 @Bindable(如果需要双向绑定)
  5. @EnvironmentObject 改为 @Environment(SomeModel.self)

迁移示例

// 旧方式
class OldViewModel: ObservableObject {
    @Published var text = ""
}
struct OldView: View {
    @StateObject private var vm = OldViewModel()
    var body: some View { TextField("", text: $vm.text) }
}

// 新方式
@Observable
class NewViewModel {
    var text = ""
}
struct NewView: View {
    @State private var vm = NewViewModel()
    var body: some View { TextField("", text: $vm.text) }
}

第五部分:实战:完整的待办事项应用(双版本对比)

传统方式(ObservableObject)

import SwiftUI
import Combine

class TodoViewModel: ObservableObject {
    @Published var todos: [Todo] = []
    @Published var newTitle = ""
    
    struct Todo: Identifiable {
        let id = UUID()
        var title: String
        var isCompleted = false
    }
    
    func addTodo() {
        guard !newTitle.isEmpty else { return }
        todos.append(Todo(title: newTitle))
        newTitle = ""
    }
    
    func toggle(_ todo: Todo) {
        if let idx = todos.firstIndex(where: { $0.id == todo.id }) {
            todos[idx].isCompleted.toggle()
        }
    }
}

struct TodoListView: View {
    @StateObject private var viewModel = TodoViewModel()
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    TextField("新待办", text: $viewModel.newTitle)
                        .textFieldStyle(.roundedBorder)
                    Button("添加") { viewModel.addTodo() }
                }
                .padding()
                List {
                    ForEach(viewModel.todos) { todo in
                        HStack {
                            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                                .onTapGesture { viewModel.toggle(todo) }
                            Text(todo.title)
                        }
                    }
                }
            }
            .navigationTitle("待办事项")
        }
    }
}

现代方式(@Observable)

import SwiftUI

@Observable
class TodoViewModel {
    var todos: [Todo] = []
    var newTitle = ""
    
    struct Todo: Identifiable {
        let id = UUID()
        var title: String
        var isCompleted = false
    }
    
    func addTodo() {
        guard !newTitle.isEmpty else { return }
        todos.append(Todo(title: newTitle))
        newTitle = ""
    }
    
    func toggle(_ todo: Todo) {
        if let idx = todos.firstIndex(where: { $0.id == todo.id }) {
            todos[idx].isCompleted.toggle()
        }
    }
}

struct TodoListView: View {
    @State private var viewModel = TodoViewModel()
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    TextField("新待办", text: $viewModel.newTitle)
                        .textFieldStyle(.roundedBorder)
                    Button("添加") { viewModel.addTodo() }
                }
                .padding()
                List {
                    ForEach(viewModel.todos) { todo in
                        HStack {
                            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                                .onTapGesture { viewModel.toggle(todo) }
                            Text(todo.title)
                        }
                    }
                }
            }
            .navigationTitle("待办事项")
        }
    }
}

总结

SwiftUI 提供了从基础到高级的完整状态管理方案:

  • 基础层@State@Binding – 适用于简单、局部的状态
  • 传统响应式层ObservableObject@Published@StateObject@ObservedObject@EnvironmentObject – 适用于 iOS 13-16 的复杂状态管理
  • 持久化层@AppStorage@SceneStorage – 适用于数据持久化
  • 现代层(iOS 17+)@Observable@Bindable – 更简洁、更高效,推荐新项目使用

选择建议:

  • 新项目且最低支持 iOS 17:优先使用 @Observable + @Environment
  • 需要兼容 iOS 16 及以下:继续使用 ObservableObject 系列
  • 两者可以在同一项目中共存,逐步迁移

掌握这些工具,你将能够构建出响应迅速、结构清晰的 SwiftUI 应用。


参考资料


本内容为《SwiftUI 进阶》第9章,涵盖从基础到现代的全部状态管理技术。欢迎关注后续更新。

《 SwiftUI 进阶第8章:表单与设置界面》

作者 90后晨仔
2026年4月19日 00:49

8.1 Form 组件

核心概念

Form 是 SwiftUI 中用于创建表单界面的专用组件,它提供了:

  • 自动的分组和分隔线
  • 自适应的布局
  • 与系统设置一致的外观
  • 支持多种表单控件

基本使用

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Text("个人信息")
                }
                
                Section {
                    Text("姓名: 张三")
                    Text("年龄: 25")
                    Text("邮箱: zhangsan@example.com")
                }
            }
            .navigationTitle("个人资料")
        }
    }
}

动态表单

import SwiftUI

struct ContentView: View {
    @State private var name = "张三"
    @State private var age = 25
    @State private var email = "zhangsan@example.com"
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Text("个人信息")
                }
                
                Section {
                    TextField("姓名", text: $name)
                    Stepper("年龄: \(age)", value: $age, in: 1...100)
                    TextField("邮箱", text: $email)
                }
            }
            .navigationTitle("编辑资料")
        }
    }
}

8.2 常见表单控件组合

基础控件

控件类型 用途 示例代码
TextField 文本输入 TextField("输入", text: $text)
SecureField 密码输入 SecureField("密码", text: $password)
Toggle 开关 Toggle("启用", isOn: $isEnabled)
Picker 选择器 Picker("选择", selection: $selection) { ... }
Stepper 步进器 Stepper("数量: \(count)", value: $count)
Slider 滑块 Slider(value: $value, in: 0...100)
DatePicker 日期选择 DatePicker("日期", selection: $date)

组合使用

import SwiftUI

struct ContentView: View {
    @State private var notifications = true
    @State private var sound = true
    @State private var theme = "浅色"
    @State private var brightness = 0.5
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Toggle("通知", isOn: $notifications)
                    Toggle("声音", isOn: $sound)
                }
                
                Section {
                    Picker("主题", selection: $theme) {
                        Text("浅色").tag("浅色")
                        Text("深色").tag("深色")
                        Text("跟随系统").tag("跟随系统")
                    }
                }
                
                Section {
                    Text("亮度: \(Int(brightness * 100))%")
                    Slider(value: $brightness, in: 0...1)
                }
            }
            .navigationTitle("设置")
        }
    }
}

8.3 表单验证

基本验证

import SwiftUI

struct ContentView: View {
    @State private var email = ""
    @State private var password = ""
    @State private var showError = false
    @State private var errorMessage = ""
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField("邮箱", text: $email)
                        .keyboardType(.emailAddress)
                    SecureField("密码", text: $password)
                }
                
                Section {
                    Button("登录") {
                        if !validateForm() {
                            showError = true
                        }
                    }
                }
            }
            .navigationTitle("登录")
            .alert("错误", isPresented: $showError) {
                Button("确定") {}
            } message: {
                Text(errorMessage)
            }
        }
    }
    
    func validateForm() -> Bool {
        if email.isEmpty {
            errorMessage = "请输入邮箱"
            return false
        }
        if !email.contains("@") {
            errorMessage = "请输入有效的邮箱"
            return false
        }
        if password.count < 6 {
            errorMessage = "密码至少需要6个字符"
            return false
        }
        return true
    }
}

实时验证

import SwiftUI

struct ContentView: View {
    @State private var email = ""
    @State private var password = ""
    
    var emailIsValid: Bool {
        !email.isEmpty && email.contains("@")
    }
    
    var passwordIsValid: Bool {
        password.count >= 6
    }
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField("邮箱", text: $email)
                        .keyboardType(.emailAddress)
                        .foregroundColor(emailIsValid ? .primary : .red)
                    
                    SecureField("密码", text: $password)
                        .foregroundColor(passwordIsValid ? .primary : .red)
                    
                    if !emailIsValid && !email.isEmpty {
                        Text("请输入有效的邮箱")
                            .foregroundColor(.red)
                            .font(.caption)
                    }
                    
                    if !passwordIsValid && !password.isEmpty {
                        Text("密码至少需要6个字符")
                            .foregroundColor(.red)
                            .font(.caption)
                    }
                }
                
                Section {
                    Button("登录") {
                        // 登录逻辑
                    }
                    .disabled(!emailIsValid || !passwordIsValid)
                }
            }
            .navigationTitle("登录")
        }
    }
}

8.4 实战:用户设置页面

完整示例

import SwiftUI

struct ContentView: View {
    @State private var notifications = true
    @State private var sound = true
    @State private var haptic = true
    @State private var darkMode = false
    @State private var language = "简体中文"
    @State private var autoLock = 5 // 分钟
    
    var body: some View {
        NavigationStack {
            List {
                Section("通知设置") {
                    Toggle("推送通知", isOn: $notifications)
                    Toggle("声音", isOn: $sound)
                    Toggle("震动", isOn: $haptic)
                }
                
                Section("外观设置") {
                    Toggle("深色模式", isOn: $darkMode)
                }
                
                Section("语言设置") {
                    Picker("语言", selection: $language) {
                        Text("简体中文").tag("简体中文")
                        Text("English").tag("English")
                    }
                }
                
                Section("安全设置") {
                    Picker("自动锁定", selection: $autoLock) {
                        Text("30秒").tag(0)
                        Text("1分钟").tag(1)
                        Text("5分钟").tag(5)
                        Text("10分钟").tag(10)
                        Text("永不").tag(-1)
                    }
                }
                
                Section("关于") {
                    HStack {
                        Text("版本")
                        Spacer()
                        Text("1.0.0")
                            .foregroundColor(.gray)
                    }
                    
                    Button("检查更新") {
                        // 检查更新逻辑
                    }
                    
                    Button("隐私政策") {
                        // 打开隐私政策
                    }
                }
            }
            .navigationTitle("设置")
        }
    }
}

分组样式

Form {
    // 表单内容
}
.formStyle(.grouped) // 分组样式

最佳实践

  1. 分组逻辑:按照功能将表单控件分组
  2. 标签清晰:为每个控件提供明确的标签
  3. 验证反馈:及时提供验证错误反馈
  4. 默认值:为控件设置合理的默认值
  5. 布局合理:使用合适的控件类型和布局

性能优化

  1. 避免复杂计算:不要在 body 中进行复杂计算
  2. 使用 @State 优化:合理使用 @State 管理表单状态
  3. 延迟加载:对于复杂表单,考虑使用延迟加载

与 iOS 专家博客对比

根据 SwiftUI by Example 的建议:

  • 使用 Section 组织表单内容
  • 为表单控件提供合适的键盘类型
  • 利用 Form 的自动布局特性
  • 结合 NavigationStack 构建设置页面层次

高级技巧

自定义表单样式

struct CustomFormStyle: FormStyle {
    func makeBody(configuration: Configuration) -> some View {
        VStack(spacing: 0) {
            ForEach(configuration.content) {
                $0
                    .padding()
                    .background(Color.white)
                    .border(Color.gray.opacity(0.2), edges: .bottom)
            }
        }
        .background(Color.gray.opacity(0.1))
    }
}

// 使用
Form {
    // 表单内容
}
.formStyle(CustomFormStyle())

表单数据持久化

import SwiftUI

struct ContentView: View {
    @AppStorage("notifications") private var notifications = true
    @AppStorage("darkMode") private var darkMode = false
    @AppStorage("language") private var language = "简体中文"
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    Toggle("通知", isOn: $notifications)
                    Toggle("深色模式", isOn: $darkMode)
                    Picker("语言", selection: $language) {
                        Text("简体中文").tag("简体中文")
                        Text("English").tag("English")
                    }
                }
            }
            .navigationTitle("设置")
        }
    }
}

总结

表单与设置界面是应用中常见的组成部分,SwiftUI 提供了强大的 Form 组件来简化开发:

  • Form:创建结构化的表单布局
  • 多种内置控件:满足各种输入需求
  • 实时验证:提供良好的用户反馈
  • 与系统风格一致:确保视觉一致性

通过合理组织表单内容、提供清晰的验证反馈、使用适当的控件类型,可以创建出既美观又实用的设置界面。


参考资料


本内容为《SwiftUI 进阶》第八章,欢迎关注后续更新。

《SwiftUI 进阶第7章:导航系统》

作者 90后晨仔
2026年4月19日 00:35

7.1 NavigationStack 基础导航

核心概念

NavigationStack 是 SwiftUI 中用于构建导航层次结构的核心组件,它替代了旧版的 NavigationView(在 iOS 16+ 中已被废弃)。

基本使用

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("前往详情页", destination: DetailView())
            }
            .navigationTitle("主页面")
        }
    }
}

struct DetailView: View {
    var body: some View {
        Text("详情页内容")
            .navigationTitle("详情页")
    }
}

程序化导航

NavigationStack 支持使用路径进行程序化导航:

import SwiftUI

struct ContentView: View {
    @State private var path: [Int] = []
    
    var body: some View {
        NavigationStack(path: $path) {
            List(1..<10) { number in
                NavigationLink(value: number) {
                    Text("项目 \(number)")
                }
            }
            .navigationTitle("主页面")
            .navigationDestination(for: Int.self) {
                DetailView(number: $0, path: $path)
            }
        }
    }
}

struct DetailView: View {
    let number: Int
    @Binding var path: [Int]
    
    var body: some View {
        VStack {
            Text("详情页 \(number)")
            Button("前往下一页") {
                path.append(number + 10)
            }
            Button("返回首页") {
                path.removeAll()
            }
        }
        .navigationTitle("详情页 \(number)")
    }
}

与官方文档对比

根据苹果官方文档,NavigationStack 提供了更灵活的导航控制,包括:

  • 路径管理:可以通过绑定的数组控制导航状态
  • 类型安全:使用 navigationDestination(for:) 提供类型安全的导航目标
  • 向后兼容:在 iOS 16+ 中推荐使用

7.2 NavigationLink 页面跳转

核心概念

NavigationLink 是用于创建导航链接的组件,它可以:

  • 直接指定目标视图
  • 使用值传递方式(配合 navigationDestination
  • 控制激活状态

直接目标方式

NavigationLink("前往详情页", destination: DetailView())

值传递方式

NavigationLink(value: item) {
    Text(item.name)
}

条件导航

NavigationLink(
    "登录", 
    destination: LoginView(),
    isActive: $isLoggedIn
)

7.3 navigationTitle 与 navigationBarTitleDisplayMode

navigationTitle

设置导航栏标题:

.navigationTitle("页面标题")

navigationBarTitleDisplayMode

控制标题显示模式:

模式 描述
.automatic 自动(默认)
.inline 内联模式(小字体)
.large 大标题模式
.navigationBarTitleDisplayMode(.large)

7.4 Sheet 模态视图

核心概念

Sheet 用于显示模态视图,通常用于:

  • 表单填写
  • 详情展示
  • 辅助操作

基本使用

import SwiftUI

struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("显示 Sheet") {
            isSheetPresented = true
        }
        .sheet(isPresented: $isSheetPresented) {
            SheetView(isPresented: $isSheetPresented)
        }
    }
}

struct SheetView: View {
    @Binding var isPresented: Bool
    
    var body: some View {
        VStack {
            Text("这是一个 Sheet 视图")
            Button("关闭") {
                isPresented = false
            }
        }
        .padding()
    }
}

带值的 Sheet

.sheet(item: $selectedItem) {
    DetailView(item: $0)
}

7.5 TabView 标签页导航

核心概念

TabView 用于创建底部标签栏导航,是构建多标签应用的基础。

基本使用

import SwiftUI

struct ContentView: View {
    @State private var selectedTab = 0
    
    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView()
                .tabItem {
                    Label("首页", systemImage: "house")
                }
                .tag(0)
            
            ProfileView()
                .tabItem {
                    Label("个人", systemImage: "person")
                }
                .tag(1)
        }
    }
}

struct HomeView: View {
    var body: some View {
        Text("首页")
    }
}

struct ProfileView: View {
    var body: some View {
        Text("个人中心")
    }
}

自定义样式

TabView {
    // 标签内容
}
.tabViewStyle(.automatic) // 自动样式

最佳实践

  1. 导航层次:保持导航层次清晰,避免过深的导航栈
  2. 标题设置:为每个页面设置合适的标题和显示模式
  3. 模态视图:合理使用 Sheet 展示临时内容
  4. 标签栏:控制标签数量(建议 3-5 个)
  5. 状态管理:使用 @State@Observable 管理导航状态

性能优化

  1. 延迟加载:使用 LazyView 包装目标视图
  2. 导航栈管理:及时清理不需要的导航路径
  3. 避免过度动画:减少导航过程中的复杂动画

建议:

  • 优先使用 NavigationStack 而非 NavigationView
  • 使用值类型传递而非对象引用
  • 结合 @ObservableObservableObject 管理复杂导航状态

实战:多页面应用

import SwiftUI

struct ContentView: View {
    @State private var path: [String] = []
    
    var body: some View {
        NavigationStack(path: $path) {
            TabView {
                HomeView(path: $path)
                    .tabItem {
                        Label("首页", systemImage: "house")
                    }
                
                SettingsView()
                    .tabItem {
                        Label("设置", systemImage: "gear")
                    }
            }
        }
    }
}

struct HomeView: View {
    @Binding var path: [String]
    
    var body: some View {
        List {
            NavigationLink(value: "detail") {
                Text("详情页")
            }
            NavigationLink(value: "profile") {
                Text("个人资料")
            }
        }
        .navigationTitle("首页")
        .navigationDestination(for: String.self) {
            switch $0 {
            case "detail":
                DetailView()
            case "profile":
                ProfileView()
            default:
                Text("未知页面")
            }
        }
    }
}

struct DetailView: View {
    var body: some View {
        Text("详情页内容")
            .navigationTitle("详情")
    }
}

struct ProfileView: View {
    var body: some View {
        Text("个人资料")
            .navigationTitle("个人")
    }
}

struct SettingsView: View {
    var body: some View {
        Text("设置页面")
            .navigationTitle("设置")
    }
}

总结

导航系统是构建 iOS 应用的核心部分,SwiftUI 提供了现代化的导航组件:

  • NavigationStack:构建导航层次结构
  • NavigationLink:创建导航链接
  • Sheet:显示模态视图
  • TabView:实现标签页导航

掌握这些组件的使用,将帮助你构建结构清晰、用户体验良好的多页面应用。


参考资料


本内容为《SwiftUI 进阶》第七章,欢迎关注后续更新。

《SwiftUI 进阶第5章:数据处理与网络请求》

作者 90后晨仔
2026年4月19日 00:11

学习目标

  • 掌握 SwiftUI 中的数据处理基本方法
  • 了解如何进行网络请求
  • 学习如何处理网络请求的加载状态和错误
  • 掌握数据过滤和排序的方法
  • 了解如何使用 JSONDecoder 解析 JSON 数据

核心概念

数据模型

在 SwiftUI 中,数据模型通常使用结构体来定义,并且需要符合 Identifiable 协议以便在列表中使用。

示例代码:

struct Post: Identifiable, Decodable {
    let id: Int
    let title: String
    let body: String
    let userId: Int
}

本地数据处理

本地数据处理包括数据的添加、删除、修改和查询等操作。

示例代码:

@State private var localData: [String] = ["本地数据1", "本地数据2", "本地数据3"]
@State private var newData = ""

HStack {
    TextField("输入新数据", text: $newData)
        .textFieldStyle(RoundedBorderTextFieldStyle())
        .padding()
    
    Button("添加") {
        if !newData.isEmpty {
            localData.append(newData)
            newData = ""
        }
    }
}

List(localData, id: \.self) { item in
    Text(item)
}

网络请求

在 SwiftUI 中,网络请求通常使用 URLSession 来实现,并且需要在后台线程中执行,然后在主线程中更新 UI。

示例代码:

@State private var posts: [Post] = []
@State private var isLoading = false
@State private var errorMessage: String? = nil

func fetchPosts() {
    isLoading = true
    errorMessage = nil
    
    guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
        self.isLoading = false
        self.errorMessage = "无效的URL"
        return
    }
    
    URLSession.shared.dataTask(with: url) { data, response, error in
        DispatchQueue.main.async {
            self.isLoading = false
            
            if let error = error {
                self.errorMessage = error.localizedDescription
                return
            }
            
            guard let data = data else {
                self.errorMessage = "无数据返回"
                return
            }
            
            do {
                let decodedPosts = try JSONDecoder().decode([Post].self, from: data)
                self.posts = decodedPosts
            } catch {
                self.errorMessage = "解析数据失败"
            }
        }
    }.resume()
}

数据状态管理

在网络请求过程中,需要管理不同的状态:加载中、加载成功和加载失败。

示例代码:

if isLoading {
    ProgressView("加载中...")
        .padding()
} else if let errorMessage = errorMessage {
    Text("错误: \(errorMessage)")
        .foregroundColor(.red)
        .padding()
} else {
    List(posts) { post in
        VStack(alignment: .leading) {
            Text(post.title)
                .font(.headline)
            Text(post.body)
                .font(.body)
                .foregroundColor(.gray)
        }
    }
}

数据过滤

使用 filter 方法可以对数据进行过滤,只显示符合条件的数据。

示例代码:

List(localData.filter { $0.contains("1") }, id: \.self) {
    Text($0)
}

数据排序

使用 sorted 方法可以对数据进行排序,按照指定的规则排列数据。

示例代码:

List(localData.sorted(), id: \.self) {
    Text($0)
}

实践示例:完整数据处理与网络请求演示

以下是一个完整的数据处理与网络请求演示示例:

import SwiftUI

// 数据模型
struct Post: Identifiable, Decodable {
    let id: Int
    let title: String
    let body: String
    let userId: Int
}

struct DataProcessingAndNetworkingDemo: View {
    // 状态管理
    @State private var posts: [Post] = []
    @State private var isLoading = false
    @State private var errorMessage: String? = nil
    @State private var localData: [String] = ["本地数据1", "本地数据2", "本地数据3"]
    @State private var newData = ""
    @State private var filterKeyword = ""
    @State private var sortAscending = true
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("数据处理与网络请求")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 1. 本地数据处理
                VStack(alignment: .leading, spacing: 12) {
                    Text("1. 本地数据处理")
                        .font(.headline)
                    
                    HStack {
                        TextField("输入新数据", text: $newData)
                            .textFieldStyle(.roundedBorder)
                        Button("添加") {
                            if !newData.isEmpty {
                                localData.append(newData)
                                newData = ""
                            }
                        }
                        .buttonStyle(.borderedProminent)
                    }
                    
                    List(localData, id: \.self) { item in
                        Text(item)
                    }
                    .frame(height: 150)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 2. 网络请求
                VStack(alignment: .leading, spacing: 12) {
                    Text("2. 网络请求")
                        .font(.headline)
                    
                    Button("获取网络数据") {
                        fetchPosts()
                    }
                    .buttonStyle(.borderedProminent)
                    
                    if isLoading {
                        ProgressView("加载中...")
                            .padding()
                    } else if let errorMessage = errorMessage {
                        Text("错误: \(errorMessage)")
                            .foregroundColor(.red)
                            .padding()
                    } else if !posts.isEmpty {
                        List(posts) { post in
                            VStack(alignment: .leading) {
                                Text(post.title)
                                    .font(.headline)
                                Text(post.body)
                                    .font(.body)
                                    .foregroundColor(.gray)
                            }
                        }
                        .frame(height: 250)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 3. 数据过滤
                VStack(alignment: .leading, spacing: 12) {
                    Text("3. 数据过滤")
                        .font(.headline)
                    
                    TextField("输入过滤关键词", text: $filterKeyword)
                        .textFieldStyle(.roundedBorder)
                    
                    List(localData.filter { 
                        filterKeyword.isEmpty ? true : $0.contains(filterKeyword) 
                    }, id: \.self) { item in
                        Text(item)
                    }
                    .frame(height: 120)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 4. 数据排序
                VStack(alignment: .leading, spacing: 12) {
                    Text("4. 数据排序")
                        .font(.headline)
                    
                    Toggle("升序排列", isOn: $sortAscending)
                    
                    List(sortAscending ? localData.sorted() : localData.sorted(by: >), id: \.self) { item in
                        Text(item)
                    }
                    .frame(height: 120)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
    
    // 网络请求方法
    func fetchPosts() {
        isLoading = true
        errorMessage = nil
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
            self.isLoading = false
            self.errorMessage = "无效的URL"
            return
        }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            DispatchQueue.main.async {
                self.isLoading = false
                
                if let error = error {
                    self.errorMessage = error.localizedDescription
                    return
                }
                
                guard let data = data else {
                    self.errorMessage = "无数据返回"
                    return
                }
                
                do {
                    let decodedPosts = try JSONDecoder().decode([Post].self, from: data)
                    self.posts = decodedPosts
                } catch {
                    self.errorMessage = "解析数据失败"
                }
            }
        }.resume()
    }
}

#Preview {
    DataProcessingAndNetworkingDemo()
}

常见问题与解决方案

1. 网络请求在主线程执行

问题:网络请求在主线程执行,导致 UI 卡顿。

解决方案:使用 DispatchQueue.global().async 将网络请求放在后台线程执行,然后在主线程中更新 UI。实际上 URLSession.dataTask 的回调默认就在后台线程,只需确保 UI 更新在 DispatchQueue.main.async 中。

URLSession.shared.dataTask(with: url) { data, response, error in
    DispatchQueue.main.async {
        // 更新 UI
    }
}.resume()

2. 数据解析失败

问题:JSON 数据解析失败。

解决方案

  • 确保数据模型与 JSON 数据结构完全匹配(字段名、类型)
  • 使用 CodingKeys 处理字段名不一致的情况
  • 使用 try?do-catch 捕获错误
struct Post: Decodable {
    let id: Int
    let title: String
    
    enum CodingKeys: String, CodingKey {
        case id
        case title = "post_title"  // 如果 JSON 字段名不同
    }
}

3. 加载状态未正确显示

问题:网络请求过程中没有显示加载状态。

解决方案:使用 @State 变量管理加载状态,并在请求开始前设置为 true,完成后设置为 false

4. 错误处理不完善

问题:网络请求失败时没有显示错误信息。

解决方案:捕获并处理网络请求中的错误,将错误信息显示给用户。

if let errorMessage = errorMessage {
    Text("错误: \(errorMessage)")
        .foregroundColor(.red)
}

总结

本章介绍了 SwiftUI 中的数据处理与网络请求,包括:

  • 数据模型的定义:使用 IdentifiableDecodable 协议
  • 本地数据处理:增删改查、列表展示
  • 网络请求的实现:使用 URLSession 和异步回调
  • 数据状态管理:加载中、成功、失败三种状态
  • 数据过滤:使用 filter 方法按条件筛选
  • 数据排序:使用 sorted 方法自定义排序规则

通过这些技术,可以实现数据的获取、处理和展示,为应用提供丰富的数据源。在实际开发中,数据处理与网络请求是应用的核心功能之一,掌握这些技术对于开发高质量的 SwiftUI 应用至关重要。


参考资料


本内容为《SwiftUI 进阶》第五章,欢迎关注后续更新。

每日一题-下标对中的最大距离🟡

2026年4月19日 00:00

给你两个 非递增 的整数数组 nums1nums2 ,数组下标均 从 0 开始 计数。

下标对 (i, j)0 <= i < nums1.length0 <= j < nums2.length 。如果该下标对同时满足 i <= jnums1[i] <= nums2[j] ,则称之为 有效 下标对,该下标对的 距离j - i

返回所有 有效 下标对 (i, j) 中的 最大距离 。如果不存在有效下标对,返回 0

一个数组 arr ,如果每个 1 <= i < arr.length 均有 arr[i-1] >= arr[i] 成立,那么该数组是一个 非递增 数组。

 

示例 1:

输入:nums1 = [55,30,5,4,2], nums2 = [100,20,10,10,5]
输出:2
解释:有效下标对是 (0,0), (2,2), (2,3), (2,4), (3,3), (3,4) 和 (4,4) 。
最大距离是 2 ,对应下标对 (2,4) 。

示例 2:

输入:nums1 = [2,2,2], nums2 = [10,10,1]
输出:1
解释:有效下标对是 (0,0), (0,1) 和 (1,1) 。
最大距离是 1 ,对应下标对 (0,1) 。

示例 3:

输入:nums1 = [30,29,19,5], nums2 = [25,25,25,25,25]
输出:2
解释:有效下标对是 (2,2), (2,3), (2,4), (3,3) 和 (3,4) 。
最大距离是 2 ,对应下标对 (2,4) 。

 

提示:

  • 1 <= nums1.length <= 105
  • 1 <= nums2.length <= 105
  • 1 <= nums1[i], nums2[j] <= 105
  • nums1nums2 都是 非递增 数组

《SwiftUI 进阶第4章:响应式布局》

作者 90后晨仔
2026年4月18日 23:19

Snip20260418_7.png

学习目标

  • 掌握 SwiftUI 中的响应式布局概念
  • 了解如何根据屏幕尺寸调整布局
  • 学习使用环境变量获取设备信息
  • 掌握动态网格布局的实现方法
  • 了解几何读取器和安全区域的使用

核心概念

响应式布局基础

在 SwiftUI 中,响应式布局是通过环境变量、条件布局和自适应组件来实现的,它可以根据不同的屏幕尺寸和设备类型自动调整布局。


环境变量

尺寸类

SwiftUI 提供了尺寸类来描述设备的屏幕尺寸,主要有两种尺寸类:

  • horizontalSizeClass - 水平尺寸类,分为 .compact(紧凑)和 .regular(常规)
  • verticalSizeClass - 垂直尺寸类,同样分为 .compact.regular

示例代码:

@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass

Text("水平尺寸类: \(horizontalSizeClass == .compact ? "紧凑" : "常规")")
Text("垂直尺寸类: \(verticalSizeClass == .compact ? "紧凑" : "常规")")

自适应布局

根据尺寸类调整布局是响应式设计的核心。

示例代码:

// 根据水平尺寸类调整布局
if horizontalSizeClass == .compact {
    // 紧凑模式 - 垂直布局
    VStack(spacing: 10) {
        Color.red
            .frame(height: 100)
            .cornerRadius(10)
        Color.green
            .frame(height: 100)
            .cornerRadius(10)
        Color.blue
            .frame(height: 100)
            .cornerRadius(10)
    }
} else {
    // 常规模式 - 水平布局
    HStack(spacing: 10) {
        Color.red
            .frame(height: 100)
            .cornerRadius(10)
        Color.green
            .frame(height: 100)
            .cornerRadius(10)
        Color.blue
            .frame(height: 100)
            .cornerRadius(10)
    }
}

动态网格布局

使用 LazyVGridGridItem 可以创建动态网格布局,根据屏幕尺寸自动调整列数。

示例代码:

// 根据水平尺寸类调整网格列数
let columns = horizontalSizeClass == .compact ? [
    GridItem(.flexible()),
    GridItem(.flexible())
] : [
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible())
]

LazyVGrid(columns: columns, spacing: 10) {
    ForEach(1..<9) { index in
        Color(hue: Double(index)/10, saturation: 0.8, brightness: 0.8)
            .frame(height: 100)
            .cornerRadius(10)
            .overlay(
                Text("\(index)")
                    .foregroundColor(.white)
                    .font(.headline)
            )
    }
}

几何读取器

GeometryReader 可以获取父视图的尺寸和位置信息,用于创建更加灵活的布局。

示例代码:

GeometryReader { geometry in
    VStack {
        Text("屏幕宽度: \(geometry.size.width, specifier: "%.0f")")
        Text("屏幕高度: \(geometry.size.height, specifier: "%.0f")")
        
        Rectangle()
            .fill(.purple)
            .frame(width: geometry.size.width * 0.8, height: 100)
            .cornerRadius(10)
    }
}
.frame(height: 200)

安全区域

安全区域是指屏幕上不会被系统 UI(如状态栏、导航栏、底部安全区域)遮挡的区域。

示例代码:

Color.blue
    .frame(height: 100)
    .ignoresSafeArea(edges: .top)
    .cornerRadius(10)

自适应文本

使用 .multilineTextAlignment() 可以创建自适应文本,根据屏幕宽度自动换行。

示例代码:

Text("这是一段自适应文本,会根据屏幕宽度自动换行")
    .font(.body)
    .multilineTextAlignment(.center)
    .padding()
    .background(.gray.opacity(0.1))
    .cornerRadius(10)

条件内容

根据尺寸类显示不同的内容,实现设备特定的布局。

示例代码:

if horizontalSizeClass == .compact {
    Text("当前是手机模式,显示手机专用内容")
        .font(.body)
        .padding()
        .background(.green)
        .foregroundColor(.white)
        .cornerRadius(10)
} else {
    Text("当前是平板模式,显示平板专用内容")
        .font(.body)
        .padding()
        .background(.blue)
        .foregroundColor(.white)
        .cornerRadius(10)
}

动态间距

根据屏幕尺寸调整组件之间的间距。

示例代码:

// 根据水平尺寸类调整间距
let spacing = horizontalSizeClass == .compact ? 10.0 : 20.0

VStack(spacing: spacing) {
    Color.red
        .frame(height: 50)
        .cornerRadius(10)
    Color.green
        .frame(height: 50)
        .cornerRadius(10)
    Color.blue
        .frame(height: 50)
        .cornerRadius(10)
}

实践示例:完整响应式布局演示

以下是一个完整的响应式布局演示示例,包含了各种响应式设计技术:

import SwiftUI

struct ResponsiveLayoutDemo: View {
    // 环境变量 - 用于获取屏幕尺寸
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    @Environment(\.verticalSizeClass) private var verticalSizeClass
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("响应式布局")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 1. 屏幕尺寸信息
                VStack {
                    Text("1. 屏幕尺寸信息")
                        .font(.headline)
                    HStack {
                        Text("水平尺寸类:")
                        Text(horizontalSizeClass == .compact ? "紧凑 (Compact)" : "常规 (Regular)")
                            .foregroundColor(.blue)
                    }
                    HStack {
                        Text("垂直尺寸类:")
                        Text(verticalSizeClass == .compact ? "紧凑 (Compact)" : "常规 (Regular)")
                            .foregroundColor(.blue)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 2. 自适应布局(垂直/水平切换)
                VStack {
                    Text("2. 自适应布局")
                        .font(.headline)
                    
                    if horizontalSizeClass == .compact {
                        VStack(spacing: 10) {
                            Color.red.frame(height: 60).cornerRadius(8)
                            Color.green.frame(height: 60).cornerRadius(8)
                            Color.blue.frame(height: 60).cornerRadius(8)
                        }
                    } else {
                        HStack(spacing: 10) {
                            Color.red.frame(height: 80).cornerRadius(8)
                            Color.green.frame(height: 80).cornerRadius(8)
                            Color.blue.frame(height: 80).cornerRadius(8)
                        }
                    }
                    Text("\(horizontalSizeClass == .compact ? "垂直堆叠" : "水平排列")")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 3. 动态网格布局
                VStack {
                    Text("3. 动态网格布局")
                        .font(.headline)
                    
                    let columns = horizontalSizeClass == .compact ? [
                        GridItem(.flexible()),
                        GridItem(.flexible())
                    ] : [
                        GridItem(.flexible()),
                        GridItem(.flexible()),
                        GridItem(.flexible()),
                        GridItem(.flexible())
                    ]
                    
                    LazyVGrid(columns: columns, spacing: 10) {
                        ForEach(1..<9) { index in
                            Color(hue: Double(index)/10, saturation: 0.7, brightness: 0.9)
                                .frame(height: 80)
                                .cornerRadius(8)
                                .overlay(
                                    Text("\(index)")
                                        .foregroundColor(.white)
                                        .font(.headline)
                                )
                        }
                    }
                    Text("\(horizontalSizeClass == .compact ? "2列网格" : "4列网格")")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 4. 几何读取器
                VStack {
                    Text("4. 几何读取器")
                        .font(.headline)
                    
                    GeometryReader { geometry in
                        VStack {
                            Text("可用宽度: \(geometry.size.width, specifier: "%.0f")")
                                .font(.caption)
                            Rectangle()
                                .fill(.purple)
                                .frame(width: geometry.size.width * 0.7, height: 40)
                                .cornerRadius(8)
                                .overlay(
                                    Text("70% 宽度")
                                        .font(.caption)
                                        .foregroundColor(.white)
                                )
                        }
                        .frame(maxWidth: .infinity)
                    }
                    .frame(height: 100)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 5. 安全区域示例
                VStack {
                    Text("5. 安全区域")
                        .font(.headline)
                    
                    Color.blue
                        .frame(height: 60)
                        .cornerRadius(8)
                        .overlay(
                            Text("默认在安全区域内")
                                .foregroundColor(.white)
                        )
                    
                    Color.orange
                        .frame(height: 60)
                        .cornerRadius(8)
                        .ignoresSafeArea(edges: .horizontal)
                        .overlay(
                            Text("忽略水平安全区域")
                                .foregroundColor(.white)
                        )
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 6. 自适应文本
                VStack {
                    Text("6. 自适应文本")
                        .font(.headline)
                    
                    Text("这是一段自适应文本,会根据屏幕宽度自动换行。当屏幕较窄时,文字会折行显示;屏幕较宽时,可以在一行内完整显示。")
                        .font(.body)
                        .multilineTextAlignment(.center)
                        .padding()
                        .background(Color.gray.opacity(0.2))
                        .cornerRadius(8)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 7. 条件内容(设备专用)
                VStack {
                    Text("7. 条件内容")
                        .font(.headline)
                    
                    if horizontalSizeClass == .compact {
                        Text("📱 手机模式:显示紧凑型布局")
                            .font(.body)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(.green)
                            .foregroundColor(.white)
                            .cornerRadius(8)
                    } else {
                        Text("🖥️ 平板模式:显示扩展型布局")
                            .font(.body)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(.blue)
                            .foregroundColor(.white)
                            .cornerRadius(8)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 8. 动态间距
                VStack {
                    Text("8. 动态间距")
                        .font(.headline)
                    
                    let dynamicSpacing = horizontalSizeClass == .compact ? 8.0 : 20.0
                    
                    VStack(spacing: dynamicSpacing) {
                        Color.red.frame(height: 40).cornerRadius(6)
                        Color.green.frame(height: 40).cornerRadius(6)
                        Color.blue.frame(height: 40).cornerRadius(6)
                    }
                    Text("当前间距: \(dynamicSpacing, specifier: "%.0f") pt")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
}

#Preview {
    ResponsiveLayoutDemo()
}

常见问题与解决方案

1. 布局在不同设备上显示不一致

问题:布局在手机上显示正常,但在平板上显示异常。

解决方案:使用尺寸类和条件布局,为不同尺寸的设备提供不同的布局方案。

// 根据水平尺寸类选择不同的布局结构
if horizontalSizeClass == .compact {
    // 手机布局:垂直堆叠
    VStack { ... }
} else {
    // 平板布局:水平排列或更复杂的网格
    HStack { ... }
}

2. 内容被安全区域遮挡

问题:内容被状态栏或导航栏遮挡。

解决方案:使用 .ignoresSafeArea() 修饰符或确保内容在安全区域内。

// 方法一:忽略安全区域(适用于背景视图)
Color.blue.ignoresSafeArea()

// 方法二:使用 safeAreaInset 添加自定义内容
List {
    // 内容
}
.safeAreaInset(edge: .bottom) {
    Button("底部按钮") { }
        .padding()
}

3. 网格布局在小屏幕上显示拥挤

问题:网格布局在小屏幕上列数过多,导致内容拥挤。

解决方案:根据屏幕尺寸动态调整网格列数。

let columns = horizontalSizeClass == .compact ? [
    GridItem(.flexible()),
    GridItem(.flexible())
] : [
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible())
]

4. GeometryReader 导致布局异常

问题:使用 GeometryReader 后,子视图大小不符合预期。

解决方案:注意 GeometryReader 会占据父视图提供的全部空间,可以在内部使用 frame(height:) 限制高度。

GeometryReader { geometry in
    // 内容
}
.frame(height: 200)  // 固定高度

总结

本章介绍了 SwiftUI 中的响应式布局技术,包括:

  • 环境变量:使用 @Environment 获取设备尺寸信息(horizontalSizeClassverticalSizeClass
  • 自适应布局:根据尺寸类调整布局结构(VStackHStack
  • 动态网格布局:使用 LazyVGridGridItem 创建响应式网格
  • 几何读取器:通过 GeometryReader 获取父视图尺寸,实现精确布局
  • 安全区域:处理状态栏、导航栏等系统 UI 遮挡问题
  • 自适应文本:使用 .multilineTextAlignment() 实现文本自动换行
  • 条件内容:为不同设备类型显示不同的 UI 组件
  • 动态间距:根据屏幕尺寸调整组件之间的间距

通过这些技术,可以创建在不同设备上都能良好显示的布局,提升用户体验。在实际开发中,响应式布局是确保应用在各种设备上都能正常显示的重要手段。


参考资料


本内容为《SwiftUI 进阶》第四章,欢迎关注后续更新。

《SwiftUI 进阶学习第3章:手势与交互》

作者 90后晨仔
2026年4月18日 23:06

手势基础

在 SwiftUI 中,手势是通过各种手势类型和修饰符来实现的,它们可以附加到任何视图上,以响应用户的交互。


常用手势类型

1. 点击手势

点击手势通过 onTapGesture 修饰符实现,用于检测用户的点击操作。

示例代码:

@State private var isTapped = false

Rectangle()
    .fill(.blue)
    .frame(width: 200, height: 100)
    .cornerRadius(10)
    .onTapGesture {
        isTapped.toggle()
    }

点击次数:

可以通过 count 参数指定点击次数,例如双击:

Rectangle()
    .onTapGesture(count: 2) {
        tapCount += 1
    }

2. 长按手势

长按手势通过 onLongPressGesture 修饰符实现,用于检测用户的长按操作。

示例代码:

@State private var isLongPressed = false

Rectangle()
    .fill(.green)
    .frame(width: 200, height: 100)
    .cornerRadius(10)
    .onLongPressGesture {
        isLongPressed.toggle()
    }

3. 拖拽手势

拖拽手势通过 DragGesture 实现,用于检测用户的拖拽操作。

示例代码:

@State private var dragOffset = CGSize.zero

Circle()
    .fill(.red)
    .frame(width: 50, height: 50)
    .offset(dragOffset)
    .gesture(
        DragGesture()
            .onChanged { value in
                dragOffset = value.translation
            }
            .onEnded { value in
                // 可以在这里添加结束拖动的逻辑
            }
    )

4. 缩放手势

缩放手势通过 MagnificationGesture 实现,用于检测用户的缩放操作。

示例代码:

@State private var scale = 1.0

Rectangle()
    .fill(.purple)
    .frame(width: 200, height: 200)
    .cornerRadius(10)
    .scaleEffect(scale)
    .gesture(
        MagnificationGesture()
            .onChanged { value in
                scale = value
            }
    )

5. 旋转手势

旋转手势通过 RotationGesture 实现,用于检测用户的旋转操作。

示例代码:

@State private var rotation = 0.0

Rectangle()
    .fill(.orange)
    .frame(width: 200, height: 200)
    .cornerRadius(10)
    .rotationEffect(.degrees(rotation))
    .gesture(
        RotationGesture()
            .onChanged { value in
                rotation = value.degrees
            }
    )

组合手势

组合手势是将多种手势效果结合在一起,可以使用 .simultaneousGesture() 修饰符。

示例代码:

@State private var offset = CGSize.zero
@State private var scale = 1.0
@State private var rotation = 0.0

Rectangle()
    .fill(.blue)
    .frame(width: 200, height: 200)
    .cornerRadius(10)
    .offset(offset)
    .scaleEffect(scale)
    .rotationEffect(.degrees(rotation))
    .gesture(
        DragGesture()
            .onChanged { value in
                offset = value.translation
            }
    )
    .simultaneousGesture(
        MagnificationGesture()
            .onChanged { value in
                scale = value
            }
    )
    .simultaneousGesture(
        RotationGesture()
            .onChanged { value in
                rotation = value.degrees
            }
    )

手势状态管理

手势通常与状态管理结合使用,以追踪手势的状态和数据。

示例代码:

@State private var isDragging = false

Rectangle()
    .fill(.purple)
    .frame(width: 100, height: 100)
    .cornerRadius(10)
    .gesture(
        DragGesture()
            .onChanged { _ in
                isDragging = true
            }
            .onEnded { _ in
                isDragging = false
            }
    )

实践示例:完整手势演示

以下是一个完整的手势演示示例,包含了各种手势类型和组合:

import SwiftUI

struct GestureAndInteractionDemo: View {
    // 状态管理
    @State private var isTapped = false
    @State private var isLongPressed = false
    @State private var offset = CGSize.zero
    @State private var scale = 1.0
    @State private var rotation = 0.0
    @State private var dragOffset = CGSize.zero
    @State private var isDragging = false
    @State private var tapCount = 0
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("手势与交互")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 1. 点击手势
                VStack {
                    Text("1. 点击手势")
                        .font(.headline)
                    Rectangle()
                        .fill(isTapped ? Color.green : Color.blue)
                        .frame(width: 200, height: 80)
                        .cornerRadius(10)
                        .onTapGesture {
                            withAnimation {
                                isTapped.toggle()
                            }
                        }
                    Text("状态: \(isTapped ? "已点击" : "未点击")")
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 2. 长按手势
                VStack {
                    Text("2. 长按手势")
                        .font(.headline)
                    Rectangle()
                        .fill(isLongPressed ? Color.red : Color.green)
                        .frame(width: 200, height: 80)
                        .cornerRadius(10)
                        .onLongPressGesture {
                            withAnimation {
                                isLongPressed.toggle()
                            }
                        }
                    Text("状态: \(isLongPressed ? "长按中" : "未长按")")
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 3. 拖拽手势
                VStack {
                    Text("3. 拖拽手势")
                        .font(.headline)
                    Circle()
                        .fill(.red)
                        .frame(width: 60, height: 60)
                        .offset(dragOffset)
                        .gesture(
                            DragGesture()
                                .onChanged { value in
                                    dragOffset = value.translation
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        dragOffset = .zero
                                    }
                                }
                        )
                    Text("拖拽小球后自动归位")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 4. 缩放手势
                VStack {
                    Text("4. 缩放手势")
                        .font(.headline)
                    Rectangle()
                        .fill(.purple)
                        .frame(width: 120, height: 120)
                        .cornerRadius(10)
                        .scaleEffect(scale)
                        .gesture(
                            MagnificationGesture()
                                .onChanged { value in
                                    scale = value
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        scale = 1.0
                                    }
                                }
                        )
                    Text("双指缩放,松手复位")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 5. 旋转手势
                VStack {
                    Text("5. 旋转手势")
                        .font(.headline)
                    Rectangle()
                        .fill(.orange)
                        .frame(width: 120, height: 120)
                        .cornerRadius(10)
                        .rotationEffect(.degrees(rotation))
                        .gesture(
                            RotationGesture()
                                .onChanged { value in
                                    rotation = value.degrees
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        rotation = 0
                                    }
                                }
                        )
                    Text("双指旋转,松手复位")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 6. 组合手势
                VStack {
                    Text("6. 组合手势")
                        .font(.headline)
                    Rectangle()
                        .fill(.blue)
                        .frame(width: 120, height: 120)
                        .cornerRadius(10)
                        .offset(offset)
                        .scaleEffect(scale)
                        .rotationEffect(.degrees(rotation))
                        .gesture(
                            DragGesture()
                                .onChanged { value in
                                    offset = value.translation
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        offset = .zero
                                    }
                                }
                        )
                        .simultaneousGesture(
                            MagnificationGesture()
                                .onChanged { value in
                                    scale = value
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        scale = 1.0
                                    }
                                }
                        )
                        .simultaneousGesture(
                            RotationGesture()
                                .onChanged { value in
                                    rotation = value.degrees
                                }
                                .onEnded { _ in
                                    withAnimation {
                                        rotation = 0
                                    }
                                }
                        )
                    Text("支持拖动、缩放、旋转")
                        .font(.caption)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 7. 双击计数
                VStack {
                    Text("7. 双击计数")
                        .font(.headline)
                    Rectangle()
                        .fill(.teal)
                        .frame(width: 200, height: 80)
                        .cornerRadius(10)
                        .onTapGesture(count: 2) {
                            tapCount += 1
                        }
                    Text("双击次数: \(tapCount)")
                    Button("重置") {
                        tapCount = 0
                    }
                    .buttonStyle(.bordered)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
}

#Preview {
    GestureAndInteractionDemo()
}

常见问题与解决方案

1. 手势不响应

问题:视图添加了手势,但没有响应。

解决方案

  • 确保视图有足够的大小(例如,不要将手势添加到 frame(width: 0, height: 0) 的视图上)
  • 检查视图是否被其他视图遮挡(使用 .contentShape(Rectangle()) 扩大可点击区域)
  • 确保没有其他手势冲突
// 扩大点击区域
Rectangle()
    .fill(.clear)
    .contentShape(Rectangle())  // 使透明区域也能响应手势
    .onTapGesture { }

2. 组合手势冲突

问题:多个手势同时应用时出现冲突。

解决方案

  • 使用 .simultaneousGesture() 修饰符来允许同时处理多个手势
  • 使用 .highPriorityGesture() 让某个手势优先
  • 使用 .gesture()including 参数控制手势识别行为
// 高优先级手势(会阻断其他手势)
view.highPriorityGesture(
    TapGesture().onEnded { }
)

// 同时识别多个手势
view.simultaneousGesture(dragGesture)
    .simultaneousGesture(rotationGesture)

3. 手势状态管理

问题:手势结束后状态没有正确更新。

解决方案

  • .onEnded 回调中正确更新状态
  • 对于需要持久化的状态,使用 @State@StateObject
DragGesture()
    .onChanged { value in
        // 实时更新
        offset = value.translation
    }
    .onEnded { value in
        // 结束时的处理
        withAnimation {
            offset = .zero
        }
    }

总结

本章介绍了 SwiftUI 中的手势与交互,包括:

  • 基本手势类型:点击、长按、拖拽、缩放、旋转
  • 手势的状态管理和数据处理
  • 组合手势的实现方法(.simultaneousGesture
  • 手势的高级应用技巧(优先级控制、自定义识别)

通过这些手势,可以使应用界面更加交互友好,提升用户体验。在实际开发中,合理使用手势可以为应用增添交互性,使界面操作更加直观自然。


参考资料


本内容为《SwiftUI 进阶》第三章,欢迎关注后续更新。

《SwiftUI 进阶学习第2章:动画与过渡》

作者 90后晨仔
2026年4月18日 22:56

学习目标

  • 掌握 SwiftUI 中的基本动画实现
  • 了解不同类型的动画效果
  • 学习如何创建组合动画
  • 掌握过渡效果的使用方法
  • 了解不同动画曲线的特点

核心概念

动画基础

在 SwiftUI 中,动画是通过 withAnimation 函数来实现的,它可以将状态变化包装在动画中,使 UI 变化更加平滑自然。

withAnimation {
    // 状态变化
}

动画类型

1. 淡入淡出动画

淡入淡出动画通过改变视图的不透明度来实现,可以使用 .transition(.opacity) 修饰符。

struct FadeAnimationDemo: View {
    @State private var isVisible = false
    
    var body: some View {
        VStack {
            Button("显示/隐藏") {
                withAnimation {
                    isVisible.toggle()
                }
            }
            
            if isVisible {
                Text("Hello, Animation!")
                    .transition(.opacity)
            }
        }
    }
}

2. 缩放动画

缩放动画通过改变视图的缩放比例来实现,可以使用 .scaleEffect() 修饰符。

struct ScaleAnimationDemo: View {
    @State private var scale = 1.0
    
    var body: some View {
        VStack {
            Button("缩放") {
                withAnimation(.spring()) {
                    scale = scale == 1.0 ? 1.5 : 1.0
                }
            }
            
            Circle()
                .fill(.red)
                .frame(width: 100, height: 100)
                .scaleEffect(scale)
        }
    }
}

3. 旋转动画

旋转动画通过改变视图的旋转角度来实现,可以使用 .rotationEffect() 修饰符。

struct RotationAnimationDemo: View {
    @State private var rotation = 0.0
    
    var body: some View {
        VStack {
            Button("旋转") {
                withAnimation(.easeInOut(duration: 1.0)) {
                    rotation += 360
                }
            }
            
            Rectangle()
                .fill(.yellow)
                .frame(width: 100, height: 100)
                .rotationEffect(.degrees(rotation))
        }
    }
}

4. 位移动画

位移动画通过改变视图的位置来实现,可以使用 .offset() 修饰符。

struct OffsetAnimationDemo: View {
    @State private var offset = CGSize.zero
    
    var body: some View {
        VStack {
            Button("移动") {
                withAnimation(.interactiveSpring()) {
                    offset = offset == .zero ? CGSize(width: 100, height: 50) : .zero
                }
            }
            
            Rectangle()
                .fill(.blue)
                .frame(width: 100, height: 100)
                .offset(offset)
        }
    }
}

5. 颜色动画

颜色动画通过改变视图的颜色来实现,可以直接动画化颜色属性。

struct ColorAnimationDemo: View {
    @State private var color = Color.blue
    
    var body: some View {
        VStack {
            Button("变色") {
                withAnimation(.easeInOut(duration: 1.0)) {
                    color = color == .blue ? .red : .blue
                }
            }
            
            Rectangle()
                .fill(color)
                .frame(width: 200, height: 100)
                .cornerRadius(10)
        }
    }
}

6. 组合动画

组合动画是将多种动画效果结合在一起,可以同时应用多个动画修饰符。

struct CombinedAnimationDemo: View {
    @State private var scale = 1.0
    @State private var rotation = 0.0
    @State private var opacity = 1.0
    
    var body: some View {
        VStack {
            Button("组合动画") {
                withAnimation(.easeInOut(duration: 1.0)) {
                    scale = scale == 1.0 ? 1.2 : 1.0
                    rotation = rotation == 0 ? 45 : 0
                    opacity = opacity == 1.0 ? 0.5 : 1.0
                }
            }
            
            Rectangle()
                .fill(.green)
                .frame(width: 100, height: 100)
                .scaleEffect(scale)
                .rotationEffect(.degrees(rotation))
                .opacity(opacity)
        }
    }
}

过渡效果

过渡效果是在视图出现或消失时应用的动画,可以使用 .transition() 修饰符。

struct TransitionDemo: View {
    @State private var isVisible = false
    
    var body: some View {
        VStack {
            Button("切换视图") {
                withAnimation {
                    isVisible.toggle()
                }
            }
            
            if isVisible {
                Text("滑入视图")
                    .transition(.slide)
            }
        }
    }
}

SwiftUI 提供了多种内置过渡效果:

过渡效果 描述
.opacity 淡入淡出
.slide 从边缘滑入/滑出
.scale 缩放出现/消失
.move(edge:) 从指定方向移动
.asymmetric(insertion:removal:) 不对称过渡(出现和消失用不同效果)

动画曲线

动画曲线定义了动画的速度变化,可以使用不同的动画曲线来实现不同的视觉效果。

常用动画曲线

曲线 描述
.linear 线性动画,速度保持不变
.easeIn 缓入动画,开始慢,逐渐加快
.easeOut 缓出动画,开始快,逐渐减慢
.easeInOut 缓入缓出动画,开始慢,中间快,结束慢
.spring() 弹簧动画,有弹性效果
.interactiveSpring() 交互式弹簧动画,响应更灵敏

示例代码

// 线性动画
withAnimation(.linear(duration: 1.0)) {
    // 动画代码
}

// 弹簧动画
withAnimation(.spring(response: 0.6, dampingFraction: 0.8, blendDuration: 0)) {
    // 动画代码
}

// 可重复动画
withAnimation(.easeInOut(duration: 1.0).repeatCount(3, autoreverses: true)) {
    // 动画代码
}

实践示例:完整动画演示

以下是一个完整的动画演示示例,包含了各种动画类型和过渡效果:

import SwiftUI

struct AnimationAndTransitionDemo: View {
    // 状态管理
    @State private var isVisible = false
    @State private var scale = 1.0
    @State private var rotation = 0.0
    @State private var opacity = 1.0
    @State private var offset = CGSize.zero
    @State private var color = Color.blue
    @State private var selectedCurve = "linear"
    
    let curveOptions = ["linear", "easeIn", "easeOut", "easeInOut", "spring"]
    
    var body: some View {
        ScrollView {
            VStack(spacing: 25) {
                // 标题
                Text("动画与过渡")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 动画曲线选择
                VStack {
                    Text("动画曲线选择")
                        .font(.headline)
                    Picker("曲线", selection: $selectedCurve) {
                        ForEach(curveOptions, id: \.self) { option in
                            Text(option).tag(option)
                        }
                    }
                    .pickerStyle(.segmented)
                }
                
                // 淡入淡出动画
                VStack {
                    Text("1. 淡入淡出")
                        .font(.headline)
                    Button("显示/隐藏") {
                        withAnimation(getAnimation()) {
                            isVisible.toggle()
                        }
                    }
                    if isVisible {
                        Text("Hello, Animation!")
                            .padding()
                            .background(Color.orange)
                            .cornerRadius(8)
                            .transition(.opacity)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 缩放动画
                VStack {
                    Text("2. 缩放动画")
                        .font(.headline)
                    Button("缩放") {
                        withAnimation(getAnimation()) {
                            scale = scale == 1.0 ? 1.5 : 1.0
                        }
                    }
                    Circle()
                        .fill(.red)
                        .frame(width: 80, height: 80)
                        .scaleEffect(scale)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 旋转动画
                VStack {
                    Text("3. 旋转动画")
                        .font(.headline)
                    Button("旋转") {
                        withAnimation(getAnimation()) {
                            rotation += 360
                        }
                    }
                    Rectangle()
                        .fill(.yellow)
                        .frame(width: 80, height: 80)
                        .rotationEffect(.degrees(rotation))
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 位移动画
                VStack {
                    Text("4. 位移动画")
                        .font(.headline)
                    Button("移动") {
                        withAnimation(getAnimation()) {
                            offset = offset == .zero ? CGSize(width: 100, height: 50) : .zero
                        }
                    }
                    Rectangle()
                        .fill(.blue)
                        .frame(width: 80, height: 80)
                        .offset(offset)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 颜色动画
                VStack {
                    Text("5. 颜色动画")
                        .font(.headline)
                    Button("变色") {
                        withAnimation(getAnimation()) {
                            color = color == .blue ? .red : .blue
                        }
                    }
                    Rectangle()
                        .fill(color)
                        .frame(width: 150, height: 80)
                        .cornerRadius(10)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 组合动画
                VStack {
                    Text("6. 组合动画")
                        .font(.headline)
                    Button("组合动画") {
                        withAnimation(getAnimation()) {
                            scale = scale == 1.0 ? 1.2 : 1.0
                            rotation = rotation == 0 ? 45 : 0
                            opacity = opacity == 1.0 ? 0.5 : 1.0
                        }
                    }
                    Rectangle()
                        .fill(.green)
                        .frame(width: 80, height: 80)
                        .scaleEffect(scale)
                        .rotationEffect(.degrees(rotation))
                        .opacity(opacity)
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                
                // 过渡效果
                VStack {
                    Text("7. 过渡效果(Slide)")
                        .font(.headline)
                    Button("切换视图") {
                        withAnimation(getAnimation()) {
                            isVisible.toggle()
                        }
                    }
                    if isVisible {
                        Text("滑入视图")
                            .padding()
                            .background(Color.purple)
                            .foregroundColor(.white)
                            .cornerRadius(8)
                            .transition(.slide)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
    
    // 根据选择的曲线返回对应的动画
    private func getAnimation() -> Animation {
        switch selectedCurve {
        case "linear":
            return .linear(duration: 0.8)
        case "easeIn":
            return .easeIn(duration: 0.8)
        case "easeOut":
            return .easeOut(duration: 0.8)
        case "easeInOut":
            return .easeInOut(duration: 0.8)
        case "spring":
            return .spring(response: 0.6, dampingFraction: 0.7)
        default:
            return .easeInOut(duration: 0.8)
        }
    }
}

#Preview {
    AnimationAndTransitionDemo()
}

常见问题与解决方案

1. 动画不生效

问题:状态变化了,但没有动画效果。

解决方案:确保状态变化被包裹在 withAnimation 函数中。

// 错误 ❌
isVisible.toggle()

// 正确 ✅
withAnimation {
    isVisible.toggle()
}

2. 动画效果不符合预期

问题:动画效果不够流畅或不符合预期。

解决方案:尝试使用不同的动画曲线,如 .spring().easeInOut(),并调整动画时长。

// 使用弹簧动画获得更自然的弹性效果
withAnimation(.spring(response: 0.5, dampingFraction: 0.6)) {
    // 状态变化
}

3. 过渡效果不显示

问题:视图出现或消失时没有过渡效果。

解决方案:确保为视图添加了 .transition() 修饰符,并且状态变化在 withAnimation 中。

if isVisible {
    Text("Hello")
        .transition(.slide)  // 必须添加 transition
}

4. 动画卡顿或掉帧

问题:动画执行时界面卡顿。

解决方案

  • 避免在动画中同时改变过多属性
  • 对于复杂视图,考虑使用 .drawingGroup() 优化渲染
  • 确保动画中不执行耗时操作

总结

本章介绍了 SwiftUI 中的动画与过渡效果,包括:

  • 基本动画类型:淡入淡出、缩放、旋转、位移、颜色动画
  • 组合动画:同时应用多种动画效果
  • 过渡效果:视图出现/消失时的动画(.transition
  • 动画曲线:线性、缓入、缓出、弹簧等不同速度曲线
  • 实践示例:完整的动画演示应用

通过这些动画效果,可以使应用界面更加生动有趣,提升用户体验。在实际开发中,合理使用动画可以为应用增添活力,使界面交互更加自然流畅。


参考资料


本内容为《SwiftUI 高级教程》第二章,欢迎关注后续更新。

《SwiftUI 进阶学习第1章:高级视图组件》

作者 90后晨仔
2026年4月18日 22:40

Snip20260418_5.png

概述

本章介绍 SwiftUI 中的高级视图组件,包括日期选择器、时间选择器、分段控件、滑块、步进器、活动指示器、进度视图和列表分组等。这些组件可以帮助您构建更加丰富和交互性更强的用户界面。

学习目标

  • 掌握各种高级视图组件的使用方法
  • 了解如何配置和自定义这些组件
  • 能够在实际项目中应用这些组件

核心组件

1. 日期选择器 (DatePicker)

功能说明

  • 可以只显示日期部分
  • 可以同时显示日期和时间
  • 支持多种显示样式

代码示例

DatePicker(
    "选择日期",
    selection: $selectedDate,
    displayedComponents: .date
)
.padding()
.background(.gray.opacity(0.1))
.cornerRadius(10)

2. 时间选择器 (DatePicker)

功能说明

  • 可以只显示小时和分钟
  • 支持24小时和12小时制

代码示例

DatePicker(
    "选择时间",
    selection: $selectedTime,
    displayedComponents: .hourAndMinute
)
.padding()
.background(.blue.opacity(0.1))
.cornerRadius(10)

3. 分段控件 (Picker)

功能说明

  • 使用 .segmented 样式
  • 支持多个选项
  • 可以绑定到状态变量

代码示例

Picker("选择选项", selection: .constant(0)) {
    Text("选项1").tag(0)
    Text("选项2").tag(1)
    Text("选项3").tag(2)
}
.pickerStyle(.segmented)
.padding()
.background(.green.opacity(0.1))
.cornerRadius(10)

4. 滑块 (Slider)

功能说明

  • 可以设置最小值和最大值
  • 支持步长
  • 可以显示当前值

代码示例

HStack {
    Text("音量: \(Int(progress * 100))%")
    Slider(value: $progress, in: 0...1)
}
.padding()
.background(.yellow.opacity(0.1))
.cornerRadius(10)

5. 步进器 (Stepper)

功能说明

  • 可以设置最小值、最大值和步长
  • 可以显示当前值
  • 支持自定义标签

代码示例

Stepper(
    "数量: \(Int(progress * 10))",
    value: $progress,
    in: 0...1,
    step: 0.1
)
.padding()
.background(.purple.opacity(0.1))
.cornerRadius(10)

6. 活动指示器 (ProgressView)

功能说明

  • 可以显示文本
  • 可以设置样式
  • 适合在异步操作时使用

代码示例

if isPlaying {
    ProgressView("加载中...")
        .padding()
}

7. 进度视图 (ProgressView)

功能说明

  • 可以设置当前值和总值
  • 支持动画效果
  • 适合显示下载、上传等进度

代码示例

ProgressView(value: progress)
    .padding()

Button("更新进度") {
    withAnimation {
        progress = progress < 1.0 ? progress + 0.1 : 0.0
    }
}

8. 列表分组 (List)

功能说明

  • 支持多个分组
  • 可以添加分组标题
  • 适合显示分类数据

代码示例

List {
    Section(header: Text("水果")) {
        Text("苹果")
        Text("香蕉")
        Text("橙子")
    }
    
    Section(header: Text("蔬菜")) {
        Text("西红柿")
        Text("黄瓜")
        Text("土豆")
    }
}
.frame(height: 200)

综合示例

以下是一个完整的高级视图组件演示:

struct AdvancedViewsDemo: View {
    // 状态管理
    @State private var selectedDate = Date()
    @State private var selectedTime = Date()
    @State private var isPlaying = false
    @State private var progress = 0.0
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                // 标题
                Text("高级视图组件")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .foregroundColor(.blue)
                
                // 日期选择器
                VStack {
                    Text("1. 日期选择器")
                        .font(.headline)
                        .fontWeight(.semibold)
                    
                    DatePicker(
                        "选择日期",
                        selection: $selectedDate,
                        displayedComponents: .date
                    )
                    .padding()
                    .background(.gray.opacity(0.1))
                    .cornerRadius(10)
                }
                
                // 其他组件...
            }
            .padding()
        }
    }
}

最佳实践

  1. 响应式设计:确保组件在不同屏幕尺寸上都能正常显示
  2. 用户体验:为组件添加适当的标签和提示
  3. 性能优化:对于复杂列表,考虑使用 ListForEach 的性能优化技巧
  4. 可访问性:确保组件支持 VoiceOver 等辅助功能
  5. 自定义样式:根据应用的设计风格自定义组件的外观

总结

本章介绍了 SwiftUI 中的各种高级视图组件,包括日期选择器、时间选择器、分段控件、滑块、步进器、活动指示器、进度视图和列表分组。这些组件是构建现代 iOS 应用界面的重要工具,掌握它们的使用方法对于开发高质量的 SwiftUI 应用至关重要。

通过本章的学习,您应该能够:

  • 熟练使用各种高级视图组件
  • 根据实际需求配置和自定义组件
  • 构建具有良好用户体验的界面
  • 应用最佳实践来提高应用质量

参考资料


本内容为《SwiftUI 进阶学习》第一章,欢迎关注后续更新。

下标对中的最大距离

2021年5月9日 13:39

方法一:双指针

提示 $1$

考虑遍历下标对中的某一个下标,并寻找此时所有有效坐标对中距离最大的另一个下标。暴力遍历另一个下标显然不满足时间复杂度要求,此时是否存在一些可以优化寻找过程的性质?

思路与算法

不失一般性,我们遍历 $\textit{nums}_2$ 中的下标 $j$,同时寻找此时符合要求的 $\textit{nums}_1$ 中最小的下标 $i$。

假设下标 $j$ 对应的最小下标为 $i$,当 $j$ 变为 $j + 1$ 时,由于 $\textit{nums}_2$ 非递增,即 $\textit{nums}_2[j] \ge \textit{nums}_2[j+1]$,那么 $\textit{nums}_1$ 中可取元素的上界不会增加。同时由于 $\textit{nums}_1$ 也非递增,因此 $j + 1$ 对应的最小下标 $i'$ 一定满足 $i' \ge i$。

那么我们就可以在遍历 $j$ 的同时维护对应的 $i$,并用 $\textit{res}$ 来维护下标对 $(i, j)$ 的最大距离。我们将 $\textit{res}$ 初值置为 $0$,这样即使存在 $\textit{nums}_1[i] \le \textit{nums}_2[j]$ 但 $i > j$ 这种不符合要求的情况,由于此时距离为负因而不会对结果产生影响(不存在时也返回 $0$)。

另外,在维护最大距离的时候要注意下标 $i$ 的合法性,即 $i < n_1$,其中 $n_1$ 为 $\textit{nums}_1$ 的长度。

代码

###C++

class Solution {
public:
    int maxDistance(vector<int>& nums1, vector<int>& nums2) {
        int n1 = nums1.size();
        int n2 = nums2.size();
        int i = 0;
        int res = 0;
        for (int j = 0; j < n2; ++j){
            while (i < n1 && nums1[i] > nums2[j]){
                ++i;
            }
            if (i < n1){
                res = max(res, j - i);
            }
        }
        return res;
    }
};

###Python

class Solution:
    def maxDistance(self, nums1: List[int], nums2: List[int]) -> int:
        n1, n2 = len(nums1), len(nums2)
        i = 0
        res = 0
        for j in range(n2):
            while i < n1 and nums1[i] > nums2[j]:
                i += 1
            if i < n1:
                res = max(res, j - i)
        return res

###Java

class Solution {
    public int maxDistance(int[] nums1, int[] nums2) {
        int n1 = nums1.length;
        int n2 = nums2.length;
        int i = 0;
        int res = 0;
        
        for (int j = 0; j < n2; j++) {
            while (i < n1 && nums1[i] > nums2[j]) {
                i++;
            }
            if (i < n1) {
                res = Math.max(res, j - i);
            }
        }
        
        return res;
    }
}

###C#

public class Solution {
    public int MaxDistance(int[] nums1, int[] nums2) {
        int n1 = nums1.Length;
        int n2 = nums2.Length;
        int i = 0;
        int res = 0;
        
        for (int j = 0; j < n2; j++) {
            while (i < n1 && nums1[i] > nums2[j]) {
                i++;
            }
            if (i < n1) {
                res = Math.Max(res, j - i);
            }
        }
        
        return res;
    }
}

###Go

func maxDistance(nums1 []int, nums2 []int) int {
    n1 := len(nums1)
    n2 := len(nums2)
    i := 0
    res := 0
    
    for j := 0; j < n2; j++ {
        for i < n1 && nums1[i] > nums2[j] {
            i++
        }
        if i < n1 {
            if j-i > res {
                res = j - i
            }
        }
    }
    
    return res
}

###C

int maxDistance(int* nums1, int nums1Size, int* nums2, int nums2Size) {
    int i = 0;
    int res = 0;
    
    for (int j = 0; j < nums2Size; j++) {
        while (i < nums1Size && nums1[i] > nums2[j]) {
            i++;
        }
        if (i < nums1Size) {
            if (j - i > res) {
                res = j - i;
            }
        }
    }
    
    return res;
}

###JavaScript

var maxDistance = function(nums1, nums2) {
    const n1 = nums1.length;
    const n2 = nums2.length;
    let i = 0;
    let res = 0;
    
    for (let j = 0; j < n2; j++) {
        while (i < n1 && nums1[i] > nums2[j]) {
            i++;
        }
        if (i < n1) {
            res = Math.max(res, j - i);
        }
    }
    
    return res;
};

###TypeScript

function maxDistance(nums1: number[], nums2: number[]): number {
    const n1 = nums1.length;
    const n2 = nums2.length;
    let i = 0;
    let res = 0;
    
    for (let j = 0; j < n2; j++) {
        while (i < n1 && nums1[i] > nums2[j]) {
            i++;
        }
        if (i < n1) {
            res = Math.max(res, j - i);
        }
    }
    
    return res;
};

###Rust

impl Solution {
    pub fn max_distance(nums1: Vec<i32>, nums2: Vec<i32>) -> i32 {
        let n1 = nums1.len();
        let n2 = nums2.len();
        let mut i = 0;
        let mut res = 0;
        
        for j in 0..n2 {
            while i < n1 && nums1[i] > nums2[j] {
                i += 1;
            }
            if i < n1 {
                res = res.max((j as i32) - (i as i32));
            }
        }
        
        res
    }
}

复杂度分析

  • 时间复杂度:$O(n_1 + n_2)$,其中 $n_1, n_2$ 分别为 $\textit{nums}_1$ 与 $\textit{nums}_2$ 的长度。在双指针寻找最大值的过程中,我们最多会遍历两个数组各一次。

  • 空间复杂度:$O(1)$,我们使用了常数个变量进行遍历。

java 经典双指针

作者 SweetTea
2021年5月9日 12:30

双指针p1、p2指向两数组的首元素,从左向右遍历。
因为i <= j 且 nums1[i] <= nums2[j]才有效,所以nums1[p1] > nums2[p2]无效,并且p1要始终保持<=p2,
所以如果p1 == p2的时候,两个指针都向后移动一格,否则p2不动p1向后移动

class Solution {
   public int maxDistance(int[] nums1, int[] nums2) {
        int p1 = 0;
        int p2 = 0;
        int res = 0;
        while (p1 < nums1.length && p2 <nums2.length){
            if(nums1[p1] > nums2[p2]){  //无效
                if(p1 == p2){
                    p1++;
                    p2++;
                }else p1++;
            }else {     //有效
                res =Math.max(res,p2-p1);
                p2++;
            }
        }
        return res;
    }
}

双指针(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2021年5月9日 12:09

枚举 $j$,随着 $j$ 的变大,$\textit{nums}_2[j]$ 变小,满足要求的最小的 $i$ 随之变大。

所以可以用双指针做:如果 $j$ 变大后,发现 $\textit{nums}_1[i] > \textit{nums}_2[j]$,那么增大 $i$,直到 $i=n$ 或者 $\textit{nums}_1 \le \textit{nums}_2[j]$ 为止。然后用 $j-i$ 更新答案的最大值(答案初始为 $0$)。

无需担心 $j-i < 0$,这不会影响答案。

class Solution:
    def maxDistance(self, nums1: List[int], nums2: List[int]) -> int:
        ans = i = 0
        for j, y in enumerate(nums2):
            while i < len(nums1) and nums1[i] > y:
                i += 1
            if i == len(nums1):
                break
            ans = max(ans, j - i)
        return ans
class Solution {
    public int maxDistance(int[] nums1, int[] nums2) {
        int ans = 0;
        int i = 0;
        for (int j = 0; j < nums2.length; j++) {
            while (i < nums1.length && nums1[i] > nums2[j]) {
                i++;
            }
            if (i == nums1.length) {
                break;
            }
            ans = Math.max(ans, j - i);
        }
        return ans;
    }
}
class Solution {
public:
    int maxDistance(vector<int>& nums1, vector<int>& nums2) {
        int ans = 0;
        int i = 0;
        for (int j = 0; j < nums2.size(); j++) {
            while (i < nums1.size() && nums1[i] > nums2[j]) {
                i++;
            }
            if (i == nums1.size()) {
                break;
            }
            ans = max(ans, j - i);
        }
        return ans;
    }
};
int maxDistance(int* nums1, int nums1Size, int* nums2, int nums2Size) {
    int ans = 0;
    int i = 0;
    for (int j = 0; j < nums2Size; j++) {
        int y = nums2[j];
        while (i < nums1Size && nums1[i] > y) {
            i++;
        }
        if (i == nums1Size) {
            break;
        }
        ans = MAX(ans, j - i);
    }
    return ans;
}
func maxDistance(nums1, nums2 []int) (ans int) {
i := 0
for j, y := range nums2 {
for i < len(nums1) && nums1[i] > y {
i++
}
if i == len(nums1) {
break
}
ans = max(ans, j-i)
}
return
}
var maxDistance = function(nums1, nums2) {
    let ans = 0;
    let i = 0;
    for (let j = 0; j < nums2.length; j++) {
        while (i < nums1.length && nums1[i] > nums2[j]) {
            i++;
        }
        if (i === nums1.length) {
            break;
        }
        ans = Math.max(ans, j - i);
    }
    return ans;
};
impl Solution {
    pub fn max_distance(nums1: Vec<i32>, nums2: Vec<i32>) -> i32 {
        let mut ans = 0;
        let mut i = 0;
        for (j, y) in nums2.into_iter().enumerate() {
            while i < nums1.len() && nums1[i] > y {
                i += 1;
            }
            if i == nums1.len() {
                break;
            }
            ans = ans.max(j as i32 - i as i32);
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n+m)$,其中 $n$ 是 $\textit{nums}_1$ 的长度,$m$ 是 $\textit{nums}_2$ 的长度。虽然我们写了个二重循环,但 $i$ 最多自增 $n$ 次,所以二重循环的循环次数至多为 $n+m$。
  • 空间复杂度:$\mathcal{O}(1)$。

专题训练

见下面双指针题单的「四、双序列双指针」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

昨天 — 2026年4月18日首页

git fetch vs git pull: What Is the Difference?

Sooner or later, every Git user runs into the same question: should you use git fetch or git pull to get the latest changes from the remote? The two commands look similar, and both talk to the remote repository, but they do very different things to your working branch.

This guide explains the difference between git fetch and git pull, how each command works, and when to use one over the other.

Quick Reference

If you want to… Use
Download remote changes without touching your branch git fetch
Download and immediately integrate remote changes git pull
Preview incoming commits before merging git fetch + git log HEAD..origin/main --oneline
Update your branch but refuse merge commits git pull --ff-only
Rebase local commits on top of remote changes git pull --rebase

What git fetch Does

git fetch downloads commits, branches, and tags from a remote repository and stores them locally under remote-tracking references such as origin/main or origin/feature-x. It does not touch your working directory, your current branch, or the index.

Terminal
git fetch origin
output
remote: Enumerating objects: 12, done.
remote: Counting objects: 100% (12/12), done.
From github.com:example/project
4a1c2e3..9f7b8d1 main -> origin/main
a6d4c02..1e2f3a4 feature-x -> origin/feature-x

The output shows which remote branches were updated. After the fetch, the local branch main is still pointing to whatever commit it was on before. Only the remote-tracking branch origin/main has moved.

You can then inspect what changed before doing anything with it:

Terminal
git log main..origin/main

This is the safe way to look at new work without merging or rebasing yet. It gives you a preview of what the remote has that your branch does not.

What git pull Does

git pull is a convenience command. Under the hood, it runs git fetch followed by an integration step (a merge by default, or a rebase if configured). In other words:

Terminal
git pull origin main

is roughly equivalent to:

Terminal
git fetch origin
git merge origin/main

After git pull, your current branch has moved forward, and your working directory may contain new files, updated files, or merge conflicts that need resolving. The change is immediate and affects your checked-out branch.

Side-By-Side Comparison

The difference between the two commands comes down to what they change in your repository.

Aspect git fetch git pull
Downloads new commits from remote Yes Yes
Updates remote-tracking branches (origin/*) Yes Yes
Updates your current local branch No Yes
Modifies the working directory No Yes
Can create merge conflicts No Yes
Safe to run at any time Yes Only when ready to integrate

git fetch is read-only from your branch’s perspective. git pull actively changes your branch.

When to Use git fetch

Use git fetch when you want to see what changed on the remote without integrating anything yet. This is useful before you start new work, before rebasing a feature branch, or when you want to inspect a teammate’s branch safely.

For example, after fetching, you can compare your branch with the remote like this:

Terminal
git log HEAD..origin/main --oneline

This shows commits that exist on the remote but not in your current branch. Running git fetch often is cheap and has no side effects on your work, which is why many developers do it regularly throughout the day.

When to Use git pull

Use git pull when you are ready to bring remote changes into your current branch and keep working on top of them. The common flow looks like this:

Terminal
git checkout main
git pull origin main

This is the quickest way to sync a branch with its upstream when you know the integration will be straightforward, such as on a shared main branch where you rarely have local commits. git pull always updates the branch you currently have checked out, so it is worth confirming where you are before you run it.

If you want a cleaner history without merge commits, configure pull to rebase:

Terminal
git config --global pull.rebase true

From that point on, git pull runs git fetch followed by git rebase instead of git merge. Your local commits are replayed on top of the fetched changes, producing a linear history.

Avoiding Merge Surprises

The biggest reason to prefer git fetch over git pull in day-to-day work is control. A bare git pull on a branch where you have local commits can create a merge commit, introduce conflicts, or rewrite history during a rebase, all in one step.

A safer pattern is:

Terminal
git fetch origin
git log HEAD..origin/main --oneline
git merge origin/main

You fetch first, review what is coming, then decide whether to merge, rebase, or defer the integration. For active feature branches, this workflow avoids most of the “what just happened to my branch” moments.

If you already ran git pull and need to inspect what happened, start with:

Terminal
git log --oneline --decorate -n 5

This helps you confirm whether Git created a merge commit, fast-forwarded the branch, or rebased your local work.

If the pull created a merge commit that you do not want, ORIG_HEAD often points to the commit your branch was on before the pull. In that case, you can reset back to it:

Terminal
git reset --hard ORIG_HEAD
Warning
git reset --hard discards uncommitted changes. Use it only when you are sure you do not need the current state of your working tree.

Useful Flags

A few flags make both commands more predictable in real workflows.

  • git fetch --all - Fetch from every configured remote, not only origin.
  • git fetch --prune - Remove remote-tracking branches that no longer exist on the remote.
  • git fetch --tags - Download tags in addition to branches.
  • git pull --rebase - Rebase your local commits on top of the fetched changes instead of merging.
  • git pull --ff-only - Update the branch only if Git can fast-forward it; prevents unexpected merge commits.

--ff-only is especially useful on shared branches. If your branch cannot move forward by simply advancing the pointer, the pull fails and you decide what to do next.

FAQ

Does git fetch modify any local files?
No. It only updates remote-tracking references such as origin/main. Your branches, working directory, and staging area stay the same.

Is git pull the same as git fetch plus git merge?
By default, yes. With pull.rebase set to true (or --rebase on the command line), it runs git fetch followed by git rebase instead.

Which one should I use daily?
Prefer git fetch for visibility and git pull --ff-only (or git pull --rebase) for the integration step. A bare git pull works, but it hides two operations behind one command.

How do I see what git fetch downloaded?
Use git log main..origin/main to see commits on the remote that are not yet in your local branch, or git diff main origin/main to compare the content directly.

Can git fetch cause conflicts?
No. Conflicts only happen when you merge or rebase the fetched commits into your branch. Fetching itself is conflict-free.

Conclusion

git fetch lets you inspect remote changes without touching your branch, while git pull fetches and integrates those changes in one step. If you want more control and fewer surprises, fetch first, review the incoming commits, and then merge or rebase on your own terms. For more Git workflow details, see the git log command and git diff command guides.

运行时vs编译时:CSS in JS四种主流方案介绍和对比

作者 漂流瓶jz
2026年4月18日 18:44

CSS作为前端代码中的重要组成部分,在工程中一般是以独立CSS文件的形式存在的。而CSS in JS,顾名思义,是在JavaScript中写CSS代码。尤其是React框架的流行,JavaScript和HTML模板都在JavaScript文件中描述了,只有CSS代码的组织还比较疏离。因此出现了很多CSS in JS的开源库,帮助我们将CSS放到JavaScript代码中,实现React组件代码的耦合性。

React工程示例

首先我们先展示一下React工程中是如何使用CSS的,以及React本身有没有CSS in JS的能力。

不使用CSS in JS

首先使用Vite创建React工程,执行命令行:

# 选择React
npm create vite@latest
# 安装依赖
npm install
# 启动开发服务
npm run dev

然后将src/App.jsx文件修改为如下内容,这是一个React组件。

import "./App.css";
export default function App() {
  return <div className="class1">你好 jzplp</div>;
}

然后是对应的src/App.css内容:

.class1 {
  color: red;
  font-size: 15px;
}

打开浏览器,可以看到对应的组件样式是生效的。这就是普通React工程中CSS的使用方式,在独立文件中被引用。像CSS Modules, Less和SCSS等也都是类似这种使用模式。那如果CSS样式本身需要根据不同的场景变化呢?可以根据不同的场景提供不同的类名。这里修改下App.jsx作为示例,就不展示App.css文件了。

import "./App.css";
export default function App({ state }) {
  return <div className={state === 1 ? "class1" : "class2"}>你好 jzplp</div>;
}

内联样式

使用类名控制样式变化可以生效,但这算是间接控制样式。有没有直接可以在JavaScript代码中控制CSS的方式呢?有的,React提供了内联样式,可以让我们直接控制:

import "./App.css";
export default function App({ state }) {
  return (
    <div
      style={{
        color: state === 1 ? "red" : "blue",
        fontSize: "14px",
      }}
    >
      你好 jzplp
    </div>
  );
}

对style属性设置为对象,对象的key是CSS属性驼峰形式,可以实现对HTML中内联样式的直接控制。看起来这样挺好用的,但是它的限制还是非常大。例如不能使用伪类或者媒体查询这种CSS规则。

直接操作DOM可以使用CSS规则,但这不优雅也失去了使用React的优势。因此,如果想要实现真正的CSS in JS,还是要看专门的工具。

styled-components初步

styled-components是最知名的CSS in JS工具,可以在React中使用。

接入方式

首先安装依赖styled-components,然后删除App.css,我们不再需要独立的CSS文件了。修改App.tsx:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  font-size: 14px;
`;
const Button = styled.button`
  color: blue;
  font-size: 14px;
`;

export default function App() {
  return (
    <div>
      <Div>你好 jzplp</Div>
      <Button>你好 jzplp</Button>
    </div>
  );
}

此时在浏览器上可以看到生效的结果。通过代码可以看到,styled-components是利用了‌ECMAScript中模板字符串的“标签模板字符串”特性。因此我们提供的CSS字符串可以被styled-components对应的函数解析,最终生成样式。

实现方式

上面的代码是如何生效的呢?这里我们修改一下代码,增加不同状态:

import styled from "styled-components";
import { useState } from "react";

const Div0 = styled.div``;
const Div = styled.div`
  color: red;
  font-size: 14px;
`;
const Button = styled.button`
  color: blue;
  font-size: 14px;
`;

export default function App() {
  const [state, setState] = useState(0);

  return (
    <div>
      <Div0>1你好 jzplp</Div0>
      <Div>2你好 jzplp</Div>
      {state % 2 === 1 && <Button>3你好 jzplp</Button>}
      <div onClick={() => setState(state + 1)}>按下+1</div>
    </div>
  );
}

css-in-js-1.png

首先看下初始状态的效果。这里有Div0和Div两个组件,都是用styled-components生成的,因此都有一个sc-开头的类名,但这个类名上并不包含样式。Div因为有了样式,所以有另一个类名,这个类名的具体样式写在head中的style标签里面。Button组件因为没有展示,所以对应的样式也没有注入。我们再切换状态试试:

css-in-js-2.png

切换状态之后,Button组件展示并附带着样式。注意style标签中增加了一行,正是Button的样式。此时如果再次切换状态将Button组件销毁,style标签中对应的样式并不会删除。

通过对于浏览器现象的观察,我们发现了styled-components的实现方式:当组件被渲染时,将JavaScript中的CSS属性集合放到style标签中,同时动态提供hash类名。将类名提供给HTML标签作为属性渲染。这样就实现了JavaScript控制CSS代码,且在组件被渲染时才注入CSS。此时我们执行如下命令:

npm run build
npm run preview

然后查看打包后生产模式的效果,发现style标签中并没有CSS代码了,但样式还是生效的,也是通过hash类名。且在浏览器调试工具上点击类的来源,也能点到那个style标签,只不过浏览器上看不到其中的内容。

传参方式

前面的例子中CSS代码全是字符串,但使用模板字符串除了能换行之外,好像也没有什么优势。使用标签模板字符串的优势在于传参。组件可以根据不同的传参切换样式:

import styled from "styled-components";

interface DivProps {
  bgColor: string;
  lineHeight?: number;
}

const Div = styled.div<DivProps>`
  color: red;
  background: ${props => props.bgColor};
  font-size: 14px;
  line-height: ${props => props.lineHeight || 20}px;
`;

export default function App() {
  return (
    <div>
      <Div bgColor="blue" lineHeight={30}>2你好 jzplp</Div>
      <Div bgColor="yellow">2你好 jzplp</Div>
    </div>
  );
}

可以看到,首先我们在styled对应标签的函数中增加了props入参的泛型TypeScript类型。然后在模板字符串的插值中传入函数,函数的入参为props,返回对应场景下的CSS代码值。由于我们使用的“标签模板字符串”功能,因此标签函数可以读取并处理模板字符串中的插值,最后整合成完整的CSS代码。我们看下浏览器效果:

css-in-js-3.png

可以看到,不同的入参会生成不同的类名。这里我有一个疑问,如果我们的入参一直在变化,会不会一直生成类名?我们试一下:

import styled from "styled-components";
import { useState } from "react";

interface DivProps {
  color: number;
}

const Div = styled.div<DivProps>`
  color: #${(props) => props.color};
`;

export default function App() {
  const [state, setState] = useState(0);
  return (
    <div>
      <Div color={state}>你好 {state}</Div>
      <div onClick={() => setState(state + 1)}>按下+1</div>
    </div>
  );
}

在上面代码中,点击一下state值加1,同时Div中的入参也会变化,生成的CSS值也会不一样。我们多点几次看看效果:

css-in-js-4.png

通过浏览器效果可以看到,我们每点击一次,就会生成一个新的类名和CSS规则。旧的CSS规则虽然永远不会被使用到了,但依然保存在浏览器中。不过代码其实不知道我们的CSS规则今后会不会被使用到。

styled-components特性

前面我们引入了styled-components,简单介绍了实现方式和传参。这里再介绍一下更多特性。

组件继承

类似于面向对象,使用styled-components生成的组件也有继承特性,子组件可以继承父组件的样式。我们举个例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
`;
const DivChild = styled(Div)`
  background: yellow;
`;

export default function App() {
  return (
    <div>
      <Div>你好 jzplp</Div>
      <DivChild>你好 jzplp</DivChild>
      <Div as="p">你好 jzplp</Div>
    </div>
  );
}

我们定义了Div父组件,DivChild继承并提供了自己的样式,通过结果可以看到,两个样式都生效了。第三个组件我们使用了as属性,它可以在使用预定义styled组件的同时,修改标签名。

css-in-js-5.png

父组件的props参数,子组件也是继承的,同时子组件也可以有自己的参数。我们看下例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  font-size:${(props) => props.size}px;
`;
const DivChild = styled(Div)`
  background: yellow;
  line-height: ${(props) => props.size + 10}px;
  border: ${(props) => props.borderSize}px solid blue;
`;

export default function App() {
  return (
    <div>
      <Div size={20}>你好 jzplp</Div>
      <DivChild size={30} borderSize={2}>你好 jzplp</DivChild>
    </div>
  );
}

css-in-js-6.png

通过结果可以看到,组件的参数和子组件都有自己的参数,子组件也可以使用父组件的参数,可以同时生效。

与React组件继承

前面我们看到的是styled组件互相的继承关系。事实上,它与普通React组件也可以互相继承。首先是普通React组件作为父组件:

import styled from "styled-components";

function Comp({ state, className }) {
  return <div className={className}>{state}</div>;
}

const DivComp = styled(Comp)`
  color: ${(props) => props.color};
  font-size: 20px;
`;

export default function App() {
  return (
    <div>
      <Comp state={1} />
      <DivComp state={2} color='red' />
    </div>
  );
}

css-in-js-7.png

可以看到,作为父组件的Comp组件,需要提供className参数,并在组件内部恰当位置作为属性。这样子组件和父组件的props都可以正常使用,styled-components会将接收到的属性透传给父组件。然后我们再看下普通React组件作为子组件的场景:

import styled from "styled-components";

const Div = styled.div`
  color: ${(props) => props.color};
  font-size: 20px;
`;

function Comp(props = {}) {
  return <Div {...props}>{props.state}</Div>;
}

export default function App() {
  return (
    <div>
      <Div color="red">1</Div>
      <Comp state={2} color="yellow" />
    </div>
  );
}

css-in-js-8.png

当普通React组件作为子组件时,我们需要手动处理透传需要的prop到子组件中。

嵌套选择器

前面使用React内联样式的时候,我们提到内联样式并不支持嵌套选择器,这其实是直接用React做CSS in JS的最大阻碍。 styled-components引入了stylis工具来处理,支持使用嵌套选择器。这里举下例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  &:hover {
    background: yellow;
  }
  &.class1 {
    border: 2px solid blue;
  }
  .class2 & {
    border: 2px solid green;
  }
`;

export default function App() {
  return (
    <div>
      <Div>1 jzplp</Div>
      <Div>2 jzplp</Div>
      <Div className="class1">3 jzplp</Div>
      <div className="class2">
        <Div>4 jzplp</Div>
      </div>
    </div>
  );
}

css-in-js-9.png

通过例子可以看到,无论是伪类,还是各种选择器都可以,其中使用&标识本标签的选择器。也能生效到子元素中,即使这个元素非styled组件:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  .class1 {
    border: 2px solid blue;
  }
`;

export default function App() {
  return (
    <div>
      <Div>
        <div className="class1">3 jzplp</div>
      </Div>
    </div>
  );
}

css-in-js-10.png

CSS片段

styled-components支持创建一个CSS片段,可以提供给组件使用,并且可以带参数,使用css方法即可。

import styled, { css } from "styled-components";

const jzCss = css`
  color: blue;
  font-size: ${props => props.size}px;
`

const Div = styled.div`
${
  props => {
    if(props.type === 1) return jzCss;
    else return `
      color: red;
      background: green;
    `;
  }
}
`;

export default function App() {
  return (
    <div>
      <Div type={1} size={20}>jzplp 1</Div>
      <Div type={1} size={30}>jzplp 2</Div>
      <Div type={2}>jzplp 3</Div>
    </div>
  );
}

css-in-js-11.png

可以看到代码中先创建了一个带参数的CSS片段,然后在组件字符串插值的返回中传入这个片段,参数可以正常生效渲染。else情况则直接返回了字符串CSS属性,看浏览器上也可以生效。那是不是说不需要css函数处理,直接返回模板字符串就可以了?这是肯定不行的,我们举个例子:

import styled from "styled-components";

const Div = styled.div`
  ${() => {
    return `
        color: blue;
        font-size: ${(props) => props.size}px;
      `;
  }}
`;

export default function App() {
  return (
    <div>
      <Div size={20}>jzplp 1</Div>
    </div>
  );
}

css-in-js-12.png

在浏览器中可以看到,模板字符串中的插值并没有被成功处理,而是被直接转为了普通字符串,最后成了错误的CSS代码。因此必须使用css函数创建CSS片段。

CSS动画

styled-components也支持创建@keyframes的CSS动画,同时在CSS中被引用。

import styled, { keyframes } from "styled-components";

const colorChange = keyframes`
  0% {
    color: red;
  }
  50% {
    color: blue;
  }
  100% {
    color: red;
  }
`;

const Div = styled.div`
  animation: ${colorChange} 2s infinite;
`;

export default function App() {
  return (
    <div>
      <Div>你好,jzplp</Div>
    </div>
  );
}

可以看到我们使用keyframes方法创建了一个keyframes动画,在需要的CSS位置中,当作animation-name属性插入动画即可。效果如下:

css-in-js-13.gif

这里只是简单介绍了styled-components的部分特性,如果希望深入了解请看styled-components相关文档。

@emotion/styled

下面来介绍一下Emotion这个库。这个库有好几种使用方式,首先我们从类似styled-components,即styled组件的使用方式开始介绍,主要使用@emotion/styled这个包。

接入方式

首先安装@emotion/styled依赖,然后修改src/App.jsx:

import styled from "@emotion/styled";

const Div = styled.div`
  color: red;
  background: ${(props) => props.bg};
`;

export default function App() {
  return (
    <div>
      <Div bg="blue">你好,jzplp</Div>
      <Div bg="yellow">你好,jzplp</Div>
    </div>
  );
}

上面代码的使用方式与styled-components一模一样,换个包名也能生效。效果也一样,区别在于开发模式下@emotion/styled有两个style标签,如下图。生产模式与styled-components一样,都是一个style标签且看不到内容。

css-in-js-14.png

CSS片段

不仅接入方式,@emotion/styled的大部分特性都和styled-components一样,包括继承,嵌套选择器等。但CSS片段有些不一样:

import styled from "@emotion/styled";
import { css } from '@emotion/react';

// 错误方式
const commonStyle1 = css`
  font-size: 20px;
  background: ${(props) => props.bg};
`;

const commonStyle2 = (props) => css`
  font-size: 20px;
  background: ${props.bg};
`;

const Div1 = styled.div`
  color: red;
  ${commonStyle1}
`;

const Div2 = styled.div`
  color: green;
  ${commonStyle2}
`;

export default function App() {
  return (
    <div>
      <Div1 bg="yellow">你好,jzplp1</Div1>
      <Div2 bg="yellow">你好,jzplp2</Div2>
    </div>
  );
}

css-in-js-15.png

首先CSS片段的函数是在另一个包@emotion/react中引入的。commonStyle1是用styled-components模式引入的,将函数直接放到CSS片段中,是不生效的。必须要将片段本身放到一个大函数内部才行,如commonStyle2的形式。

函数入参

@emotion/styled中的组件也支持函数作为入参,而非模板字符串。函数可以返回style对象,也能返回拼好的字符串。

import styled from "@emotion/styled";

const Div1 = styled.div((props) => {
  return {
    color: "red",
    background: props.bg,
  };
});

const Div2 = styled.div((props) => {
  return `
    color: pink;
    background: ${props.bg};
  `;
});

export default function App() {
  return (
    <div>
      <Div1 bg="yellow">你好,jzplp1</Div1>
      <Div1 bg="green">你好,jzplp2</Div1>
      <Div2 bg="yellow">你好,jzplp3</Div2>
      <Div2 bg="green">你好,jzplp4</Div2>
    </div>
  );
}

css-in-js-16.png

@emotion/react

接入css属性

Emotion除了styled组件使用方式之外,也可以使用组件css属性(css Prop)的使用方式。这种方式是在React中JSX的组件上增加一个css属性。因此这种方式需要修改React编译相关参数。这里以vite@8和@vitejs/plugin-react@6为例来说明。首先安装依赖@emotion/react。然后修改vite.config.js配置文件:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react({
      // 指定转换jsx语法的模块
      jsxImportSource: '@emotion/react',
    })],
})

然后就可以使用了。如果需要TypeScript类型正确提示,则需要修改tsconfig.json:

{
  "compilerOptions": {
    // types在原有基础上新增"@emotion/react/types/css-prop"
    "types": ["...", "@emotion/react/types/css-prop"],
    "jsxImportSource": "@emotion/react",
  },
}

然后我们修改App.jsx,内容如下:

export default function App() {
  return (
    <>
      <div
        css={{
          color: "red",
          "&:hover": {
            background: "green",
          },
        }}
      >
        你好 jzplp
      </div>
    </>
  );
}

可以看到,我们直接以对象的形式对组件JSX的css属性赋值,而且还包含hover伪类,最后在开发模式和生产模式都可以正常生效。

css-in-js-17.png

除了对象形式之外,css属性还支持CSS片段的形式:

import {css} from "@emotion/react";
export default function App() {
  return (
    <>
      <div
        css={css`
          color: red;
          &:hover {
            background: green;
          }
        `}
      >
        你好 jzplp
      </div>
    </>
  );
}

css属性继承

从前面的接入例子中我们看到,css属性还是通过class名的形式生效的。因此它的优先级低于style属性,即内联样式。如果父组件提供了css属性想让子组件生效,则需要传入className参数。那么如果父子组件都有css属性,他们的优先级如何呢?这里举个例子:

function Comp1({ className }) {
  return (
    <div
      css={{
        background: 'yellow',
        color: "red",
      }}
      className={className}
    >
      你好 jzplp
    </div>
  );
}

function Comp2() {
  return (
    <Comp1
      css={{
        fontSize: "20px",
        color: "blue",
      }}
    >
      你好 jzplp
    </Comp1>
  );
}

export default function App() {
  return (
    <>
      <Comp1 />
      <Comp2 />
    </>
  );
}

这里创建了两个组件,Comp1是子组件,Comp2是父组件。子组件和父组件中的CSS属性不冲突的可以同时生效,冲突属性以父组件的为准,例如这里的color。

css-in-js-18.png

样式对象

前面我们演示过,不管是styled组件,css属性还是CSS片段,都可以接收对象类型的样式数据。关于对象类型还有其它特性,这里我们一起描述一下。首先是数组类型,这里列举了两个例子:

import styled from "@emotion/styled";

const styleList = [
  {
    color: "red",
  },
  {
    fontSize: "20px",
  },
];

const Comp1 = styled.div(styleList);

function Comp2() {
  return <div css={styleList}>你好 jzplp2</div>;
}

export default function App() {
  return (
    <>
      <Comp1>你好 jzplp1</Comp1>
      <Comp2 />
    </>
  );
}

styled组件也同时支持多个入参:

import styled from "@emotion/styled";

const Comp1 = styled.div(
  {
    color: "red",
  },
  (props) => {
    return {
      fontSize: `${props.size}px`,
    };
  },
);

export default function App() {
  return <Comp1 size={20}>你好 jzplp1</Comp1>;
}

样式对象还支持回退值(默认值)。通过对属性值传入一个数组,最后面的值优先级最高,如果不存在则取前面的值:

function Div1({ color, children }) {
  return (
    <div
      css={{
        color: ["red", color],
      }}
    >{children}</div>
  );
}

export default function App() {
  return (
    <>
      <Div1>你好 jzplp1</Div1>
      <Div1 color="blue">你好 jzplp2</Div1>
    </>
  );
}

css-in-js-19.png

全局样式

@emotion/react支持创建全局样式,也是使用组件的形式。当组件被渲染时,全局样式生效,组件不被渲染时则不生效。

import { useState } from "react";
import { Global } from "@emotion/react";

export default function App() {
  const [state, setState] = useState(0);
  return (
    <>
      <Global
        styles={{
          ".class1": {
            color: "red",
          },
        }}
      />
      {!!(state % 2) && (
        <Global
          styles={{
            ".class2": {
              background: "yellow",
            },
          }}
        />
      )}
      <div className="class1 class2">你好 jzplp</div>
      <div onClick={() => setState(state + 1)}>按下变换</div>
    </>
  );
}

css-in-js-20.png

使用全局样式时,我们只要使用普通类名即可生效。当我们切换state的状态时,黄色的背景颜色也在变化。对应Global组件不渲染时,插入的CSS代码会被移除,渲染时会被引入。

@emotion/css

前面我们描述Emotion相关包,都是使用在React框架之内的。在React之外,还提供了@emotion/css包,用法和前面类似。

接入方式

首先我们创建一个工程接入@emotion/css。这里选用Vite。首先执行命令行:

npm init -y
npm add -D vite
npm add @emotion/css

然后在package.json的scripts中增加三个命令,分别是开发模式运行,打包和预览打包后的成果。

{
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
}

然后创建index.html,为浏览器入口文件,其中引入了index.js。

<html>
  <meta charset="UTF-8">
  <head>
    <title>jzplp的@emotion/css实验</title>
  </head>
  <body>
    <script src="./index.js" type="module"></script>
  </body>
</html>

然后是index.js的实现,引入了@emotion/css:

import { css } from '@emotion/css'

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const styles1 = css`
  color: red;
`;
const styles2 = css({
  color: 'blue',
});

console.log(styles1, styles2);
genEle('test1', styles1);
genEle('test2', styles2);

/* 输出结果
css-qm6gfa css-14ksm7b
*/

最后执行npm run dev命令,在浏览器查看结果。可以看到,我们和在React中使用一样,还是用模板字符串或者样式对象创建,但是创建后得到的结果是类名,我们直接放到DOM元素上即可。

css-in-js-21.png

样式数据

除了上面的模板字符串和样式对象之外,@emotion/css也支持数组类型以及嵌套选择器等属性,这里举例看一下。

import { css } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const styles1 = css`
  color: red;
  &:hover {
    background: yellow;
  }
`;
const styles2 = css([
  {
    color: "blue",
  },
  {
    "&:hover": {
      background: "yellow",
    },
  },
]);

console.log(styles1, styles2);
genEle("test1", styles1);
genEle("test2", styles2);

全局样式

@emotion/css也支持全局样式,但是引入方式不一样:

import { injectGlobal  } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

injectGlobal`
  .class1 {
    color: red;
    &:hover {
      background: yellow;
    } 
  }
`;

genEle("test1", 'class1');

cx优先级处理

@emotion/css提供了一个cx方法,它的作用和知名的classnames包一样,都是合并class类名的。但是这个cx提供了优先级功能,即后面的类中的样式优先级比前面的高:

import { cx, css } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const cls1 = css`
  font-size: 20px;
  background: green;
`;
const cls2 = css`
  font-size: 20px;
  background: blue;
`;

genEle("test1", cx(cls1, cls2));
genEle("test2", cx(cls2, cls1));

这里我们创建了两个样式,其中背景颜色是冲突的。然后我创建了两个div,使用cx方法合并类名,但是连接各个div合并的顺序相反。在浏览器查看效果发现,实际展示的背景色也不一样,以后面的类名为准。

css-in-js-22.png

linaria与React

前面介绍的styled-components和@emotion,都是运行时CSS,即对应的生成样式的代码执行到的时候,这段CSS才会生成。因此这种库需要在打包后的代码中保留注入CSS的逻辑。还有另一类CSS in JS的库是零运行时的,即编译时生成CSS文件,在生产代码中直接引入即可,无需额外注入。这里先介绍linaria,它就是一个零运行时的库。

接入方式

我们依然以vite为例接入。linaria的背后依赖WyW-in-JS,它是一个零运行时CSS库的辅助工具包。首先执行命令行:

npm create vite@latest
npm add @linaria/core @linaria/react @wyw-in-js/vite

修改vite.config.ts,增加wyw配置:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import wyw from '@wyw-in-js/vite';

export default defineConfig({
  plugins: [react(), wyw()],
})

然后修改App.tsx,接入linaria:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: red;
`;

const Div2 = styled.div`
  color: blue;
  &:hover {
    background: yellow;
  }
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

可以看到,这个使用方式就是styled组件的使用方式,和前面的库基本一致。以开发模式在浏览器中运行效果如下:

css-in-js-23.png

零运行时特性

在前面的接入方式中,我们并没有看到它与其它CSS in JS库的不同点。这一节我们专门看一下零运行时和前面的库究竟有什么不一样。首先修改App.tsx:

import { useState } from "react";
import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: red;
`;

const Div2 = styled.div`
  color: blue;
  &:hover {
    background: yellow;
  }
`;

export default function App() {
  const [state, setState] = useState(0);

  return (
    <div>
      <Div1>jzplp1</Div1>
      {state % 2 === 1 && <Div2>jzplp2</Div2>}
      <div onClick={() => setState(state + 1)}>按下切换</div>
      <div>当前state {state}</div>
    </div>
  );
}

在代码中我们设置了变化的state状态,一开始为0时不展示Div2,当按下切换时,Div2才被执行和创建。注意当一开始Div2没被执行的时候,运行时的CSS in JS库是不会创建Div2对应的CSS代码的(因为代码都没执行到那里)。但linaria却会将CSS代码全都创建和引入,即使这些代码没有被使用。我们看下初始化时,linaria的效果:

css-in-js-24.png

注意看浏览器网络请求中有一个CSS请求,它的内容为当前引入的CSS代码,包括没被创建的元素的CSS代码:

css-in-js-25.png

然后我们将代码打包(npm run build)后,查看一下打包文件。可以看到我们在JavaScript中写的CSS代码,已经被编译成一个独立的CSS文件被引入到HTML中了,变成了普通CSS的形式。

css-in-js-26.png

零运行时中的参数

前面我们说过零运行时的库,会把CSS提前编译好放到文件中。对于固定的CSS代码来说非常容易,但当CSS中出现变量参数时,即CSS代码不确定时,应该怎么实现呢?这里我们举个例子看一下:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: ${(props) => props.color || "yellow"};
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div1 color="red">jzplp1</Div1>
      <Div1 color="blue">jzplp1</Div1>
    </div>
  );
}

通过代码可以看到,传参方式和其它库基本一致。但是查看生成的代码,却发现不一致之处。运行时的库会直接生成完整的CSS代码提供,并且有一个自己专属的类名。但零运行时的库却将参数作为一个CSS变量引用。同时在对应组件的style属性中提供对应的CSS属性值,以此实现代码的零运行时特性。

css-in-js-27.png

打包后查看生成代码,也可以看到生成的带变量的CSS规则代码,以及我们根据处理props属性生成CSS变量的函数。这个函数只能在运行时处理。

css-in-js-28.png

CSS变量参数的限制

CSS变量虽然有效,但它的逻辑并不是字符串匹配,因此使用方式还是和运行时库有区别。我们先举一个普通的CSS代码作为例子,尝试了三种变量组合的场景。

.class1 {
  --classVar1: 21px;
  font-size:var(--classVar1);
}
.class2 {
  --classVar1: 21;
  font-size:var(--classVar1)px;
}
.class3 {
  --classVar1: 1px;
  font-size: 2var(--classVar1);
}

然后是对应的React代码:

import "./App.css";

export default function App() {
  return (
    <div>
      <div className="class1">jzplp1</div>
      <div className="class2">jzplp2</div>
      <div className="class3">jzplp3</div>
    </div>
  );
}

css-in-js-29.png

看可以看到只有第一个例子生效了,是21px整个作为CSS变量值。将这个值拆开是不能生效的,更不能像字符串那样随意拼合。那利用CSS变量作为参数方案的linaria呢?我们试一下:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  font-size: ${(props) => props.size + "px"};
`;

const Div2 = styled.div`
  font-size: ${(props) => props.size}px;
`;

const Div3 = styled.div`
  font-size: ${(props) => props.size}1px;
`;

const Div4 = styled.div`
  font-size: 2${(props) => props.size}px;
`;

export default function App() {
  return (
    <div>
      <Div1 size={21}>jzplp1</Div1>
      <Div2 size={21}>jzplp2</Div2>
      <Div3 size={2}>jzplp3</Div3>
      <Div4 size={1}>jzplp4</Div4>
    </div>
  );
}

css-in-js-30.png

通过例子可以看到,21px整个作为参数与21作为参数都是可以的,2或者1单独作为参数就不行了,会造成CSS代码解析错误。这里21生效应该是linaria特殊处理过,把后面的px拼接上了。

CSS声明块作为参数

linaria也可以接收CSS声明块作为模板参数:

import { styled } from "@linaria/react";

const cssObj = {
  color: "red",
  fontSize: "20px",
}
const cssStr = `
  color: red;
  font-size: 20px;
`;

const Div1 = styled.div`
  background: yellow;
  ${cssObj}
`;
const Div2 = styled.div`
  background: yellow;
  ${cssStr}
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

css-in-js-31.png

可以看到,不管是字符串模板还是对象形式都是支持的。但整个块对象不能是由函数返回的,即只能在编译时处理完成,不能有运行时特性。这还是由于前面CSS变量作为实现方案的原因,导致无法生效。这里举个例子:

import { styled } from "@linaria/react";

const cssObj = {
  color: "red",
  fontSize: "20px",
}
const cssStr = `
  color: red;
  font-size: 20px;
`;

const Div1 = styled.div`
  background: yellow;
  ${() => cssObj}
`;
const Div2 = styled.div`
  background: yellow;
  ${() => cssStr}
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

css-in-js-32.png

通过结果看到,用函数包裹起来(假设它是接收props之后运行时计算的CSS代码)并未生效。这里为了方便查看效果,我们打个包看一下构建成果:

css-in-js-33.png

通过生产代码可以看到,这个函数逻辑以及返回值被原封不动的保留下来返回了,但是linaria却无法识别整个CSS声明,因此不能生效。

linaria中的css片段

linaria中也有css方法,使用方式与其它库类似,而且支持不在React框架中使用。

纯JavaScript接入方式

这里我们抛弃React,使用纯JavaScript的方式接入linaria。使用linaria必须要编译,这里依然选用Vite。首先执行命令行:

npm init -y
npm add -D vite @wyw-in-js/vite 
npm add @linaria/core

然后在package.json的scripts中增加三个命令,分别是开发模式运行,打包和预览打包后的成果。

{
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
}

然后创建index.html,为浏览器入口文件,其中引入了index.js。

<html>
  <meta charset="UTF-8">
  <head>
    <title>jzplp的linaria实验</title>
  </head>
  <body>
    <script src="./index.js" type="module"></script>
  </body>
</html>

然后是index.js的实现:

import { css } from '@linaria/core';

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const cssData = css`
  color: red;
  font-size: 20px;
`;

console.log(data);
genEle('jzplp', cssData);

/* 输出结果
cf71da1
*/

可以看到,css函数也是使用模板字符串来写CSS规则,返回值是一个类名,可以直接用在元素上。使用linaria还要修改打包配置,创建vite.config.js:

import { defineConfig } from "vite";
import wyw from "@wyw-in-js/vite";

export default defineConfig({
  plugins: [wyw()],
});

然后执行npm run dev,开发模式下正常生效:

css-in-js-34.png

然后我们执行npm run build,查看打包后的生成代码:

css-in-js-35.png

可以看到,我们用css函数生成的CSS代码,已经被放到独立的CSS文件中,css函数使用的位置,已经直接变成了class类名。这也是零运行时库的效果,在编译时就将CSS代码生成完毕。

全局样式

linaria也支持创建全局样式,这里我们试一下。

import { css } from "@linaria/core";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

css`
  :global() {
    div {
      color: red;
    }
    .class1 {
      background: yellow;
    }
  }
`;

const cssData = css`
  font-size: 20px;
`;

console.log(cssData);
genEle("jzplp1", "class1");
genEle("jzplp2", cssData);

css-in-js-36.png

将全局特性包裹在:global()中,即可生效。这段CSS甚至都不需要被哪个标签引用。我们再看看打包后的结果:

css-in-js-37.png

可以看到,对应的这段css函数代码没有了,全局特性转移到了CSS文件中。

vanilla-extract

vanilla-extract是另一个CSS in JS库,正如它的名字,vanilla表示不使用框架的纯JavaScript。vanilla-extract这个库是框架无关的,同样他也是一个零运行时库。

接入方式

我们还是使用Vite接入,但这次试一下Vite提供的vanilla模板。执行命令行:

npm create vite@latest
# 选择 Vanilla + TypeScript模板
npm add @vanilla-extract/css @vanilla-extract/vite-plugin

创建vite.config.js文件,内容如下:

import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';

export default {
  plugins: [vanillaExtractPlugin()]
};

创建src/styles.css.ts文件,内容为创建样式,并导出。

import { style } from '@vanilla-extract/css';

export const cls1 = style({
  color: 'red'
});

然后删除无用的文件,修改src/main.js的内容为引入创建的样式,并作为类名放到HTML标签上。

import { cls1 } from "./styles.css.ts";

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

css-in-js-38.png

在开发模式运行,通过浏览器可以看到,也是在head中插入了style标签提供样式。我们再打包看看生成文件:

css-in-js-39.png

通过生成文件可以看到,vanilla-extract也是在编译时就生成独立的样式文件引入,不需要运行时处理。

独立.css.ts文件

vanilla-extract与其它CSS in JS方案不同点在于,虽然它确实是用JavaScript写CSS代码,但却要求独立的文件类型“.css.ts”。如果在其它文件中写入会造成错误。这里我们试一试,修改前面的src/main.js:

import { style } from '@vanilla-extract/css';

const cls1 = style({
  color: 'red'
});

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

这时候开发模式打开浏览器,会看到报错,元素也没有正常展示:

css-in-js-40.png

回想起使用CSS in JS方案的重要原因就是希望CSS代码与组件的联系更紧密。这样强制的独立.css.ts文件,看起来没有增加紧密感。

生成style

vanilla-extract使用style创建样式,返回对应的类名。style方法接收对象形式的CSS规则,但是与常规CSS写法有点不同,这里介绍部分不同点。

px单位

首先是如果属性值的单位是px,可以省略,写成数字形式。这里举个例子:

export const cls1 = style({
  fontSize: 10,
  margin: 20,
  padding: "10px",
  flex: 1,
});

/* 生成结果
.r9osg00 {
  flex: 1;
  margin: 20px;
  padding: 10px;
  font-size: 10px;
}
*/

我们直接对代码打包,观察生成的CSS文件。可以看到vanilla-extract并不是所有数字都会转换,那些没有单位的CSS属性并不会被转换。

浏览器引擎前缀写法

在对象中写CSS属性需要以camelCase驼峰命名法,但对于浏览器引擎前缀这种最前面带中划线的形式,vanilla-extract要求使用PascalCase帕斯卡命名法,即最前面大写。

export const cls1 = style({
  WebkitTapHighlightColor: "rgba(0, 0, 0, 0)",
});

/* 生成结果
.r9osg00 {
  -webkit-tap-highlight-color: #0000;
}
*/

媒体查询/容器查询/@layer等

它们的写法多了一层嵌套:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  "@container": {
    "(min-width: 768px)": {
      padding: 10,
    },
  },
  "@media": {
    "screen and (min-width: 768px)": {
      padding: 10,
    },
    "(prefers-reduced-motion)": {
      transitionProperty: "color",
    },
  },
  "@layer": {
    typography: {
      fontSize: "1rem",
    },
  },
  "@supports": {
    "(display: grid)": {
      display: "grid",
    },
  },
});

/* 生成结果
@layer typography {
  .r9osg00 {
    font-size: 1rem;
  }
}
@media screen and (width>=768px) {
  .r9osg00 {
    padding: 10px;
  }
}
@media (prefers-reduced-motion) {
  .r9osg00 {
    transition-property: color;
  }
}
@supports (display: grid) {
  .r9osg00 {
    display: grid;
  }
}
@container (width>=768px) {
  .r9osg00 {
    padding: 10px;
  }
}
*/

后备值

在之前讲PostCSS中postcss-custom-properties插件的时候,我们提到过当浏览器读取到一个不支持的CSS属性值时,如果这个属性前面已经有一个后备值了,那就使用那个后备值,不会应用不支持的属性值。

但以对象的形式写CSS属性,key同一个的情况下,没办法写两个值。这里vanilla-extract接收一个值数组,实现后备值功能:

export const cls1 = style({
  overflow: ['auto', 'overlay']
});

/* 生成结果
.r9osg00 {
  overflow: auto;
  overflow: overlay;
}
*/

CSS变量

vanilla-extract支持使用CSS变量,需要在vars属性内部。创建后的CSS变量可以在任意位置使用,但需要满足选择器的条件。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  vars: {
    "--jzplp1": "red",
  },
  color: "var(--jzplp1)",
});

export const cls2 = style({
  color: "var(--jzplp1)",
  fontSize: 20,
});

/* 生成结果
.r9osg00 {
  --jzplp1: red;
  color: var(--jzplp1);
}
.r9osg01 {
  color: var(--jzplp1);
  font-size: 20px;
}
*/

还可以通过createVar方法创建带有哈希的模块化CSS变量,在使用的位置引用即可。

import { style, createVar } from "@vanilla-extract/css";

const cssVar = createVar();

export const cls1 = style({
  vars: {
    [cssVar]: "red",
  },
  color: cssVar,
});

export const cls2 = style({
  color: cssVar,
  fontSize: 20,
});

/* 生成结果
.r9osg01 {
  --r9osg00: red;
  color: var(--r9osg00);
}
.r9osg02 {
  color: var(--r9osg00);
  font-size: 20px;
}
*/

嵌套选择器

vanilla-extract支持使用嵌套选择器,但是有一些特殊的规则。

顶层使用

对于简单的无参数的伪类或者伪元素选择器,可以与其它CSS属性放在一起顶层使用。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  ":hover": {
    background: "yellow",
  },
  "::before": {
    content: "jzplp",
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00:hover {
  background: #ff0;
}
.r9osg00:before {
  content: "jzplp";
}
*/

可以看到,与属性一同使用时可以省略&符号。但是这里不能添加带参数或者复杂的组合选择器,否则会编译报错:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  "&:hover": {
    background: "yellow",
  },
  ":not(.cls)": {
    content: "jzplp",
  },
});

/* 分别报错
error TS2353: Object literal may only specify known properties, and '"&:hover"' does not exist in type 'ComplexStyleRule'.
error TS2353: Object literal may only specify known properties, and '":not(.cls)"' does not exist in type 'ComplexStyleRule'.
*/

selectors中使用

在selectors属性中可以编写复杂的选择器,但要自己写&符号。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  selectors: {
    "&:hover": {
      background: "yellow",
    },
    "&:not(.cls)": {
      content: "jzplp",
    },
    ".abc &": {
      fontSize: 20
    },
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00:hover {
  background: #ff0;
}
.r9osg00:not(.cls) {
  content: "jzplp";
}
.abc .r9osg00 {
  font-size: 20px;
}
*/

禁止场景

选择器也不是随便写都行的,vanilla-extract要求选择器必须作用于当前类。如果作用于其它类,那么编译时也会报错:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  selectors: {
    ":hover": {
      background: "yellow",
    },
    "& .abc": {
      fontSize: 20
    },
  },
});

/* 分别报错
Error: Invalid selector: :hover
Error: Invalid selector: & .abc
Style selectors must target the '&' character (along with any modifiers), e.g. `${parent} &` or `${parent} &:hover`.
*/

它会实际分析CSS规则,并不是把&写在什么位置就能避开的。

传入类名

嵌套选择器也支持传入其它style函数生成的类名,同样需要遵守选择器必须作用于当前类。

import { style } from "@vanilla-extract/css";

const cls = style({
  color: 'red',
})

export const cls1 = style({
  selectors: {
    [`.${cls} &`]: {
      background: "yellow",
    },
    [`&:not(.${cls})`]: {
      fontSize: 20
    },
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00 .r9osg01 {
  background: #ff0;
}
.r9osg01:not(.r9osg00) {
  font-size: 20px;
}
*/

全局样式

vanilla-extract可通过globalStyle函数创建全局样式,第一个参数是选择器,第二是样式对象。

import { globalStyle } from "@vanilla-extract/css";

globalStyle("body", {
  vars: {
    "--jzplp": "10px",
  },
  margin: 0,
});

globalStyle(".abc .bcd:hover", {
  color: "red",
});

/* 生成结果
body {
  --jzplp: 10px;
  margin: 0;
}
.abc .bcd:hover {
  color: red;
}
*/

全局样式中也能包含使用style函数生成的模块化类名,这时候就没有选择器作用限制了:

import { globalStyle, style } from "@vanilla-extract/css";

const cls = style({
  color: "red",
});

globalStyle(`body .${cls}`, {
  margin: 0,
});

globalStyle(`.${cls} :not(div)`, {
  fontSize: 10,
});

/* 生成结果
.r9osg00 {
  color: red;
}
body .r9osg00 {
  margin: 0;
}
.r9osg00 :not(div) {
  font-size: 10px;
}
*/

主题

创建主题

vanilla-extract支持使用createTheme方法创建主题,主题实际上就是一组预设CSS变量。首先我们创建主题并直接打包,看下输出结果:

import { createTheme } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

console.log(theme1);
console.log(vars);

/* 打包命令行输出
r9osg00
{
  color: { banner: 'var(--r9osg01)', font: 'var(--r9osg02)' },
  size: { margin: 'var(--r9osg03)' }
}
*/

/* 打包后CSS文件内容
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
*/

上面创建了一个主题。其中vars表示这个主题模式的对象,其中包含变量的引用。theme1是类名,使用这个类即可对这些CSS变量提供值。

使用主题

下面我们再来看使用方式。首先是styles.css.ts,除了创建主题之外还创建了样式,其中引用了vars中的变量。

import { createTheme, style } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const cls1 = style({
  color: vars.color.font,
});

export { theme1, cls1 };

/* 生成结果
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
.r9osg04 {
  color: var(--r9osg02);
}
*/

然后是main.js,将主题类放到body中,再将应用主题的类放到div上。这样div会使用body上的变量,使主题生效。

import { theme1, cls1 } from './styles.css';

document.body.className = theme1;

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

css-in-js-41.png

复用主题

既然是主题,那么应该不会只有一个,主题应该是同变量但是值不同的一组结构,这时候可以复用vars继续创建主题。

import { createTheme } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const theme2 = createTheme(vars, {
  color: {
    banner: "yellow",
    font: "pink",
  },
  size: {
    margin: "30px",
  },
});

export { theme1, theme2 };

/* 生成结果
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
.r9osg04 {
  --r9osg01: yellow;
  --r9osg02: pink;
  --r9osg03: 30px;
}
*/

可以看到,创建的theme2应该遵守同样的结构,但是值不同。theme2仅生成类名,在对应的标签上赋值即可实现主题切换。

仅创建vars

通过上面的例子可以看到,vars表示主题的结构和引用,生成的类名表示实际的主题值。但是现在创建主题结构和创建主题值合二为一了,如果希望分开生成,vanilla-extract提供了createThemeContract方法。可以将上面的代码改下如下,效果不变。

import { createTheme, createThemeContract } from "@vanilla-extract/css";

const vars = createThemeContract({
  color: {
    banner: "",
    font: "",
  },
  size: {
    margin: "",
  },
});

const theme1 = createTheme(vars, {
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const theme2 = createTheme(vars, {
  color: {
    banner: "yellow",
    font: "pink",
  },
  size: {
    margin: "30px",
  },
});

export { theme1, theme2 };

总结

库列表

前面我们介绍了四个CSS in JS的库,但这仅仅是九牛一毛。社区中CSS in JS的库非常非常多,这里用表格列举一些知名度较高的:

库名称 零运行时 纯JS 适配React框架 备注
styled-components 不支持 支持 本文已介绍
Emotion 支持 支持 本文已介绍
linaria 支持 支持 本文已介绍
vanilla-extract 支持 不支持 本文已介绍
Panda CSS 支持 支持 -
Compiled 不支持 支持 -
Radium 不支持 支持 已停止维护
JSS 插件支持 支持 支持 已停止维护

特点总结

本文只是简单介绍了几个CSS in JS库的使用方式和特点,并未详细探究原理和区别。事实上关于CSS in JS库还有很多值得探讨的主题,例如服务端渲染,性能优化等。根据介绍的几个CSS in JS库以及网络上相关分析,这里我们总结一下CSS in JS的特点,其中有优点,也有缺点:

  • 使用方式和API各有特色,但也有很多相似之处
    • 可以看到很多API设计都是类似的,例如styled组件、css的props、模板字符串或对象作为CSS规则表示,这些API在很多库中都以类似的方式出现
    • 但很多库的设计都有自己的特点和使用方式,并没有千篇一律
  • 虽然CSS in JS的设计各有特色,但还是可以大致分成 运行时库和零运行时库两类
    • 运行时库对于CSS传参的灵活性高,但是运行时生成CSS,有额外的性能损失
    • 零运行时库在编译时生成CSS文件,没有运行时性能损失,但对于CSS参数等灵活性低
  • CSS in JS库对于类型检查和提示更友好,支持TypeScript更完善
  • 由于CSS是在JavaScript中撰写的,因此控制和切换CSS要更方便
  • CSS in JS的类名时根据hash生成的,自带模块化类名,省去了起名的烦恼,也防止了CSS污染
  • 类名是自动生成的,因此有些开发者认为比较难看,不方便查找元素等。相比较CSS Modules可以配置原类名+hash,可以轻松识别类名
  • 在JavaScript中写CSS,更方便组织代码,例如将CSS与HTML和JS放在一起,以组件化的形式组织
  • 对于运行时CSS,CSS代码只有在需要的时候才加载,对于部分页面场景,具有更小的首屏文件体积和其它优势
  • 使用独立的CSS文件我们不清楚哪些样式是真正使用的,哪些样式没有被用到。而CSS in JS的CSS规则引用关系明显,我们可以轻松找到未被使用的样式,也可以利用tree-shaking等技术编译时去掉不需要的样式
  • 有些人很喜欢CSS in JS来组织CSS代码,但是有些人却觉得多此一举。萝卜青菜,各有所爱
  • CSS in JS在React框架使用居多,Vue框架有自己的方案(组件作用域CSS,我们之前介绍过),基本不需要CSS in JS

参考

深入浅出 Tree Shaking:Rollup 是如何“摇”掉死代码的?

2026年4月18日 18:35

前言

在前端性能优化中,减小 JS 包体积是重中之重。Tree Shaking(摇树优化) 就像它的名字一样:通过摇晃代码这棵大树,让那些无用的“枯叶”(死代码)掉落。本文将带你揭秘 Rollup 实现 Tree Shaking 的底层原理。

一、 核心基石:为什么是基于ESM?

Tree Shaking 的实现并非偶然,它深度依赖于 ESM (ES Module) 规范。

  • 静态分析:ESM 要求 importexport 必须在代码顶层,不能出现在 if 块或函数内部。
  • 编译时确定:这意味着 Rollup 不需要执行代码,只需扫描一遍源码,就能在编译阶段清晰地知道模块间的依赖关系。
  • 对比 CommonJSrequire 是动态加载的,只有运行到那一行才知道加载了什么,因此 CJS 无法进行彻底的 Tree Shaking。

二、Rollup Tree Shaking 实现原理:从扫描到删除的四步曲

Rollup 的“摇树”过程可以分为以下四个精密步骤:

1. 递归扫描与依赖图构建

从入口文件(如 main.js)开始,递归扫描所有 import/export 语句。Rollup 会记录:

  • 每个模块导出了哪些变量/函数。
  • 每个模块导入了哪些内容。
  • 模块间的引用链路(A 引用了 B 的哪个具体成员)。
  • 基于这些信息,Rollup 会构建出一个完整的模块依赖图,清晰呈现整个项目的代码引用链路。

步骤 2:标记活代码与死代码

在模块依赖图的基础上,Rollup 会从入口文件出发,反向追踪所有被引用的内容,标记出活代码和死代码:

  • 首先标记出哪些导出项被外部(其他模块或入口文件)引用;
  • 接着判断这些被引用的导出项,是否真的在代码中被使用(而非仅导入未使用),若被使用则标记为活代码,未被使用则标记为死代码

步骤 3:AST 分析优化(补充细节)

在标记活代码的过程中,Rollup 会深入分析每个模块的 AST(抽象语法树) ,精准追踪变量、函数的定义和引用关系。这里有一个容易被忽略的点:

  • 即使一个变量、函数在模块内定义了,但既没有被 export 导出,也没有在模块内部被引用,它依然会被判定为死代码,被 Tree Shaking 摇掉——也就是说,Tree Shaking 不仅会处理“导出未使用”的代码,也会清理模块内部“定义未使用”的冗余代码。

步骤 4:删除死代码,生成最终产物

最后,Rollup 会遍历所有模块,只保留标记为活代码的内容,直接删除所有死代码(未被引用的导出项、模块内部未使用的定义等),最终生成精简、无冗余的打包产物。

Rollup 的 Tree Shaking 是原生支持的,无需额外配置,打包时会自动执行上述流程,且输出的代码更接近手写风格,无多余的运行时代码,优化效果直观可见。


三、 实战:不同导出方式的“招魂”效果

Tree Shaking 的效果,很大程度上取决于代码的导出方式——只有静态导出才能被 Rollup 精准分析,动态导出则无法实现 Tree Shaking。以下是常见的导出方式对比:

导出方式 是否支持树摇 深度原因分析
export const a = 1 完美支持 静态导出,引用关系明确。
export function b() {} 完美支持 未被调用时可被精准识别并删除。
export default { a:1 } 不支持/效果差 默认导出是一个对象,工具难以判断你是否会动态访问对象的某个 Key。
export * from './x.js' 支持 按需转发,只会转发那些被下游真正引用的成员。

四、 Tree Shaking 避坑指南

避坑点 1:CommonJS 模块会导致 Tree Shaking 罢工

  • 如果项目中引入了使用 require/module.exports 的第三方库,Tree Shaking 会直接失效。原因如下:

    • CommonJS 模块是动态模块,require 可以接收变量(如 require(./${name}.js)),导入导出关系只能在运行时确定,Rollup 无法在打包前进行静态分析,因此无法识别死代码,Tree Shaking 自然无法生效。

    实战建议:优先使用支持 ESM 规范的第三方库(如 lodash-es 替代 lodash),避免在 ESM 项目中混用 CommonJS 模块。

避坑点 2:副作用代码会干扰 Tree Shaking

  • 如果模块中存在“副作用代码”(即执行后会影响全局环境、修改外部变量、执行 DOM 操作等的代码,如顶层的 console.log、window.xxx = xxx),即使这些代码未被引用,Rollup 也会保守地保留它们,避免影响项目运行逻辑,从而导致部分死代码无法被摇掉。

    解决方案:如果确认模块无副作用,可在 package.json 中添加 "sideEffects": false,告诉 Rollup 该模块可安全删除未引用代码;若有部分文件有副作用(如 CSS 文件),可显式声明:"sideEffects": ["./src/style.css"]

避坑点 3:动态访问会导致 Tree Shaking 失效

  • 如果代码中存在动态访问导出项的情况(如 import * as utils from './utils.js'; utils[dynamicKey]()),Rollup 无法在静态分析阶段确定哪些导出项被使用,会保留整个模块的所有导出项,导致 Tree Shaking 失效。

    实战建议:尽量使用具名导入(import { func } from './utils.js'),避免动态访问导出项。


五、 总结

Rollup Tree Shaking 的核心是“基于 ESM 静态规范,通过静态分析识别并删除死代码”,其流程简洁高效,且原生支持无需额外配置。想要用好 Tree Shaking,关键记住 3 点:

  • 坚持使用 ESM 规范(import/export),避免混用 CommonJS;
  • 优先使用静态具名导出,避免默认导出对象、动态导出;
  • 处理好副作用代码,必要时通过 package.json 的 sideEffects 字段声明。

老外抢着当张雪机车代理,张雪机车全球订单狂飙

2026年4月18日 17:15
在第139届广交会的摩托车展区,围满了来自世界各地的采购商,很多人专程赶来,只为一睹半个月前在WSBK世界超级摩托车锦标赛上“一战封神”的中国品牌——张雪机车。张雪机车外贸部部长黄秦表示:“这两天现场非常火爆,超过5000名客商直接冲着我们品牌来的,来了之后都要求成为当地的代理,要求直接下订单,我们收到了上千台的订单。”海关总署发布数据:第一季度,我国内燃机摩托车出口463万辆,价值211.4亿元,同比分别增长13.5%、14.2%。第一季度,我国摩托车出口1114万辆,价值344.45亿元,同比分别增长18.8%、18.4%。从赛场夺冠到亮相广交会,张雪机车不仅用硬核实力吸引了全球客商的目光,更以亮眼表现,带动整个国产摩托车行业热度���升、销量增长。(央视财经)

美计划用 “猎鹰重型”火箭发射欧航局火星车

2026年4月18日 17:14
美国航空航天局16日表示,已启动2028年发射欧洲航天局“罗莎琳德·富兰克林”号火星车的实施工作,计划使用太空探索技术公司“猎鹰重型”运载火箭执行发射任务。“罗莎琳德·富兰克林”号计划于2028年发射升空,在火星地表以下寻找过去或现存生命迹象。欧航局主导实施该任务,负责提供包括运载模块、着陆平台和火星车在内的全套航天器系统,并为火星表面的探测工作提供运维支持。(央视新闻)
❌
❌