普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月23日掘金 前端

实现流式布局的几种方式

作者 娜妹子辣
2026年1月23日 16:58

🎯 流式布局实现方式概览

方式 适用场景 兼容性 复杂度
百分比布局 简单两栏、三栏布局 优秀 简单
Flexbox布局 一维布局、导航栏、卡片 现代浏览器 中等
CSS Grid布局 二维布局、复杂网格 现代浏览器 中等
浮动布局 传统多栏布局 优秀 复杂
视口单位布局 全屏应用、响应式组件 现代浏览器 简单
表格布局 等高列布局 优秀 简单

1️⃣ 百分比布局

基本原理

使用百分比作为宽度单位,元素宽度相对于父容器计算。

实现示例

经典两栏布局

HTML
<div class="container">
  <div class="sidebar">侧边栏</div>
  <div class="content">主内容</div>
</div>
CSS
.container {
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
}

.sidebar {
  width: 25%;           /* 占25%宽度 */
  float: left;
  background: #f0f0f0;
  min-height: 500px;
}

.content {
  width: 75%;           /* 占75%宽度 */
  float: right;
  background: #fff;
  padding: 20px;
  box-sizing: border-box;
}

/* 清除浮动 */
.container::after {
  content: "";
  display: table;
  clear: both;
}

三栏等宽布局

HTML
<div class="three-columns">
  <div class="column">列1</div>
  <div class="column">列2</div>
  <div class="column">列3</div>
</div>
CSS
.three-columns {
  width: 100%;
  display: flex;
}

.column {
  width: 33.333%;       /* 每列占33.333% */
  padding: 20px;
  box-sizing: border-box;
  background: #e9e9e9;
  margin-right: 1%;
}

.column:last-child {
  margin-right: 0;
}

优点:  简单易懂,兼容性好
缺点:  需要精确计算,处理间距复杂


2️⃣ Flexbox布局

基本原理

使用弹性盒子模型,容器内元素可以灵活伸缩。

实现示例

自适应导航栏

HTML
<nav class="navbar">
  <div class="logo">Logo</div>
  <ul class="nav-menu">
    <li><a href="#">首页</a></li>
    <li><a href="#">产品</a></li>
    <li><a href="#">关于</a></li>
    <li><a href="#">联系</a></li>
  </ul>
  <div class="user-actions">
    <button>登录</button>
    <button>注册</button>
  </div>
</nav>
CSS
.navbar {
  display: flex;
  align-items: center;
  width: 100%;
  padding: 0 20px;
  background: #333;
  color: white;
}

.logo {
  flex: 0 0 auto;       /* 不伸缩,保持原始大小 */
  font-size: 24px;
  font-weight: bold;
}

.nav-menu {
  display: flex;
  flex: 1;              /* 占据剩余空间 */
  justify-content: center;
  list-style: none;
  margin: 0;
  padding: 0;
}

.nav-menu li {
  margin: 0 20px;
}

.user-actions {
  flex: 0 0 auto;       /* 不伸缩 */
}

.user-actions button {
  margin-left: 10px;
  padding: 8px 16px;
}

卡片网格布局

HTML
<div class="card-container">
  <div class="card">卡片1</div>
  <div class="card">卡片2</div>
  <div class="card">卡片3</div>
  <div class="card">卡片4</div>
</div>
CSS
.card-container {
  display: flex;
  flex-wrap: wrap;      /* 允许换行 */
  gap: 20px;            /* 间距 */
  padding: 20px;
}

.card {
  flex: 1 1 300px;      /* 增长因子1,收缩因子1,基础宽度300px */
  min-height: 200px;
  background: #f9f9f9;
  border-radius: 8px;
  padding: 20px;
  box-sizing: border-box;
}

/* 响应式调整 */
@media (max-width: 768px) {
  .card {
    flex: 1 1 100%;     /* 移动端每行一个 */
  }
}

圣杯布局(Flexbox版本)

HTML
<div class="holy-grail">
  <header class="header">头部</header>
  <div class="body">
    <nav class="nav">导航</nav>
    <main class="content">主内容</main>
    <aside class="ads">广告</aside>
  </div>
  <footer class="footer">底部</footer>
</div>
CSS
.holy-grail {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

.header, .footer {
  flex: 0 0 auto;       /* 固定高度 */
  background: #333;
  color: white;
  padding: 20px;
  text-align: center;
}

.body {
  display: flex;
  flex: 1;              /* 占据剩余空间 */
}

.nav {
  flex: 0 0 200px;      /* 固定宽度200px */
  background: #f0f0f0;
  padding: 20px;
}

.content {
  flex: 1;              /* 占据剩余空间 */
  padding: 20px;
  background: white;
}

.ads {
  flex: 0 0 150px;      /* 固定宽度150px */
  background: #e0e0e0;
  padding: 20px;
}

/* 移动端响应式 */
@media (max-width: 768px) {
  .body {
    flex-direction: column;
  }
  
  .nav, .ads {
    flex: 0 0 auto;
  }
}

优点:  灵活强大,处理对齐和分布简单
缺点:  主要适用于一维布局


3️⃣ CSS Grid布局

基本原理

二维网格系统,可以同时控制行和列。

实现示例

响应式网格布局

HTML
<div class="grid-container">
  <div class="item">项目1</div>
  <div class="item">项目2</div>
  <div class="item">项目3</div>
  <div class="item">项目4</div>
  <div class="item">项目5</div>
  <div class="item">项目6</div>
</div>
CSS
.grid-container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 20px;
  padding: 20px;
}

.item {
  background: #f9f9f9;
  padding: 20px;
  border-radius: 8px;
  min-height: 150px;
}

/* 自动响应效果:
   - 容器宽度 > 1000px: 4列
   - 容器宽度 750-1000px: 3列  
   - 容器宽度 500-750px: 2列
   - 容器宽度 < 500px: 1列
*/

复杂布局网格

HTML
<div class="layout-grid">
  <header class="header">头部</header>
  <nav class="sidebar">侧边栏</nav>
  <main class="content">主内容</main>
  <aside class="widget">小组件</aside>
  <footer class="footer">底部</footer>
</div>
CSS
.layout-grid {
  display: grid;
  grid-template-areas: 
    "header header header"
    "sidebar content widget"
    "footer footer footer";
  grid-template-columns: 200px 1fr 150px;
  grid-template-rows: auto 1fr auto;
  min-height: 100vh;
  gap: 10px;
}

.header { 
  grid-area: header; 
  background: #333;
  color: white;
  padding: 20px;
}

.sidebar { 
  grid-area: sidebar; 
  background: #f0f0f0;
  padding: 20px;
}

.content { 
  grid-area: content; 
  background: white;
  padding: 20px;
}

.widget { 
  grid-area: widget; 
  background: #e0e0e0;
  padding: 20px;
}

.footer { 
  grid-area: footer; 
  background: #333;
  color: white;
  padding: 20px;
}

/* 响应式调整 */
@media (max-width: 768px) {
  .layout-grid {
    grid-template-areas: 
      "header"
      "content"
      "sidebar"
      "widget"
      "footer";
    grid-template-columns: 1fr;
  }
}

图片画廊网格

HTML
<div class="gallery">
  <img src="img1.jpg" alt="图片1" class="tall">
  <img src="img2.jpg" alt="图片2">
  <img src="img3.jpg" alt="图片3" class="wide">
  <img src="img4.jpg" alt="图片4">
  <img src="img5.jpg" alt="图片5">
  <img src="img6.jpg" alt="图片6" class="big">
</div>
CSS
.gallery {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  grid-auto-rows: 200px;
  gap: 10px;
  padding: 20px;
}

.gallery img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 8px;
}

/* 特殊尺寸 */
.tall {
  grid-row: span 2;     /* 占据2行 */
}

.wide {
  grid-column: span 2;  /* 占据2列 */
}

.big {
  grid-column: span 2;
  grid-row: span 2;     /* 占据2x2网格 */
}

优点:  强大的二维布局能力,语义清晰
缺点:  学习曲线较陡,兼容性要求较高


4️⃣ 浮动布局

基本原理

使用float属性让元素脱离文档流,实现多栏布局。

实现示例

传统三栏布局

HTML
<div class="container">
  <div class="left">左侧栏</div>
  <div class="right">右侧栏</div>
  <div class="center">中间内容</div>
</div>
CSS
.container {
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
}

.left {
  width: 20%;
  float: left;
  background: #f0f0f0;
  min-height: 500px;
}

.right {
  width: 25%;
  float: right;
  background: #e0e0e0;
  min-height: 500px;
}

.center {
  margin-left: 20%;     /* 为左侧栏留空间 */
  margin-right: 25%;    /* 为右侧栏留空间 */
  background: white;
  min-height: 500px;
  padding: 20px;
  box-sizing: border-box;
}

/* 清除浮动 */
.container::after {
  content: "";
  display: table;
  clear: both;
}

响应式浮动网格

HTML
<div class="float-grid">
  <div class="grid-item">项目1</div>
  <div class="grid-item">项目2</div>
  <div class="grid-item">项目3</div>
  <div class="grid-item">项目4</div>
</div>
CSS
.float-grid {
  width: 100%;
}

.float-grid::after {
  content: "";
  display: table;
  clear: both;
}

.grid-item {
  width: 23%;           /* 4列布局 */
  margin-right: 2.666%; /* 间距 */
  float: left;
  background: #f9f9f9;
  padding: 20px;
  box-sizing: border-box;
  margin-bottom: 20px;
}

.grid-item:nth-child(4n) {
  margin-right: 0;      /* 每行最后一个不要右边距 */
}

/* 响应式 */
@media (max-width: 768px) {
  .grid-item {
    width: 48%;         /* 2列布局 */
    margin-right: 4%;
  }
  
  .grid-item:nth-child(4n) {
    margin-right: 4%;
  }
  
  .grid-item:nth-child(2n) {
    margin-right: 0;
  }
}

@media (max-width: 480px) {
  .grid-item {
    width: 100%;        /* 1列布局 */
    margin-right: 0;
  }
}

优点:  兼容性极好,支持所有浏览器
缺点:  需要清除浮动,布局复杂,难以维护


5️⃣ 视口单位布局

基本原理

使用vw、vh、vmin、vmax等视口单位,直接相对于浏览器视口尺寸。

实现示例

全屏分屏布局

HTML
<div class="viewport-layout">
  <div class="left-panel">左面板</div>
  <div class="right-panel">右面板</div>
</div>
CSS
.viewport-layout {
  display: flex;
  width: 100vw;         /* 占满视口宽度 */
  height: 100vh;        /* 占满视口高度 */
}

.left-panel {
  width: 40vw;          /* 占视口宽度40% */
  background: #f0f0f0;
  padding: 2vw;         /* 内边距也使用视口单位 */
}

.right-panel {
  width: 60vw;          /* 占视口宽度60% */
  background: #e0e0e0;
  padding: 2vw;
}

响应式卡片布局

HTML
<div class="vw-cards">
  <div class="vw-card">卡片1</div>
  <div class="vw-card">卡片2</div>
  <div class="vw-card">卡片3</div>
</div>
CSS
.vw-cards {
  display: flex;
  flex-wrap: wrap;
  gap: 2vw;
  padding: 2vw;
}

.vw-card {
  width: calc(33.333vw - 4vw); /* 3列布局,减去间距 */
  min-width: 250px;            /* 最小宽度限制 */
  height: 30vh;                /* 高度相对视口 */
  background: #f9f9f9;
  border-radius: 1vw;
  padding: 2vw;
  box-sizing: border-box;
}

/* 响应式调整 */
@media (max-width: 768px) {
  .vw-card {
    width: calc(50vw - 3vw);   /* 2列布局 */
  }
}

@media (max-width: 480px) {
  .vw-card {
    width: calc(100vw - 4vw);  /* 1列布局 */
  }
}

响应式字体和间距

HTML
<div class="responsive-content">
  <h1>响应式标题</h1>
  <p>这是一段响应式文本内容。</p>
</div>
CSS
.responsive-content {
  padding: 5vw;
  max-width: 80vw;
  margin: 0 auto;
}

.responsive-content h1 {
  font-size: clamp(24px, 5vw, 48px); /* 最小24px,最大48px */
  margin-bottom: 3vw;
}

.responsive-content p {
  font-size: clamp(16px, 2.5vw, 20px);
  line-height: 1.6;
  margin-bottom: 2vw;
}

优点:  真正的响应式,直接相对于视口
缺点:  在极端尺寸下可能过大或过小


6️⃣ 表格布局

基本原理

使用display: table相关属性模拟表格布局,实现等高列。

实现示例

等高列布局

HTML
<div class="table-layout">
  <div class="table-cell sidebar">侧边栏内容比较少</div>
  <div class="table-cell content">
    主内容区域内容很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多很多
  </div>
  <div class="table-cell ads">广告栏内容中等</div>
</div>
CSS
.table-layout {
  display: table;
  width: 100%;
  table-layout: fixed;  /* 固定表格布局算法 */
}

.table-cell {
  display: table-cell;
  vertical-align: top;  /* 顶部对齐 */
  padding: 20px;
}

.sidebar {
  width: 20%;
  background: #f0f0f0;
}

.content {
  width: 60%;
  background: white;
}

.ads {
  width: 20%;
  background: #e0e0e0;
}

/* 响应式处理 */
@media (max-width: 768px) {
  .table-layout {
    display: block;     /* 改为块级布局 */
  }
  
  .table-cell {
    display: block;
    width: 100%;
  }
}

优点:  天然等高,垂直居中简单
缺点:  语义不佳,响应式处理复杂


🎯 选择指南

根据项目需求选择

需求 推荐方案 备选方案
简单两栏布局 Flexbox 百分比 + 浮动
复杂网格布局 CSS Grid Flexbox + 换行
导航栏 Flexbox 浮动
卡片网格 CSS Grid Flexbox
等高列 Flexbox 表格布局
全屏应用 视口单位 + Grid Flexbox
兼容老浏览器 浮动 + 百分比 表格布局

现代推荐组合

CSS
/* 现代流式布局最佳实践 */
.modern-layout {
  /* 使用CSS Grid作为主要布局方式 */
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: clamp(16px, 2vw, 32px);
  
  /* 容器使用视口单位和限制 */
  width: min(95vw, 1200px);
  margin: 0 auto;
  padding: clamp(16px, 4vw, 48px);
}

.modern-layout > * {
  /* 内部使用Flexbox处理对齐 */
  display: flex;
  flex-direction: column;
  
  /* 响应式内边距 */
  padding: clamp(12px, 3vw, 24px);
}

选择合适的流式布局方式关键在于理解项目需求、浏览器兼容性要求和团队技术水平,现代项目推荐优先使用CSS Grid和Flexbox组合。

从编译期到运行时:TypeScript 和 Zod 的职责分工

作者 清妍_
2026年1月23日 16:56

在前端工程里,TypeScript 几乎已经成了默认配置。
类型提示、自动补全、编译期报错,极大提升了开发体验。

但随着项目开始接触更多运行时不确定的数据(接口返回、配置文件、iframe、AI 输出),一个问题逐渐显现:

TypeScript 的“类型安全”,只存在于编译期。

这也是 Zod 频繁出现在近几年项目和 demo 中的原因。


一、TS 和 Zod 各自解决什么问题?

先把两者的职责说清楚。

维度 TypeScript Zod
作用阶段 编译期 运行时
是否参与打包后代码 ❌ 不存在 ✅ 存在
是否校验真实数据
是否影响开发体验 ✅ 极强 ⚠️ 辅助
主要目标 防止你写错代码 防止数据把程序搞崩

一句话总结:

TS 约束“你怎么写代码”,
Zod 约束“程序实际接收到什么数据”。

两者不是替代关系,而是分工关系。


二、有了 TS,为什么还需要 Zod?

这是很多人第一次看到 Zod 时的自然疑问。

关键原因在于:
TS 是静态的,Zod 是运行时的。

1️⃣ TypeScript 只存在于编译期

type User = {
  name: string;
  age: number;
};

这段类型:

  • 在你写代码时存在
  • 在 IDE 里给你提示
  • 打包之后会被完全移除

也就是说,运行时根本不存在 User 这个概念


2️⃣ 一个非常常见的错误直觉(90% TS 新手都会这样)

type User = {
  name: string;
  age: number;
};

const user: User = JSON.parse(localStorage.getItem('user')!);

TS:✔️ 没问题
运行时:❓ 完全未知

为什么这是危险的?

因为 localStorage 里的内容可能是:

{ "name": "Alice", "age": "18" }

或者:

null

甚至:

"hacked"

但 TS 不会、也不可能在运行时帮你检查这些。


三、Zod 在这里解决了什么?

Zod 的核心价值只有一个:

在运行时,对真实数据做结构校验,并在失败时明确抛错。

同样的逻辑,用 Zod 写是这样的:

const UserSchema = z.object({
  name: z.string(),
  age: z.number(),
});

const raw = JSON.parse(localStorage.getItem('user')!);
const user = UserSchema.parse(raw);

如果数据不符合约定:

  • 立即抛出错误
  • 明确指出哪一项不合法
  • 不会让 bug 延迟到某个业务逻辑里才爆炸

这一步,TS 是完全做不到的


四、什么叫“不可信输入来源”?

简单说一句不抽象的定义:

任何不是你当前代码自己 new 出来的数据,都是不可信的。

在实际项目中,以下全部属于不可信输入:

  • JSON.parse(...)
  • fetch(...) / 接口返回
  • postMessage
  • localStorage
  • 第三方 SDK 返回值
  • iframe / Web Worker 通信
  • AI 模型返回的 JSON

这些数据的共同点是:

它们的真实形状,TS 在运行时一无所知。


五、一个更直观的“跨边界”例子

postMessage 场景

window.addEventListener('message', (event) => {
  const data = event.data;
});

在这里:

  • event.data 的类型是 any
  • TS 无法保证它来自谁
  • 也无法保证结构正确

如果你直接用:

data.type === 'LOGIN'
data.payload.userId

只要数据结构稍有变化,就会在运行时崩掉。


用 Zod 明确边界

const MessageSchema = z.object({
  type: z.literal('LOGIN'),
  payload: z.object({
    userId: z.string(),
  }),
});

window.addEventListener('message', (event) => {
  const msg = MessageSchema.parse(event.data);
  // 从这里开始,TS 才真正安全
});

这一步的意义是:

把“外部世界的数据”,转化成“TS 世界里可信的数据”。


六、Zod 一般会出现在什么项目里?

你在传统 CRUD 业务中很少看到 Zod,并不奇怪。

Zod 通常出现于这些场景:

  • BFF / Node 服务
  • 插件系统 / 扩展机制
  • 配置驱动系统
  • 微前端 / iframe 通信
  • AI / Agent / Tool 调用
  • MCP / function calling

它们的共同特征是:

数据频繁跨越系统、进程、运行时边界。


七、TS + Zod 的合理分工方式

一个成熟的工程里,两者的职责通常是:

  • Zod:守住边界

    • 校验所有外部输入
    • JSON、接口、消息、AI 输出
  • TypeScript:管理内部

    • 业务逻辑
    • 状态流转
    • 组件和函数之间的协作

可以用一句话概括:

Zod 负责“别让脏数据进来”,
TS 负责“进来之后别把代码写错”。

前端代码规范 ESLint 和 Prettier 的使用及冲突解决

作者 28256_
2026年1月23日 16:55

工具

ESLint

代码质量检查工具

  • 检查 JavaScript/TypeScript 代码中的错误
  • 发现潜在问题(未使用变量、错误的 API 使用等)
  • 强制执行代码规范
  • 包含一部分的代码格式功能

安装和使用

  • 安装
npm install eslint -D
  • 使用
    • 在项目目录下面创建 eslint.config.mjs 文件,配置eslint相关的规则(具体参考官网)
    • 创建index.js测试文件,书写一些代码
    • 在控制台通过 npx eslint index.js 检测当前文件是否符合规范
    • 如果不符合规范,通过 npx eslint --fix index.js 修复当前文件不合法的地方

Prettier

代码格式化工具

  • 自动格式化代码(缩进、换行、引号等)
  • 保持团队代码风格一致
  • 不检查代码逻辑错误

安装和使用

  • 安装
npm install prettier -D
  • 使用
    • 在项目目录下面创建 .prettierrc 文件,可以配置相关规则,也可以不写,为空也行,后续再添加
    • 还是使用上面的index文件做测试
    • 在控制台通过 npx prettier index.js --check 检测当前文件是否符合规范
    • 如果不符合规范,通过 npx prettier index.js --write 修复当前文件不合法的地方

解决两者的冲突

eslint-config-prettier 官方地址

关闭所有与 Prettier 冲突的 ESLint 规则
具体说明:

  1. 为什么需要它:
    • ESLint 有很多代码格式相关的规则(比如 indent、quotes、semi 等)
    • Prettier 也会处理这些格式问题
    • 两者规则可能会冲突,导致 ESLint 报错但 Prettier 又能自动修复
  2. 它的作用:
    • 自动关闭所有可能与 Prettier 冲突的 ESLint 规则
    • 让 Prettier 专心负责格式化
    • ESLint 专注于代码质量检查(比如未使用变量、错误的 API 使用等)
  3. 使用方式:
 // eslint.config.js (flat config)
   import eslintConfigPrettier from 'eslint-config-prettier'
   
    export default [
     // ...其他配置
     eslintConfigPrettier, // 必须放在最后,覆盖前面的格式规则
    ]
  1. 实践
    未配置的情况下

image.png 抛出了自定义的不合法规则

image.png 配置之后,原来的自定义规则的报错也没了。关闭了eslint和prettier冲突的规则。

eslint-plugin-prettier 官方地址

将 Prettier 作为 ESLint 规则运行 具体来说:

  1. 集成 Prettier 到 ESLint:让你可以在 ESLint 的规则体系中使用 Prettier 的代码格式化规则
  2. 统一代码检查和格式化:不需要单独运行 Prettier,ESLint 就能同时做代码质量检查和格式化
  3. 配合 prettier-eslint 使用:通常还会配合 eslint-config-prettier 来关闭所有与 Prettier 冲突的 ESLint 规则

简单说,它让你能在 ESLint 工作流中无缝使用 Prettier 的格式化能力,一次运行同时搞定代码质量和格式。

image.png 配置之后,抛出的错误提示里面指出了prettier的规则

使用npx eslint --fix index.js 修复之后,以prettier为准了,即使两者冲突了,还是以prettier的标准修复了代码。

为什么要使用

eslint-config-prettier 只做一件事:

  • 关闭与 Prettier 冲突的 ESLint 规则
  • 不会把 Prettier 集成到 ESLint 中

eslint-plugin-prettier 做的事:

  • 把 Prettier 作为 ESLint 规则运行
  • 在 ESLint 运行时自动格式化代码
  • 可以在 ESLint 检查中看到 Prettier 的错误

最佳实践

  • 两个一起使用。既能使用规则又能解决提示的报错
  • 只使用eslint-config-prettier可能会导致规则遗漏。
  • 只使用eslint-plugin-prettier某些报错可能还是提示

结合VS Code编辑器使用

通过终端输入命令行来执行操作也可以,但是很频繁,很麻烦。因此结合编辑器的插件,可以实现代码保存,自动检查和修复代码不符合规则的地方

VS Code编辑器安装如下两个插件

ESLint 插件

"source.fixAll.eslint": "explicit" 因为这个配置需要这个插件,所以必须安装,如果不用这个配置,可以卸载掉这个插件

image.png

安装之后,在index测试文件,直接会有红色提示,不用通过命令行来查看不符合的地方

image.png 会有红色波浪线

通过修改编辑器的配置文件 setting.json 文件,配置如下

{
  // 开启编辑器保存自动格式化
  "editor.formatOnSave": true,
  // 保存文件时,自动执行某些某些操作,比如:代码修复
  // 这个操作可以避免挨个手动修复
  "editor.codeActionsOnSave": {
     // 自动修复eslint抛出的问题
    "source.fixAll.eslint": "explicit"
  },
}

按照以上配置,保存代码时,会自动使用eslint来检查和修复代码不符合的地方

Prettier插件

"source.fixAll.prettier": "explicit" 因为这个配置需要这个插件,所以必须安装,如果不用这个配置,可以卸载掉这个插件

image.png
安装插件之后,通过配置setting.json 文件可以实现保存自动格式化代码

{
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
     // 自动修复prettier抛出的问题
    "source.fixAll.prettier": "explicit"
  },
}

两者同时配置

{
 "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit",  // 先执行
    "source.fixAll.prettier": "explicit"  // 再执行
    // 执行顺序从上到下
  },
}

这样配置,执行会有重复性的问题,同理,安装 eslint-plugin-prettier
安装完之后,就不在需要配置 "source.fixAll.prettier": "explicit"相应的插件也可以卸载了
因为执行eslint格式化的时候,代码质量是按eslint来,格式化风格是按prettier
因此只要配置一个就可以了(同时编辑器的插件prettier也可以卸载了,用不到了)

Cursor通过Mcp Server对接Figma

作者 xiaowang
2026年1月23日 16:44

自从向公司申请下Cursor账号后,就开始鼓捣怎么能对接Figma,不过年底了才有空研究这。

大概做下介绍

Cursor Talk to Figma MCP 实现了 Cursor AI 和 Figma 之间的模型上下文协议(Model Context Protocol,MCP)集成,允许 Cursor 与 Figma 进行通信,以读取设计内容并以编程方式对其进行修改。

Cursor Talk to Figma MCP项目目前只能通过本地运行服务的方式启动,需要 克隆项目到本地、安装依赖、初始化项目、启动socket、启动MCP 等步骤来完成服务的启动。

一、克隆项目

git clone https://github.com/sonnylazuardi/cursor-talk-to-figma-mcp.git

二、安装bun

npm i bun -g

使用bun -v来看是否安装成功

三、初始化项目

我看网上都是bun setup,不过setup.sh我这边没执行成功,新建了一个setup.js

import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';

// Create .cursor directory if it doesn't exist
const cursorDir = '.cursor';
try {
  mkdirSync(cursorDir, { recursive: true });
  console.log(`✓ Created ${cursorDir} directory`);
} catch (error) {
  if (error.code !== 'EEXIST') {
    throw error;
  }
}

// Create mcp.json with the current directory path
const mcpJson = {
  mcpServers: {
    TalkToFigma: {
      command: 'bunx',
      args: ['cursor-talk-to-figma-mcp@latest']
    }
  }
};

const mcpJsonPath = join(cursorDir, 'mcp.json');
writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2), 'utf8');
console.log(`✓ Created ${mcpJsonPath}`);

console.log('\n✓ Setup completed! Run "bun install" if needed.');


package.json中的setup替换

"setup": "bun run scripts/setup.js && bun install",

四、启动 Websocket

bun scoket

默认端口是3055

五、在Cursor中配置Mcp

image.png

使用下面json替换

{
  "mcpServers": {
    "TalkToFigma": {
      "command": "bunx",
      "args": ["cursor-talk-to-figma-mcp"]
    }
  }
}

显示绿色即为成功

image.png

六、配置Figma插件

需要安装Figma客户端,客户端里才能通过manifest添加Figma插件

image.png

如果没该figma权限时没有此功能,需要将项目给复制出来一份 找到找到 cursor-talk-to-figma-mcp -> src -> cursor_mcp_plugin -> manifest.json文件

加载完后会有这个

image.png

点击该插件就会弹出插件并连接到第四步启动的Websocket服务

image.png

可以通过对话栏直接建立通道

image.png

此时即可读到figma文档,通道链接成功!

image.png

地理坐标转换指南:从"地图错位"到"精准定位"的修炼之路

作者 子兮曰
2026年1月23日 16:43

开篇

周五晚上7点,小明准时下班,准备和女朋友去看电影。刚到电影院门口,手机突然震动——产品经理发来微信:"线上地图偏移问题,用户投诉定位不准,马上修复!"

小明叹了口气,翻出笔记本电脑,打开地图页面一看:定位标记在马路对面,和用户实际位置差了300米。

"什么情况?明明用的是GPS坐标啊!"小明一头雾水。

折腾了两小时,小明才发现:百度地图用的是 BD09 坐标,而 GPS 返回的是 WGS84 坐标,两者之间需要转换。加了几行代码,定位终于准确了。

坐标系转换,这个看似简单的问题,坑了多少前端开发者?这篇从「前端宇宙 × 3D 空间」双视角拆解地理坐标转换,帮你彻底搞懂 WGS84、GCJ-02、BD09 等坐标系,看完能直接套用的坐标转换代码。


模块一:常见坐标系解析

技术原理

地理坐标系就像"语言的方言",不同地方说不同的话。全球通用的"普通话"是 WGS84,但出于各种原因,各地还有自己的"方言"——GCJ-02、BD09 等。

先来认识这些"方言":

WGS84(World Geodetic System 1984)

  • 全球统一坐标系,GPS、国际地图服务使用
  • 地心坐标系,原点为地球质心
  • 坐标精度 1-2 米

GCJ-02(国测局坐标系,俗称火星坐标系)

  • 中国官方使用的加密坐标系
  • 基于 WGS84 进行非线性偏移
  • 谷歌地图、高德地图、腾讯地图使用
  • 偏移范围:100-700 米

BD09(百度坐标系)

  • 百度地图专用坐标系
  • 在 GCJ-02 基础上再次加密
  • 偏移叠加,误差更大

CGCS2000(2000 国家大地坐标系)

  • 中国最新的国家大地坐标系
  • 地心坐标系,原点为包括海洋和大气的整个地球质量中心
  • 与 WGS84 相差几厘米,一般工程测量可视为一致

坐标系对比表:

坐标系 使用服务商 偏移特点 适用场景
WGS84 GPS、国际地图 无偏移 全球定位
GCJ-02 高德、谷歌中国 100-700 米 国内地图服务
BD09 百度地图 多次偏移 百度地图应用
CGCS2000 天地图 几厘米 国家测绘数据

实操案例

案例:GPS 定位在不同地图上的表现

假设用户在北京天安门广场,GPS 返回的真实坐标是:

WGS84: (116.397455, 39.909187)

直接在不同地图上显示:

  • 谷歌地图(中国区):偏移约 300 米(需转 GCJ-02)
  • 百度地图:偏移约 400 米(需转 BD09)
  • 高德地图:偏移约 300 米(需转 GCJ-02)

落地步骤

  1. 第一步:确认当前坐标系

    • 查看数据来源说明
    • 测试在不同地图服务上的显示位置
  2. 第二步:确定目标坐标系

    • 查看地图服务的官方文档
    • 确认使用的坐标系类型
  3. 第三步:选择转换工具

    • 开源库:gcoord、coordtransform
    • 自定义算法(下文详解)

避坑指南

  • 新手常犯:直接使用 GPS 坐标在百度地图上显示,不做转换
  • 正确做法:根据目标地图服务,使用对应的坐标系
  • ⚠️ 注意:CGCS2000 与 WGS84 差异极小,一般应用可直接互换,但精密测量需要考虑

模块二:坐标系转换算法

技术原理

坐标转换的数学原理核心是椭球体模型偏移算法

椭球体参数:

地球不是完美的球体,而是椭球体,不同坐标系使用不同的椭球体模型:

坐标系 长半轴 a (米) 扁率 f 说明
WGS84 6378137.0 1/298.257223563 GPS 标准
CGCS2000 6378137.0 1/298.257222101 中国国家标准
克拉索夫斯基 6378245.0 1/298.3 旧坐标系

GCJ-02 转换算法(俗称"火星算法"):

核心思路是使用非线性加密算法,在经纬度中加入"随机"偏移:

// Step 1:计算纬度偏移量
dLat = transformLat(lng - 105.0, lat - 35.0);

// Step 2:计算经度偏移量
dLng = transformLng(lng - 105.0, lat - 35.0);

// Step 3:应用偏移
radLat = lat / 180.0 * PI;
magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
sqrtmagic = Math.sqrt(magic);

dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * PI);
dLng = (dLng * 180.0) / (a / sqrtmagic * Math.cos(radLat) * PI);

mglat = lat + dLat;
mglng = lng + dLng;

七参数转换(高精度转换):

对于需要厘米级精度的场景,使用七参数转换法:

interface SevenParams {
  dx: number;  // X 轴平移
  dy: number;  // Y 轴平移
  dz: number;  // Z 轴平移
  rx: number;  // X 轴旋转
  ry: number;  // Y 轴旋转
  rz: number;  // Z 轴旋转
  m: number;   // 比例因子
}

实操案例

WGS84 转 GCJ-02 完整代码:

// 定义常量
const x_PI = 3.14159265358979324 * 3000.0 / 180.0;
const PI = 3.1415926535897932384626;
const a = 6378245.0;  // 长半轴
const ee = 0.00669342162296594323;  // 偏心率平方

// Step 1:判断是否在中国境内
function outOfChina(lng: number, lat: number): boolean {
  return (lng < 72.004 || lng > 137.8347 ||
          lat < 0.8293 || lat > 55.8271);
}

// Step 2:计算纬度偏移
function transformLat(lng: number, lat: number): number {
  let ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat +
           0.1 * lng * lat + 0.2 * Math.sqrt(Math.abs(lng));
  ret += (20.0 * Math.sin(6.0 * lng * PI) +
          20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0;
  ret += (20.0 * Math.sin(lat * PI) +
          40.0 * Math.sin(lat / 3.0 * PI)) * 2.0 / 3.0;
  ret += (160.0 * Math.sin(lat / 12.0 * PI) +
          320.0 * Math.sin(lat * PI / 30.0)) * 2.0 / 3.0;
  return ret;
}

// Step 3:计算经度偏移
function transformLng(lng: number, lat: number): number {
  let ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng +
           0.1 * lng * lat + 0.1 * Math.sqrt(Math.abs(lng));
  ret += (20.0 * Math.sin(6.0 * lng * PI) +
          20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0;
  ret += (20.0 * Math.sin(lng * PI) +
          40.0 * Math.sin(lng / 3.0 * PI)) * 2.0 / 3.0;
  ret += (150.0 * Math.sin(lng / 12.0 * PI) +
          300.0 * Math.sin(lng / 30.0 * PI)) * 2.0 / 3.0;
  return ret;
}

// Step 4:WGS84 转 GCJ-02
export function wgs84ToGcj02(lng: number, lat: number): [number, number] {
  if (outOfChina(lng, lat)) {
    return [lng, lat];
  }

  let dLat = transformLat(lng - 105.0, lat - 35.0);
  let dLng = transformLng(lng - 105.0, lat - 35.0);

  let radLat = lat / 180.0 * PI;
  let magic = Math.sin(radLat);
  magic = 1 - ee * magic * magic;
  let sqrtmagic = Math.sqrt(magic);

  dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * PI);
  dLng = (dLng * 180.0) / (a / sqrtmagic * Math.cos(radLat) * PI);

  let mglat = lat + dLat;
  let mglng = lng + dLng;

  return [mglng, mglat];
}

GCJ-02 转 BD09:

export function gcj02ToBd09(lng: number, lat: number): [number, number] {
  let z = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * x_PI);
  let theta = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * x_PI);
  let bd_lng = z * Math.cos(theta) + 0.0065;
  let bd_lat = z * Math.sin(theta) + 0.006;
  return [bd_lng, bd_lat];
}

WGS84 转 BD09(组合转换):

export function wgs84ToBd09(lng: number, lat: number): [number, number] {
  // 先转 GCJ-02,再转 BD09
  const [gcjLng, gcjLat] = wgs84ToGcj02(lng, lat);
  return gcj02ToBd09(gcjLng, gcjLat);
}

测试用例:

// 测试:天安门坐标
const wgs84: [number, number] = [116.397455, 39.909187];

// 转换结果
const gcj02 = wgs84ToGcj02(...wgs84);
console.log('GCJ-02:', gcj02);
// 输出: [116.403874, 39.915119]

const bd09 = wgs84ToBd09(...wgs84);
console.log('BD09:', bd09);
// 输出: [116.41661560068297, 39.92196580126834]

落地步骤

  1. 第一步:安装坐标转换库

    npm install gcoord
    
  2. 第二步:使用库进行转换

    import gcoord from 'gcoord';
    
    const result = gcoord.transform(
      [116.403988, 39.914266],  // 经纬度坐标
      gcoord.WGS84,              // 当前坐标系
      gcoord.BD09                // 目标坐标系
    );
    
  3. 第三步:在地图服务中应用

    // 百度地图
    const bdCoord = wgs84ToBd09(gpsLng, gpsLat);
    map.setCenter(new BMap.Point(bdCoord[0], bdCoord[1]));
    
    // 高德地图
    const gcjCoord = wgs84ToGcj02(gpsLng, gpsLat);
    map.setCenter([gcjCoord[0], gcjCoord[1]]);
    

避坑指南

  • 新手常犯:只转一次坐标系,不考虑中间转换
  • 正确做法:WGS84 → GCJ-02 → BD09,逐层转换
  • ⚠️ 注意:中国境外不需要使用 GCJ-02,直接使用 WGS84
  • ⚠️ 注意:百度地图 API 提供了官方转换接口,建议优先使用 官方文档

模块三:实战应用场景

技术原理

在前端地图开发中,坐标转换贯穿多个场景:用户定位、POI 搜索、轨迹绘制、地理围栏等。不同场景对精度要求不同,转换策略也不同。

前端地图服务坐标系对比:

服务商 API 坐标系 推荐转换方式
百度地图 API BD09 使用官方 convertor.convert
高德地图 API GCJ-02 GPS 坐标需转 GCJ-02
谷歌地图 API 中国区 GCJ-02 国际区 WGS84
腾讯地图 API GCJ-02 GPS 坐标需转 GCJ-02

实操案例

场景 1:用户定位 + 附近 POI 搜索

// 需求:获取用户当前位置,并在百度地图上显示附近餐厅

import BMap from 'BMap';

// Step 1:获取 GPS 定位(返回 WGS84)
function getCurrentPosition(): Promise<[number, number]> {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(
      (position) => {
        resolve([position.coords.longitude, position.coords.latitude]);
      },
      (error) => {
        reject(error);
      }
    );
  });
}

// Step 2:坐标转换 WGS84 → BD09
async function initMap() {
  const [wgsLng, wgsLat] = await getCurrentPosition();

  // 转换坐标系
  const [bdLng, bdLat] = wgs84ToBd09(wgsLng, wgsLat);

  // 创建百度地图实例
  const map = new BMap.Map('map-container');
  const point = new BMap.Point(bdLng, bdLat);

  // 设置中心点
  map.centerAndZoom(point, 15);

  // 添加用户标记
  const marker = new BMap.Marker(point);
  map.addOverlay(marker);

  // 搜索附近餐厅(百度 API 自动处理 BD09 坐标)
  const local = new BMap.LocalSearch(map, {
    renderOptions: { map: map }
  });
  local.searchNearby('餐厅', point, 1000);  // 1公里范围
}

场景 2:轨迹绘制(GPS 轨迹回放)

// 需求:在 Leaflet 地图上绘制 GPS 轨迹(使用 GCJ-02)

import L from 'leaflet';

interface TrackPoint {
  lng: number;
  lat: number;
  timestamp: number;
}

// Step 1:批量转换坐标
function convertTrack(track: TrackPoint[]): TrackPoint[] {
  return track.map(point => {
    const [gcjLng, gcjLat] = wgs84ToGcj02(point.lng, point.lat);
    return { lng: gcjLng, lat: gcjLat, timestamp: point.timestamp };
  });
}

// Step 2:绘制轨迹
function drawTrackOnMap(track: TrackPoint[]) {
  const map = L.map('map').setView([39.915, 116.404], 13);

  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '© OpenStreetMap'
  }).addTo(map);

  const convertedTrack = convertTrack(track);

  // 创建多段线
  const polyline = L.polyline(
    convertedTrack.map(p => [p.lat, p.lng]),
    { color: '#3388ff', weight: 4 }
  ).addTo(map);

  // 添加起点和终点标记
  const start = convertedTrack[0];
  const end = convertedTrack[convertedTrack.length - 1];

  L.marker([start.lat, start.lng])
    .bindPopup('起点')
    .addTo(map);

  L.marker([end.lat, end.lng])
    .bindPopup('终点')
    .addTo(map);

  // 自动调整视野
  map.fitBounds(polyline.getBounds());
}

场景 3:3D 地图坐标转换(Three.js + Cesium)

// 需求:在 Three.js 中实现 3D 地图,将地理坐标转换为 3D 空间坐标

import * as THREE from 'three';

interface Coordinate {
  lng: number;
  lat: number;
  alt?: number;  // 海拔高度
}

class Geo3DConverter {
  private earthRadius: number = 6371000;  // 地球半径(米)

  /**
   * 将地理坐标转换为 3D 笛卡尔坐标
   * @param coord 地理坐标(WGS84)
   * @param center 中心点坐标(用于偏移,避免浮点精度问题)
   */
  geoToCartesian(coord: Coordinate, center: Coordinate): THREE.Vector3 {
    // Step 1:转换为弧度
    const lngRad = (coord.lng * Math.PI) / 180;
    const latRad = (coord.lat * Math.PI) / 180;
    const alt = coord.alt || 0;

    const centerLngRad = (center.lng * Math.PI) / 180;
    const centerLatRad = (center.lat * Math.PI) / 180;
    const centerAlt = center.alt || 0;

    // Step 2:球面坐标转笛卡尔坐标
    const cosLat = Math.cos(latRad);
    const sinLat = Math.sin(latRad);
    const cosLng = Math.cos(lngRad);
    const sinLng = Math.sin(lngRad);

    const r = this.earthRadius + alt;

    const x = r * cosLat * cosLng;
    const y = r * cosLat * sinLng;
    const z = r * sinLat;

    // Step 3:计算中心点
    const cosCenterLat = Math.cos(centerLatRad);
    const sinCenterLat = Math.sin(centerLatRad);
    const cosCenterLng = Math.cos(centerLngRad);
    const sinCenterLng = Math.sin(centerLngRad);

    const rCenter = this.earthRadius + centerAlt;

    const cx = rCenter * cosCenterLat * cosCenterLng;
    const cy = rCenter * cosCenterLat * sinCenterLng;
    const cz = rCenter * sinCenterLat;

    // Step 4:计算相对位置(偏移)
    return new THREE.Vector3(
      x - cx,
      z - cz,  // Three.js Y 轴向上
      y - cy   // Three.js 使用右手坐标系
    );
  }

  /**
   * 批量转换坐标并生成 3D 对象
   */
  createGeoPoints(points: Coordinate[], center: Coordinate): THREE.Points {
    const geometry = new THREE.BufferGeometry();
    const positions: number[] = [];

    points.forEach(point => {
      const vector = this.geoToCartesian(point, center);
      positions.push(vector.x, vector.y, vector.z);
    });

    geometry.setAttribute('position',
      new THREE.Float32BufferAttribute(positions, 3)
    );

    const material = new THREE.PointsMaterial({
      color: 0xff0000,
      size: 0.1
    });

    return new THREE.Points(geometry, material);
  }
}

// 使用示例
const converter = new Geo3DConverter();

const points: Coordinate[] = [
  { lng: 116.397455, lat: 39.909187, alt: 100 },
  { lng: 116.407455, lat: 39.919187, alt: 150 },
  { lng: 116.417455, lat: 39.929187, alt: 200 }
];

const center: Coordinate = {
  lng: 116.407455,
  lat: 39.919187,
  alt: 150
};

const geoPoints = converter.createGeoPoints(points, center);
scene.add(geoPoints);

落地步骤

  1. 第一步:选择合适的地图 SDK

    • 考虑坐标系支持
    • 评估转换性能
    • 确认文档完整性
  2. 第二步:封装坐标转换工具类

    // utils/coordinate.ts
    export class CoordinateConverter {
      static toWGS84(lng: number, lat: number, from: 'GCJ02' | 'BD09'): [number, number] {
        // 实现转换逻辑
      }
    
      static toGCJ02(lng: number, lat: number, from: 'WGS84' | 'BD09'): [number, number] {
        // 实现转换逻辑
      }
    
      static toBD09(lng: number, lat: number, from: 'WGS84' | 'GCJ02'): [number, number] {
        // 实现转换逻辑
      }
    }
    
  3. 第三步:在地图组件中集成

    // components/MapComponent.vue
    import { CoordinateConverter } from '@/utils/coordinate';
    
    export default {
      methods: {
        handleMapClick(event) {
          const { lng, lat } = event.point;
    
          // 假设地图使用 BD09,需要存 WGS84
          const [wgsLng, wgsLat] = CoordinateConverter.toWGS84(lng, lat, 'BD09');
    
          this.saveCoordinate(wgsLng, wgsLat);
        }
      }
    }
    

避坑指南

  • 新手常犯:在不同地图服务之间切换时,忘记转换坐标系
  • 正确做法:使用统一的内部坐标系(如 WGS84),只在展示时转换
  • ⚠️ 注意:3D 地图中,大范围场景需使用高精度坐标(64-bit float),避免浮点精度问题
  • ⚠️ 注意:批量转换时,注意性能优化,使用 TypedArray 和 Web Worker

性能优化技巧:

// ❌ 低效:逐个转换
const results = [];
for (const point of points) {
  results.push(wgs84ToGcj02(point.lng, point.lat));
}

// ✅ 高效:使用批量转换库
import gcoord from 'gcoord';

const results = gcoord.transform(
  points.map(p => [p.lng, p.lat]),
  gcoord.WGS84,
  gcoord.GCJ02
);

结尾

现在的小明,再也不怕地图偏移问题了。遇到坐标转换需求,淡定地打开工具类,三行代码搞定:

const [bdLng, bdLat] = wgs84ToBd09(gpsLng, gpsLat);
map.setCenter(new BMap.Point(bdLng, bdLat));

老板路过问:"小明,你最近怎么不加班了?"

小明笑了笑:"因为我掌握了坐标转换的秘密武器——统一坐标系,按需转换。"

坐标系转换看似复杂,其实本质就是"语言的翻译"。GPS 说"普通话",百度地图说"方言",你需要做的,就是当好"翻译官"。

你在坐标转换中遇到过哪些坑?评论区交流,我们一起讨论。


数据来源

useTemplateRef和ref的区别

作者 Hone
2026年1月23日 16:39

useTemplateRefref 都是用来创建响应式引用(Reactive References)的,但在 Vue 3.5+ 中,useTemplateRef 是一个专门为模板引用(Template Refs)设计的组合式 API (Composable) 。让我们详细对比一下它们:

ref

  • 核心功能: ref 是 Vue 最基础的响应式系统 API 之一。它可以包装任何值(原始类型、对象、DOM 元素、组件实例等),使其成为响应式的。

  • 用途广泛:

    • 存储和响应式地更新本地组件状态(如 count = ref(0))。
    • 作为模板引用(虽然在 3.5+ 之前常用)。
    • 在任何需要响应式引用的地方。
  • 在模板引用中的用法 (旧方式) :

    <template>
      <div ref="divRef">Hello World</div>
    </template>
    
    <script setup>
    import { ref, onMounted } from 'vue';
    
    const divRef = ref(null); // 创建一个 ref
    
    onMounted(() => {
      // divRef.value 现在是 DOM 元素
      console.log(divRef.value); // <div>Hello World</div>
      divRef.value.focus(); // 例如,聚焦到元素上
    });
    </script>
    
    • 问题: 在 <script setup> 中,divRef 会暴露给模板,即使你只想在脚本内部使用它。这可能会污染模板的上下文。

useTemplateRef (Vue 3.5+)

  • 核心功能: 专门用于获取对模板中元素或组件的引用。它返回一个getter 函数,而不是一个 ref 对象。

  • 目的: 解决 ref 作为模板引用时暴露到模板上下文的问题,提供更清晰、更符合直觉的 API。

  • 用途: 用于模板引用。

  • 返回值: 一个 getter 函数,调用它会返回最新的模板引用值。这个函数本身是响应式的,但其返回值(即引用的元素或组件实例)不是。

  • 优势:

    • 不污染模板上下文: useTemplateRef 返回的 getter 不会被自动暴露到模板中,保持了模板上下文的整洁。
    • 意图明确: 使用 useTemplateRef 明确表示你正在创建一个模板引用,提高了代码的可读性。
    • 类型推断: 在 TypeScript 中,useTemplateRef 能提供更精确的类型推断。
  • 在模板引用中的用法 (新方式) :

    <template>
      <div ref="divRef">Hello World</div>
    </template>
    
    <script setup>
    import { useTemplateRef, onMounted } from 'vue';
    
    // useTemplateRef 返回一个 getter 函数
    const getDivRef = useTemplateRef('divRef');
    
    onMounted(() => {
      // 调用 getter 函数获取 DOM 元素
      console.log(getDivRef()); // <div>Hello World</div>
      getDivRef()?.focus(); // 例如,聚焦到元素上
    });
    </script>
    
    • 注意:在 ref 指令中使用的字符串(如 'divRef')必须与 useTemplateRef 的参数完全匹配。
    • getDivRef() 返回的是实际的 DOM 元素或组件实例,如果元素未挂载,则可能返回 nullundefined

对比总结

特性 ref useTemplateRef
主要目的 创建通用的响应式引用 专门用于模板引用
返回值 一个包含 .value 属性的 ref 对象 一个 getter 函数
模板暴露 会暴露到模板上下文(如果在 <script setup> 中定义) 不会暴露到模板上下文
类型推断 一般 更好(尤其是在 TS 中)
意图表达 通用,需看上下文 明确
Vue 版本要求 3.0+ 3.5+
何时使用 通用响应式状态、旧项目中的模板引用 Vue 3.5+ 项目中的模板引用 (推荐)

结论

  • 对于模板引用(获取 DOM 元素或子组件实例),强烈推荐在 Vue 3.5+ 项目中使用 useTemplateRef。它更清晰、更安全、类型更友好。
  • 对于通用的响应式状态管理(如计数器、布尔标志等),继续使用 ref
  • 在你的项目中,如果已经升级到了 Vue 3.5 或更高版本,并且需要获取模板引用,请优先考虑 useTemplateRef

uniapp国际化方案

2026年1月23日 16:22

本项目当前采用 “多端同构、统一语言包” 的国际化实现方式,H5 与小程序共享同一套 vue-i18n 初始化与语言包加载逻辑,业务层统一使用 $t / useI18n,无需端侧分支。以下内容以现有工程为准,整理清晰的落地方案与使用规范。

一、现状与目标

  1. 多端一致:H5 与小程序均加载 vue-i18n,无需条件编译分支。
  2. 统一入口src/i18n/index.ts 负责语言包聚合与 i18n 实例创建。
  3. 统一使用方式:模板使用 $t,脚本使用 useI18n() 获取 t
  4. 语言状态持久化:Pinia locale store 持久化保存,App 启动时初始化语言。

二、目录结构与语言包组织

src/
├── i18n/
│   ├── index.ts        # i18n 初始化入口
│   ├── zh/             # 中文模块
│   │   ├── demo.ts
│   │   ├── login.ts
│   │   └── ...
│   └── en/             # 英文模块
│       ├── demo.ts
│       ├── login.ts
│       └── ...
├── store/
│   └── locale.ts       # 语言状态持久化
├── components/
│   └── ChangeLocale/   # 切换语言组件
└── main.js             # app.use(i18n)

语言包按模块文件拆分,模块名与文件名保持一致,例如:

  • src/i18n/zh/demo.ts → 使用 demo.xxx 作为 key
  • src/i18n/en/login.ts → 使用 login.xxx 作为 key

三、i18n 初始化机制

项目使用 import.meta.glob 聚合语言包,构建时自动注入:

// src/i18n/index.ts
const messages = {
  zh: {},
  en: {},
}
const zhModules = import.meta.glob('./zh/*.ts', { eager: true })
const enModules = import.meta.glob('./en/*.ts', { eager: true })

最终通过 createI18n 创建实例并全局注入 $t

const i18n = createI18n({
  legacy: false,
  locale: getInitialLocale(),
  fallbackLocale: 'zh',
  messages: loadModuleMessages(),
  globalInjection: true,
})

四、全局挂载与语言初始化

  1. 全局挂载src/main.jsapp.use(i18n) 对所有端生效。
  2. 语言初始化src/App.vueonLaunch 阶段读取本地 locale 或系统语言设置,并同步到 i18nlocale store。
  3. 持久化src/store/locale.ts 使用 Pinia 持久化,存储 key 为 locale

五、业务层使用方式

5.1 模板中使用

<text>{{ $t('demo.iconPreviewTitle') }}</text>

5.2 脚本中使用

import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
const title = t('login.login')

5.3 切换语言

项目已有切换组件 src/components/ChangeLocale/ChangeLocale.vue

locale.value = 'en'
useLocaleStore().setLocale('en')

六、编写规范(与项目规则一致)

  1. 禁止硬编码中文:所有展示文本必须走 t('xx.yy')
  2. 模块化 key 结构module.section.item 风格,模块名与语言包文件名一致。
  3. 插值规范:小程序端使用列表插值 {0}/{1},参数传数组,避免具名插值。
  4. 新增模块:新增页面同时补充 src/i18n/zh/*.tssrc/i18n/en/*.ts

七、可选优化方向(当前未落地)

如需进一步缩减小程序包体,可考虑:

  1. 条件编译隔离:将 vue-i18n 初始化与语言包加载包裹到 #ifdef H5
  2. 小程序降级策略:小程序端返回默认中文或只加载 zh
  3. 端侧依赖裁剪:配置 mp-weixin externals 排除 vue-i18n

当前代码未实现上述优化,如需启用请单独评审与改造。

参考: 官方国际化:uniapp.dcloud.net.cn/tutorial/i1…

解决依赖冲突overrides 和 resolutions

2026年1月23日 16:10

最近构建时又遇到版本冲突的问题了,所以记录下来。

依赖冲突的场景

项目 A
├── 依赖库 B@1.0.0
│   └── 依赖库 D@1.0.0
└── 依赖库 C@2.0.0
    └── 依赖库 D@2.0.0

解决方案一:overrides

overrides 是 npm 官方在 v8.3 版本引入的字段,用于精确控制依赖版本。

{
  "name": "your-project",
  "dependencies": {
    "library-b": "^1.0.0",
    "library-c": "^2.0.0"
  },
  "overrides": {
    // 覆盖所有地方的库D,强制使用 1.0.1
    "d": "1.0.1"
  }
}

解决方案二:resolutions

resolutions 最初由 Yarn 包管理器引入,后来 npm 也兼容了这个字段。

{
  "name": "your-project",
  "dependencies": {
    "library-b": "^1.0.0",
    "library-c": "^2.0.0"
  },
  "resolutions": {
    // 强制所有地方的库D使用 1.0.1
    "**/d": "1.0.1"
  }
}

使用场景

选择 overrides 当:

  • 项目使用 npm >= 8.3
  • 需要精确控制特定依赖关系
  • 想要使用 npm 官方推荐方案

选择 resolutions 当:

  • 项目使用 Yarn
  • npm 版本 < 8.3
  • 需要跨包管理器兼容
  • 项目配置需要与 Yarn 生态保持一致

注意事项

  1. 重新安装依赖:修改后需运行 npm install 或 yarn install
  2. 测试兼容性:强制覆盖版本可能引入兼容性问题

【Playwright 学习笔记 05】Xpath选择

作者 鄭郑
2026年1月23日 16:05

XPath (XML Path Language) 是由国际标准化组织W3C指定的,用来在 XML 和 HTML 文档中选择节点的语言。

目前主流浏览器 (chrome、firefox,edge,safari) 都支持XPath语法,xpath有多个版本,目前浏览器支持的是 xpath 1的语法。

语法介绍

xpath 语法中,整个HTML文档根节点用'/'表示

绝对路径选择

从根节点开始的,到某个节点,每层都依次写下来,每层之间用 / 分隔的表达式,就是某元素的 绝对路径

上面的xpath表达式 /html/body/div ,就是一个绝对路径的xpath表达式, 类似 css表达式 html>body>div

相对路径选择

有的时候,我们需要选择网页中某个元素, 不管它在什么位置

xpath需要前面加 // , 表示从当前节点往下寻找所有的后代元素,不管它在什么位置。

所以xpath表达式,应该这样写: //div

通配符

如果要选择所有div节点的所有直接子节点,可以使用表达式 //div/*

* 是一个通配符,对应任意节点名的元素

根据属性选择

Xpath 可以根据属性来选择元素。

根据属性来选择元素 是通过 这种格式来的 [@属性名='属性值']

注意:

  • 属性名注意前面有个@
  • 属性值一定要用引号, 可以是单引号,也可以是双引号

根据id属性选择

选择 id 为 west 的元素,可以这样 //*[@id='west'] (选择任意元素中含有id=west的)

根据其他属性

同样的道理,我们也可以利用其它的属性选择

比如选择 具有multiple属性的所有页面元素 ,可以这样 //*[@multiple]

属性值包含字符串

要选择 style属性值 包含 color 字符串的 页面元素 ,可以这样 //*[contains(@style,'color')]

要选择 style属性值 以 color 字符串 开头 的 页面元素 ,可以这样 //*[starts-with(@style,'color')]

要选择 style属性值 以 某个 字符串 结尾 的 页面元素 ,大家可以推测是 //*[ends-with(@style,'color')] , 但是,很遗憾,这是xpath 2.0 的语法 ,目前浏览器都不支持

按次序选择

某类型 第几个 子元素

比如要选择 p类型第2个的子元素,就是

//p[2]

第几个子元素

也可以选择第2个子元素,不管是什么类型,采用通配符

比如 选择父元素为div的第2个子元素,不管是什么类型

//div/*[2]

某类型 倒数第几个 子元素

当然也可以选取倒数第几个子元素

比如:

  • 选取p类型倒数第1个子元素
//p[last()]

  • 选取p类型倒数第2个子元素
//p[last()-1]

范围选择

xpath还可以选择子元素的次序范围。

比如

  • 选取option类型第1到2个子元素
//option[position()<=2]

  • 选择class属性为multi_choice的后3个子元素
//*[@class='multi_choice']/*[position()>=last()-2]

组选择、父节点、兄弟节点

组选择

xpath也有组选择, 是用 竖线 隔开多个表达式

比如,要选所有的option元素 和所有的 h4 元素,可以使用

//option | //h4

选择父节点

xpath可以选择父节点, 这是css做不到的。

某个元素的父节点用 /.. 表示

要选择 id 为 china 的节点的父节点,可以这样写 //*[@id='china']/..

兄弟节点选择

xpath也可以选择 后续 兄弟节点,用这样的语法 following-sibling::

比如,要选择 class 为 single_choice 的元素的所有后续兄弟节点 //*[@class='single_choice']/following-sibling::*

等同于CSS选择器 .single_choice ~ *

如果,要选择后续节点中的div节点, 就应该这样写 //*[@class='single_choice']/following-sibling::div

xpath还可以选择 前面的 兄弟节点,用这样的语法 preceding-sibling::

比如,要选择 class 为 single_choice 的元素的 所有 前面的兄弟节点,这样写

//*[@class='single_choice']/preceding-sibling::*

通俗易懂的 rem、em、vh 用法解释

作者 娜妹子辣
2026年1月23日 16:00

让我用最简单的方式来解释这三个单位:

🎯 核心理解

rem = "以网页根部为准"

  • 想象网页有一个"总开关"(html标签)
  • rem就是以这个"总开关"的字体大小为标准
  • 1rem = html的字体大小

em = "以当前元素为准"

  • 每个元素都有自己的字体大小
  • em就是以"自己"的字体大小为标准
  • 1em = 自己的字体大小

vh = "以屏幕高度为准"

  • vh就是把屏幕高度分成100份
  • 1vh = 屏幕高度的1%
  • 100vh = 整个屏幕高度

📝 实际例子对比

场景1:做一个按钮

CSS
/* 方法1:用rem - 所有按钮大小统一 */
html { font-size: 16px; } /* 总开关设为16px */

.button {
  width: 10rem;        /* = 160px (16×10) */
  height: 3rem;        /* = 48px (16×3) */
  font-size: 1rem;     /* = 16px */
}

/* 方法2:用em - 按钮大小跟随自己的字体 */
.button {
  font-size: 18px;     /* 自己的字体18px */
  width: 8em;          /* = 144px (18×8) */
  height: 2.5em;       /* = 45px (18×2.5) */
  padding: 0.5em;      /* = 9px (18×0.5) */
}

什么时候用哪个?

  • 用 rem:想让所有按钮保持统一比例
  • 用 em:想让按钮大小跟随自己的文字大小

场景2:做一个全屏页面

CSS
/* 用vh做全屏效果 */
.hero-section {
  height: 100vh;       /* 占满整个屏幕高度 */
}

.header {
  height: 10vh;        /* 占屏幕高度的10% */
}

.content {
  height: 80vh;        /* 占屏幕高度的80% */
}

.footer {
  height: 10vh;        /* 占屏幕高度的10% */
}

为什么用vh?

  • 不管什么设备,页面都能完美占满屏幕
  • 手机、平板、电脑都自动适配

🔍 直观对比

同样做一个卡片,看区别:

HTML
<div class="card-rem">用rem的卡片</div>
<div class="card-em">用em的卡片</div>
<div class="card-vh">用vh的卡片</div>
CSS
html { font-size: 16px; }

/* rem卡片 - 大小固定,只跟html有关 */
.card-rem {
  width: 20rem;        /* 永远是320px */
  height: 15rem;       /* 永远是240px */
  font-size: 1.2rem;   /* 永远是19.2px */
}

/* em卡片 - 大小跟自己的字体有关 */
.card-em {
  font-size: 20px;     /* 设置自己的字体 */
  width: 16em;         /* = 320px (20×16) */
  height: 12em;        /* = 240px (20×12) */
  padding: 1em;        /* = 20px (20×1) */
}

/* vh卡片 - 大小跟屏幕高度有关 */
.card-vh {
  width: 50vw;         /* 屏幕宽度的50% */
  height: 30vh;        /* 屏幕高度的30% */
}

🎪 什么时候用什么?

用 rem 的情况:

CSS
/* ✅ 整体布局 - 希望统一缩放 */
.container { max-width: 80rem; }
.sidebar { width: 20rem; }

/* ✅ 组件尺寸 - 希望保持比例 */
.avatar { width: 4rem; height: 4rem; }
.icon { width: 2rem; height: 2rem; }

/* ✅ 字体层级 - 希望统一管理 */
h1 { font-size: 3rem; }
h2 { font-size: 2.5rem; }
p { font-size: 1rem; }

用 em 的情况:

CSS
/* ✅ 内边距 - 希望跟文字大小成比例 */
.button {
  font-size: 18px;
  padding: 0.5em 1em;  /* 跟按钮文字大小成比例 */
}

/* ✅ 图标 - 希望跟文字一样大 */
.text-with-icon {
  font-size: 20px;
}
.text-with-icon .icon {
  width: 1em;          /* 跟文字一样大 */
  height: 1em;
}

用 vh/vw 的情况:

CSS
/* ✅ 全屏效果 */
.hero { height: 100vh; }

/* ✅ 移动端布局 */
.mobile-header { height: 10vh; }
.mobile-content { height: 80vh; }
.mobile-footer { height: 10vh; }

/* ✅ 响应式容器 */
.modal {
  max-width: 90vw;     /* 不超过屏幕宽度90% */
  max-height: 90vh;    /* 不超过屏幕高度90% */
}

🚀 记忆口诀

  • remRoot(根部),统一标准,整齐划一
  • emElement(元素),自己做主,跟随自己
  • vhViewport Height(视口高度),屏幕为王,自动适配

💡 实用建议

  1. 新手推荐:先学会用 rem 做布局,用 vh 做全屏
  2. 进阶使用:在按钮、表单等组件内部用 em
  3. 避免混乱:一个项目尽量统一使用规则

react:Hook:useEffect

2026年1月23日 15:58

如果你用过 Vue 3 的 Composition API(setup 语法),你会觉得这非常眼熟。如果你主要用 Vue 2(Options API),那你需要转换一下思维:React 没有明确的 mountedupdateddestroyed 钩子,所有的生命周期都在 useEffect 里解决。


1. 什么是 useEffect?(副作用)

在 React 函数组件里,主流程(函数体)必须是纯净的:输入 Props,返回 JSX。

任何“除此之外”的事情,都叫副作用 (Side Effects) ,比如:

  • 修改 document.title
  • 发送 Ajax 请求 (API)
  • 设置定时器 (setInterval)
  • 手动操作 DOM

useEffect 就是 React 给你开的一个“后门”,让你在组件渲染完之后去做这些事。


2. Vue vs React:生命周期的映射

这是理解 useEffect 的一把钥匙。它的行为完全取决于第二个参数(依赖数组)

Vue 生命周期 (Options API) React useEffect 写法 含义
mounted (只执行一次) useEffect(() => { ... }, []) 数组是的,代表“我不依赖任何变量,所以我只在出生时运行一次”。
watch / updated (数据变了就执行) useEffect(() => { ... }, [count]) 数组里有 count,代表“只要 count 变了,我就运行”。
updated (任意更新都执行) useEffect(() => { ... }) 没写数组。每次组件渲染它都跑。(⚠️ 慎用,容易死循环)
beforeUnmount (销毁前) return () => { ... } useEffect 的函数里返回一个函数,这个返回的函数会在销毁时执行。

3. 实战演示:标题随计数器变化

需求: 每次点击按钮更新 count 时,浏览器的标签页标题 (document.title) 也要跟着变成 "当前次数: X"。

代码对比:

Vue 写法 (Watch)

JavaScript

// Vue 3
watch(count, (newVal) => {
  document.title = `当前次数: ${newVal}`;
});

React 写法 (useEffect)

JavaScript

import { useState, useEffect } from 'react'; // 1. 引入

function App() {
  const [count, setCount] = useState(0);

  // 2. 使用 useEffect
  // 翻译:当 [count] 发生变化之后,执行这个箭头函数
  useEffect(() => {
    document.title = `当前次数: ${count}`;
    console.log("副作用执行了!");
  }, [count]); // <--- 关键在这里!依赖数组

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

实验:

如果你把 [count] 改成空数组 [],你会发现:只有刷新页面第一次标题变了,后面怎么点按钮,标题都不动。因为 React 认为“这个副作用不需要依赖任何状态更新”。


4. 难点:清理副作用 (Cleanup)

这是 Vue 开发者最容易懵的地方。

在 Vue 里,你在 mounted 里开启定时器,必须记得在 beforeUnmount 里清除。

在 React 里,这写在同一个地方

场景: 做一个自动计时器。

JavaScript

import { useState, useEffect } from 'react';

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    // 1. 开启定时器 (相当于 mounted)
    const intervalId = setInterval(() => {
      console.log("定时器在跑...");
      setSeconds(s => s + 1);
    }, 1000);

    // 2. 返回清理函数 (相当于 beforeUnmount)
    // React 会在组件销毁时,或者下一次 effect 执行前,调用这个函数
    return () => {
      console.log("清除定时器!");
      clearInterval(intervalId);
    };

  }, []); // [] 代表只在挂载时启动一次

  return <h1>已经过去了 {seconds} 秒</h1>;
}

逻辑闭环: React 认为“如何创建”和“如何销毁”是强相关的逻辑,所以强制你写在一起,防止你忘记清除。


💡 核心思维转换总结

  • Vue: 是基于时机的 (这时候该干嘛 -> mounted, destroyed)。
  • React: 是基于依赖的 (数据变了该干嘛 -> 依赖数组)。

你只要问自己两个问题:

  1. 我想做什么? (写在函数体里)

  2. 我想什么时候做?

    • 只有出生时? -> []
    • userId 变的时候? -> [userId]
    • 只要有风吹草动? -> 不传数组 (危险)

React 的 State (状态)

2026年1月23日 15:54

在 React 中,我们从来不使用 document.getElementById 去抓取输入框的值。

React 采用了一种叫 “受控组件” (Controlled Component) 的模式。

简单来说:React 的 State (状态) 是老大,输入框只是负责显示 State 里的内容。


核心公式 (三步走)

要“控制”一个输入框,你需要做三件事:

  1. 设状态: 造一个变量(State)来存字。
  2. 绑显示: 告诉输入框 value 等于这个变量(React -> Input)。
  3. 绑输入: 监听 onChange,用户每打一个字,就立马更新变量(Input -> React)。

代码演示

请看这段最简代码,这就是所有 React 表单的基础:

JavaScript

import { useState } from 'react';

function App() {
  // 1. 设状态:专门用来存输入框里的字
  const [inputValue, setInputValue] = useState("");

  return (
    <div style={{ padding: '20px' }}>
      {/* 输入框 */}
      <input 
        // 2. 绑显示让输入框的内容完全由 state 决定
        value={inputValue}
        
        // 3. 绑输入用户打字 -> 触发事件(e) -> 拿到新值 -> 更新 state
        onChange={(e) => setInputValue(e.target.value)}
        
        placeholder="请输入内容..."
      />
      
      {/* 实时显示:证明 state 真的变了 */}
      <p>你正在输入:{inputValue}</p>
    </div>
  );
}

export default App;

关键词解析:e.target.value

  • e: 事件对象 (Event)。
  • target: 触发事件的元素 (也就是那个 input 标签)。
  • value: 元素当前里面的值 (用户刚刚打进去的字)。

🧩 拼图时刻:完成你的“评论区”挑战

结合刚才学的 List (列表) 和现在的 Input (输入) ,这里是完成作业的完整思路。

你需要维护两个 State

  1. comments: 存所有的评论列表(数组)。
  2. newComment: 存输入框里正在打的字(字符串)。

参考代码逻辑(试着自己敲一遍):

JavaScript

function App() {
  // 状态 1: 评论列表
  const [comments, setComments] = useState([
    { id: 1, text: "第一条评论" }
  ]);
  
  // 状态 2: 输入框的内容
  const [newComment, setNewComment] = useState("");

  // 发布按钮的逻辑
  function handlePublish() {
    // 1. 造一个新的评论对象
    const newItem = {
      id: Date.now(), // 用时间戳做唯一的 key
      text: newComment // 取出输入框 state 里的字
    };

    // 2. 把新对象加到列表里 (用 ... 扩展运算符)
    setComments([...comments, newItem]);

    // 3. 关键体验优化:清空输入框!
    setNewComment("");
  }

  return (
    <div>
      {/* 输入区域 */}
      <input 
        value={newComment} 
        onChange={(e) => setNewComment(e.target.value)} 
      />
      <button onClick={handlePublish}>发布</button>

      {/* 列表区域 */}
      <ul>
        {comments.map(item => (
          <li key={item.id}>{item.text}</li>
        ))}
      </ul>
    </div>
  );
}

从0到1 学习 react

2026年1月23日 15:47

这是一个充满勇气的选择!React 是目前全球范围内就业市场最广阔、生态最成熟的前端库(由 Facebook/Meta 维护)。

比起 Vue 的“自动挡”(很多东西框架帮你做了),React 更像是“手动挡”赛车——它给你极高的自由度,但要求你对 JavaScript 的理解更纯粹。

核心思想只有一句话:All in JS(一切皆 JS)。


第一步:创建你的 React 工程

现在我们需要一个专门为 React 优化的环境。请打开终端(Terminal),退出当前项目(Ctrl+C),回到上一级目录,然后:

  1. 创建项目:

    Bash

    npm create vite@latest my-react-app
    
    • Select a framework: React
    • Select a variant: JavaScript (先暂时不用 TypeScript,减少干扰)
  2. 启动:

    Bash

    cd my-react-app
    npm install
    npm run dev
    

打开浏览器链接,你会看到 React 的旋转图标。


第二步:解剖 React —— 什么是 JSX?

打开项目里的 src/App.jsx 文件。

你会看到一段看起来很像 HTML,但又混在 JS 里的代码。这就是 JSX (JavaScript XML)

⚠️ React 的三条铁律(死记硬背)

  1. 函数即组件: React 组件本质上就是一个首字母大写的函数,它返回 UI 结构。
  2. 只能有一个根节点: return 出来的最外层必须包在一个 <div><> (空标签) 里。
  3. {} 包裹变量: 在 HTML 里想写 JS(比如变量、计算),必须用花括号包起来。

第三步:动手改写 (清理现场)

为了不被默认代码干扰,请把 src/App.jsx 的内容全部删掉,替换成下面这一段最简代码:

JavaScript

// App.jsx
// 1. 这是一个组件 (函数名首字母大写)
function App() {
  
  const name = "React 新手";
  const score = 99;

  // 2. 返回 JSX (看起来像 HTML)
  return (
    <div className="container">
      {/* 3. 使用变量要用花括号 */}
      <h1>你好,{name}!</h1>
      <p>你的分数是:{score + 1}</p>
      
      <button>点我没反应</button>
    </div>
  );
}

// 4. 导出给 index.js 使用
export default App;

观察浏览器: 你应该能看到文字和分数(100)。

注意: 在 React 里,HTML 的 class 属性要写成 className(因为 class 是 JS 的保留字)。


第四步:组件化 (搭积木)

React 的精髓在于把页面拆成小块。我们来新建一个组件。

  1. src 下新建文件 MyButton.jsx
  2. 写入代码:

JavaScript

// MyButton.jsx
function MyButton() {
  return (
    <button style={{ backgroundColor: 'blue', color: 'white' }}>
      我是独立的按钮组件
    </button>
  );
}

export default MyButton;
  1. 回到 App.jsx,像引用 HTML 标签一样使用它:

JavaScript

// 先导入
import MyButton from "./MyButton";

function App() {
  return (
    <div>
      <h1>主页面</h1>
      {/* 使用组件 */}
      <MyButton />
      <MyButton />
    </div>
  );
}
export default App;

你看,组件可以复用!这就好比你定义了一个“乐高积木”,然后想插哪里插哪里。


第五步:React 的灵魂 —— useState

这是 React 最大的难点,也是最迷人的地方。

在原生 JS 里,你修改变量 count = 100,界面不会自动变。你需要手动去找 DOM 修改。

在 React 里,我们通过修改状态 (State) 来驱动界面自动更新。

钩子 (Hook): useState

请把 App.jsx 改成这样,体验一下“数据驱动视图”:

JavaScript

import { useState } from 'react'; // 1. 引入钩子

function App() {
  // 2. 定义状态
  // count: 当前的值
  // setCount: 专门用来修改 count 的函数 (必须用它改,不能直接改 count)
  // useState(0): 初始值是 0
  const [count, setCount] = useState(0);

  // 定义一个点击处理函数
  function handleClick() {
    // 告诉 React:请把 count 变成原来的值 + 1,并重新渲染页面
    setCount(count + 1);
  }

  return (
    <div>
      <h1>当前计数:{count}</h1>
      {/* 3. 绑定事件 (注意 onClick 驼峰写法) */}
      <button onClick={handleClick}>
        点击 +1
      </button>
    </div>
  );
}

export default App;

尝试点击按钮:

数字在变!你没有写任何 document.getElementById,也没有手动修改 DOM 文字。你只是修改了数据 (count),React 自动帮你画出了新的界面。


💡 React 新手第一关挑战

为了确认你掌握了 useState 和组件,请完成下面这个小任务:

做一个“点赞”按钮

  1. 创建一个组件。

  2. 里面有一个状态 isLiked (布尔值,初始为 false)。

  3. 显示一个按钮。

    • 如果 isLikedtrue,按钮文字显示 "已赞 ❤️",背景色变粉色。
    • 如果 isLikedfalse,按钮文字显示 "点赞 🤍",背景色灰色。
  4. 点击按钮,切换状态(从 true 变 false,从 false 变 true)。

提示:

  • 切换状态逻辑:setIsLiked(!isLiked) (取反)
  • 三元运算符 (JS 里的 if/else 简写):{ isLiked ? "已赞" : "点赞" }

Three.js 着色器打造银河系粒子效果

2026年1月23日 15:46

概述

本文将详细介绍如何使用 Three.js 和自定义着色器来创建一个动态旋转的银河系粒子效果。我们将通过编写顶点着色器和片元着色器来实现旋转的星系动画效果,这能帮助你理解粒子系统的构建原理和着色器的使用方法。

screenshot_2026-01-23_15-44-06.gif

准备工作

首先,我们需要引入必要的 Three.js 库和相关工具:

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import gsap from "gsap";
import * as dat from "dat.gui";
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
import { Color } from "three";
import fragmentShader from "../shader/basic/fragmentShader.glsl";
import vertexShader from "../shader/basic/vertexShader.glsl";

场景初始化

首先,我们需要创建一个基本的 Three.js 场景:

// 初始化场景
const scene = new THREE.Scene();

// 创建透视相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerHeight / window.innerHeight,
  0.1,
  1000
);

// 设置相机位置
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
camera.position.set(0, 0, 5);
scene.add(camera);

// 加入辅助轴,帮助我们查看3维坐标轴
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

纹理加载

为了创建漂亮的粒子效果,我们需要加载一些纹理图像:

// 导入纹理
const textureLoader = new THREE.TextureLoader();
const texture = textureLoader.load('textures/particles/10.png');
const texture1 = textureLoader.load('textures/particles/9.png');
const texture2 = textureLoader.load('textures/particles/11.png');

银河系参数配置

为了方便调整效果,我们定义银河系的参数:

// 设置星系的参数
const params = {
  count: 1000,        // 粒子数量
  size: 0.1,          // 粒子大小
  radius: 5,          // 银河系半径
  branches: 4,        // 分支数量
  spin: 0.5,          // 旋转系数
  color: "#ff6030",   // 内部颜色
  outColor: "#1b3984", // 外部颜色
};

// 定义颜色
let galaxyColor = new THREE.Color(params.color);
let outGalaxyColor = new THREE.Color(params.outColor);

生成银河系函数

这是核心函数,用于生成带有旋转动画的银河系粒子:

let geometry = null;
let points = null;
let material;

const generateGalaxy = () => {
  // 如果已经存在这些顶点,那么先释放内存,在删除顶点数据
  if (points !== null) {
    geometry.dispose();
    material.dispose();
    scene.remove(points);
  }

  // 生成顶点几何
  geometry = new THREE.BufferGeometry();
  // 随机生成位置
  const positions = new Float32Array(params.count * 3);
  const colors = new Float32Array(params.count * 3);
  const scales = new Float32Array(params.count);
  // 图案属性
  const imgIndex = new Float32Array(params.count);

  // 循环生成点
  for (let i = 0; i < params.count; i++) {
    const current = i * 3;

    // 计算分支的角度 = (计算当前的点在第几个分支)*(2*Math.PI/多少个分支)
    const branchAngel =
      (i % params.branches) * ((2 * Math.PI) / params.branches);

    const radius = Math.random() * params.radius;
    
    // 随机设置x/y/z偏移值
    const randomX =
      Math.pow(Math.random() * 2 - 1, 3) * 0.5 * (params.radius - radius) * 0.3;
    const randomY =
      Math.pow(Math.random() * 2 - 1, 3) * 0.5 * (params.radius - radius) * 0.3;
    const randomZ =
      Math.pow(Math.random() * 2 - 1, 3) * 0.5 * (params.radius - radius) * 0.3;

    // 设置当前点坐标
    positions[current] = Math.cos(branchAngel) * radius + randomX;
    positions[current + 1] = randomY;
    positions[current + 2] = Math.sin(branchAngel) * radius + randomZ;

    // 颜色渐变处理
    const mixColor = galaxyColor.clone();
    mixColor.lerp(outGalaxyColor, radius / params.radius);

    // 设置颜色
    colors[current] = mixColor.r;
    colors[current + 1] = mixColor.g;
    colors[current + 2] = mixColor.b;

    // 顶点的大小
    scales[current] = Math.random();

    // 根据索引值设置不同的图案
    imgIndex[current] = i % 3;
  }

  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
  geometry.setAttribute("aScale", new THREE.BufferAttribute(scales, 1));
  geometry.setAttribute("imgIndex", new THREE.BufferAttribute(imgIndex, 1));

  // 设置点的着色器材质
  material = new THREE.ShaderMaterial({
    vertexShader: vertexShader,
    fragmentShader: fragmentShader,
    transparent: true,
    vertexColors: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
    uniforms: {
      uTime: {
        value: 0,
      },
      uTexture: {
        value: texture
      },
      uTexture1: {
        value: texture1
      },
      uTexture2: {
        value: texture2
      },
      uColor: {
        value: galaxyColor
      }
    },
  });

  // 生成点
  points = new THREE.Points(geometry, material);
  scene.add(points);
};

着色器详解

顶点着色器 (Vertex Shader)

顶点着色器负责计算每个粒子的位置和大小:

varying vec2 vUv;

attribute float imgIndex;
attribute float aScale;
varying float vImgIndex;

uniform float uTime;

varying vec3 vColor;

void main(){
    vec4 modelPosition = modelMatrix * vec4( position, 1.0 );
    
    // 获取顶点的角度
    float angle = atan(modelPosition.x,modelPosition.z);
    // 获取顶点到中心的距离
    float distanceToCenter = length(modelPosition.xz);
    // 根据顶点到中心的距离,设置旋转偏移度数
    float angleOffset = 1.0/distanceToCenter*uTime;
    // 目前旋转的度数
    angle+=angleOffset;

    modelPosition.x = cos(angle)*distanceToCenter;
    modelPosition.z = sin(angle)*distanceToCenter;

    vec4 viewPosition = viewMatrix*modelPosition;
    gl_Position =  projectionMatrix * viewPosition;

    // 设置点的大小
    gl_PointSize =200.0/-viewPosition.z*aScale;
    vUv = uv;
    vImgIndex=imgIndex;
    vColor = color;
}

片元着色器 (Fragment Shader)

片元着色器负责计算每个粒子的颜色和透明度:

varying vec2 vUv;

uniform sampler2D uTexture;
uniform sampler2D uTexture1;
uniform sampler2D uTexture2;
varying float vImgIndex;
varying vec3 vColor;

void main(){
    vec4 textureColor;
    if(vImgIndex==0.0){
       textureColor = texture2D(uTexture,gl_PointCoord);
    }else if(vImgIndex==1.0){
       textureColor = texture2D(uTexture1,gl_PointCoord);
    }else{
       textureColor = texture2D(uTexture2,gl_PointCoord);
    }
    
    gl_FragColor = vec4(vColor,textureColor.r) ;
}

动画与渲染

最后,我们需要在渲染循环中更新时间参数,以实现旋转动画效果:

const clock = new THREE.Clock();

function animate(t) {
  const elapsedTime = clock.getElapsedTime();
  material.uniforms.uTime.value = elapsedTime;
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

animate();

渲染器和控制器设置

设置渲染器和控制器以获得更好的交互体验:

// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
renderer.shadowMap.enabled = true;

// 设置渲染尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);

// 监听屏幕大小改变的变化,设置渲染的尺寸
window.addEventListener("resize", () => {
  // 更新摄像头
  camera.aspect = window.innerWidth / window.innerHeight;
  // 更新摄像机的投影矩阵
  camera.updateProjectionMatrix();

  // 更新渲染器
  renderer.setSize(window.innerWidth, window.innerHeight);
  // 设置渲染器的像素比例
  renderer.setPixelRatio(window.devicePixelRatio);
});

// 将渲染器添加到body
document.body.appendChild(renderer.domElement);

// 初始化控制器
const controls = new OrbitControls(camera, renderer.domElement);
// 设置控制器阻尼
controls.enableDamping = true;

总结

通过这个项目,我们学习了如何使用 Three.js 创建动态的银河系粒子效果。关键在于:

  1. 使用 BufferGeometry 创建大量的粒子顶点
  2. 通过自定义着色器实现旋转动画效果
  3. 使用不同的纹理为粒子添加多样化的外观
  4. 实现颜色渐变以增强视觉效果

这种技术可以应用于各种场景,如星空、火焰、烟雾、雨雪等自然现象的模拟,或者作为游戏中的特效元素。掌握粒子系统和着色器编程能够让你创建出更加丰富和生动的三维场景。

想给App加个日程提醒?试试鸿蒙 Calendar Kit,超好用!

2026年1月23日 15:44

在做应用开发的时候,你是不是经常遇到这种需求:

"用户买了票,得自动把行程加到日历里提醒他。" "这个直播课很重要,能不能在日历里占个坑,到时候发个通知?" "还款日快到了,得有个强提醒,不然用户又要逾期了..."

以前做这些功能,可能得自己写定时器、搞后台服务,不仅麻烦,还容易被系统杀后台导致提醒不准。现在好了,鸿蒙给我们提供了 Calendar Kit(日历服务),直接把这些事儿交给系统日历去管,既省心又稳定,还能蹭一波系统日历的流量,多香啊!

今天咱们就来聊聊怎么用这个 Calendar Kit,放心,不整那些晦涩难懂的官方术语,咱们就按平时写代码的逻辑,一步步把功能做出来。


1. 这玩意儿能干啥?

简单来说,Calendar Kit 就是应用和系统日历之间的桥梁。

  • 创建账户:你可以在日历里建一个属于你App的专属账户,比如"我的App日历",这样你的数据就和别人的分开了,互不干扰。
  • 管理日程:你可以往这个账户里加日程(Event),设置时间、地点、提醒,还能改能删。
  • 一键服务(大招!):这个最酷!你可以在日程里埋一个"传送门",用户在日历里看到日程,点一下按钮(比如"立即观看"、"一键入会"),直接就跳回你的App里了。这就是所谓的"流量回流"啊!

2. 准备工作:权限得先要

跟系统打交道,第一步永远是申请权限。咱们要读写日历,肯定得征得用户同意。

打开你的 module.json5,在 requestPermissions 里加上这两位爷:

"requestPermissions": [
  {
    "name": "ohos.permission.READ_CALENDAR",
    "reason": "$string:reason_read_calendar",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  },
  {
    "name": "ohos.permission.WRITE_CALENDAR",
    "reason": "$string:reason_write_calendar",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  }
]

别忘了在 string.json 里把 reason 填好,告诉用户你为什么要读写日历(比如:"用于添加行程提醒")。

然后,在代码里动态申请权限。这块代码有点长,但都是标准动作,建议封装个工具类,或者直接在 EntryAbilityonCreate 里搞定。这里就不贴那一大坨样板代码了,记得用 abilityAccessCtrl 去弹窗就行。


3. 手把手教你写代码

好了,权限搞定,咱们开始撸代码。

第一步:拿到日历管家

所有操作的入口都是 CalendarManager

import { calendarManager } from '@kit.CalendarKit';

// 建议把这玩意儿存成全局变量,或者单例,别每次都get一遍
let calendarMgr = calendarManager.getCalendarManager(context);

第二步:创建你的专属日历账户

别直接往默认账户里塞数据,那样显得很乱,而且容易误删用户自己的日程。咱们创建一个专属的。

async function initMyCalendar() {
  // 定义账户信息
  const myAccount: calendarManager.CalendarAccount = {
    name: 'MyCoolApp', // 内部标识,别跟别人撞名
    type: calendarManager.CalendarType.LOCAL,
    displayName: '酷应用日程' // 这才是用户在日历App里看到的名字
  };

  try {
    // 1. 创建账户
    // 注意:如果账户已经存在,这步也不会报错,会直接返回已有的,很贴心
    const calendar = await calendarMgr.createCalendar(myAccount);
    
    // 2. 顺手配置一下颜色和提醒
    // 设置个骚气的颜色,让用户一眼就能认出是你的日程
    const config: calendarManager.CalendarConfig = {
      enableReminder: true, // 开启提醒
      color: '#FF0000' // 红色预警!(开玩笑的,选个跟App主题搭的颜色)
    };
    await calendar.setConfig(config);
    
    console.log('账户创建成功,可以开始造作了!');
    return calendar;
  } catch (err) {
    console.error('哎呀,创建账户翻车了:', err);
  }
}

第三步:加个日程试试

假设咱们是个买票App,用户刚买了一张明天下午的高铁票。

async function addTrainTicketEvent(calendar: calendarManager.Calendar) {
  // 搞定时间戳(这里随便写个时间,实际开发记得用真实时间)
  const startTime = new Date('2025-05-01T14:00:00').getTime();
  const endTime = startTime + 4 * 60 * 60 * 1000; // 4小时车程

  // 构造日程对象
  const event: calendarManager.Event = {
    title: 'G1234 北京南 -> 上海虹桥', // 标题要清晰
    type: calendarManager.EventType.NORMAL,
    startTime: startTime,
    endTime: endTime,
    location: { location: '北京南站' }, // 地点加上,万一用户不知道去哪坐车呢
    reminderTime: [60, 15], // 划重点!提前60分钟和15分钟各提醒一次,防误车神器
    description: '座位:05车 12A,记得带身份证!' // 备注里放点关键信息
  };

  try {
    const id = await calendar.addEvent(event);
    console.log(`搞定!日程ID是 ${id},回头要改要删就靠它了`);
  } catch (err) {
    console.error('添加日程失败,可能是权限没给够?', err);
  }
}

写完这段代码,运行一下,打开系统日历,你应该就能看到那条鲜艳的日程躺在那儿了,是不是很有成就感?😎


4. 进阶玩法:一键回流(DeepLink)

如果只是加个提醒,那还不够完美。用户看到提醒了,想查看车票详情,还得退出日历,滑屏找你的App,多麻烦。

Calendar Kit 的"一键服务"就是解决这个痛点的。咱们可以在日程里加个按钮,比如"查看车票",用户一点,直接跳回你的App详情页。

服务端配置(很重要,别忘了这步!)

这功能比较高级,光改客户端代码不行,还得去 小艺开放平台 注册一下。 简单说就是告诉华为:"嘿,如果有人点击 myapp://ticket/detail 这种链接,记得拉起我的App。"

具体步骤比较繁琐(要填一堆表单),大家去开发者联盟后台按指引操作就行,记得选 HAP LINK 方式。

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

客户端代码

注册完拿到链接模板后,代码里加上 service 字段就行:

const event: calendarManager.Event = {
  // ... 其他字段同上 ...
  
  // 加上这个,逼格瞬间提升
  service: {
    type: calendarManager.ServiceType.TRIP, // 告诉系统这是个出行服务
    uri: 'myapp://ticket/detail?id=888888' // 你的DeepLink,记得跟服务端注册的匹配
  }
};

系统会自动识别 TRIP 类型,在日历里显示一个"立即查看"或者类似的按钮。


5. 避坑指南 & 小技巧 🛡️

在实际开发中,我踩过几个坑,分享给大家避避雷:

  1. 权限死循环: 有时候你觉得自己申请了权限,但代码还是报 Permission denied解决:一定要检查 module.json5 里的 reason 字段是不是用了资源引用($string:...),直接写死字符串有时候是不生效的!还有,真机调试的时候,记得去设置里看看权限是不是真的开了。

  2. 账户重复创建: 虽然 createCalendar 会处理重复,但建议还是先 getCalendar 查一下。如果每次启动App都调一次创建,虽然不出错,但日志看着也闹心不是?

  3. 提醒时间不准reminderTime 单位是分钟。如果你填了个负数,或者填了个比当前时间还早的时间,系统可能会忽略或者报错。全天日程(isAllDay: true)的提醒时间算法不一样(0代表当天9点),这个要特别注意文档说明。

  4. DeepLink跳不过去: 要是点击日历里的按钮没反应,或者跳错页面了。 检查module.json5 里的 skills 配置对不对?actionsuris 必须跟你的链接严丝合缝。


6. 总结

Calendar Kit 真的是个提升用户体验的小甜点。几行代码就能让你的App不仅有了系统级的强提醒能力,还能通过日历这个高频入口把用户拉回来。

不管是做工具类、电商类还是内容类应用,都可以想一想:我的App里有什么事件是值得被记录在日历里的? 找到这个点,加上这个功能,用户绝对会觉得你的App很贴心!

好了,赶紧去试试吧,把你的App塞进用户的日历里!

关于git merge -s ours xxx 的使用

2026年1月23日 15:36

需求背景:开发分支(feat1、feat2、....featn),测试分支(dev),预生产分支(release),上线分支(master)。协作过程是,项目一个大版本上线从release合并到master,上线后反合dev。下一个大版本初始大家都从release分支拉取feat,提测后所有feat都合并到dev,有不规范的操作会从dev直接提交代码,测试完成从dev合并到release。但是在测试的过程中,feat1出现了需求变更,不跟随本次上线了

解决方案

使用git merge -s ours xxx

这条命令是 Git 合并操作 的一种特殊用法,核心是用当前分支的内容覆盖掉要合并的分支内容

假设你当前在 release 分支,执行 git merge -s ours feat

  1. Git 会创建一个新的合并提交(merge commit);
  2. 这个合并提交的内容和当前 dev 分支完全一致origin/feat 分支的所有修改都不会被引入;
  3. Git 的分支历史中会显示 release 已经合并过 origin/feat,后续如果再执行常规合并,Git 会认为该合并的都合过了不会再处理这个分支的历史修改

如上操作之后,如3所说,再次合并feat到release分支,会显示:Already up to date.

但是,我们的需求是feat分支的内容只是当前版本不上线了,但是后续的版本还是会上线的,这并不满足我们的需求。 所以,我们可以,先merge再reset再cherry-pick

  1. 切换到release分支,执行git merge -s ours feat => 得到一个没有内容变更的merge
  2. release分支上,执行git merge origin dev => 得到一个没有feat分支变更内容的dev分支到release分支的merge,同时我们记下这个commitId
  3. release分支reset到执行git merge -s ours feat之前的那个提交 => 通过git reset xxx --hard
  4. 然后将刚才2记下的commitId通过cherry-pcik拿过来 => 使用git cherry-pick xxx -m 1(此处的xxx是个mergeId)
  5. 推送到远端

如上所述,即可实现把dev合并到release且不包含feat分支的改动,并且后续也不会影响feat分支往release分支的合并。但是,对release分支进行reset --hard并不符合常规的要求,对于很多公司来讲,release分支应该是受保护的分支,不应该被随便reset。所以,我们可以从release分支上先切出一个新分支,在新分支上进行上述操作,操作完成之后,再把新分支合并到release分支上。

关于其它解决方案的考虑

  1. 不使用dev合并到release,而是用feat1、feat2...featn逐一合并到release。
  2. 找到 dev 分支上所有非 feat 产生的提交,逐个挑选(cherry-pick)到 release 分支。
  3. dev上使用git rebase -i剔除feat的提交。 对于方案1,在实际开发过程中,每个feat分支可能就对应一个开发者,逐一合并可能会涉及到很多人来处理,而且比如在测试过程中有些非常规的操作(在dev上直接提交一些代码),那这部分的提交就会被忽略掉。对于方案2、3,变更比较少的情况下是适用的,但是变更比较多的情况下逐一排查commit也不现实。

思考

一个未解决的问题留给大家:在release分支使用git merge -s ours feat之后,如何在不reset的情况下,可以让feat分支正常合并release呢?

CSS Margin 合并(Collapsing)详解

作者 娜妹子辣
2026年1月23日 15:31

🎯 什么是 Margin 合并

Margin 合并(也叫 Margin 折叠)是指相邻元素的垂直 margin 会合并成一个 margin,取两者中的较大值,而不是相加。

📊 Margin 合并的三种情况

1. 相邻兄弟元素

问题演示

HTML
<div class="sibling1">第一个元素</div>
<div class="sibling2">第二个元素</div>
CSS
.sibling1 {
  margin-bottom: 30px;
  background: lightblue;
  padding: 10px;
}

.sibling2 {
  margin-top: 20px;
  background: lightcoral;
  padding: 10px;
}

/* 
期望间距: 30px + 20px = 50px
实际间距: max(30px, 20px) = 30px ← 发生了合并!
*/

2. 父子元素

问题演示

HTML
<div class="parent">
  <div class="child">子元素</div>
</div>
CSS
.parent {
  margin-top: 40px;
  background: lightgreen;
}

.child {
  margin-top: 60px;
  background: lightyellow;
  padding: 10px;
}

/* 
期望: 父元素距离上方40px,子元素再距离父元素60px
实际: 父元素距离上方60px,子元素紧贴父元素 ← 合并了!
*/

3. 空元素

问题演示

HTML
<div class="before">前面的元素</div>
<div class="empty"></div>
<div class="after">后面的元素</div>
CSS
.before {
  margin-bottom: 25px;
  background: lightblue;
  padding: 10px;
}

.empty {
  margin-top: 15px;
  margin-bottom: 35px;
  /* 没有内容、padding、border、height */
}

.after {
  margin-top: 20px;
  background: lightcoral;
  padding: 10px;
}

/* 
空元素的上下margin会合并: max(15px, 35px) = 35px
然后与相邻元素继续合并: max(25px, 35px, 20px) = 35px
*/

🔧 解决方案详解

方案1: 使用 BFC(块级格式化上下文)

触发 BFC 的方法

CSS
/* 方法1: overflow */
.bfc-overflow {
  overflow: hidden; /* 或 auto、scroll */
}

/* 方法2: display */
.bfc-display {
  display: flow-root; /* 专门用于创建BFC */
}

/* 方法3: position */
.bfc-position {
  position: absolute; /* 或 fixed */
}

/* 方法4: float */
.bfc-float {
  float: left; /* 或 right */
}

/* 方法5: flex/grid容器 */
.bfc-flex {
  display: flex;
  flex-direction: column;
}

实际应用

HTML
<div class="container">
  <div class="item">元素1</div>
  <div class="item">元素2</div>
</div>
CSS
/* 解决父子margin合并 */
.container {
  overflow: hidden; /* 创建BFC */
  background: #f0f0f0;
}

.item {
  margin: 20px;
  padding: 10px;
  background: lightblue;
}

/* 现在margin不会与父元素合并了 */

方案2: 添加边界内容

使用 padding 替代 margin

CSS
/* 问题代码 */
.problematic {
  margin-top: 30px;
  margin-bottom: 30px;
}

/* 解决方案 */
.solution-padding {
  padding-top: 30px;
  padding-bottom: 30px;
  /* padding 不会发生合并 */
}

添加边框或内容

CSS
/* 阻止父子margin合并 */
.parent-with-border {
  border-top: 1px solid transparent; /* 透明边框 */
  /* 或者 */
  padding-top: 1px;
  /* 或者 */
  overflow: hidden;
}

.parent-with-border .child {
  margin-top: 30px; /* 现在不会与父元素合并 */
}

方案3: 使用现代布局

Flexbox 解决方案

CSS
.flex-container {
  display: flex;
  flex-direction: column;
  gap: 30px; /* 使用gap替代margin */
}

.flex-item {
  padding: 20px;
  background: lightblue;
  /* 不需要设置margin */
}

Grid 解决方案

CSS
.grid-container {
  display: grid;
  grid-template-rows: repeat(auto-fit, auto);
  gap: 30px; /* 统一间距 */
}

.grid-item {
  padding: 20px;
  background: lightcoral;
}

方案4: CSS 自定义属性 + calc()

动态间距管理

CSS
:root {
  --spacing-unit: 20px;
  --spacing-small: calc(var(--spacing-unit) * 0.5);
  --spacing-medium: var(--spacing-unit);
  --spacing-large: calc(var(--spacing-unit) * 1.5);
}

.spaced-element {
  margin-bottom: var(--spacing-medium);
  /* 统一管理,避免不同值的合并问题 */
}

/* 特殊情况下强制不合并 */
.force-spacing {
  margin-bottom: calc(var(--spacing-medium) + 1px);
  /* 微小差异阻止合并 */
}

🚀 实际应用场景解决方案

场景1: 卡片列表

问题代码

HTML
<div class="card-list">
  <div class="card">卡片1</div>
  <div class="card">卡片2</div>
  <div class="card">卡片3</div>
</div>
CSS
/* 有问题的写法 */
.card {
  margin: 20px 0;
  padding: 15px;
  background: white;
  border: 1px solid #ddd;
  /* 相邻卡片间距只有20px,而不是期望的40px */
}

解决方案

CSS
/* 方案1: 使用flexbox */
.card-list {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.card {
  padding: 15px;
  background: white;
  border: 1px solid #ddd;
  /* 不需要margin */
}

/* 方案2: 只设置一个方向的margin */
.card-list-v2 .card {
  margin-bottom: 20px;
  padding: 15px;
  background: white;
  border: 1px solid #ddd;
}

.card-list-v2 .card:last-child {
  margin-bottom: 0;
}

/* 方案3: 使用相邻选择器 */
.card-list-v3 .card + .card {
  margin-top: 20px;
}

场景2: 文章内容

问题代码

HTML
<article class="article">
  <h1>标题</h1>
  <p>第一段内容</p>
  <p>第二段内容</p>
  <blockquote>引用内容</blockquote>
</article>
CSS
/* 有问题的写法 */
h1 { margin: 30px 0; }
p { margin: 15px 0; }
blockquote { margin: 25px 0; }
/* margin会发生合并,间距不均匀 */

解决方案

CSS
/* 方案1: 统一间距系统 */
.article > * {
  margin-top: 0;
  margin-bottom: 1.5rem;
}

.article > *:last-child {
  margin-bottom: 0;
}

/* 方案2: 使用相邻选择器 */
.article h1 + p { margin-top: 1rem; }
.article p + p { margin-top: 1rem; }
.article p + blockquote { margin-top: 1.5rem; }

/* 方案3: CSS Grid */
.article {
  display: grid;
  gap: 1.5rem;
}

场景3: 模态框居中

问题代码

HTML
<div class="modal-overlay">
  <div class="modal">
    <div class="modal-header">标题</div>
    <div class="modal-body">内容</div>
  </div>
</div>
CSS
/* 有问题的写法 */
.modal {
  margin: auto; /* 水平居中 */
  margin-top: 50px; /* 想要距离顶部50px */
}

.modal-header {
  margin-bottom: 20px;
}

.modal-body {
  margin-top: 20px; /* 可能与header的margin合并 */
}

解决方案

CSS
/* 方案1: Flexbox居中 */
.modal-overlay {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  padding: 50px 20px;
}

.modal {
  background: white;
  border-radius: 8px;
  overflow: hidden; /* 创建BFC,防止内部margin合并 */
}

.modal-header {
  padding: 20px;
  background: #f5f5f5;
}

.modal-body {
  padding: 20px;
}

/* 方案2: Grid居中 */
.modal-overlay-grid {
  display: grid;
  place-items: center;
  min-height: 100vh;
  padding: 50px 20px;
}

🔍 调试和检测工具

CSS 调试样式

CSS
/* 显示margin区域 */
.debug-margins * {
  outline: 1px solid red;
  background-clip: content-box;
}

/* 显示所有盒模型 */
.debug-all * {
  box-shadow: 
    0 0 0 1px red,           /* border */
    0 0 0 2px yellow,        /* padding */
    0 0 0 3px blue;          /* margin的近似显示 */
}

/* 检测BFC */
.debug-bfc {
  background: rgba(255, 0, 0, 0.1);
}

.debug-bfc::before {
  content: 'BFC';
  position: absolute;
  top: 0;
  left: 0;
  font-size: 12px;
  background: red;
  color: white;
  padding: 2px 4px;
}

JavaScript 检测工具

JavaScript
// 检测元素是否创建了BFC
function hasBFC(element) {
  const style = getComputedStyle(element);
  
  return (
    style.overflow !== 'visible' ||
    style.display === 'flow-root' ||
    style.position === 'absolute' ||
    style.position === 'fixed' ||
    style.float !== 'none' ||
    style.display === 'flex' ||
    style.display === 'grid' ||
    style.display === 'inline-block' ||
    style.display === 'table-cell'
  );
}

// 计算实际margin
function getActualMargin(element) {
  const rect = element.getBoundingClientRect();
  const style = getComputedStyle(element);
  
  return {
    top: parseFloat(style.marginTop),
    right: parseFloat(style.marginRight),
    bottom: parseFloat(style.marginBottom),
    left: parseFloat(style.marginLeft)
  };
}

// 使用示例
const element = document.querySelector('.my-element');
console.log('是否创建BFC:', hasBFC(element));
console.log('实际margin:', getActualMargin(element));

⚡ 性能优化建议

避免频繁的margin变化

CSS
/* 不推荐:频繁改变margin */
.animated-bad {
  transition: margin 0.3s;
}

.animated-bad:hover {
  margin-top: 20px; /* 会触发重排 */
}

/* 推荐:使用transform */
.animated-good {
  transition: transform 0.3s;
}

.animated-good:hover {
  transform: translateY(20px); /* 只触发重绘 */
}

批量处理margin设置

CSS
/* 使用CSS自定义属性统一管理 */
:root {
  --spacing-xs: 0.25rem;
  --spacing-sm: 0.5rem;
  --spacing-md: 1rem;
  --spacing-lg: 1.5rem;
  --spacing-xl: 2rem;
}

/* 统一的间距类 */
.mt-sm { margin-top: var(--spacing-sm); }
.mt-md { margin-top: var(--spacing-md); }
.mt-lg { margin-top: var(--spacing-lg); }

.mb-sm { margin-bottom: var(--spacing-sm); }
.mb-md { margin-bottom: var(--spacing-md); }
.mb-lg { margin-bottom: var(--spacing-lg); }

📚 最佳实践总结

预防策略

  1. 使用现代布局: Flexbox 和 Grid 的 gap 属性
  2. 统一间距系统: 使用设计令牌管理间距
  3. 单向margin: 只设置 margin-bottom 或 margin-top
  4. BFC容器: 为需要的容器创建BFC

解决策略

  1. overflow: hidden: 简单有效的BFC创建方法
  2. display: flow-root: 专门用于创建BFC
  3. padding替代: 在合适的场景下使用padding
  4. 相邻选择器: 使用 + 选择器精确控制间距

调试策略

  1. 开发者工具: 查看盒模型面板
  2. CSS调试: 使用outline显示边界
  3. 渐进增强: 从简单布局开始,逐步添加复杂性

记住:理解margin合并的规则,选择合适的解决方案,让布局更加可控和可预测!

前端向架构突围系列模块化 [4 - 3]:复杂组件的通信与组合模式

2026年1月22日 13:44

写在前面

在封装复杂组件时,习惯使用**“配置对象驱动”**的模式。

比如写一个 Tabs 组件,他们会定义一个 items 属性,让用户传入一个数组:[{ title: 'A', content: '...' }]。 这种写法看似简洁,实则是架构的死胡同。一旦用户说:“我想在第二个 Tab 的标题旁边加个红点”,或者“我想让第三个 Tab 的内容懒加载”,你的组件 API 就会瞬间爆炸,变成 renderTitlelazyLoad 等无数个补丁属性。

直觉告诉我们:好的组件 API 应该是声明式的,而不是配置式的。

本篇我们将探讨如何通过复合组件(Compound Components)模式重建父子关系,利用隐式状态共享消灭 Props Drilling,并最终通过控制反转实现组件能力的无限扩展。

image.png


一、 拒绝巨型配置:复合组件 (Compound Components) 的哲学

复合组件模式的核心思想是:组件不应该是一个黑盒,而应该是一组协同工作的零件。

1.1 什么是复合组件?

看看 HTML 原生的 <select><option>,这就是世界上最古老且完美的复合组件:

<select>
  <option value="1">Option A</option>
  <option value="2">Option B</option>
</select>

它们分开写,但共享同一个状态(当前选中的值)。

1.2 实战:重构 Tabs 组件

错误示范(配置式):

//  扩展性极差,只能通过增加 props 来修补
<Tabs 
  items={[{ title: 'Tab 1', content: 'Content 1' }]} 
  activeTabColor="red"
  renderTitle={(title) => <span>{title}</span>} // 丑陋的补丁
/>

架构级示范(复合式):

// 声明式,结构清晰,用户拥有完全的渲染控制权
<Tabs defaultIndex={0} onChange={console.log}>
  <Tabs.List>
    <Tabs.Tab>Tab 1</Tabs.Tab>
    <Tabs.Tab disabled>Tab 2</Tabs.Tab>
    <Tabs.Tab>
        <Badge>Tab 3</Badge> {/* 用户可以随意组合 */}
    </Tabs.Tab>
  </Tabs.List>
  <Tabs.Panels>
    <Tabs.Panel>Content 1</Tabs.Panel>
    <Tabs.Panel>Content 2</Tabs.Panel>
    <Tabs.Panel>Content 3</Tabs.Panel>
  </Tabs.Panels>
</Tabs>

底层实现原理: 这不仅仅是把组件切开那么简单。为了让 <Tabs.Tab> 知道自己是否被选中,我们需要使用 Context 进行隐式状态通信。 父组件 <Tabs> 创建一个 Context,向下广播 activeIndexsetActiveIndex。子组件自动订阅,无需用户显式传递。


二、 隐形纽带:Context Module Pattern (模块化上下文)

在复合组件中,Context 是胶水。但架构师要注意:不要把 Context 暴露给全世界。

2.1 作用域污染的风险

如果你在全局定义了一个 TabContext 并导出,很可能会被其他组件误用,或者在嵌套的 Tabs 中发生冲突(内层 Tab 消费了外层 Tab 的 Context)。

2.2 最佳实践:创建自定义 Scope

参考 Radix UI 或 React Spectrum 的设计,我们应该为每个复合组件创建独立的 Context Scope。

// 伪代码:构建受保护的 Context
const [TabsProvider, useTabsContext] = createContextScope("Tabs");

function TabsRoot({ children }) {
  // ... 状态逻辑
  return <TabsProvider value={state}>{children}</TabsProvider>;
}

function Tab({ children }) {
  // 只能在 TabsRoot 内部使用,否则报错
  const context = useTabsContext("Tab"); 
  return ...;
}

这种模式保证了组件的自洽性。用户不需要关心 Tabs 内部是怎么通信的,他们只管像拼乐高一样组合组件。


三、 终极控制权:State Reducer (状态归约器)

当你封装了一个通用组件,总会遇到这种需求:

  • “我想让 Modal 点击遮罩层关闭,但按下 ESC 键时不关闭。”
  • “我想让 Switch 开关只能打开,不能关闭(一次性锁死)。”

初级做法是加 Props:closeOnEsc={false}, preventToggleOff={true}高级做法是:Inversion of Control (控制反转)。

借鉴 Redux 的思想,我们可以允许用户传入一个 stateReducer,拦截并篡改组件内部的状态更新。

JavaScript

// 组件内部实现
function useToggle({ reducer = (state, action) => action.changes }) {
  const [on, setOn] = useState(false);
  
  const toggle = () => {
    // 在更新前,先问问用户的 reducer
    const changes = { on: !on };
    const finalState = reducer(on, { type: 'TOGGLE', changes });
    setOn(finalState.on);
  };
  // ...
}

// 用户使用:彻底改变组件行为,而无需修改组件源码
<Toggle 
  stateReducer={(state, action) => {
    // 拦截:如果是点击操作且想要关闭,则阻止
    if (action.type === 'TOGGLE' && state.on === true) {
      return { on: true }; // 强制保持开启
    }
    return action.changes; // 否则默认行为
  }} 
/>

架构价值: stateReducer 模式将**“状态更新的逻辑”**从组件内部剥离出来,交给了使用者。这使得组件能够适应极其边缘的业务场景,而无需增加任何 API 负担。


四、 哲学思辨:受控与非受控的完美共存

这是组件设计中最经典的难题:State 到底应该由组件自己管(非受控),还是由父组件管(受控)?

  • 非受控 (Uncontrolled): <input defaultValue="hello" />。简单,不需要写 onChange,但父组件很难干预。
  • 受控 (Controlled): <input value={val} onChange={setVal} />。灵活,但父组件必须维护状态,哪怕是很简单的场景。

架构师的答案:Hybrid (混合模式)。 优秀的组件库(如 AntD, MUI)都支持“双模式”。

4.1 混合 Hook 的实现机制

你需要实现一个 useControllableState

  1. 检查用户是否传入了 value 属性。
  2. 如果传入了 value,则判定为受控模式,内部状态直接引用 valuesetState 只触发 onChange
  3. 如果没传,则判定为非受控模式,内部维护 useStatesetState 既更新内部状态也触发 onChange

TypeScript

// 完美的组件接口
<Tabs /> // 模式 A: 内部自己玩,非受控
<Tabs value={index} onChange={setIndex} /> // 模式 B: 外部完全控制,受控

这种兼容性设计,是区分“玩具组件”和“工程级组件”的重要分水岭。


五、 结语:组件设计的冰山之下

当我们谈论组件化时,不要只盯着 UI 看。 Headless UI 解决了垂直方向的逻辑剥离,而 Compound Components 和 State Reducer 解决了水平方向的通信与扩展。

一个优秀的架构师,在设计组件时,脑海里不应该只有 DOM 结构,而应该是一张张状态流转图

至此,我们已经搭建好了组件的“骨架”。但一个活的系统,还需要血液的流动——即模块与模块之间如何解耦通信? 除了 props 和 context,我们是否还有更强大的武器来应对跨模块的复杂联动?

Next Step: 下一节,我们将进入“灵魂”篇,探讨前端设计模式中最经典、也最容易被滥用的模式。 请看**《第四篇:灵魂(上)——解耦的神器:前端核心设计模式之观察者与发布订阅》**。

前端向架构突围系列模块化 [4 - 2]:逻辑与视图的极致分离(Headless UI)

2026年1月22日 10:02

写在前面

你是否经历过这样的场景:

你的团队维护了一个功能强大的 <SuperSelect /> 组件,集成了搜索、多选、远程加载、虚拟滚动。 某天,产品经理走过来说:“这个下拉框在移动端能不能变成一个从底部弹出的半屏抽屉(ActionSheet)?逻辑不变,就是样式改改。”

你看着那 2000 行包含着 divulli 和无数 CSS 类的代码,陷入了绝望。你发现根本改不动,因为交互逻辑(打开/关闭/选中)DOM 结构 像是纠缠在一起的藤蔓,剪不断理还乱。

这就是逻辑与视图强耦合的代价。

本篇我们将探讨 Headless UI(无头组件) 模式。它不仅是 Shadcn/UI、TanStack Table 背后的秘密武器,更是前端架构师解耦复杂业务组件的必修课。

image.png


一、 进化的必然:从“全家桶”到“发动机”

在组件化发展的早期(Bootstrap、AntD v3 时代),我们推崇的是 "All-in-One" 模式。一个组件包办一切:

  • 状态(State): isOpen, selectedIndex
  • 行为(Behavior): 点击打开、键盘回车选中、ESC 关闭
  • 样式(Style): CSS Class, Styled-components
  • 结构(Markup): <div>, <span>

这种模式在业务初期跑得很快,但随着设计系统的迭代,它很快就会变成**“配置地狱”**。你一定见过这种组件 API:

// 典型的“过度封装”组件
<Table 
  data={data}
  useVirtualScroll={true}
  // 为了改一个样式,不得不暴露无数个 render props
  renderHeader={(props) => <div className="bg-red-500" {...props} />} 
  rowClassName={(record) => record.active ? 'bg-blue' : ''}
  dropdownStyle={{ zIndex: 9999 }} // 甚至开始直接透传 CSS
/>

架构反思: 这种设计的本质错误在于:试图用有限的配置(Props),去穷尽无限的 UI 变化。 视图(View)是易变的,就像时尚潮流;而逻辑(Logic)是相对稳定的,就像人体骨骼。把它们焊死在一起,必然会导致僵化。

于是,Headless UI 应运而生。它的核心哲学只有一句话:我给你提供逻辑的“发动机”,你自己去造“车壳子”。


二、 Headless UI 的解剖学:只有大脑,没有皮肤

所谓的“无头”,指的是不渲染具体的 DOM 节点(或者只渲染最语义化的标签),不包含任何样式,只负责提供交互逻辑和可访问性(A11y)。

2.1 两种主流实现形态

在 React 和 Vue 的现代生态中,Headless 主要有两种落地形态:

形态一:Hooks / Composables (纯逻辑层)

这是最彻底的分离。组件完全消失,只剩下一个函数。

  • React 示例 (useToggle):

    // Headless 逻辑
    function useToggle() {
      const [on, setOn] = useState(false);
      const toggle = () => setOn(!on);
      // 返回状态和绑定到 DOM 上的属性
      return { 
        on, 
        toggle, 
        togglerProps: { 
          'aria-pressed': on, 
          onClick: toggle 
        } 
      };
    }
    
    // UI 实现 A:普通的按钮
    function ButtonToggle() {
      const { on, togglerProps } = useToggle();
      return <button {...togglerProps}>{on ? 'ON' : 'OFF'}</button>;
    }
    
    // UI 实现 B:复杂的 Switch 开关
    function SwitchToggle() {
      const { on, togglerProps } = useToggle();
      return (
        <div {...togglerProps} className={`switch ${on ? 'active' : ''}`}>
          <div className="slider" />
        </div>
      );
    }
    

形态二:Render Props / Slots (组件容器层)

这种形态通常用于涉及父子组件通信的复杂场景(如 Select, Tabs)。它负责处理 DOM 的层级关系和键盘导航,但把渲染权交还给用户。

  • 代表库: Headless UI (Tailwind Labs), Radix UI.
// Radix UI 风格
<Tabs.Root defaultValue="tab1">
  <Tabs.List>
    <Tabs.Trigger value="tab1">Account</Tabs.Trigger>
    <Tabs.Trigger value="tab2">Password</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="tab1">...</Tabs.Content>
  <Tabs.Content value="tab2">...</Tabs.Content>
</Tabs.Root>

注意:这里的 <Tabs.Root> 并不强制你输出特定的 div 结构,它主要负责上下文(Context)的传递键盘焦点的管理


三、 架构师的实战:如何设计一个 Headless 组件?

假设我们要设计一个企业级的 Datepicker(日期选择器) 。如果按照传统思路,你会被 CSS 搞死。如果按照 Headless 思路,你应该关注什么?

3.1 步骤一:提取“纯数据模型”

首先,剥离任何与 DOM 无关的数学逻辑。这部分代码应该是纯 JS/TS,可以在 Node.js 里跑单测。

class CalendarModel {
  // 给定年月,返回一个 6x7 的二维数组,包含日期、是否是上月残留、是否禁用等信息
  generateGrid(year: number, month: number): DateCell[][] { ... }
  
  // 判断两个日期是否相等
  isSameDate(d1: Date, d2: Date): boolean { ... }
}

3.2 步骤二:封装“交互钩子”

接下来,处理用户的交互行为。这是 Headless 的核心。

// useCalendar.ts
export function useCalendar({ selectedDate, onChange }) {
  const [viewDate, setViewDate] = useState(new Date()); // 当前查看的月份

  const grid = useMemo(() => new CalendarModel().generateGrid(...), [viewDate]);

  const selectDate = (date) => {
    onChange(date);
    // 只有逻辑,没有样式
  };

  const nextMonth = () => setViewDate(d => addMonths(d, 1));

  // 关键:返回 Props Getter 而不是直接返回 JSX
  // 这就是 Inversion of Control (控制反转)
  const getDayProps = (dateCell) => ({
    onClick: () => selectDate(dateCell.date),
    role: 'gridcell',
    'aria-selected': isSameDate(dateCell.date, selectedDate),
    'aria-disabled': dateCell.disabled,
    tabIndex: isSameDate(dateCell.date, viewDate) ? 0 : -1, // 键盘导航逻辑
  });

  return { grid, viewDate, nextMonth, getDayProps };
}

3.3 步骤三:注入视图(UI层)

最后,才是业务开发者干活的地方。

function MyDatePicker() {
  const { grid, getDayProps } = useCalendar({...});

  return (
    <div className="my-calendar-wrapper">
      {grid.map(row => (
        <div className="row">
          {row.map(cell => (
            // 只需要解构 props,所有的交互、A11y 自动生效
            <span className="cell" {...getDayProps(cell)}>
              {cell.date.getDate()}
            </span>
          ))}
        </div>
      ))}
    </div>
  );
}

架构收益:

  1. UI 自由: 你可以用 table 渲染,也可以用 div+flex 渲染,甚至可以在 Canvas 里渲染(只要能透传事件)。
  2. 测试稳定: 你只需要针对 useCalendar 编写逻辑测试用例,覆盖率 100%。UI 层的变化不会导致逻辑测试失败。
  3. 多端复用: 这个 useCalendar 可以无缝移植到 React Native,因为里面没有 HTMLDivElement

四、 复杂度的克星:TanStack Table 的启示

如果说 toggle 是小儿科,那么表格(Table)就是前端复杂度的珠穆朗玛峰。 TanStack Table (原 React Table) 是 Headless 理念的集大成者。

拒绝渲染任何 HTML。它不给你 <Table> 组件,它给你的是:

  • useReactTable() 钩子
  • getRowModel() 核心算法
  • getSortedRowModel() 排序算法

开发者: "那表格长什么样?" TanStack:*"我怎么知道?你可以用 HTML table,也可以用 CSS Grid 模拟的虚拟列表 div。我只告诉你,第二行第三列的数据是什么,以及它现在是不是被选中状态。"

这种设计带来的爆发力是惊人的: 企业 A 想要一个类似 Excel 的表格;企业 B 想要一个类似 Trello 的看板(本质也是数据列表)。 在 Headless 架构下,它们可以使用同一套核心逻辑(排序、筛选、分页、分组) ,仅仅是 View 层不同。这在传统组件库(如 AntD Table)中是几乎不可能做到的。


五、 陷阱与权衡:何时不该 Headless?

Headless UI 虽然优雅,但它是高成本的。

5.1 缺点:

  1. 上手门槛高: 开发者不能“开箱即用”。为了画一个简单的下拉框,可能需要写 50 行代码来组装 Headless 的各个部件。
  2. 样式管理负担: 所有 CSS 都要自己写,或者依赖 Tailwind。

5.2 架构分层策略

作为架构师,你不应该让所有业务开发都直接使用 Headless 底层。你应该采用 “三层架构”

  1. Level 1: Headless Core (逻辑层)

    • 使用 useSelect, Radix Select
    • 处理 ARIA、键盘事件、状态管理。
    • 面向对象:资深开发 / 架构组。
  2. Level 2: Styled System Component (标准组件层)

    • 引入公司的 Design Token。
    • 封装样式:<CompanySelect /> = Radix Select + Tailwind Classes
    • 面向对象:业务线通用开发。
  3. Level 3: Business Component (业务组件层)

    • 绑定业务数据:<UserSelect /> = <CompanySelect /> + fetchUserList API
    • 面向对象:初级开发 / 实习生。

通过这种分层,我们既保留了 Headless 的灵活性(底层可换),又保证了业务开发的高效性(顶层开箱即用)。


结语:控制反转的艺术

组件化设计的最高境界,不是你帮用户把所有事情都做了,而是你把控制权优雅地交还给用户

Headless UI 就像是把组件的“灵魂”提取了出来,允许用户随心所欲地塑造“肉体”。理解了这一点,你就掌握了应对前端需求万变不离其宗的法门。

Next Step: 有了灵活的组件骨架(Headless UI),如果组件之间需要复杂的通信(比如一个树形组件,子节点要通知祖先节点),或者需要跨层级的状态共享,仅仅靠 Props 传递显然是不够的。 下一节,我们将探讨**《第三篇:骨架(下)——组件化深度设计:复杂组件的通信与组合模式》**

❌
❌