普通视图
微前端(无界)样式架构重构方案
日期: 2025-02-10
影响范围: 主应用apps/main、共享包packages/ui、所有子应用
一、现状诊断
1.1 样式文件分布(主应用)
当前主应用存在 3 个互不关联的样式目录,没有统一入口:
apps/main/src/
├── assets/
│ ├── main.css ← Vue 脚手架残留 + Tailwind 入口
│ ├── base.css ← Vue 脚手架残留 CSS Reset + 变量
│ └── styles/
│ └── micro-app.css ← 微前端"隔离"样式(!important 硬覆盖)
├── styles/
│ ├── index.scss ← 样式入口(⚠️ 从未被任何文件引用!)
│ ├── var.css ← 布局 + 主题 CSS 变量
│ ├── dark.css ← 暗色主题变量
│ ├── resizeStyle.scss ← 632 行巨型文件(EP 覆盖 + 布局 + 业务混杂)
│ ├── dialog.scss ← 弹窗尺寸
│ ├── theme.scss ← 空文件(全注释)
│ ├── variables.scss ← 仅 2 个 SCSS 变量
│ └── global.module.scss ← 导出命名空间给 JS
1.2 main.ts 样式导入链
main.ts
├── assets/main.css → @import base.css + @import tailwindcss
├── assets/styles/micro-app.css
├── element-plus 组件样式 ×4(message-box / message / notification / loading)
└── @cmclink/ui/styles → variables + element-override + base + utilities + animations + tailwindcss
关键发现:styles/index.scss(包含 var.css、resizeStyle.scss、dialog.scss)在整个项目中没有被任何文件 import。这意味着 632 行的 Element Plus 覆盖样式、布局变量、暗色主题等可能完全没有生效,或者曾经生效但在某次重构中被遗漏。
1.3 核心问题清单
| # | 问题 | 严重程度 | 说明 |
|---|---|---|---|
| 1 | Tailwind CSS 重复引入 | 🔴 高 |
assets/main.css 和 @cmclink/ui/styles 各引入一次,产生重复样式 |
| 2 | CSS Reset 重复执行 | 🔴 高 |
assets/base.css 和 @cmclink/ui/base.scss 各做一次全局 reset |
| 3 | 主色定义冲突 | 🔴 高 |
var.css→#005aae、@cmclink/ui→#004889、resizeStyle.scss→硬编码 #005aae,三处不一致 |
| 4 | styles/index.scss 未被引用 | 🔴 高 | 632 行 EP 覆盖 + 布局变量 + 暗色主题可能完全未生效 |
| 5 | body 样式双重定义 | 🟡 中 |
base.css 和 @cmclink/ui/base.scss 都设置了 body 样式 |
| 6 | 微前端样式隔离 = !important 战争 | 🔴 高 |
micro-app.css 用 !important 硬覆盖,没有利用 wujie CSS 沙箱 |
| 7 | 脚手架残留代码 | 🟡 中 |
base.css 的 --vt-c-* 变量、main.css 的 .green 类 |
| 8 | 废文件堆积 | 🟡 中 |
theme.scss(空)、variables.scss(2 行)、global.module.scss(价值存疑) |
| 9 | !important 泛滥 | 🟡 中 |
resizeStyle.scss 和 micro-app.css 大量使用 |
| 10 | 样式职责不清 | 🔴 高 | EP 覆盖、页面布局、业务样式、主题变量全混在一个文件 |
1.4 @cmclink/ui 样式包分析
packages/ui/src/styles/ 已经有一套相对完整的设计体系:
- variables.scss — 完整的设计令牌(颜色、字体、间距、圆角、阴影等)+ CSS 变量映射
- mixins.scss — 响应式断点、文本截断、布局、按钮变体等混合器
- base.scss — 全局 reset、标题、段落、表格、滚动条、打印、暗色、无障碍
- element-override.scss — Element Plus 样式覆盖
-
utilities.scss — CMC 专用工具类(
cmc-*前缀) - animations.scss — 动画库(1250 行,含大量与 Tailwind 重复的工具类)
问题:@cmclink/ui 的 animations.scss 中有大量 .translate-*、.scale-*、.rotate-*、.duration-* 等类名,与 Tailwind CSS 完全重复。
二、目标架构设计
2.1 设计原则
-
单一真相源(Single Source of Truth):设计令牌只在
@cmclink/ui中定义一次 - 分层隔离:全局样式、主题变量、EP 覆盖、布局样式、业务样式严格分层
-
微前端天然隔离:依赖 wujie 的 CSS 沙箱(WebComponent + iframe),而非
!important - Tailwind 唯一入口:整个主应用只在一个地方引入 Tailwind CSS
- 可维护性:每个文件职责单一,命名语义化,新人 5 分钟能理解结构
2.2 目标目录结构
apps/main/src/
├── styles/ ← 主应用样式唯一目录
│ ├── index.scss ← ⭐ 唯一入口文件(main.ts 只 import 这一个)
│ ├── tailwind.css ← Tailwind CSS 唯一引入点
│ │
│ ├── tokens/ ← 主应用级设计令牌(覆盖/扩展 @cmclink/ui)
│ │ ├── _variables.scss ← SCSS 变量(供 SCSS 文件内部使用)
│ │ └── _css-variables.scss ← CSS 自定义属性(布局变量、主应用专属变量)
│ │
│ ├── themes/ ← 主题系统
│ │ ├── _light.scss ← 亮色主题变量
│ │ └── _dark.scss ← 暗色主题变量
│ │
│ ├── overrides/ ← Element Plus 样式覆盖(主应用级)
│ │ ├── _button.scss ← 按钮覆盖
│ │ ├── _table.scss ← 表格覆盖
│ │ ├── _form.scss ← 表单覆盖(filterBox 等)
│ │ ├── _dialog.scss ← 弹窗覆盖
│ │ ├── _message-box.scss ← 消息框覆盖
│ │ ├── _select.scss ← 下拉框覆盖
│ │ ├── _tag.scss ← 标签覆盖
│ │ ├── _tabs.scss ← 标签页覆盖
│ │ └── _index.scss ← EP 覆盖汇总入口
│ │
│ ├── layout/ ← 布局样式
│ │ ├── _app-view.scss ← AppViewContent / AppViewScroll
│ │ ├── _login.scss ← 登录页样式
│ │ └── _index.scss ← 布局汇总入口
│ │
│ ├── vendors/ ← 第三方库样式适配
│ │ └── _nprogress.scss ← NProgress 主题色适配
│ │
│ └── legacy/ ← 遗留业务样式(逐步迁移到组件 scoped 中)
│ ├── _si-detail.scss ← 出口单证详情
│ ├── _marketing.scss ← 营销模块
│ └── _index.scss ← 遗留样式汇总入口
│
├── assets/ ← 仅保留静态资源
│ └── images/ ← 图片资源
│ ├── avatar.gif
│ ├── cmc-logo.png
│ └── ...
2.3 入口文件设计
styles/index.scss(唯一入口):
// =============================================================================
// CMCLink 主应用样式入口
// 加载顺序严格按照优先级排列,请勿随意调整
// =============================================================================
// 1️⃣ Tailwind CSS(最先加载,作为基础原子类层)
@use './tailwind.css';
// 2️⃣ 主应用级设计令牌(覆盖/扩展 @cmclink/ui 的变量)
@use './tokens/css-variables';
// 3️⃣ 主题系统
@use './themes/light';
@use './themes/dark';
// 4️⃣ Element Plus 样式覆盖
@use './overrides/index';
// 5️⃣ 布局样式
@use './layout/index';
// 6️⃣ 第三方库适配
@use './vendors/nprogress';
// 7️⃣ 遗留业务样式(逐步清理)
@use './legacy/index';
main.ts(重构后):
// 样式:唯一入口
import './styles/index.scss'
// Element Plus 反馈组件样式(非按需导入的全局组件)
import 'element-plus/es/components/message-box/style/css'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/notification/style/css'
import 'element-plus/es/components/loading/style/css'
// 组件库样式(@cmclink/ui 提供设计令牌 + 基础样式 + EP 覆盖)
import '@cmclink/ui/styles'
// 其余保持不变...
注意:
@cmclink/ui/styles中的 Tailwind CSS 引入需要移除,改为只在主应用的styles/tailwind.css中引入一次。
2.4 样式加载顺序
┌─────────────────────────────────────────────────────────┐
│ 1. Tailwind CSS(原子类基础层) │
├─────────────────────────────────────────────────────────┤
│ 2. @cmclink/ui/styles │
│ ├── variables.scss → 设计令牌 + CSS 变量映射 │
│ ├── base.scss → 全局 reset + 排版 │
│ ├── element-override.scss → 组件库级 EP 覆盖 │
│ ├── utilities.scss → CMC 工具类 │
│ └── animations.scss → 动画(需清理与 Tailwind 重复部分)│
├─────────────────────────────────────────────────────────┤
│ 3. Element Plus 反馈组件样式(message/notification 等) │
├─────────────────────────────────────────────────────────┤
│ 4. 主应用 styles/index.scss │
│ ├── tokens/ → 主应用级变量(布局尺寸、主题色扩展) │
│ ├── themes/ → 亮色/暗色主题 │
│ ├── overrides/ → 主应用级 EP 覆盖 │
│ ├── layout/ → 页面布局 │
│ ├── vendors/ → 第三方库适配 │
│ └── legacy/ → 遗留业务样式 │
├─────────────────────────────────────────────────────────┤
│ 5. 组件 <style scoped>(组件级样式,天然隔离) │
└─────────────────────────────────────────────────────────┘
三、微前端样式隔离策略
3.1 wujie CSS 沙箱机制
wujie 使用 WebComponent(shadowDOM)+ iframe 双重沙箱:
- JS 沙箱:子应用 JS 运行在 iframe 中,天然隔离
- CSS 沙箱:子应用 DOM 渲染在 WebComponent 的 shadowDOM 中,样式天然隔离
这意味着:
- ✅ 子应用的样式不会泄漏到主应用
- ✅ 主应用的样式不会侵入子应用(shadowDOM 边界)
- ⚠️ 但是:Element Plus 的弹窗(Dialog/Drawer/MessageBox)默认挂载到
document.body,会逃逸出 shadowDOM
3.2 弹窗逃逸问题的正确解决方案
当前做法(错误):在主应用 micro-app.css 中用 !important 覆盖子应用弹窗样式。
正确做法:在子应用中配置 Element Plus 的 teleported 和 append-to 属性,让弹窗挂载到子应用自身的 DOM 容器内,而非 document.body。
方案 A:子应用全局配置(推荐)
在子应用的入口文件中配置 Element Plus 的全局属性:
// 子应用 main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
const app = createApp(App)
app.use(ElementPlus, {
// 弹窗不使用 teleport,保持在子应用 DOM 树内
// 这样弹窗样式就在 shadowDOM 内,天然隔离
})
同时在子应用的弹窗组件中统一设置:
<!-- 子应用中使用 Dialog -->
<el-dialog :teleported="false">
<!-- 内容 -->
</el-dialog>
<!-- 或者指定挂载到子应用容器 -->
<el-dialog append-to=".child-app-root">
<!-- 内容 -->
</el-dialog>
方案 B:wujie 插件拦截(自动化)
通过 wujie 的插件系统,自动为子应用的弹窗组件注入配置:
// plugins/wujie.ts 中增加插件配置
import { setupApp } from 'wujie'
setupApp({
name: 'child-app',
// ... 其他配置
plugins: [
{
// CSS loader:可以在子应用 CSS 加载时做处理
cssLoader: (code: string) => code,
// JS loader:可以在子应用 JS 加载时做处理
jsLoader: (code: string) => code,
}
]
})
方案 C:共享样式包(兜底方案)
如果确实需要主应用和子应用共享某些样式(如统一的弹窗规范),应该通过 @cmclink/ui 包来分发,而非在主应用中硬写覆盖:
packages/ui/src/styles/
├── shared/ ← 可被子应用独立引入的共享样式
│ ├── dialog-standard.scss ← 弹窗标准样式
│ └── form-standard.scss ← 表单标准样式
子应用按需引入:
// 子应用 main.ts
import '@cmclink/ui/styles/shared/dialog-standard.scss'
3.3 样式隔离总结
| 场景 | 隔离机制 | 是否需要额外处理 |
|---|---|---|
| 子应用普通样式 | wujie shadowDOM 天然隔离 | ❌ 无需处理 |
| 子应用弹窗/抽屉 | 配置 :teleported="false"
|
✅ 子应用侧配置 |
| 主应用与子应用共享样式 | 通过 @cmclink/ui 包分发 |
✅ 包级别管理 |
| 主应用全局样式 | 不会侵入子应用(shadowDOM) | ❌ 无需处理 |
3.4 删除 micro-app.css
重构完成后,assets/styles/micro-app.css 应该被完全删除。其中的内容处理方式:
| 原内容 | 处理方式 |
|---|---|
.micro-app .el-dialog 覆盖 |
子应用配置 :teleported="false" 后不再需要 |
.micro-app-test.marketing |
测试代码,直接删除 |
.test-paragraph / .test-box
|
测试代码,直接删除 |
四、主色统一方案
4.1 现状:三处主色定义
| 位置 | 主色值 | 说明 |
|---|---|---|
styles/var.css |
#005aae |
主应用布局变量 |
@cmclink/ui/variables.scss |
#004889 |
组件库设计令牌 |
resizeStyle.scss |
#005aae(硬编码) |
登录页等处直接写死 |
4.2 统一方案
以 @cmclink/ui 的设计令牌为唯一真相源。
-
确认最终主色值(与设计团队对齐):
- 如果设计稿是
#004889→ 主应用的var.css需要同步修改 - 如果设计稿是
#005aae→@cmclink/ui的variables.scss需要同步修改
- 如果设计稿是
-
主应用中所有硬编码的颜色值,改为引用 CSS 变量:
// ❌ 错误 background-color: #005aae; // ✅ 正确 background-color: var(--el-color-primary); -
主应用的
tokens/_css-variables.scss只定义布局相关的变量,颜色变量全部继承@cmclink/ui。
五、文件迁移映射
5.1 需要删除的文件
| 文件 | 原因 |
|---|---|
assets/main.css |
脚手架残留,Tailwind 迁移到 styles/tailwind.css
|
assets/base.css |
脚手架残留,与 @cmclink/ui/base.scss 重复 |
assets/styles/micro-app.css |
!important 硬覆盖,改用 wujie 沙箱 |
styles/theme.scss |
空文件 |
styles/variables.scss |
仅 2 行,合并到 tokens/_variables.scss
|
styles/global.module.scss |
价值存疑,如需保留可合并 |
5.2 需要拆分的文件
styles/var.css(154 行)→ 拆分为:
| 目标文件 | 内容 |
|---|---|
tokens/_css-variables.scss |
布局变量(--left-menu-*、--top-header-*、--tags-view-*、--app-content-* 等) |
| 删除 | 颜色变量(--el-color-primary 等),改为继承 @cmclink/ui
|
styles/resizeStyle.scss(632 行)→ 拆分为:
| 目标文件 | 内容 | 行数(约) |
|---|---|---|
overrides/_button.scss |
按钮样式覆盖(L1-20) | ~20 |
overrides/_tag.scss |
标签样式(L29-33) | ~5 |
overrides/_select.scss |
下拉框样式(L36-38) | ~3 |
overrides/_table.scss |
表格样式(L41-80, L374-417) | ~80 |
overrides/_message-box.scss |
消息框样式(L83-98, L399-406) | ~20 |
overrides/_form.scss |
filterBox 表单样式(L484-618) | ~135 |
layout/_login.scss |
登录页样式(L100-117) | ~18 |
layout/_app-view.scss |
AppViewContent / AppViewScroll / tabPage(L119-306) | ~190 |
legacy/_si-detail.scss |
出口单证详情(index.scss L50-105) |
~55 |
legacy/_marketing.scss |
营销模块(L308-373, L418-438) | ~70 |
| 删除 |
.container-selected-row(箱管选中行)→ 迁移到对应组件 scoped |
~8 |
styles/index.scss(126 行)→ 拆分为:
| 目标文件 | 内容 |
|---|---|
vendors/_nprogress.scss |
NProgress 主题色适配(L22-38) |
overrides/_form.scss |
append-click-input(L39-49) |
legacy/_si-detail.scss |
si-detail-dialog / si-detail-tabs(L50-105) |
| 删除 |
.reset-margin、.el-popup-parent--hidden、.el-scrollbar__bar → 评估是否仍需要 |
styles/dark.css(85 行)→ 迁移到:
| 目标文件 | 说明 |
|---|---|
themes/_dark.scss |
完整保留暗色主题变量,清理注释掉的废代码 |
styles/dialog.scss(17 行)→ 迁移到:
| 目标文件 | 说明 |
|---|---|
overrides/_dialog.scss |
弹窗尺寸规范 |
5.3 @cmclink/ui 需要调整的部分
| 文件 | 调整内容 |
|---|---|
styles/index.scss |
移除 @use 'tailwindcss',Tailwind 只在应用层引入 |
styles/animations.scss |
清理与 Tailwind 重复的工具类(.translate-*、.scale-*、.rotate-*、.duration-* 等约 800 行) |
styles/utilities.scss |
保留 cmc-* 前缀的工具类,删除与 Tailwind 重复的部分 |
六、实施步骤
Phase 1:基础清理(低风险,可立即执行)
预计耗时:1-2 小时
-
创建新目录结构
- 创建
styles/tokens/、styles/themes/、styles/overrides/、styles/layout/、styles/vendors/、styles/legacy/
- 创建
-
创建
styles/tailwind.css@import "tailwindcss"; -
删除废文件
-
styles/theme.scss(空文件)
-
-
统一主色
- 与设计团队确认最终主色值
- 更新所有硬编码颜色为 CSS 变量引用
Phase 2:文件拆分迁移(中风险,需逐步验证)
预计耗时:3-4 小时
-
拆分
resizeStyle.scss(632 行 → 8 个文件) -
拆分
var.css(布局变量 →tokens/_css-variables.scss) -
迁移
dark.css→themes/_dark.scss -
迁移
dialog.scss→overrides/_dialog.scss -
拆分
index.scss(NProgress → vendors,业务 → legacy) - 创建新的
styles/index.scss入口文件
Phase 3:入口重构(高风险,需完整回归测试)
预计耗时:1-2 小时
-
修改
main.ts- 移除
import './assets/main.css' - 移除
import './assets/styles/micro-app.css' - 添加
import './styles/index.scss' - 调整
@cmclink/ui/styles的导入顺序
- 移除
-
删除旧文件
assets/main.cssassets/base.cssassets/styles/micro-app.css-
assets/styles/目录
-
完整回归测试
- 登录页样式
- 主布局(侧边栏、顶部导航、标签页)
- 表格页面(筛选框、表格、分页)
- 弹窗/抽屉
- 暗色主题切换
- 子应用加载和样式隔离
Phase 4:微前端隔离优化(需子应用配合)
预计耗时:2-3 小时(每个子应用)
-
子应用配置弹窗不逃逸
- 全局配置
:teleported="false"或append-to - 在
@cmclink/micro-bootstrap中提供统一配置能力
- 全局配置
-
删除
micro-app.css -
验证子应用样式隔离
- 主应用样式不侵入子应用
- 子应用样式不泄漏到主应用
- 弹窗/抽屉样式正确
Phase 5:@cmclink/ui 优化(独立进行)
预计耗时:2-3 小时
-
移除 Tailwind CSS 引入(从
@cmclink/ui/styles/index.scss) -
清理
animations.scss中与 Tailwind 重复的工具类(约 800 行) -
审查
utilities.scss中与 Tailwind 重复的部分
七、风险评估与回退方案
7.1 风险点
| 风险 | 概率 | 影响 | 缓解措施 |
|---|---|---|---|
styles/index.scss 未被引用但样式实际生效 |
低 | 高 | 先在浏览器 DevTools 确认哪些样式实际生效 |
| 拆分后样式加载顺序变化导致覆盖失效 | 中 | 中 | 严格按照原有顺序组织 @use
|
子应用弹窗 :teleported="false" 导致层级问题 |
中 | 中 | 逐个子应用测试,必要时用 z-index 调整 |
删除 base.css 后某些页面样式异常 |
低 | 中 |
@cmclink/ui/base.scss 已覆盖所有 reset |
7.2 回退方案
每个 Phase 独立提交 Git,如果出现问题可以精确回退到任意阶段:
feat(styles): phase-1 基础清理和目录结构
feat(styles): phase-2 文件拆分迁移
feat(styles): phase-3 入口重构
feat(styles): phase-4 微前端隔离优化
feat(styles): phase-5 @cmclink/ui 优化
八、验证清单
8.1 样式正确性
- 登录页样式正常(背景、表单、按钮)
- 主布局样式正常(侧边栏展开/收起、顶部导航、面包屑)
- 标签页样式正常(激活态、hover、关闭按钮)
- 表格页面样式正常(筛选框、表头、行、分页)
- 弹窗/抽屉样式正常(大/中/小尺寸)
- 表单样式正常(输入框、下拉框、日期选择器)
- 按钮样式正常(主要、链接、危险、禁用)
- NProgress 进度条颜色正确
- 暗色主题切换正常
8.2 微前端隔离
- 子应用加载后样式正常
- 主应用样式未侵入子应用
- 子应用样式未泄漏到主应用
- 子应用弹窗样式正确(不逃逸到主应用 body)
- 多个子应用切换时样式无残留
8.3 构建产物
- 构建无报错
- CSS 产物体积不增长(预期减少 30%+)
- 无重复的 Tailwind CSS 输出
- 无重复的 CSS Reset
8.4 开发体验
- HMR 样式热更新正常
- 新增样式时知道该放在哪个文件
- SCSS 变量和 CSS 变量可正常引用
九、预期收益
| 维度 | 现状 | 重构后 |
|---|---|---|
| CSS 产物体积 | Tailwind ×2 + Reset ×2 + 大量重复 | 减少约 30-40% |
| 样式文件数 | 散落 3 个目录,8+ 个文件 | 1 个目录,清晰分层 |
| 入口文件 | main.ts 导入 2 个样式文件 + 隐式依赖 | main.ts 导入 1 个入口 |
| 新人上手 | 不知道样式该写在哪 | 5 分钟理解结构 |
| 微前端隔离 | !important 硬覆盖 | wujie 沙箱天然隔离 |
| 主色一致性 | 3 处定义,2 个不同值 | 1 处定义,全局统一 |
| !important 使用 | 泛滥 | 仅在必要的 EP 覆盖中使用 |
React学习-setState、useState
setState
语法:
this.setState(updater, [callback])
写法总结:
// 语法:this.setState({ newState })
this.setState({ count: 1 });
// 推荐使用,尤其是状态更新依赖于前一次状态时。它可以接收当前 state 和 props
// 语法:this.setState((state, props) => { return { newState } })
this.setState((state, props) => ({
count: state.count + 1
}));
// 在 setState 更新完成并重新渲染界面后执行,可确保获取到最新状态
this.setState({ count: 1 }, () => {
console.log('更新后的状态:', this.state.count);
});
特性:
-
setState是异步的:React 会将多个setState调用合并(batch)成一次更新,以提升性能。 - 在事件处理函数(如 onClick)中,
setState是批量的。 - 在原生事件、setTimeout、Promise 等异步上下文中,
setState可能不会自动批处理(但在 React 18+ 中已改进),可能表现为`同步状态。
⚠️ 注意:React 18 开始,无论在哪里调用
setState,默认都会自动批处理(automatic batching)。
底层原理(React 16+ Fiber 架构)
- 调用
setState会触发enqueueSetState。 - React 将更新放入一个更新队列(Update Queue) ,并标记该 Fiber 节点为“需要更新”。
- 在渲染阶段(render phase) ,React 会遍历 Fiber 树,收集所有待更新的节点。
- 在提交阶段(commit phase) ,应用 DOM 更新并触发副作用(如生命周期方法)。
关键点:
setState并不立即修改this.state,而是创建一个更新对象(update object),由 React 调度器(Scheduler)决定何时处理。
useState
它允许你向组件添加一个 状态变量,语法:
const [state, setState] = useState(initialState)
它没有回调函数;
特性:
- 和
setState一样,useState的setXXX也是异步且批量更新的。 - React 18 后,在任何上下文中(包括 setTimeout)都会自动批处理。
底层原理(Hooks 机制)
-
useState是 React Hooks 的一部分,其状态存储在 Fiber 节点的memoizedState字段中。 -
每个 Hook(如
useState,useEffect)在 Fiber 节点上按调用顺序形成一个链表(Hook 链表) 。 -
调用
setCount时,React 会:- 创建一个更新对象(类似类组件的 update)。
- 将其加入对应 Hook 的更新队列。
- 触发组件重新渲染(schedule update)。
-
在下一次渲染时,React 会遍历 Hook 链表,应用所有 pending updates,计算出新的 state。
📌 关键:Hooks 的状态与组件实例绑定,通过 Fiber 节点维持状态,而不是像类组件那样通过
this.state。
setState vs useState 对比
| 特性 |
setState(类组件) |
useState(函数组件) |
|---|---|---|
| 状态结构 | 单个对象(可包含多个字段) | 多个独立状态(每个 useState 管理一个) |
| 更新方式 | 合并对象(shallow merge) | 替换整个值(非合并) |
| 初始值 | 构造函数中定义 | useState(initialValue) |
| 底层存储 |
this.state(实际由 Fiber 管理) |
Fiber 的 memoizedState 链表 |
| 性能 | 批量更新,Fiber 调度 | 同左,支持并发模式 |
| 推荐使用 | 已逐渐被函数组件取代 | React 官方推荐方式 |
💡 注意:
useState不会自动合并对象!
底层共通机制(React 18+)
无论是 setState 还是 useState,最终都依赖于:
- Fiber 架构:每个组件对应一个 Fiber 节点,状态和更新都挂载其上。
- 更新队列(Update Queue) :存放待处理的状态变更。
- 调度器(Scheduler) :基于优先级(如用户交互高优先级)决定何时执行更新。
- 协调(Reconciliation) :通过 diff 算法生成最小 DOM 操作。
- 自动批处理(Automatic Batching) :React 18 起,所有状态更新默认批处理。
2连板博纳影业:公司目前经营情况正常,不存在应披露而未披露的重大事项
台积电董事会核准449.62亿美元资本预算,将用于建置及升级先进制程产能
晶核能源发布降低固态电池成本的核心技术成果
2连板大位科技:张北数据中心项目不涉及算力租赁业务
TCL智能科技宁波公司注册资本增至约15.6亿元
国家发展改革委等部门发布加快招标投标领域人工智能推广应用的实施意见
中国商业航天:“关键先生”与黄金十年
2026年甫一开年,行业便迎来第一个大热点——商业航天。
政策层面,2025年11月,国家航天局专门成立“商业航天司”;2025年12月,朱雀三号成功入轨,上交所发布“9号指引”,二级市场掀起连续涨停潮;2026年1月,五大独角兽加速冲刺IPO,多项历史纪录被打破。
政策窗口、技术突破、市场需求、资本环境的全面爆发,走到这一天,中国商业航天用了整整十年。
自2015年起步,中国商业航天走过了从无到有、由弱到强的十年。这十年间,商业航天技术迎来突破,低轨星座组网驶入快车道,可回收火箭技术出现“拐点”,覆盖全球的卫星通信网络建成,产投融合形成合力。
这也是英雄辈出的十年。这十年间,蓝箭航天张昌武、时空道宇王洋等“关键先生”,怀揣梦想,躬身入局,他们不仅定义了民营航天的赛道格局,更构建起“火箭-卫星-应用”的协同生态,推动了中国商业航天从单点突破到全链融合的产业进程。
这是中国商业航天的“黄金十年”。
十年突围:从政策破冰到商业竞速
这一切,要从2015年的那份文件开始讲起。
2015年10月,国家发展改革委、财政部、国防科工局《国家民用空间基础设施中长期发展规划(2015—2025年)》中明确提出“鼓励社会力量参与”,正式拉开了中国商业航天的大幕。
这是中国商业航天元年,但坦白说,也是中国商业航天落后之年。
早在20世纪90年代,海外通信巨头就已着手商业卫星通信产业的研发。2002年SpaceX成立后,更是在此领域不断深耕,并于2014年成立星链(Starlink)业务,向全球提供商业卫星互联网宽带服务。
2015年12月,当中国商业航天刚刚迎来政策破冰时,SpaceX的猎鹰9号已成功实现轨道级火箭回收,为全球商业航天创下历史。
等不是办法,干才有希望。
2014—2020年,中国民营航天企业应声而起,欧科微、蓝箭航天、零壹空间、星际荣耀、时空道宇等一大批创业者扎进“无人区”,有人造火箭、有人组星座、有人建工厂、有人搭体系……
他们中,既有多年的航空航天工程师,也有从体制到市场化的连续创业者;有背景丰富的跨界领袖,也有深耕细分赛道的行业专家。
在他们的努力下,我国商业航天快速走过了行业发展早期的技术验证阶段。
2020年4月,国家发展改革委首次将卫星互联网纳入“新基建”范畴,市场迎来快速爆发,新增企业数量也在这一年迎来高峰。
同时,随着技术的不断成熟,我国火箭发射频次显著提升,民营企业常态化发射成为可能,中国星网、千帆星座、时空道宇星座等低轨项目启动密集组网;
卫星制造上,制造成本下降,模块化设计普及,时空道宇台州卫星超级工厂引入汽车工业生产线设计,将每颗卫星的制造周期压缩到了惊人的28天;
下游应用更是迎来一轮小爆发,手机直连卫星通信开始商用,车路协同系统引入高精度定位,金融、农业、应急等领域采用遥感数据;连2023杭州亚运会、2025哈尔滨亚冬会都用上了卫星的服务。
![]()
时空道宇星座赋能智能网联汽车
十年前,我国商业航天企业不足10家,而今天,这一数量已经超过了600家。
根据华西证券数据,我国商业航天市场规模从2015年约0.38万亿元,一路飙升至2024年的2.3万亿元,年均复合增长率高达22%;若按照25%的增速计算,2030年,市场规模将达10万亿元。
这是我们真正的“星辰大海”。
关键路径,“关键先生”
在中国商业航天从政策破冰走向产业竞速的十年间,推动行业成型的,不止有宏观政策,更有一批沉得下心、看得清路、敢押注长期价值的“关键先生”。
他们大多出身体制内科研体系,却选择跳入市场化深水区;他们面对的是高风险、长周期、重资产的赛道,却以不同路径完成了从0到1的突破。
其中,蓝箭航天创始人张昌武与时空道宇创始人王洋,分别代表了中国商业航天两大核心方向——运载火箭与低轨卫星星座运营——的差异化生存逻辑。
先说“上天”。
2015年,张昌武离开金融行业,成立了蓝箭航天。
然而,蓝箭航天早期的一项商业选择,却显得格外“另类”。
![]()
蓝箭航天创始人张昌武 图片来源:蓝箭航天官网
彼时,国内商业航天刚起步,多数企业选择技术门槛低、研发周期短、资金回笼快的固体火箭路线。但张昌武却自创立之初便坚定押注液氧甲烷液体火箭,并启动相关研制:彼时,液氧甲烷液体火箭全球尚无成功火箭入轨的先例,技术路径复杂,地面试验成本高昂,失败率极高。
这是一条更难的路,却是一条天花板更高的赛道。液氧甲烷液体火箭的可复用、低成本、环保型特性,使得它更符合火箭高频次发射的需求。
2023年7月,“朱雀二号”火箭成功入轨,成为全球首枚实现入轨的液氧甲烷火箭。这一里程碑不仅验证了技术路线的可行性,更向市场证明:中国企业有能力攻克航天最硬核的环节。
![]()
朱雀二号遥三 图片来源:蓝箭航天官网
更重要的是,蓝箭航天并未止步于“能飞”:围绕液体运载火箭,蓝箭航天已构建起覆盖研制、生产、试验、发射的完整产业链条;其在液氧甲烷发动机等领域实现多项关键技术突破,正从技术执行者转变为行业共建者。
张昌武所走的,是一条“极致技术”的长征:目标不仅仅是单次成功,而是高频、可靠的常态化发射能力。
说完“上天”,我们说说“入地”。
与张昌武不同,时空道宇创始人王洋的战场不仅在大气之上,也在轨道之上、地面之上——低轨卫星物联网星座。
张昌武是复合背景跨界玩家的典型代表,王洋则长期深耕于航天系统内部,有着体制内航天人才的典型成长路径。
早年间,王洋在中国科学院上海微小卫星工程中心参与国家重大航天型号��务,深度参与遥感、通信等多类卫星平台的总体设计与系统集成,积累了完整的宇航工程经验。
作为深耕技术与行业多年的工程人才,王洋早在政策正式破冰前便洞察到微小卫星技术成熟、成本下降和应用场景拓展带来的结构性机会。
![]()
时空道宇CEO 王洋
2014年,得益于单位的灵活机制,王洋以“保留编制、暂时离岗”方式创业,成立欧科微,成为中国最早一批商业卫星公司。当时王洋身边充斥着两种声音,一种声音认为这是“从商”、“做买卖”,另一种声音则认为以企业的机制没准能快速推动技术发展速率和项目的执行效率。
欧科微聚焦低轨道微小卫星整星研制,验证了市场化路径的可行性,其2018年的“嘉定一号”卫星由长征二号丁火箭成功发射入轨,成为中国首颗由民营企业自主研发并成功入轨的商业通信卫星。
然而,单星交付却只是起点,王洋逐渐意识到,商业航天的价值终点不仅仅是“把卫星送上天”,而是“让卫星技术服务大众”。
“一代航天人有一代航天人的使命。”王洋曾经不止一次这么感叹过。
2018年,王洋创立时空道宇,将目光投向低轨卫星物联网星座这一更宏大的赛道;这一次,他不再只是卫星设计师,更是系统架构师、制造颠覆者和全球服务推动者。
2019年,时空道宇星座建设计划正式启动,规划覆盖全球的低轨通信网络;2021年,时空道宇主导建成台州卫星超级工厂,其在卫星制造中引入汽车工业的柔性产线理念,将传统需数月甚至以年为单位的卫星研制周期压缩至28天;2022年6月2日,时空道宇星座首轨9星发射成功,9颗卫星成功实现轨道面级部署,创下国内商业公司又一纪录。
在此期间,建设星座的同时,王洋正同步构建“规模化应用”的运营闭环。
2023年,时空道宇与极氪合作,将卫星通信技术集成进智能电动汽车,完成了航天产品首次车规级研发与前装量产应用。
2024年6月,时空道宇在中东阿曼完成星座首次海外通信商用部署测试,开启了时空道宇海外商业部署之路。
2025年9月,时空道宇正式完成低轨卫星物联网星座一期64颗卫星组网,中国商业航天企业首次有能力面向全球用户提供卫星通信服务。同年,时空道宇已与20多个海外国家的电信运营商签署合作协议,将服务落地非洲、亚洲、拉美等地。
![]()
时空道宇星座六轨卫星成功发射入轨
作为中国最早提出并实践“低轨卫星物联网星座”的商业主体之一,王洋团队所推动的“卫星+行业”的融合模式,如今正与海洋渔业、工程机械、智能网联汽车等产业深度融合,打破了传统航天“只造不营”的局限,打通了中国商业航天“技术—制造—应用—运营”的闭环路径,成为从航天技术提供商到全球卫星通信服务商的“生态破局者”。
张昌武与王洋,还有无数商业航天的“关键先生”们,拒绝短期套利,坚持长期主义,不迷信国外模式,基于中国产业基础与市场需求,走出本土化创新路线。
正是他们的不断探索,才逐渐推动中国商业航天从零散试验走向体系化产业:火箭可复用、卫星可量产、服务可运营、应用可落地。
这不是英雄叙事,更是中国商业航天的一场全面演进。当更多“张昌武”和“王洋”在各自赛道持续深耕,中国商业航天才在2025年底迎来了这场全面爆发。
从“能不能飞”到“能不能用”
今天,中国商业航天已越过“能不能上天”的门槛。现在的问题是:能不能用?好不好用?
行业重心正在下移,火箭发射不再是终点。卫星制造、地面终端、运营服务、场景融合——这些环节,也是价值所在。
根据方正证券数据,2024年全球太空经济市场规模约2.9万亿元,其中发射服务占2%,卫星制造、地面设备、卫星服务等分别占5%、38%、26%。
对于卫星的市场,真正的价值创造与利润来源,在于“怎么把卫星服务用好”。
当前,全球卫星服务已形成三大核心业务方向:宽带接入、手机直连卫星通信、广域物联网。这三类服务对应不同技术架构、终端形态与商业模式,也决定了各自的价值重心。
第一类是宽带接入,以SpaceX的星链为代表,主打宽带低轨星座的高通量、低延迟互联网服务,为家庭、企业、海事和航空用户提供百兆级带宽。
第二类是手机直连卫星通信,这一方向近年加速落地,苹果、华为、谷歌等巨头纷纷入局,让普通智能手机无需外接设备即可发送紧急短信或短报文。
第三类是广域物联网,即通过窄带低轨星座连接分布广泛、功耗敏感的终端设��,例如新能源汽车状态回传、远洋渔船定位、电力铁塔监测、跨境物流追踪等。这类服务不追求高带宽,而强调低功耗、高并发、低成本和全球覆盖。
![]()
时空道宇星座赋能海洋渔业
三类业务并行发展,无法相互替代。
例如,SpaceX星链的宽带低轨星座,主要解决“人接入互联网”的问题,其终端功耗高、价格贵、体积大,核心目标是“更快、更大带宽”;
而时空道宇等窄带通信服务商恰恰与其相反,其核心价值在于低功耗、广覆盖、高并发和低成本,核心解决的是“物联网终端能否永远在线”的问题,其终端环境往往非常严苛:成本低、支持数年待机、超强网络可靠性、长期恶劣环境等等,核心目标是“稳定、低成本地保持连接”。
未来,谁能率先在这三条赛道中构建成本可控、生态开放、服务可靠的闭环,谁就将在下一代太空经济中掌握主动权。
请回答2026
这是中国商业航天的黄金十年。
2015年,中国商业航天破冰启航,十年间,产业从亦步亦趋的“跟跑”,到独立潮头的“并跑”,一场跨越式发展就此上演。
这十年里,朱雀三号一飞冲天,可回收火箭技术迎来拐点;低轨星座驶入快车道,卫星组网加速推进;“技术—制造—应用—运营”闭环路径打通,低轨通信网络覆盖全球;超级工厂深夜里灯火通明,海南商业航天发射场二期工程紧锣密鼓。
这十年里,“张昌武”和“王洋”们用各自路径回答了“中国商业航天如何生存与发展”,他们起步于无人区,早期融资难、人才缺、政策不明,试验屡屡受挫。冷板凳坐过,质疑声听过,但他们从未退场。
正是千千万万个“关键先生”的不懈努力,共同推动了中国商业航天从单点突破走向系统构建,让中国商业航天从技术追随的“小学生”,成长为独当一面的“参赛者”。
然而,商业航天是一场绝对的耐力长跑竞赛。
根据公开数据统计,从2015到2025年,国内商业航天领域融资的总额超过500亿元;同比之下,国内半导体产业融资规模超过9000亿元,机器人产业领域也在2000亿元以上。
同为“投资高、周期长、回报慢”的重资产赛道,中国商业航天,还有很长的路要走。
站在2026年这个关键节点,站在中国商业航天全面爆发的前夜,当万星组网全面提速,火箭发射趋于常态,当“商业航天第一股”正式落锤定音,时代的考验,这才刚刚开始。
中钨高新:拟1.45亿元实施新增PCB钻针棒3000万支/年项目
*ST金灵:因执行重整计划进行资本公积金转增股本,股票停牌一天
踩坑小记之闭包陷阱
问题背景
在页面中,用户可以通过表格中的开关(Switch)组件快速切换计划的启用/禁用状态。系统的预期行为是:
- 点击第一行的开关从"开启"切换到"关闭"
- 立即点击第二行的开关从"关闭"切换到"开启"
- 预期结果:第一行变关闭,第二行变开启
但实际发生的问题是:两行的开关状态会变成一样,要么都变成开启,要么都变成关闭。
问题现象
这个问题在刷新页面后首次操作时必现,尤其是在以下场景:
- 用户快速连续点击多行的状态开关
- 点击第一行后,在接口请求还未返回时,立即点击第二行
- 两个请求基本同时发出
技术实现(错误版本)
原始代码
// src/page/UpgradePlan/index.js
const [appList, setAppList] = useState([]) // 表格数据列表
/**
* 处理计划状态变更
* @param {object} record - 当前行数据
* @param {string} newStatus - 新状态值 ('ACTIVE' 或 'INACTIVE')
*/
const handlePlanStatusChange = (record, newStatus) => {
// ❌ 问题代码:基于闭包捕获的 appList
const updatedList = appList.map(item => {
if (item.planId === record.planId) {
return {
...item,
planStatus: newStatus,
}
}
return item
})
setAppList(updatedList)
}
表格状态渲染
// src/page/UpgradePlan/UpgradePlanTable.js
const columns = [
{
title: '计划状态',
width: 100,
dataIndex: 'planStatus',
fixed: 'left',
render: (text, record) => {
return (
<Switch
checked={text === 'ACTIVE' ? true : false}
checkedChildren="开启"
unCheckedChildren="关闭"
loading={loadingPlanIds.has(record.planId)}
onChange={checked => {
handlePlanStatusChange(checked, record, text)
}}
/>
)
},
},
// ... 其他列
]
问题根源分析
1. 闭包陷阱
这是一个经典的 React 状态闭包问题:
// ❌ 每次 handlePlanStatusChange 执行时,appList 都是被闭包捕获的"旧值"
const handlePlanStatusChange = (record, newStatus) => {
const updatedList = appList.map(item => { // appList 来自闭包,可能已过时
if (item.planId === record.planId) {
return { ...item, planStatus: newStatus }
}
return item
})
setAppList(updatedList)
}
2. 执行流程演示
假设初始状态:
appList = [
{ planId: 1, planStatus: 'ACTIVE' }, // 第一行:开启
{ planId: 2, planStatus: 'INACTIVE' } // 第二行:关闭
]
快速点击两行的执行流程:
时刻 T0: 初始 appList = [{id:1, status:'ACTIVE'}, {id:2, status:'INACTIVE'}]
时刻 T1: 用户点击第一行开关 (ACTIVE → INACTIVE)
└─ 调用 handlePlanStatusChange(record1, 'INACTIVE')
└─ 函数内捕获的 appList 仍是 T0 时刻的值
└─ updatedList = [{id:1, status:'INACTIVE'}, {id:2, status:'INACTIVE'}]
└─ setAppList(updatedList) // 开始异步更新
时刻 T2: 用户立即点击第二行开关 (INACTIVE → ACTIVE) [此时第一个更新还未完成]
└─ 调用 handlePlanStatusChange(record2, 'ACTIVE')
└─ 函数内捕获的 appList 仍是 T0 时刻的值 ⚠️ 关键问题!
└─ updatedList = [{id:1, status:'ACTIVE'}, {id:2, status:'ACTIVE'}]
└─ setAppList(updatedList) // 这个更新会覆盖 T1 的更新
时刻 T3: React 处理状态更新
└─ 第二个 setAppList 覆盖了第一个
└─ 最终结果:[{id:1, status:'ACTIVE'}, {id:2, status:'ACTIVE'}]
└─ ❌ 第一行的状态变更丢失了!
3. 问题本质
-
闭包捕获过时值:
handlePlanStatusChange函数体内的appList是在函数定义时捕获的,不是执行时的最新值 -
异步状态更新:两个
setAppList调用都会加入 React 的更新队列,但都基于同一时刻的旧状态快照 -
后者覆盖前者:第二个
setAppList执行时,会用包含过时数据的updatedList覆盖第一个的更新
改正措施
解决方案:使用函数式状态更新
// ✅ 改正后的代码
/**
* 处理计划状态变更
* @param {object} record - 当前行数据
* @param {string} newStatus - 新状态值
*/
const handlePlanStatusChange = (record, newStatus) => {
// ✅ 使用函数式更新,prevAppList 始终是最新的状态
setAppList(prevAppList => {
return prevAppList.map(item => {
if (item.planId === record.planId) {
return {
...item,
planStatus: newStatus,
}
}
return item
})
})
}
改正后的执行流程
时刻 T0: 初始 appList = [{id:1, status:'ACTIVE'}, {id:2, status:'INACTIVE'}]
时刻 T1: 用户点击第一行开关 (ACTIVE → INACTIVE)
└─ 调用 setAppList(prevAppList => {...})
└─ prevAppList = T0 时刻的值
└─ updatedList = [{id:1, status:'INACTIVE'}, {id:2, status:'INACTIVE'}]
└─ 加入更新队列
时刻 T2: 用户立即点击第二行开关 (INACTIVE → ACTIVE)
└─ 调用 setAppList(prevAppList => {...})
└─ ⚠️ 但此时 prevAppList = T1 更新后的值!
└─ prevAppList = [{id:1, status:'INACTIVE'}, {id:2, status:'INACTIVE'}]
└─ 只更新 id:2,保留 id:1 的状态
└─ updatedList = [{id:1, status:'INACTIVE'}, {id:2, status:'ACTIVE'}]
└─ 加入更新队列
时刻 T3: React 处理状态更新(批处理)
└─ 先执行 T1 的更新
└─ 再执行 T2 的更新(基于 T1 的结果)
└─ 最终结果:[{id:1, status:'INACTIVE'}, {id:2, status:'ACTIVE'}]
└─ ✅ 两行状态都正确!
关键区别对比
❌ 错误方式:直接访问闭包变量
const handlePlanStatusChange = (record, newStatus) => {
// appList 是闭包捕获的,在快速连续调用时是同一时刻的值
const updatedList = appList.map(...)
setAppList(updatedList)
}
问题:
- 多次快速调用时,所有调用都基于同一时刻的
appList - 后面的调用会覆盖前面的结果
- 状态变更会丢失
✅ 正确方式:函数式状态更新
const handlePlanStatusChange = (record, newStatus) => {
// prevAppList 参数由 React 提供,始终是最新的状态
setAppList(prevAppList => {
return prevAppList.map(...)
})
}
优势:
- React 保证每次调用时,
prevAppList都是最新的状态 - 多次快速调用时,每次都基于前一次更新的结果
- 状态更新会正确链式执行
- 充分利用 React 的批处理机制
深入理解
React 状态更新的本质
React 的状态更新机制:
// React 内部维护的更新队列
const updateQueue = []
// 当调用 setAppList(newValue) 时
setAppList(newValue)
// React 会加入队列
updateQueue.push({ type: 'direct', value: newValue })
// 当调用 setAppList(prevValue => newValue) 时
setAppList(prevValue => newValue)
// React 会记录更新函数,并在需要时执行
updateQueue.push({ type: 'function', fn: (prevValue) => newValue })
批处理时:
// ❌ 直接值更新(会被覆盖)
setAppList(value1) // → queue: [{ type: 'direct', value: value1 }]
setAppList(value2) // → queue: [{ type: 'direct', value: value2 }] 覆盖前一个
// 最终结果:只有 value2 生效
// ✅ 函数式更新(会链式执行)
setAppList(prev => newValue1(prev)) // → queue: [fn1]
setAppList(prev => newValue2(prev)) // → queue: [fn1, fn2]
// 执行:fn1(initialState) → state1
// fn2(state1) → state2
// 最终结果:两个更新都生效
应用场景
这个问题常见于以下场景:
- 列表行操作:快速切换表格中多行的状态
- 表单快速提交:连续提交多个表单项
- 购物车操作:快速添加/删除多个商品
- 批量操作:连续执行多个列表项的操作
总结
核心要点
| 项目 | 错误方式 | 正确方式 |
|---|---|---|
| 方式 | setAppList(updatedList) |
setAppList(prev => updatedList(prev)) |
| 状态来源 | 闭包捕获的旧值 | React 提供的最新值 |
| 快速操作 | 后面的调用覆盖前面的 | 链式执行,都生效 |
| 适用场景 | 单次更新 | 多次连续更新 |
最佳实践
在 React 中更新状态时,如果新状态依赖于旧状态,始终使用函数式更新:
// ✅ 推荐写法(避免闭包陷阱)
setState(prevState => {
return {
...prevState,
// 基于 prevState 的更新
}
})
// ❌ 避免(容易踩坑)
setState({
...state,
// 基于当前 state 的更新
})
参考资源
教训: 在 React 中处理依赖于前一状态的更新时,永远优先考虑使用函数式状态更新,这是避免闭包陷阱最直接有效的方法。