普通视图

发现新文章,点击刷新页面。
今天 — 2025年7月12日技术

React入门实战:从静态渲染到动态状态管理

2025年7月12日 14:04

前言

最近在学习react,发现很多新手对React的理解还停留在"写JSX语法"的层面。今天就通过一个简单的todo应用,带大家深入理解React的核心概念:组件化、状态管理和动态渲染。

项目结构分析

这个demo项目包含三个关键文件:

  • App.jsx - 主组件逻辑
  • App.css - 组件样式
  • index.css - 全局样式

从静态到动态的演进过程

第一阶段:静态数据渲染

function App() {
  // 定义静态数据数组
  const todos = ['吃饭', '喝酒', '打太极']; 
  return (
    <>
      <table>
        <thead>
          <tr>
            <th>序号</th>
            <th>任务</th>
          </tr>
        </thead>
        <tbody>
        {
          // 使用map方法遍历数组,生成表格行
          // JSX中使用{}语法嵌入JavaScript表达式
          todos.map((item, index) => (
            <tr>
              <td>{index + 1}</td>
              <td>{item}</td>
            </tr>
            )
          )
        }
        </tbody>
      </table>
    </>
  )
}

这个阶段的代码展示了React最基础的能力:在JSX中嵌入JavaScript表达式。通过{}语法,我们可以在模板中动态渲染数据。

关键点分析:

  • 使用map方法遍历数组并生成DOM结构
  • 每个{}都是一个JavaScript表达式的执行环境
  • React Fragment(<></>)避免多余的DOM节点包装

第二阶段:引入状态管理

function App(){
  // 使用useState Hook管理组件状态
  const [todos, setTodos] = useState(['吃饭', '喝酒', '打太极']);
  const [title,setTitle] = useState('斗尊 强者如斯');
  
  // 模拟异步数据更新
  setTimeout(() => {
    setTodos(['吃饭', '喝酒', '打太极', '钓鱼']);
    setTitle('腾讯强者 恐怖如斯');
  }, 2000)
  
  return(
    <div>
      <h1 className='title'>{title}</h1>
      <table>
        <thead>
          <tr>
            <th>序号</th>
            <th>任务</th>
          </tr>
        </thead>
        <tbody>
          {
            // 动态渲染列表数据
            todos.map((item,index) => (
              <tr>
                <td>{index + 1}</td>
                <td>{item}</td>
              </tr>
            ))
          }
        </tbody>
      </table>
    </div>
  )
}

这里体现了React的核心思想:数据驱动视图

技术要点解析:

  1. useState Hook的使用

    • const [todos, setTodos] = useState(['吃饭', '喝酒', '打太极'])
    • 数组解构赋值获取状态值和更新函数
    • 初始状态通过参数传入
  2. 状态更新机制

    • 通过setTodossetTitle更新状态
    • React会自动重新渲染组件
    • 2秒后数据自动更新,展示动态效果

一开始:

image.png

2秒后:

image.png

  1. 组件重渲染

    • 状态变化 → 组件重新执行 → 虚拟DOM对比 → 更新真实DOM

样式设计分析

组件样式(App.css)

*{
  margin: 0;
  padding: 0;
}
.title{
  background-color: rgb(71, 234, 82);
  color: rgb(228, 53, 53);
}

简洁的重置样式加上个性化的标题设计,体现了组件化的样式管理思路。

全局样式(index.css)

:root {
  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;
  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;
}

@media (prefers-color-scheme: light) {
  :root {
    color: #213547;
    background-color: #ffffff;
  }
}

现代化CSS特性:

  • 使用CSS变量(:root
  • 支持暗黑模式切换
  • 响应式字体系统
  • 系统字体栈优化

React设计理念分析

通过对比静态渲染和动态状态管理,我们可以看出React的设计哲学:

函数式编程思想:

  • 组件就是函数,接收props返回JSX
  • 状态更新触发重新渲染
  • 纯函数特性保证了可预测性

数据驱动视图:

  • 状态变化自动更新UI
  • 单向数据流确保数据流向清晰
  • 声明式编程降低心智负担

实战经验总结

1. 组件设计原则

  • 单一职责:每个组件只做一件事
  • 数据流向:从上到下传递数据
  • 状态提升:共享状态放在公共父组件

2. 性能优化要点

  • 合理使用key属性(代码中缺少这一点)
  • 避免在render中创建新对象
  • 使用React.memo优化重渲染

3. 常见陷阱

// ❌ 错误:缺少key属性
todos.map((item,index) => (
  <tr>
    <td>{index + 1}</td>
    <td>{item}</td>
  </tr>
))

// ✅ 正确:添加key属性
todos.map((item,index) => (
  <tr key={index}>
    <td>{index + 1}</td>
    <td>{item}</td>
  </tr>
))

结语

这个简单的todo应用虽然功能基础,但完整展示了React开发的核心思想:组件化、状态管理和声明式编程。理解这些概念后,就能够构建更复杂的React应用了。

记住,React的精髓不在于语法,而在于思想。掌握了数据驱动视图的理念,你就踏上了现代前端开发的正确道路。

提升CSS开发效率的必备Chrome插件

作者 土豆1250
2025年7月12日 12:29

精选工具助力设计师与开发者高效协作,告别繁琐调试流程

在Web开发和设计工作中,CSS调试常常是耗时最长的环节之一。幸运的是,Chrome插件生态提供了许多强大的工具,能显著提升我们的工作效率。本文将介绍几款专业高效的CSS开发插件,涵盖响应式测试、颜色管理、视觉调整等核心场景。

🎨 1. ColorZilla - 终极颜色解决方案

官网www.colorzilla.com/

核心功能

  • 高级取色器:从页面上任意位置精准获取颜色值
  • 格式转换:支持HEX、RGB/RGBA、HSL/HSLA格式互转
  • 渐变生成器:可视化创建CSS渐变代码
  • 调色板管理:保存常用颜色组合,支持导入导出
  • 颜色分析:自动提取页面所有配色方案

使用场景

<!-- 设计稿中的颜色值 -->
<div style="background-color: #3a86ff; color: rgba(255,255,255,0.9)"></div>

<!-- 开发实现时通过ColorZilla确保一致性 -->
.button {
  background: #3a86ff; 
  color: rgba(255,255,255,0.9);
}

适用场景:设计交付验收、品牌色彩一致性检查、快速匹配客户指定颜色

✨ 2. Visbug - 设计师的开发者工具

介绍文章juejin.cn/post/721515…

核心功能

  • 拖拽布局:直接调整元素位置、间距和尺寸
  • 实时样式编辑:所见即所得的CSS属性修改
  • 对齐参考线:智能显示元素对齐状态
  • 元素测量:可视化查看间距和尺寸数值
  • 无障碍检查:快速检测对比度和可访问性问题

典型工作流

  1. 设计师指出按钮需要下移5px
  2. 开发者开启Visbug直接拖拽调整
  3. 实时查看效果并复制生成的CSS代码
  4. 粘贴到代码库中完成修改

协作价值:减少设计师与开发者之间的沟通成本,避免反复截图标注的繁琐流程

📱 3. Responsive Viewer - 多设备响应式测试

官网responsiveviewer.org/

核心功能

设备类型 预设分辨率 自定义支持
手机 iPhone 14/SE等
平板 iPad Pro/Air等
笔记本 MacBook 13-16寸
桌面显示器 1080p/4K/超宽屏
自定义设备 任意宽高组合

使用场景

// 传统响应式测试方式
1. 打开Chrome设备工具栏
2. 选择一种设备模式
3. 检查布局
4. 切换另一种设备
5. 重复以上步骤...

// 使用Responsive Viewer后
- 同时查看4种设备布局
- 实时滚动同步对比
- 一键截图所有视图

核心优势:并行测试节省70%响应式调试时间,特别适合电商大促页、落地页等多端适配场景

🖥 4. Window Resizer - 精准分辨率控制

安装链接Chrome应用商店

功能亮点

  • 预设分辨率:包含1080p、4K、超宽屏等常用尺寸
  • 自定义配置:保存团队专用设备尺寸
  • DPI模拟:测试高分辨率屏幕显示效果
  • 多窗口布局:快速创建分屏测试环境

典型应用

# 测试流体布局断点
1. 设置1920px → 检查布局
2. 设置1440px → 检查变化
3. 设置1280px → 触发响应式调整
4. 设置1024px → 验证移动端布局

价值体现:精确验证CSS媒体查询断点,确保各分辨率下的显示效果

🔍 5. CSS Peeper - 智能样式提取器

官网csspeeper.com/

功能特性

  • 视觉化样式查看:以设计软件风格展示元素样式
  • 一键复制属性:单独复制颜色/字体/间距等属性
  • 资源提取:自动获取页面使用的所有图片/图标资源
  • 设计规范生成:导出页面的色彩和字体系统

工作场景

当需要复刻某个网站的样式时:
1. 激活CSS Peeper
2. 悬停在目标元素上
3. 查看完整的CSS属性:
   - 字体:Inter, 16px, #333
   - 边距:margin: 0 0 24px 0
   - 阴影:box-shadow: 0 2px 10px rgba(0,0,0,0.1)
4. 点击复制所需属性

独特优势:设计师无需查看源代码即可获取精确样式参数

💡 高效工作流建议

  1. 开发阶段:Window Resizer + Visbug 快速搭建和调整布局
  2. 样式设计:ColorZilla + CSS Peeper 确保设计实现一致性
  3. 测试阶段:Responsive Viewer 一站式完成多设备验证
  4. 交付前:使用所有工具交叉验证最终效果

这些工具将传统需要数小时的调试工作压缩到几分钟内完成,让开发者能专注于创造性的编码工作而非机械调试。

通过合理组合使用这些工具,设计师和开发者可以建立无缝协作流程,提升团队整体产出效率约40%。现代前端开发已进入可视化、实时化的新时代,善用这些利器将让您在项目中始终保持竞争优势。

5 个理由告诉你为什么有了 JS 还要用 TypeScript

作者 Sun_light
2025年7月12日 12:06

在前端开发圈,JavaScript(简称JS)几乎无处不在。但你有没有发现,越来越多的大型项目和团队都在用 TypeScript(简称TS)?明明 JS 已经这么强大,为什么还要多此一举用 TS 呢?今天就用通俗易懂的语言,结合具体例子,带你彻底搞懂这个问题!🌟


1. JS的弱类型让大型项目“踩坑”不断

JavaScript 是一种弱类型语言,也就是说,变量的类型可以随时变化。虽然这让 JS 写起来很灵活,但在大型项目中却容易埋下隐患。

举个例子:

// JS 代码
function sum(a, b) {
  return a + b;
}

console.log(sum(1, 2));      // 输出 3
console.log(sum('1', 2));    // 输出 '12',字符串拼接
console.log(sum(true, []));  // 输出 'true',奇怪的结果

在 JS 里,sum 函数参数类型完全不受限制,传什么都行。小项目还好,项目一大,团队一多,类型混乱就会导致各种难以发现的bug,甚至上线后才暴雷,影响开发效率和用户体验。


2. TS的类型检查让错误“消灭在摇篮里”

TypeScript 是 JS 的超集,在 JS 的基础上增加了类型系统。这意味着你可以在写代码时就发现类型错误,而不是等到运行时才发现。

同样的例子,用 TS 改写:

// TS 代码
function sum(a: number, b: number): number {
  return a + b;
}

sum(1, 2);        // 正常
sum('1', 2);      // ❌ 报错:参数类型不匹配

TS 会在你写代码时就提示错误,防止类型不一致带来的 bug。这样,开发效率和代码质量都大大提升


3. TS的类型推断让开发更智能

你可能担心,TS 要写很多类型声明,会不会很麻烦?其实不用担心,TS 有类型推断功能,能根据你的代码自动判断类型。

例子:

let age = 18; // TS 自动推断 age 是 number 类型
age = '二十'; // ❌ 报错:不能把 string 赋值给 number

你只需要在关键地方声明类型,其他地方 TS 会帮你自动推断,大大减少了重复劳动。


4. TS让团队协作更高效

在多人协作的大型项目中,TS 的类型系统就像一份“契约”,让每个人都能清楚知道每个函数、对象、变量的类型,极大减少沟通成本和踩坑概率

例子:

// 定义一个工具函数
function formatUser(user: { name: string; age: number }) {
  return `${user.name} (${user.age})`;
}

// 调用时,TS 会自动检查参数类型
formatUser({ name: '小明', age: 20 }); // 正常
formatUser({ name: '小红', age: '二十' }); // ❌ 报错

有了类型约束,团队成员只要看类型定义就能明白怎么用,不用再靠口头说明或文档补充,协作效率大大提升。


5. TS支持现代开发工具,体验更丝滑

TS 的类型信息可以被编辑器和IDE(如 VSCode)利用,带来更智能的自动补全、跳转、重构、查找引用等功能,让开发体验飞升!

例子:

  • 输入对象名时,编辑器会自动提示有哪些属性;
  • 修改类型定义,相关代码会自动高亮出错,方便全局重构;
  • 查找函数引用时,TS 能精确定位所有用到的地方。

这些功能在 JS 里是做不到的,TS 让开发更高效、更安全、更快乐! 😄


TS的常见类型一览表

类型 说明 示例
any 任意类型 let a: any
unknown 未知类型 let b: unknown
never 永不存在的类型 function error(): never { throw new Error() }
string 字符串 let s: string
number 数字 let n: number
boolean 布尔 let b: boolean
null let n: null
undefined 未定义 let u: undefined
symbol 符号 let s: symbol
bigint 大整数 let b: bigint
object 狭义对象类型 let o: object
Object 广义对象类型 let O: Object

小贴士:

  • any 虽然灵活,但会失去类型检查,不推荐使用;
  • unknown 更安全,推荐用来接收不确定类型的数据。

TS的安装与使用

TypeScript 的安装和使用也非常简单:

npm install -g typescript
npm install -g ts-node
  • typescript 用于编译 .ts 文件, 在当前目录生成一个同名的 .js 文件;
  • ts-node 可以直接运行 TS 文件,开发更方便。

总结

有了 JS,为什么还要用 TS?
归根结底,TS 让代码更安全、开发更高效、协作更顺畅、体验更丝滑。尤其是在大型项目和团队协作中,TS 的优势会越来越明显。

5个理由再回顾:

  1. JS 弱类型,容易埋坑,TS 静态类型,提前发现错误;
  2. TS 类型检查,bug 消灭在摇篮里;
  3. TS 类型推断,开发更智能;
  4. TS 类型约束,团队协作更高效;
  5. TS 支持现代开发工具,体验更丝滑。

如果你还没用过 TypeScript,不妨试试,相信你会爱上它!💙

实现 React 函数组件渲染

作者 june18
2025年7月12日 11:32

基于文章 实现 React 类组件渲染

本文将介绍如何渲染函数组件。

function FunctionComponent() {
    return (
        <div>
            <h3>FunctionComponent<h3>
        </div>
    )
}

Render 阶段

BeginWork 阶段

beginWork 函数增加函数组件的 case。

function updateFunctionComponent(current: Fiber | null, workInProgress: Fiber) {
  const { type, pendingProps } = workInProgress;
  // 函数执行结果就是 children
  const children = type(pendingProps);
  reconcileChildren(current, workInProgress, children);
  return workInProgress.child;
}

// 1. 处理当前 fiber,因为不同组件对应的 fiber 处理方式不同,
// 2. 返回子节点 child
export function beginWork(
  current: Fiber | null,
  workInProgress: Fiber
): Fiber | null {
  switch (workInProgress.tag) {
    // 根节点
    case HostRoot:
      return updateHostRoot(current, workInProgress);
    // 原生标签,div、span...
    case HostComponent:
      return updateHostComponent(current, workInProgress);
    // 文本节点
    case HostText:
      return updateHostText(current, workInProgress);
    // Fragment
    case Fragment:
      return updateHostFragment(current, workInProgress);
    case ClassComponent:
      return updateClassComponent(current, workInProgress);
    case FunctionComponent:
      return updateFunctionComponent(current, workInProgress);
  }
  throw new Error(
    `Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
      "React. Please file an issue."
  );
}

createFiberFromTypeAndProps 函数,增加函数组件的 case。

// 根据 TypeAndProps 创建fiber
export function createFiberFromTypeAndProps(
  type: any,
  key: null | string,
  pendingProps: any
) {
  let fiberTag: WorkTag = IndeterminateComponent;

  if (isFn(type)) {
    // 函数组件、类组件
    if (type.prototype.isReactComponent) {
      fiberTag = ClassComponent;
    } else {
      fiberTag = FunctionComponent;
    }
  } else if (isStr(type)) {
    // 原生标签
    fiberTag = HostComponent;
  } else if (type === REACT_FRAGMENT_TYPE) {
    fiberTag = Fragment;
  }
  const fiber = createFiber(fiberTag, pendingProps, key);
  fiber.elementType = type;
  fiber.type = type;
  return fiber;
}

Complete 阶段

completeWork 函数增加函数组件的 case。

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber
): Fiber | null {
  const newProps = workInProgress.pendingProps;
  switch (workInProgress.tag) {
    case Fragment:
    case HostRoot:
    case ClassComponent:
    case FunctionComponent:{
      return null;
    }
    case HostComponent: {
      // 原生标签,type 是标签名
      const { type } = workInProgress;
        // 1. 创建真实 DOM
        const instance = document.createElement(type);
        // 2. 初始化 DOM 属性
        finalizeInitialChildren(instance, newProps);
        // 3. 把子 dom 挂载到父 dom 上
        appendAllChildren(instance, workInProgress);
        workInProgress.stateNode = instance;
      return null;
    }
    case HostText: {
      workInProgress.stateNode = document.createTextNode(newProps);
      return null;
    }
  }

  throw new Error(
    `Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
      "React. Please file an issue."
  );
}

被 50px 到 200px 的闪烁整破防了?useLayoutEffect 和 useEffect 的区别原来在这

作者 归于尽
2025年7月12日 11:27

写了个简单的布局:一个浅蓝色块默认高 50px,加载后要改成 200px 并居中。结果每次刷新都先看到矮块闪一下,再变成高块 —— 明明就两行样式,咋就出了这种幺蛾子?

排查半天发现,罪魁祸首是我用错了钩子。今天就借着这个真实案例,聊聊 useEffect 和 useLayoutEffect 到底咋区分,以及为啥换个钩子就能解决闪烁问题。

先搞懂俩钩子到底啥时候干活?

不管是 useEffect 还是 useLayoutEffect,本质都是用来处理 “副作用” 的 —— 比如操作 DOM、发请求、订阅事件这些。但它们干活的时间点,差了关键一步。

先简单说下 useEffect:它就像个 “慢性子”,等页面渲染完、浏览器把内容画到屏幕上之后,才慢悠悠地执行。比如你改个 DOM 样式,它会等页面已经显示出来了再动手,所以有时候会看到 “先这样,再那样” 的跳变。

而 useLayoutEffect 是个 “急性子”,它会在 DOM 更新完但还没画到屏幕上的时候就冲上去干活。相当于说:“等一下!先别画!让我改完这处再显示!” 所以它能保证你改的样式在屏幕上一次性到位,不会出现中间状态。

为啥会有 “闪烁”?用个案例说清楚

最典型的场景就是处理 DOM 样式的时候。比如你想让一个元素从 A 样式瞬间变成 B 样式,用不好就会闪一下。

我做了个简单的例子:

import { 
  useEffect,
  useLayoutEffect,
  useRef
 } from 'react'

function Show(){
   const ref = useRef(null)
  useEffect(()=>{
    const heightPx = 200; // 明确使用数值
    ref.current.style.height = `${heightPx}px`;
    ref.current.style.marginTop = `${(window.innerHeight - heightPx) / 2}px`;
  },[])

   return (
    <div ref={ref} style={{height:'50px',background:'lightblue'}}>
      内容
    </div>
   )
}

当你在浏览器快速刷新页面的时候,会看到明显的残影,也就是所谓的‘闪烁’

动画.gif 换成 useLayoutEffect 试试:

// 阻塞渲染 同步的感觉
useLayoutEffect(()=>{
    const heightPx = 200; // 明确使用数值
    ref.current.style.height = `${heightPx}px`;
    ref.current.style.marginTop = `${(window.innerHeight - heightPx) / 2}px`;
},[])

动画1.gif 这次无论怎么刷新,都不会出现‘闪烁效果’!因为 useLayoutEffect 在浏览器把内容画到屏幕之前就改完了样式,相当于 “偷偷” 改好再展示,自然不会有中间状态。

核心区别就一句话

  • useEffect:渲染完 → 浏览器画到屏幕上 → 再执行(异步,不阻塞渲染)
  • useLayoutEffect:渲染完 → 改 DOM → 浏览器再画(同步,会阻塞渲染)

简单说,useLayoutEffect 是 “赶在显示前插队改样式”,useEffect 是 “显示完了再慢慢改”。

啥时候用哪个?

大多数时候用 useEffect 就行,毕竟不阻塞渲染,性能更好。但如果遇到这些情况,就得请 useLayoutEffect 出场了:

  1. 操作 DOM 样式时出现闪烁(比如上面的例子)
  2. 需要 “同步” 拿到 DOM 更新后的状态(比如获取元素宽高后立即调整位置)

不过要注意,useLayoutEffect 里别写太耗时的代码,不然会卡住页面 —— 它可是会等你执行完才让浏览器画画的。

最后再总结下:useEffect 是 “佛系处理”,useLayoutEffect 是 “急着搞定”。记住它们干活的时间点,以后遇到样式闪烁的坑,就知道该找谁帮忙啦~ 你们平时用这俩钩子的时候,还遇到过啥有意思的问题?评论区交流下呗~

实现 React 类组件渲染

作者 june18
2025年7月12日 11:06

基于文章 实现 React Fragment 节点渲染

本文将介绍如何渲染类组件。

类组件已不推荐使用,本文不会讲太多。

class ClassComponent extends Component {
    render() {
        return (
            <div>
                <h3>ClassComponent</h3>
            </div>
        )
    }
}

初始化 Component

export function Component(props: any) {
    this.props = props;
}

// 区分类组件和函数组件
Component.prototype.isReactComponent = {}

Render 阶段

BeginWork 阶段

beginWork 函数增加类组件的 case。

function updateClassComponent(current: Fiber | null, workInProgress: Fiber) {
  const { type, pendingProps } = workInProgress;
  const instance = new type(pendingProps);
  const children = instance.render();
  reconcileChildren(current, workInProgress, children);
  return workInProgress.child;
}

// 1. 处理当前 fiber,因为不同组件对应的 fiber 处理方式不同,
// 2. 返回子节点 child
export function beginWork(
  current: Fiber | null,
  workInProgress: Fiber
): Fiber | null {
  switch (workInProgress.tag) {
    // 根节点
    case HostRoot:
      return updateHostRoot(current, workInProgress);
    // 原生标签,div、span...
    case HostComponent:
      return updateHostComponent(current, workInProgress);
    // 文本节点
    case HostText:
      return updateHostText(current, workInProgress);
    // Fragment
    case Fragment:
      return updateHostFragment(current, workInProgress);
    case ClassComponent:
      return updateClassComponent(current, workInProgress);
  }
  throw new Error(
    `Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
      "React. Please file an issue."
  );
}

createFiberFromTypeAndProps 函数,增加类组件的 case。

// 根据 TypeAndProps 创建fiber
export function createFiberFromTypeAndProps(
  type: any,
  key: null | string,
  pendingProps: any
) {
  let fiberTag: WorkTag = IndeterminateComponent;

  if (isFn(type)) {
    // 函数组件
    if (type.prototype.isReactComponent) {
      fiberTag = ClassComponent;
    }
  } else if (isStr(type)) {
    // 原生标签
    fiberTag = HostComponent;
  } else if (type === REACT_FRAGMENT_TYPE) {
    fiberTag = Fragment;
  }
  const fiber = createFiber(fiberTag, pendingProps, key);
  fiber.elementType = type;
  fiber.type = type;
  return fiber;
}

Complete 阶段

completeWork 函数增加类组件的 case。

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber
): Fiber | null {
  const newProps = workInProgress.pendingProps;
  switch (workInProgress.tag) {
    case Fragment:
    case HostRoot:
    case ClassComponent:{
      return null;
    }
    case HostComponent: {
      // 原生标签,type 是标签名
      const { type } = workInProgress;
        // 1. 创建真实 DOM
        const instance = document.createElement(type);
        // 2. 初始化 DOM 属性
        finalizeInitialChildren(instance, newProps);
        // 3. 把子 dom 挂载到父 dom 上
        appendAllChildren(instance, workInProgress);
        workInProgress.stateNode = instance;
      return null;
    }
    case HostText: {
      workInProgress.stateNode = document.createTextNode(newProps);
      return null;
    }
  }

  throw new Error(
    `Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
      "React. Please file an issue."
  );
}

谈谈JavaScript的异步函数发展历程

作者 前端_ID林
2025年7月12日 10:51

JavaScript的异步编程是先到Web开发的核心技能之一。从最初的回调函数,再到Promise,再到async/await。异步的写法越来越简单简便和易读。

下面就来讲讲关于异步函数的发展历程。

什么是异步函数

异步函数允许程序在某些等待操作的时候执行其他代码,而不阻塞整个程序的运行。

什么是同步

同步则是在代码编写的格式中,规定是从上往下挨个执行。

示例

pixlap_1752286591187.png

回调函数时间

在ES6之前,javascript主要使用的是回调函数来实现异步操作的

Pixlap_1752286954716.png

但是使用回调函数就会出现下面这个弊端,如果存在多个需要处理的异步操作,就会产生回调地狱。

回调地狱

当处理多个异步操作的时候,回调函数就使其调用加深

Pixlap_1752287126642.png

为了解决这个问题,后面引入了Promise解决回调地狱的问题,极大的改善了处理异步操作的书写规范。

Promise

Pixlap_1752287293636.png

promise存在三个状态

  1. Pending:初始状态,不是成功也不是失败
  2. Fulfilled:已经成功,不会在改变了
  3. Rejected:已经失败,不会在改变了

async/await

ES2017引入了async/await在书写上让异步操作看起来像同步代码。

Pixlap_1752287478629.png

实际应用场景

  1. Api请求 Pixlap_1752287781591.png

  2. 文件读取 Pixlap_1752288059579.png

最佳实践

  1. 错误处理 Pixlap_1752288070578.png
  2. 避免忘记await Pixlap_1752288256570.png
  3. 合理使用Promise.all() Pixlap_1752288271569.png

总结

javascript异步操作经过回调函数,到promise,再到async/await的发展,对于异步操作书写代码越来越简便易读。 建议:在现代web处理异步操作优选async/await,处理并发请求的时候使用Promise.all()。

后续还会继续谈论javascript的闭包,原型链,模块化等技术。

实现 React Fragment 节点渲染

作者 june18
2025年7月12日 10:49

基于文章 实现 React 文本节点渲染

本文将从三方面介绍如何渲染 Fragment 节点。

  1. 子节点 <>
  2. 根节点 <>
  3. 标签 <Fragment>

子节点 <>

let fragment1: any = (
    <>
        <h3>1</h3>
        <h4>2</h4>
    </>
)
const jsx = (
  <div className="box border">
    <h1 className="border">omg</h1>
    <h2>react</h2>
    omg2
    {fragment1}
  </div>
)

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(jsx);

Render 阶段

BeginWork 阶段

beginWork 函数增加 Fragment 点的 case。

function updateHostFragment(current: Fiber | null, workInProgress: Fiber) {
  const nextChildren = workInProgress.pendingProps.children;
  reconcileChildren(current, workInProgress, nextChildren);
  return workInProgress.child;
}

// 1. 处理当前 fiber,因为不同组件对应的 fiber 处理方式不同,
// 2. 返回子节点 child
export function beginWork(
  current: Fiber | null,
  workInProgress: Fiber
): Fiber | null {
  switch (workInProgress.tag) {
    // 根节点
    case HostRoot:
      return updateHostRoot(current, workInProgress);
    // 原生标签,div、span...
    case HostComponent:
      return updateHostComponent(current, workInProgress);
    // 文本节点
    case HostText:
      return updateHostText(current, workInProgress);
    // Fragment
    case Fragment:
      return updateHostFragment(current, workInProgress);
  }
  throw new Error(
    `Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
      "React. Please file an issue."
  );
}

修改 createFiberFromTypeAndProps 函数,增加 Fragment 的 case。

// 根据 TypeAndProps 创建fiber
export function createFiberFromTypeAndProps(
  type: any,
  key: null | string,
  pendingProps: any
) {
  let fiberTag: WorkTag = IndeterminateComponent;

  if (isStr(type)) {
    // 原生标签
    fiberTag = HostComponent;
  } else if (type === REACT_FRAGMENT_TYPE) {
    fiberTag = Fragment;
  }
  const fiber = createFiber(fiberTag, pendingProps, key);
  fiber.elementType = type;
  fiber.type = type;
  return fiber;
}

CompleteWork 阶段

completeWork 函数增加 Fragment 的 case。

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber
): Fiber | null {
  const newProps = workInProgress.pendingProps;
  switch (workInProgress.tag) {
    case Fragment:
    case HostRoot: {
      return null;
    }
    case HostComponent: {
      // 原生标签,type 是标签名
      const { type } = workInProgress;
        // 1. 创建真实 DOM
        const instance = document.createElement(type);
        // 2. 初始化 DOM 属性
        finalizeInitialChildren(instance, newProps);
        // 3. 把子 dom 挂载到父 dom 上
        appendAllChildren(instance, workInProgress);
        workInProgress.stateNode = instance;
      return null;
    }
    case HostText: {
      workInProgress.stateNode = document.createTextNode(newProps);
      return null;
    }
  }

  throw new Error(
    `Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
      "React. Please file an issue."
  );
}

appendAllChildren 需要做相应的修改。

// fiber.stateNode是DOM节点
export function isHost(fiber: Fiber): boolean {
  return fiber.tag === HostComponent || fiber.tag === HostText;
}

function appendAllChildren(parent: Element, workInProgress: Fiber) {
  let nodeFiber = workInProgress.child; // 链表结构
  while (nodeFiber !== null) {
    if (isHost(nodeFiber)) {
      parent.appendChild(nodeFiber.stateNode); // nodeFiber.stateNode是DOM节点
    } else if (nodeFiber.child !== null) {
      // 如果 node 这个 fiber 本身不直接对应 DOM 节点,那么就往上找它的子节点,直到找到有真实的 DOM 节点的 fiber 为止
      nodeFiber = nodeFiber.child;
      continue;
    }
    if (nodeFiber === workInProgress) {
      return;
    }
    // 如果 nodeFiber 没有兄弟节点了,那么就往上找它的父节点
    while (nodeFiber.sibling === null) {
      if (nodeFiber.return === null || nodeFiber.return === workInProgress) {
        return;
      }

      nodeFiber = nodeFiber.return;
    }

    nodeFiber = nodeFiber.sibling;
  }
}

根节点 <>

let fragment1: any = (
    <>
        <h3>1</h3>
        <h4>2</h4>
    </>
)

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(fragment1);

Commit 阶段

commitPlacement 增加 Fragment 的 case。

function commitPlacement(finishedWork: Fiber) {
  if (finishedWork.stateNode && isHost(finishedWork)) {
    // finishedWork 是有 dom 节点
    const domNode = finishedWork.stateNode
    // 找 domNode 的父 DOM 节点对应的 fiber
    const parentFiber = getHostParentFiber(finishedWork);

    let parentDom = parentFiber.stateNode;

    if (parentDom.containerInfo) {
      // HostRoot
      parentDom = parentDom.containerInfo;
    }

    parentDom.appendChild(domNode)
  } else {
    // Fragment
    let kid = finishedWork.child;
    while (kid !== null) {
      commitPlacement(kid);
      kid = kid.sibling;
    }
  }
}

标签 <Fragment>

let fragment1: any = (
    <Fragment key='sy'>
        <h3>1</h3>
        <h4>2</h4>
    </Fragment>
)

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(fragment1);

实现比较简单,就两句话。

export const REACT_FRAGMENT_TYPE: symbol = Symbol.for("react.fragment");

export { REACT_FRAGMENT_TYPE as Fragment } from "shared/ReactSymbols";

99%前端不知道的API WebTransport

作者 gnip
2025年7月11日 16:16

概述

在Web开发领域,实时通信一直是关键需求之一。从早期的 HTTP 轮询WebSocket,到后来的 HTTP/2 Server Push,技术不断演进,但仍存在一些局限性,如高延迟、队头阻塞(Head-of-Line Blocking)等。

WebTransport 的出现,旨在解决这些问题。它基于 QUIC 协议(HTTP/3 的底层协议),提供 低延迟、多路复用、支持可靠和不可靠传输 的能力,适用于游戏、视频会议、实时数据推送等场景。

# 什么是 QUIC 和 HTTP/3

在谈WebTransport之前,我们先来了解下,啥是QUIC协议和 HTTP/3

快速 UDP 互联网连接 (QUIC) 是一种通用传输层协议,旨在通过其灵活性、内置安全性、更少的性能问题和更快的采用率来取代传输控制协议 (TCP)。 QUIC 最初由 Google 开发,使用用户数据报协议 (UDP) 作为在客户端和服务器之间移动数据包的低级传输机制。 值得注意的是,QUIC 还将传输层安全性(TLS)作为一个整体组件,而不是像 HTTP/1.1 和 HTTP/2 那样作为附加层。

HTTP/3 基于 QUIC,是超文本传输协议 (HTTP) 的第三个主要版本,并于 2022 年被采纳为IETF 标准。 QUIC+HTTP/3 的创建是为了解决 TCP 固有的限制,这些限制限制了性能和用户体验。

HTTP1 HTTP2 HTTP3对比

大家可以看下如下这张图,HTTP/1HTTP/2都是基于TCP安全安全可靠传输协议,而HTTP/3是基于不可靠传输协议UDP

HTTP-v1-v2-v3-stacks.png

UDP、TCP 和 TLS

UDP 是一种简单、轻量级的协议,不需要像 TCP 那样进行复杂的三次握手来建立第一个连接。 这种简单性使得 UDP 快速且无连接,但这也意味着与 TCP 相比,它缺乏可靠和安全通信所必需的功能。

QUIC 的独特之处在于它融合了 UDP 和 TCP 协议的优点。 虽然它是无连接的并且利用 UDP 作为低级传输协议来减少连接和传输延迟,但由于它重新实现了 TCP 的连接建立和丢失检测功能(从而保证数据包的传输,可以理解自行实现了类似TCP可靠传输的算法),因此它在较高层是面向连接的。 它负责识别丢失的数据并完成重新传输的任务,以确保无缝的用户体验。

QUIC 还将 TLS 作为一个整体组件,而不是像 HTTP/1.1 和 HTTP/2 那样作为附加层。 这种合并可确保消息默认被加密。

WebTransport是什么?

知道了上面的QUIC和相关概念,引入我们本文的主角WebTransport WebTransport 是一种新的 Web API,允许浏览器与服务器建立 低延迟、双向、多路复用 的通信通道。它基于 QUIC 协议(HTTP/3 的传输层),提供:

  • 不可靠的数据报传输(类似 UDP,适用于实时性要求高的场景)。
  • 可靠的流传输(类似 TCP,适用于需要数据完整性的场景)。
  • 多路复用(多个独立数据流共享同一连接,避免队头阻塞)。

WebTransport 的关键优势

特性 WebTransport WebSocket HTTP/2
底层协议 QUIC (UDP) TCP TCP
多路复用 ✅(无队头阻塞) ✅(但有队头阻塞)
不可靠传输 ✅(UDP风格)
流控制
典型用途 游戏、实时音视频 聊天室、通知 网页加载

为什么需要 WebTransport?

WebSocket 的局限性

WebSocket 虽然广泛用于实时通信,但仍有一些缺点:

  1. 基于 TCP:TCP 的队头阻塞问题会影响实时性。
  2. 单一数据流:无法同时传输多个独立数据流。
  3. 缺乏不可靠传输:所有数据必须可靠送达,不适合实时音视频等场景。

HTTP/2 的局限性

HTTP/2 虽然支持多路复用,但:

  • 仍然依赖 TCP,存在队头阻塞问题。
  • 主要用于请求-响应模式,不适合双向实时通信。

2.3 WebTransport 的解决方案

  • 基于 QUIC(UDP) :避免 TCP 的队头阻塞,降低延迟。
  • 支持混合传输模式:可靠流 + 不可靠数据报,适应不同场景。
  • 多路复用:多个流并行传输,提高效率。

WebTransport 的核心特性

不可靠数据报(Datagram)

  • 类似 UDP,数据可能丢失,但延迟极低。

  • 适用于 实时游戏、视频会议、传感器数据 等场景。

  • API 示例

    const transport = new WebTransport('https://example.com');
    transport.datagrams.send(new Uint8Array([1, 2, 3])); // 发送数据报
    

可靠流(Stream)

  • 类似 TCP,数据有序、可靠传输。

  • 适用于 文件传输、聊天消息 等场景。

  • API 示例

    const stream = await transport.createBidirectionalStream();
    stream.writable.getWriter().write(data); // 发送数据
    

多路复用

  • 多个流共享同一连接,互不干扰。
  • 避免队头阻塞,提高传输效率。

WebTransport vs WebSocke

对比项 WebTransport WebSocket
底层协议 QUIC (UDP) TCP
传输模式 可靠流 + 不可靠数据报 仅可靠字节流
多路复用 ✅(无队头阻塞)
延迟 更低(基于UDP) 较高(依赖TCP)
适用场景 游戏、实时音视频 聊天室、通知

WebTransport 的工作原理

连接建立

  1. QUIC 握手:基于 UDP 建立连接,协商 TLS 加密和多路复用能力。
  2. HTTP/3 Upgrade:通过 HTTP/3 扩展帧确认 WebTransport 支持。

数据传输

  • 不可靠数据报:直接发送,无重传机制。
  • 可靠流:通过 QUIC 的流控制保证数据顺序和完整性。

连接关闭

  • 客户端或服务器可主动关闭连接。

WebTransport 的适用场景

  1. 实时游戏(低延迟位置同步)。
  2. 视频会议(音视频 + 信令混合传输)。
  3. IoT 设备控制(双向通信 + 多路复用)。
  4. 金融实时数据推送(高频率更新)。

浏览器兼容性与未来展望

  • 已支持:Chrome、Edge、Firefox(部分版本)。
  • 实验性支持:Safari。
  • 未来趋势:可能成为 WebRTC 的替代方案。 附浏览器的支持情况情况:

image.png

如何使用 WebTransport

客户端代码示例

const transport = new WebTransport('https://example.com');

// 发送数据报(UDP风格)
transport.datagrams.send(new Uint8Array([1, 2, 3]));

// 创建可靠双向流
const stream = await transport.createBidirectionalStream();
const writer = stream.writable.getWriter();
writer.write(new TextEncoder().encode("Hello WebTransport!"));

服务器端(Node.js 示例)

import { createServer } from 'node:http3';
import { WebTransport } from '@fails-components/webtransport';

const server = createServer();
server.listen(443);

server.on('session', (session) => {
  const wt = new WebTransport(session);
  wt.on('datagram', ({ data }) => {
    console.log('Received datagram:', data);
  });
});

总结

WebTransport 是 Web 实时通信的重要演进,它:

  • 基于 QUIC,提供低延迟、多路复用能力。
  • 支持混合传输模式(可靠流 + 不可靠数据报)。
  • 适用于游戏、视频会议、IoT 等场景

尽管目前浏览器支持仍在完善,但 WebTransport 无疑是未来 Web 实时通信的重要技术。

参考资料

Framer Motion & GSAP 实现酷炫动画

作者 imber
2025年7月11日 15:27

Framer Motion & GSAP 实现酷炫动画

GSAP 一个老牌的动画库,兼容性好,方便易用,功能强大,最近开源了更多插件,目前依旧是最好的选择,更喜欢它的文档和使用方式。

越来越多的开源库,开源项目使用 Framer Motion 来实现动画效果,Framer Motion 变得越来越重要。

动画能让用户有更好的体验,而好看酷炫的动画不仅跟设计师有关,也跟我们前端息息相关,普通的设计师往往没有太多动画的灵感,或者想出的方案我们不太好实现,不如自己提需求,依靠自己的经验去 push 做出合理好看的动画。

imber Animation

imber Animation 是动画 Demo 展示,里面有各种动画效果可以参考,文章下面的所有动画效果,都可以在 Github 源码仓库 找到,动画的右上角有查看源码功能,网站部分动画用 GSAP 和 Framer Motion 实现过。

screenshot.png

文字拆分动画(Split)

GSAP 开源后的 SplitText 插件,实现文字拆分动画非常方便,注意在完成时 revert,减少后续性能开销,framer motion 的 SplitText 没有开源,需要钱使用它的 Motion+ ,只能自己实现会麻烦很多。

标题,logo 等地方可以使用这个动画

animation - split

split.gif

组件代码

'use client'

import { gsap } from 'gsap'
import { useGSAP } from '@gsap/react'
import { SplitText } from 'gsap/SplitText'
import { useRef } from 'react'

gsap.registerPlugin(SplitText)

const SplitTextGsap = ({ text, className }: { text: string; className?: string }) => {
  const gsapTextRef = useRef<HTMLDivElement>(null)
  useGSAP(() => {
    if (gsapTextRef.current) {
      let split = new SplitText(gsapTextRef.current, { type: 'chars,words' })

      gsap.from(split.chars, {
        autoAlpha: 0,
        yPercent: 'random([-100,100])',
        rotation: 'random([-30,30])',
        ease: 'back.out',
        // repeat: -1,
        // yoyo: true,
        stagger: {
          amount: 0.5,
          from: 'random'
        },
        onComplete: () => {
          split.revert()
        }
      })
    }
  })

  return (
    <div className={`${className}`} ref={gsapTextRef}>
      {text}
    </div>
  )
}

export default SplitTextGsap

文字高斯模糊动画(Blur)

博客首页的文字高斯模糊动画,也是使用 GSAP 的 SplitText 插件实现,核心逻辑和上面差不多,主要使用 blur 去做每个字的效果。

标题,logo 等地方可以使用这个动画

animation - blur

blur.gif

组件代码

'use client'

import React, { useRef } from 'react'
import { gsap } from 'gsap'
import { useGSAP } from '@gsap/react'
import { SplitText } from 'gsap/SplitText'

// 注册SplitText插件
gsap.registerPlugin(SplitText)

const BlurTextGsap = ({
  text = '',
  children,
  delay = 50, // 默认更快的delay,像React Spring版本
  className = '',
  animateBy = 'words',
  direction = 'top',
  onAnimationComplete,
  ease = 'none' // 使用linear ease让动画更匀速
}) => {
  const containerRef = useRef<HTMLParagraphElement>(null)
  const splitRef = useRef<SplitText | null>(null)

  useGSAP(() => {
    if (!containerRef.current) return

    // 使用SplitText拆分文字
    splitRef.current = new SplitText(containerRef.current, {
      type: animateBy
    })

    const elements = splitRef.current[animateBy] as HTMLElement[]

    // 设置初始状态 - 更接近React Spring版本
    gsap.set(elements, {
      filter: 'blur(10px)',
      opacity: 0,
      y: 0, // 移除y轴移动,更专注于模糊效果
      willChange: 'transform, filter, opacity'
    })

    // 创建时间线动画
    const tl = gsap.timeline({
      onComplete: onAnimationComplete
    })

    // 第一阶段:从完全模糊到半模糊
    tl.to(elements, {
      filter: 'blur(5px)',
      opacity: 0.5,
      y: 0,
      stagger: {
        amount: (elements.length * delay) / 1000, // 使用amount而不是each来控制总时间
        ease: 'none' // stagger也使用linear
      },
      ease: ease
    }).to(elements, {
      filter: 'blur(0px)',
      opacity: 1,
      stagger: {
        amount: (elements.length * delay) / 1000,
        ease: 'none'
      },
      ease: ease
    })

    return () => {
      // 清理SplitText实例
      splitRef.current?.revert()
    }
  }, [text, children, delay, animateBy, direction, onAnimationComplete, ease])

  return (
    <p ref={containerRef} className={`${className}`}>
      {children || text}
    </p>
  )
}

export default BlurTextGsap

数字递增动画(Add)

GSAP 的 textContent 和 roundProps,可以很方便实现这个效果,Framer Motion 同样简单。

有数字的时候可以考虑这个动画。

animation - add

add.gif

组件代码 GSAP

'use client'

import React, { useRef } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'

const AddTextGsap = ({ from, to, className }: { from: number; to: number; className: string }) => {
  const ref = useRef<HTMLHeadingElement>(null)
  useGSAP(() => {
    gsap.to(ref.current, {
      textContent: to,
      duration: 1,
      ease: 'power2.inOut',
      roundProps: 'textContent'
    })
  })

  return (
    <h1 ref={ref} className={className}>
      {from}
    </h1>
  )
}

export default AddTextGsap

组件代码 Framer Motion

'use client'

import { motion, useMotionValue, useTransform, animate } from 'framer-motion'
import { useEffect } from 'react'

const AddTextFramer = ({ from, to, className }: { from: number; to: number; className: string }) => {
  const count = useMotionValue(from)
  const rounded = useTransform(count, (latest) => Math.round(latest))

  useEffect(() => {
    const animation = animate(count, to, {
      duration: 1,
      ease: 'easeInOut'
    })

    return animation.stop
  }, [count, to])

  return (
    <motion.h1 transition={{ duration: 1, ease: 'easeInOut' }} className={className}>
      <motion.span>{rounded}</motion.span>
    </motion.h1>
  )
}

export default AddTextFramer

错开动画(Stagger)

gsap 使用 stagger 错开动画,framer motion 使用 transition 的 delay 错开动画。

列表,网格布局 等地方可以使用这个动画。

animation - stagger

stagger.gif

同理,这种小方块的布局也是列表形式的,也比较适合 stagger 动画。

stagger2.gif

核心逻辑

// gsap
gsap.from('.stagger-item', {
  opacity: 0,
  y: 15,
  stagger: 0.1,
  duration: 0.4
})

// framer motion
{
  postsConfig.map((post, index) => (
    <motion.div
      key={index}
      initial={{ opacity: 0, y: 15 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.4, delay: index * 0.1 }}
    ></motion.div>
  ))
}

时间轴动画(Timeline)

做一些简单有执行顺序的动画,比如入场动画片,GSAP 有 timeline 时间线,让动画依次执行,而 Framer Motion 可以设置 delay。

使用 GSAP 的时候这些控制时间轴的 API 比较重要,首先默认是 0.5s

  • +=1 - 时间轴末尾后 1 秒(产生间隙)

  • -=1 - 时间轴结束前 1 秒(重叠)

  • myLabel+=2 - 标签myLabel后 2 秒

  • <+=3 - 上一个动画开始后 3 秒

  • <3 - 与<+=3 相同(<或>后面隐含+=)

  • > -0.5 - 上一个动画结束前 0.5 秒。这就像说上一个动画的结束加上 -0.5

适合入场动画,各种有顺序动画,甚至左右布局的模块,可以依次从透明到显示。

animation - timeline

timeline.gif

核心逻辑

import { motion } from 'framer-motion'
import Image from 'next/image'

const Framer = () => {
  return (
    <section className="min-h-screen overflow-x-hidden bg-black pt-52 text-center text-white">
      <div className="absolute top-32 left-1/2 z-10 -translate-x-1/2">
        <motion.div
          initial={{ opacity: 0, y: 30 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5, delay: 0.8 }}
          className="z-10 mx-auto mb-10"
        >
          <Image src="https://fms.res.meizu.com/dms/2023/03/29/221febae-e651-410a-903f-29e0bd051ac7.png" />
        </motion.div>

        <MotionImage
          src="https://fms.res.meizu.com/dms/2023/03/29/399cc024-ff70-4cf2-8011-5b86e6313b1f.png"
          alt="framer motion"
          width={548}
          height={80}
          className="title2"
          containerClassName="z-10"
          initial={{ scale: 5, opacity: 0 }}
          animate={{ scale: 1, opacity: 1 }}
          transition={{ duration: 0.8 }}
        />
      </div>

      <motion.div
        initial={{ x: '10%', opacity: 0 }}
        animate={{ x: 0, opacity: 1 }}
        transition={{ duration: 1, delay: 1.3 }}
      >
        <Image src="https://fms.res.meizu.com/dms/2023/05/24/a8ee0203-5636-4b61-b6bf-7e66b5f671b5.jpg" />
      </motion.div>
    </section>
  )
}

export default Framer

React 退出动画(Exit)

在 React 中,组件删除,通常被立即删除,而没有动画,但可以使用 Framer Motion 和 GSAP 来实现退出动画,比如做打开关闭弹层,这类动画用 Framer Motion 更方便,因为它封装了一个 AnimatePresence 组件,而 GSAP 需要用 requestAnimationFrame 来判断下一帧的时候等 DOM 渲染好了,再执行动画。

适合弹层,有切换关闭效果的动画。

exit.gif

核心逻辑

import { AnimatePresence, motion } from 'framer-motion'
;<AnimatePresence>
  {true && (
    <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
      <motion.div
        initial={{
          opacity: 0,
          scale: 0.95,
          x: '-50%',
          y: '-48%'
        }}
        animate={{
          opacity: 1,
          scale: 1,
          x: '-50%',
          y: '-50%'
        }}
        exit={{
          opacity: 0,
          scale: 0.95,
          x: '-50%',
          y: '-48%'
        }}
        transition={{ duration: 0.2, ease: 'easeOut' }}
      ></motion.div>
    </motion.div>
  )}
</AnimatePresence>

布局动画(Layout)

这个动画经常使用到,比如博客的主题切换按钮,正常的 flex 布局的切换是没有过渡效果的,使用 Framer Motion 提供的 layout 参数,可以实现布局切换的过渡效果,如果是 GSAP 的话,需要用 Flip 插件实现。

适合布局切换,比如 flex 的 flex-start 和 flex-end 切换。

animation - layout

animation - flip

layout.gif

flip.gif

核心逻辑

// 核心是这里的 justify-end justify-start 切换
<button className={` ${isDark ? 'justify-end bg-gray-700 shadow-inner' : 'justify-start bg-blue-200 shadow-inner'} }`}>
  <motion.div
    // 自动处理布局变化
    layout
    transition={{
      // 指定动画类型为弹簧动画
      type: 'spring',
      // 设置动画的视觉感知持续时间为 0.2 
      visualDuration: 0.2,
      // 控制弹簧的弹跳强度
      bounce: 0.2
    }}
    // 确保这个元素不会阻挡点击事件
    style={{ pointerEvents: 'none' }}
  >
    {isDark ? <Moon className="h-3 w-3" /> : <Sun className="h-3 w-3" />}
  </motion.div>
</button>

布局动画 - Tab切换(Tab)

不同元素之间的切换状态,这里使用官方的 Demo,正常这个下划线是没有动画效果,加上 layoutId 和 id 后,可以实现动画过渡效果。

适合布局切换,比如 tab 切换。

animation - tab

tab.gif

核心逻辑

{
  item === selectedTab ? (
    <motion.div
      className="absolute right-0 -bottom-0.5 left-0 h-0.5 bg-[#ff4132]"
      layoutId="underline"
      id="underline"
    />
  ) : null
}

基础滚动动画(Scroll)

使用 GSAP 的时候,这些 scrollTrigger 的 API 配置很关键,对于 Framer Motion 来说,用 whileInView 来做这种动画,不能用 useScroll 和 useTransform 因为无法删掉它们的链接到滚动条效果。

animation - scroll

useGSAP(() => {
  const t1 = gsap.timeline({
    scrollTrigger: {
      markers: true, // 显示标记,方便开发时调试动画触发时机
      trigger: '.box', // 触发动画的元素
      start: 'top center', // 元素相对自身的位置 和 视口的位置,两者重合即触发动画开始
      end: 'bottom 40%', // 元素相对自身的位置 和 视口的位置,两者重合即触发动画结束
      toggleActions: 'play none none reverse', // 动画触发时机,常用的值是 play none reverse,对应 onEnter, onLeave, onEnterBack, and onLeaveBack
      onEnter: () => {
        console.log('onEnter')
      },
      onLeave: () => {
        console.log('onLeave')
      },
      onEnterBack: () => {
        console.log('onEnterBack')
      },
      onLeaveBack: () => {
        console.log('onLeaveBack')
      },
      onComplete: () => {
        console.log('onComplete')
      }
      // scrub: true // 动画与滚动条绑定,也就是不会直接执行完动画,会跟着滚动条执行
    }
  })

  t1.from('.box', {
    y: 50,
    duration: 0.5, // 默认 0.5s
    autoAlpha: 0, // 透明度
    ease: 'power1.inOut' // 默认 power1.inOut
  })
})

注意 scrollTrigger 是有顺序的,并且对于图片一定要给个父级和父亲给宽高,否则会计算出问题

GSAP 是基于 ScrollTrigger 插件来滚动动画,Framer Motion 是基于 useScroll 和useTransform 钩子,两者都可以实现滚动动画。

适合滚动动画,比如滚动到某个位置时,出现某个元素。

animation - scroll

scroll.gif

核心逻辑

useGSAP(() => {
  // 通用的 scrollTrigger 配置
  const commonScrollTriggerConfig = {
    markers: true, // 显示标记,方便开发时调试动画触发时机
    start: 'center 90%', // 元素相对自身的位置 和 视口的位置,两者重合即触发动画开始
    toggleActions: 'play none none reverse', // 动画触发时机
    onEnter: () => console.log('onEnter'),
    onLeave: () => console.log('onLeave'),
    onEnterBack: () => console.log('onEnterBack'),
    onLeaveBack: () => console.log('onLeaveBack')
  }

  // 通用的动画配置
  const commonAnimationConfig = {
    duration: 0.5,
    autoAlpha: 0,
    ease: 'power1.inOut'
  }

  // 为每一对 box 创建动画(0-1, 2-3)
  for (let i = 0; i < 4; i += 2) {
    const timeline = gsap.timeline({
      scrollTrigger: {
        ...commonScrollTriggerConfig,
        trigger: `.box${i}` // 以每对的第一个元素作为触发器
      }
    })

    // 第一个 box 从左进入
    timeline.from(`.box${i}`, {
      ...commonAnimationConfig,
      x: '-10%'
    })

    // 第二个 box 从右进入,与第一个动画重叠
    timeline.from(
      `.box${i + 1}`,
      {
        ...commonAnimationConfig,
        x: '10%'
      },
      '-=0.5'
    )
  }
})

还可以设置文字的滚动动画,比如边滚动边高亮。

scroll2.gif

核心逻辑

gsap
  .timeline({
    scrollTrigger: {
      trigger: ref.current,
      start: 'top 90%',
      end: '+=1000',
      scrub: 1, // 慢 1s 跟上滚动条
      toggleActions: 'play none none reverse'
    }
  })

  .to('.section2_line', {
    stagger: 0.1,
    y: -40,
    keyframes: {
      '0%': { color: '#4c4c4c' },
      '25%': { color: '#4c4c4c' },
      '50%': { color: '#ffffff' },
      '75%': { color: '#4c4c4c' },
      '100%': { color: '#4c4c4c' }
    }
  })

滚动动画 + scrub 效果(Scrub)

gsap 的 scrub 效果,也就是动画与滚动条绑定,也就是不会直接执行完动画,会跟着滚动条执行,对于 Framer Motion 来说,用 useScroll 和 useTransform 是默认就有这个效果,而且默认会有 reverse。

animation - scrub

scrub.gif

核心逻辑

useGSAP(() => {
  const t1 = gsap.timeline({
    scrollTrigger: {
      trigger: containerRef.current,
      markers: true,
      start: 'center bottom',
      end: 'top top',
      scrub: 1,
      toggleActions: 'play none none reverse'
    }
  })

  t1.to(containerRef.current, {
    right: '0'
  })
})

滚动动画 + pin 效果(Pin)

gsap 的 pin 效果,也就是滚动到某个位置时,元素会固定在页面不动,对于 Framer Motion 来说,比较难做和难理解这个效果,方案可以用 paddingBottom 撑开或者给个很高的高度,暂时不做深入研究,还是 GSAP 方便。

animation - pin

pin.gif

核心逻辑

useGSAP(() => {
  const t1 = gsap
    .timeline({
      scrollTrigger: {
        trigger: '.section-block',
        start: 'center center',
        end: '+=2000',
        toggleActions: 'play none reverse none',
        pin: true,
        scrub: 1,
        markers: true
      }
    })
    .addLabel('spin')

  t1.to(
    '.section-video ',
    {
      width: '50px',
      height: '50px',
      left: 23,
      top: 265
    },
    'spin'
  )

  t1.to(
    '.section-img',
    {
      autoAlpha: 1
    },
    'spin'
  )

  // 第一组气泡从右侧依次进入
  t1.to(['.bubble1', '.bubble2', '.bubble3'], {
    x: '0%',
    autoAlpha: 1,
    stagger: 0.5 // 每个元素间隔0.5秒
  })

  // 第二组气泡在第一组完成后从左侧依次进入
  t1.to(
    ['.bubble4', '.bubble5', '.bubble6'],
    {
      x: '0%',
      autoAlpha: 1,
      stagger: 0.5, // 每个元素间隔0.5秒
      duration: 0.6
    },
    '+=0.3'
  ) // 在上一个动画完成后延迟0.3秒开始
})

滚动动画 + 垂直叠层效果(Vertical)

也就是固定对应的屏,之前写的项目用这个也比较好看,如 领克Z10 starbuff,此外除了下面这种,还有种 snap 的效果在一些网页里也挺常见

animation - vertical

vertical.gif

useGSAP(() => {
  ScrollTrigger.create({
    trigger: '.box1',
    start: 'top top',
    end: `+=${window.innerHeight}`,
    pin: '.box1',
    markers: true,
    pinSpacing: false
    // anticipatePin: 1,
  })

  ScrollTrigger.create({
    trigger: '.box2',
    start: 'top top',
    end: `+=${window.innerHeight}`,
    pin: '.box2',
    markers: true,
    pinSpacing: false
    // anticipatePin: 1
  })
})

滚动动画 + 水平叠层效果(Horizontal)

这里麻烦的地方是一个位置计算和 FOUC 问题,暂时用 invisible 优化

animation - horizontal

horizontal.gif

核心逻辑

useGSAP(() => {
  const t1 = gsap.timeline({
    scrollTrigger: {
      trigger: '#container',
      start: 'top top',
      end: `+=${window.innerHeight}`,
      pin: true,
      markers: true,
      scrub: 1
    }
  })

  t1.set('.box1', {
    visibility: 'visible'
  })

  t1.from('.box1', {
    height: window.innerHeight,
    width: window.innerWidth,
    transform: `translateX(calc(240px + 50vw))` // 按情况计算
  })

  t1.to('#other-container', {
    x: 0
  })

  t1.to('#inner-container', {
    right: 0
  })
})

滚动动画 + 视差效果(Parallax)

  • data-speed 表示比正常滚动慢多少,对于图片来说,可以在父元素上 overflow hidden,然后内层图片添加 data-speed

  • data-lag 表示延迟多少秒开始滚动,需要一定时间赶上

animation - parallax

parallax.gif

也就是产生不同速率滚动效果,比如背景图片滚动速度不一样,或者文字滚动速度不一样。

核心逻辑

useGSAP(() => {
  ScrollSmoother.create({
    smooth: 1, //需要多长时间(以秒为单位)才能“赶上”原始滚动位置
    effects: true, //查找元素上的数据速度和数据滞后属性
    wrapper: '#smooth-wrapper',
    content: '#smooth-content'
  })
})

“虚拟DOM”到底是什么?我们用300行代码来实现一个

作者 ErpanOmer
2025年7月11日 14:53

image.png

提到现代前端框架,比如React、Vue,你一定听过“虚拟DOM”(Virtual DOM)这个词。它被认为是提升性能的关键所在,是框架设计的核心思想之一。

但是,虚拟DOM到底是什么?它为什么能带来性能提升?它内部又是如何工作的?

与其停留在概念层面,不如我们一起动手,用大约300行左右的JavaScript代码,实现一个最简化的“虚拟DOM”,来揭开它。

什么是“虚拟DOM”?

简单来说,虚拟DOM就是一个用普通的JavaScript对象(plain JavaScript objects)来描述真实DOM结构的“轻量级副本”。

想象一下,真实的DOM就像一棵庞大而复杂的树,包含各种HTML元素、属性、事件等等。直接操作真实DOM的代价是昂贵的,因为这会触发浏览器的重排(Layout)和重绘(Paint),影响性能。

而虚拟DOM,就像是存在于内存中的一个“草稿”,我们可以在这个“草稿”上进行各种修改,最后再将“修改稿”批量更新到真实的DOM上。

用JavaScript对象描述DOM

我们的“虚拟DOM”需要能够表示HTML元素及其属性。我们可以用一个简单的JavaScript对象来描述一个DOM节点:

比如,一个这样的真实DOM节点:

<div id="app" class="container">
    <h1>Hello, Virtual DOM!</h1>
</div>

可以用这样的虚拟DOM对象来表示:

const virtualDom = {
  type: 'div',
  props: {
    id: 'app',
    className: 'container'
  },
  children: [{
    type: 'h1',
    props: {},
    children: ['Hello, Virtual DOM\!']
  }]
};

可以看到,每个虚拟DOM节点都有以下几个关键属性:

  • type: 节点的标签名(比如 'div', 'h1')。
  • props: 一个包含节点属性的对象(比如 { id: 'app', className: 'container' })。
  • children: 一个包含子节点的数组。子节点可以是其他的虚拟DOM对象,也可以是简单的文本内容(字符串)。

创建真实DOM节点

现在,我们需要一个函数,能将我们的虚拟DOM对象“渲染”成真实的DOM节点:

function createElement(vnode) {
  if (typeof vnode === 'string') {
    return document.createTextNode(vnode);
  }
  const $el = document.createElement(vnode.type);
  for (const key in vnode.props) {
    if (vnode.props.hasOwnProperty(key)) {
      $el.setAttribute(key, vnode.props(key));
    }
  }
  vnode.children.map(createElement).forEach($el.appendChild.bind($el));
  return $el;
}

这个 createElement 函数:

  • 如果 vnode 是字符串,直接创建一个文本节点。
  • 否则,创建一个对应 vnode.type 的HTML元素。
  • 遍历 vnode.props,将属性设置到创建的元素上。
  • 递归地处理 vnode.children,将它们创建成真实的DOM节点,并添加到当前元素的子节点中。

现在,如果我们执行 createElement(virtualDom),我们就能得到对应的真实DOM结构。

对比两棵虚拟DOM树(Diffing)

虚拟DOM的核心价值在于“按需更新”。当数据发生变化时,我们不是直接操作真实DOM,而是先创建一个新的虚拟DOM树,然后将新的虚拟DOM树与旧的虚拟DOM树进行比较(diff),找出它们之间的差异,最后只更新那些真正发生变化的部分到真实DOM上。

这是最复杂,也是最关键的一步。我们的简化版Diff算法会关注以下几个方面:

function diff(oldVnode, newVnode) {
  // 1. 类型不同,直接替换
  if (oldVnode.type !== newVnode.type) {
    return {
      type: 'REPLACE',
      newNode: createElement(newVnode)
    };
  }

  // 2. 文本节点内容不同,更新文本
  if (typeof oldVnode === 'string' && typeof newVnode === 'string' && oldVnode !== newVnode) {
    return {
      type: 'TEXT',
      content: newVnode
    };
  }

  // 3. 比较属性差异
  const propsDiff = diffProps(oldVnode.props, newVnode.props);

  // 4. 比较子节点差异
  const childrenDiff = diffChildren(oldVnode.children, newVnode.children);

  if (propsDiff.length > 0 || childrenDiff.length > 0) {
    return {
      type: 'PROPS_AND_CHILDREN',
      props: propsDiff,
      children: childrenDiff
    };
  } else {
    return null; // 没有变化
  }
}

function diffProps(oldProps, newProps) {
  const patches = [];
  const allProps = {
    ...oldProps,
    ...newProps
  };
  for (const key in allProps) {
    if (oldProps(key) !== newProps(key)) {
      patches.push({
        type: 'CHANGE',
        key,
        value: newProps(key)
      });
    }
  }
  return patches;
}

function diffChildren(oldChildren, newChildren) {
  const patches = [];
  const maxLength = Math.max(oldChildren.length, newChildren.length);
  for (let i = 0; i < maxLength; i++) {
    patches.push(diff(oldChildren(i), newChildren(i)));
  }
  return patches;
}

我们的简化版 diff 函数:

  • 如果新旧虚拟DOM节点的类型不同,我们直接返回一个 REPLACE 类型的更新。
  • 如果都是文本节点,且内容不同,我们返回一个 TEXT 类型的更新。
  • 调用 diffProps 比较属性的差异。
  • 调用 diffChildren 递归地比较子节点的差异。
  • 如果属性或子节点有变化,返回一个 PROPS_AND_CHILDREN 类型的更新,包含具体的属性差异和子节点差异。

更新真实DOM

最后,我们需要一个 patch 函数,根据 diff 函数返回的差异对象,来更新真实的DOM:

function patch($node, patches) {
  if (!patches) {
    return;
  }

  switch (patches.type) {
    case 'REPLACE':
      return $node.parentNode.replaceChild(patches.newNode, $node);
    case 'TEXT':
      return ($node.textContent = patches.content);
    case 'PROPS_AND_CHILDREN':
      patchProps($node, patches.props);
      patches.children.forEach((childPatch, i) => {
        patch($node.childNodes(i), childPatch);
      });
      break;
    default:
      break;
  }
}

function patchProps($node, propsPatches) {
  propsPatches.forEach(propPatch => {
    if (propPatch.type === 'CHANGE') {
      $node.setAttribute(propPatch.key, propPatch.value);
    }
  });
}

这个 patch 函数:

  • 根据 patches.type 来执行不同的更新操作。
  • REPLACE: 直接替换整个节点。
  • TEXT: 更新节点的文本内容。
  • PROPS_AND_CHILDREN: 调用 patchProps 更新属性,并递归地处理子节点的 patches

一个简单的例子

现在,我们把这些函数串联起来,看一个简单的例子:

const initialVDOM = {
  type: 'div',
  props: {
    id: 'app'
  },
  children: [{
      type: 'p',
      props: {},
      children: ['Count: ', {
        type: 'span',
        props: {
          class: 'count'
        },
        children: ['0']
      }]
    },
    {
      type: 'button',
      props: {
        onclick: () => updateCount()
      },
      children: ['Increment']
    }
  ]
};

let currentVDOM = initialVDOM;
const $root = document.getElementById('root');
const $el = createElement(initialVDOM);
$root.appendChild($el);

let count = 0;

function updateCount() {
  count++;
  const newVDOM = {
    type: 'div',
    props: {
      id: 'app'
    },
    children: [{
        type: 'p',
        props: {},
        children: ['Count: ', {
          type: 'span',
          props: {
            class: 'count'
          },
          children: [count + '']
        }]
      },
      {
        type: 'button',
        props: {
          onclick: () => updateCount()
        },
        children: ['Increment']
      }
    ]
  };
  const patches = diff(currentVDOM, newVDOM);
  patch($el, patches);
  currentVDOM = newVDOM;
}

在这个例子中:

  • 我们创建了一个初始的虚拟DOM initialVDOM 并渲染到页面上。
  • updateCount 函数模拟了数据更新,创建了一个新的虚拟DOM newVDOM
  • 我们使用 diff 函数比较 currentVDOMnewVDOM,得到差异 patches
  • 我们使用 patch 函数将这些差异应用到真实的DOM $el 上。
  • 最后,更新 currentVDOMnewVDOM,为下一次更新做准备。

当你点击按钮时,你会发现只有 <span> 标签里的数字更新了,而整个 <div><p> 标签并没有重新创建或渲染,这就是虚拟DOM带来的“按需更新”的性能优化。


我们用不到300行的代码,实现了一个非常简化的虚拟DOM。它包含了虚拟DOM的核心思想:

  1. 用JavaScript对象描述DOM结构。
  2. 将虚拟DOM渲染成真实DOM。
  3. 当数据变化时,创建新的虚拟DOM树。
  4. 比较新旧虚拟DOM树的差异(Diffing)。
  5. 只将差异更新到真实的DOM上(Patching)。

当然,真实的React、Vue等框架的虚拟DOM实现要复杂得多,它们会考虑更多的性能优化、Key的处理、组件的生命周期等等。但是,理解了这个最核心的流程,你就能对虚拟DOM的本质有一个更清晰、更深刻的认识。

分析完毕,谢谢大家🙂

史诗级更新!sv-print虽然不是很强,但却是很能打的设计器组件

作者 不简说
2025年7月11日 14:44

哈喽哇!我是小不不简说的不。在代码世界疯狂蹦跶的 “非资深选手”🙋‍♂️!主打一个*“踩坑我来,避坑你学”*。毕竟独乐乐不如众乐乐,让大家少走弯路,才是咱的终极使命✨~

sv-print 可视化打印设计器 迎来重大更新!适配移动端啦~ 虽然功能不是很完美,但是确实很能打!

移动端可能不存在设计打印模板的情况,但是~ 有这功能和不用是两回事☺️

更多使用文档,可访问网站 www.ibujian.cn/svp 查看。

更新日志:

🌈 调整优化 支持触摸事件 & 适配移动端UI

🌈 新增支持 touchZoom 方法,以适配移动端预览双指缩放

const box = globalThis.$("#preview_content .hiprint-printPaper");
const ww = window.innerWidth - 64; // 64: 边距
const tw = box.width();
const zoom = Math.round((ww / tw) * 100) / 100;
box.css({
  transform: `scale(${zoom})`,
  "transform-origin": "0px 0px",
});
globalThis.$(box).touchZoom({
  minScale: 0.2,
  maxScale: 6.0,
  transform: true, // 启用 transform 缩放
});

🌈 新增支持 ${paperNo} ${paperCount} 和 ${field} 值替换

🌈 新增支持 addPrintElementToPaperByTid 根据 tid 添加元素

printTemplate?.addPrintElementToPaperByTid("defaultModule.text");
// 添加到指定位置 pt
const left = 10;
const top = 30;
printTemplate?.addPrintElementToPaperByTid("defaultModule.text", left,top);

🌈 新增支持 providerMap 支持传自定义渲染函数

const providerMap = ref({
    container: '.hiprintEpContainer',
    value: 'defaultModule',
    render: (list) => {
      let container = globalThis.$('<div class="hiprint-printElement-type"></div>');
      // xxx
      return container;
    },
});

hiprint.PrintElementTypeManager.build('.hiprintEpContainer', 'defaultModule', (list) => {
  let container = globalThis.$('<div class="hiprint-printElement-type"></div>');
  list.forEach(function (item) {
    const box = globalThis.$(`
          <div class="draggable-ele-group">
            <span class="title">${item.name}</span>
            <div class="list"></div>
          </div>
          `);
    const typeList = box.find('.list');
    item.printElementTypes.forEach(function (t) {
      typeList.append(`
              <div class="draggable-ele ep-draggable-item" tid=${t.tid}>
                <i class="svicon sv-base sv-${t.type}"></i>
                <p style="white-space:nowrap">${t.getText()}</p>
              </div>
          `);
    });
    container.append(box);
  });
  return container;
});

🌈 调整优化 setElsAlign 支持对齐纸张;单项默认对齐纸张, 多选按住 ctrl/command 键可批量对齐

printTemplate?.setElsAlign("left", true);
printTemplate?.setElsAlign("right", true);

🌈 调整优化 锁定元素 禁止调整大小 & 全局配置 disableNoDraggableSize 默认 true

hiprint.setConfig({
  disableNoDraggableSize: false, // 锁定元素,可编辑属性 宽高大小
})

🌈 调整新增 buildPagination 构建多面板菜单

// 手动构建多面板菜单
printTemplate.buildPagination("#printPanels");

✨ 优化 导出PDF,导出图片部分元素清晰度 & 导出图片支持 quality: 0~1

✨ 优化 双击编辑功能(可编辑为html)

✨ 优化 删除元素逻辑(锁定元素,全选元素) & 右键菜单删除情况

✨ 优化 预览支持设置是否隐藏 导出图片,导出PDF以及直接打印按钮

✨ 优化 全选时触发元素选中事件

✨ 优化 designer 组件传 template 为模板对象时,默认事件处理

✨ fix setConfig 方法可能导致 tab 显示错误问题

面试:SASS/LESS/SCSS区别

2025年7月11日 10:41

我们从基本使用,作用域,变量,嵌套,运算等方面讨论

基本使用

less和scss

.box {
  display: block;
}

sass

.box 
  display: block

变量

sass/scss使用$

less使用@

Scss:

$b-white: white;
$p-fixed: fixed;
.div{
   position: $p-fixed;
   border: 1 solid $b-white;
}

Less

@b-white: white;
@p-fixed: fixed;
.div{
 position: @p-fixed;
 border: 1 solid @b-white;
}

作用域

scss,sass和less作用域机制相同,这里用scss举例

  1. 局部作用域 (Local Scope):
  • 如果你在一个特定的选择器块内部定义了一个变量,那么这个变量只在该选择器块及其内部的子选择器中可用
.sidebar {
  $sidebar-width: 200px; // 局部变量,只在 .sidebar 及其子元素可用
  width: $sidebar-width;

  .widget {
    padding-left: $sidebar-width / 2; // 在子元素 .widget 中可用
  }
}

.content {
  // 错误:在这里使用 $sidebar-width 会报错,因为它只在 .sidebar 的作用域内定义
   width: $sidebar-width;
}
  1. 顶层作用域 (Top-Level Scope) / 文件作用域 (File Scope):
  • 如果你在任何选择器块之外直接定义一个变量(在文件的最顶层),那么这个变量在整个 SASS/SCSS 文件中都是可用的。
  • 顶层变量和局部变量冲突时,局部变量优先
// 顶层变量,在整个文件都可用
$primary-color: #3498db;
$base-font-size: 16px;

body {
  font-size: $base-font-size;
  background-color: lighten($primary-color, 50%);
}

.header {
  background-color: $primary-color;
  color: white;
}

.footer {
  font-size: $base-font-size * 0.9;// 在另一个选择器中使用顶层变量
}

3. !global 在局部作用域设置全局变量

$myColor: red;

h1 {
  $myColor: green !global;  // 全局作用域
  color: $myColor;
}

p {
  color: $myColor;
}

嵌套

三者的嵌套语法都是一致的,甚至连引用父级选择器的标记 & 也相同

区别只是 Sass 用没有大括号的方式书写

&的作用

1. 引用父选择器本身

这是 & 最基本和最常见的用法。

.parent {
  color: blue;// CSS: .parent { color: blue; }

  &.child {// '&' 代表 '.parent'
    color: red;// CSS: .parent.child { color: red; }
  }
}

2. 引用伪类或伪元素

& 常常与伪类(如 :hover, :focus, :active, :nth-child() 等)和伪元素(如 ::before, ::after)结合使用。

// Sass/SCSS 示例
.button {
  background-color: navy;

  &:hover { // '&' 代表 '.button'
    background-color: darkblue; // CSS: .button:hover { ... }
  }

  &:active { // '&' 代表 '.button'
    background-color: deepskyblue; // CSS: .button:active { ... }
  }

  &::before { // '&' 代表 '.button'
    content: ">> "; // CSS: .button::before { ... }
  }

  &.disabled { // '&' 代表 '.button'
    opacity: 0.5; // CSS: .button.disabled { ... }
  }
}

运算

加减乘除

@base-width: 100px;
@padding: 10px;

.element {
  width: @base-width + @padding * 2; // 结果:120px
  height: @base-width - @padding;   // 结果:90px
  font-size: @base-width / 2;       // 结果:50px
}

为了避免歧义,SCSS 在进行除法运算时需要满足以下条件之一:

条件一:值是变量或函数的返回值

$width: 200px;
$half-width: $width / 2;// 这是合法的除法

条件二:值被括号括起来

$ratio: (100px + 50px) / 2;// 括号内作为一个表达式

条件三:值是另一个表达式的一部分

$size: 100px + (50px / 2);// 括号内的除法作为表达式的一部分

小结一下:

  • SCSS 和 SASS 的区别

    区别在sass使用缩进语法,不使用大括号和分号

    SCSS使用大括号 {} 和分号 ; 任何 CSS 代码都是有效的 SCSS 代码。

    变量都是用$

    • SCSS 和 LESS 区别
    特性 SCSS LESS
    语法 CSS 超集,$ 声明变量,@mixin/@include 定义/调用混合 CSS 扩展,@ 声明变量,.mixin 定义混合,混合调用带括号或不带括号
    变量符号 $ @
    混合定义方式 @mixin . (类选择器方式)
    继承 @extend 指令(更强的继承能力) 没有直接的 @extend,通过混合实现部分复用
    作用域 更严格的局部作用域 相对宽松,变量和混合更易全局化

给朋友们分享个好消息 7天时间23.5k

作者 37丫37
2025年7月11日 10:31

我的免费在线拼图工具已经上线一周多的时间了,接入数据统计正好满7天,在朋友们的支持下,7天时间已经有9.45k用户访问了10.5k次,共计斩获了23.5k的浏览量,远远超过我个人的预期,还是很欣慰的,感谢大家的偏爱

朋友们提的BUG或者需求基本都已经上线,工具越来越完善了,欢迎大家继续体验

更新

今天更新了个小版本,主要内容如下

1.边框和间距分离,之前的边框计算逻辑是:边框+间距,这也就导致了一个问题,我没办法在有间距的情况下把边框设为0,或者说没办法让边框小于间距,本次更新,将边框配置和间距配置完全分离,想要多大的边框就要多大的边框,再也不会受到间距大小的影响

2.支持快捷键粘贴,有朋友提到不能用Ctrl+V粘贴图片,安排,新版本除了点击上传外,同时也支持Windows下Ctrl+V以及Mac下Command+V直接粘贴图片了,易用性又提升了一丢丢

3.SEO优化,现在网站的流量大部分还是来自于我的文章以及我发出的帖子,来自搜索引擎的流量还很少,所以这个版本集中优化了一下SEO,希望能有一些自然的流量过来,但个人对SEO理解有限,有没有效果就只能交给时间了

除了这三个大的更新外,同时还更新了网站的ICON,之前就是一个「拼」字比较简单,其实也挺好的,只是项目已经做了国际化支持,老外可能看不懂这个汉字,于是又让AI生成了一个简单的图标,还是比较符合主题的,应该无论什么语言都能看的懂了吧

工具

工具地址:img.ops-coffee.cn

主打一个没有套路、完全免费、不用登录、不加水印、高清下载、简洁干净、体验友好,可放心使用

Vue计算属性:为什么我的代码突然变优雅了?

2025年7月11日 10:20

大家好,我是小杨,一个写了6年前端的老油条。今天想和大家聊聊Vue中一个看似简单但超级实用的功能——计算属性。记得我刚接触Vue时,总觉得data和methods已经够用了,直到发现了计算属性这个"神器",我的代码才真正开始变得优雅起来。

一、计算属性是什么?

简单来说,计算属性就是基于现有数据计算出来的新数据。它就像一个智能的中间人,帮你处理data中的数据,给你想要的结果。

举个🌰,假设我要显示用户的全名:

data() {
  return {
    firstName: '小',
    lastName: '杨'
  }
}

没有计算属性时,我可能会这样写:

<p>{{ firstName + ' ' + lastName }}</p>

或者用方法:

methods: {
  fullName() {
    return this.firstName + ' ' + this.lastName
  }
}

但有了计算属性,事情就变得简单多了:

computed: {
  fullName() {
    return this.firstName + ' ' + this.lastName
  }
}

看起来和方法差不多?别急,它的妙处还在后面呢!

二、计算属性的三大超能力

1. 自动缓存 - 懒人的福音

计算属性最厉害的特点就是缓存。只要依赖的数据不改变,多次访问计算属性会立即返回之前缓存的结果,而不会重新计算。

比如我有一个复杂的计算:

computed: {
  complicatedCalculation() {
    console.log('重新计算了!')
    return this.someData * 100 / Math.PI + 1000
  }
}

即使我在模板里用十次:

<p>{{ complicatedCalculation }}</p>
<p>{{ complicatedCalculation }}</p>
<p>{{ complicatedCalculation }}</p>

控制台只会输出一次"重新计算了!"。如果是方法,每次都会重新执行。

2. 响应式依赖追踪 - 智能的管家

计算属性会自动追踪它依赖的响应式数据。只有当依赖变化时,它才会重新计算。

computed: {
  userInfo() {
    return {
      name: this.user.name,
      age: this.user.age,
      // 只要user.address没被用到,address变化不会触发重新计算
    }
  }
}

3. 可读可写 - 灵活的双向门

计算属性默认只有getter,但也可以提供setter:

computed: {
  fullName: {
    get() {
      return this.firstName + ' ' + this.lastName
    },
    set(newValue) {
      const names = newValue.split(' ')
      this.firstName = names[0]
      this.lastName = names[1] || ''
    }
  }
}

现在你可以这样用:

// 读取
console.log(this.fullName)

// 设置
this.fullName = '大 杨'

三、什么时候该用计算属性?

根据我的经验,以下场景特别适合使用计算属性:

1. 复杂的数据转换

比如从后端拿到一组数据,需要加工后再显示:

computed: {
  filteredProducts() {
    return this.products.filter(p => p.price > 100)
                      .sort((a,b) => b.price - a.price)
                      .slice(0, 5)
  }
}

2. 表单验证

computed: {
  emailError() {
    if (!this.email) return '邮箱不能为空'
    if (!/.+@.+..+/.test(this.email)) return '邮箱格式不正确'
    return ''
  },
  passwordError() {
    if (!this.password) return '密码不能为空'
    if (this.password.length < 6) return '密码太短'
    return ''
  },
  isValid() {
    return !this.emailError && !this.passwordError
  }
}

3. 组件props的派生状态

props: ['size'],
computed: {
  normalizedSize() {
    return this.size.trim().toLowerCase()
  }
}

四、计算属性 vs 方法 vs 侦听器

很多新手会困惑:什么时候用计算属性,什么时候用方法,什么时候用watch?

计算属性 vs 方法

  • 计算属性:适合需要缓存的结果,基于响应式依赖
  • 方法:适合不需要缓存,或者需要参数的情况
// 计算属性 - 无参数,自动缓存
computed: {
  currentDate() {
    return new Date().toLocaleDateString()
  }
}

// 方法 - 可以有参数,每次调用都执行
methods: {
  formatDate(date) {
    return new Date(date).toLocaleDateString()
  }
}

计算属性 vs 侦听器

  • 计算属性:声明式的,你告诉Vue你想要什么
  • 侦听器:命令式的,你告诉Vue当某些数据变化时要做什么
// 计算属性 - 更简洁
computed: {
  fullName() {
    return this.firstName + ' ' + this.lastName
  }
}

// 侦听器 - 更灵活
watch: {
  firstName(newVal) {
    this.fullName = newVal + ' ' + this.lastName
  },
  lastName(newVal) {
    this.fullName = this.firstName + ' ' + newVal
  }
}

五、计算属性的高级用法

1. 结合v-model使用

computed: {
  searchQuery: {
    get() {
      return this.$store.state.searchQuery
    },
    set(value) {
      this.$store.commit('updateSearchQuery', value)
    }
  }
}

然后在模板中:

<input v-model="searchQuery">

2. 动态计算属性

有时候你可能需要动态创建计算属性:

computed: {
  dynamicComputed() {
    return () => {
      // 根据某些条件返回不同的计算逻辑
      if (this.mode === 'simple') {
        return this.data.length
      } else {
        return this.data.reduce((sum, item) => sum + item.value, 0)
      }
    }
  }
}

3. 组合式API中的计算属性

在Vue3的组合式API中,计算属性用起来也很简单:

import { computed } from 'vue'

setup() {
  const count = ref(0)
  
  const doubleCount = computed(() => count.value * 2)
  
  return {
    count,
    doubleCount
  }
}

六、性能优化小技巧

  1. 避免在计算属性中做复杂操作:计算属性会在依赖变化时重新计算,复杂的操作会影响性能
  2. 不要修改依赖数据:计算属性应该是纯函数,不要在里面修改依赖的数据
  3. 合理拆分计算属性:一个计算属性只做一件事,可以提高可读性和维护性
  4. 避免长依赖链:计算属性依赖其他计算属性时,链条太长会影响性能

七、常见坑点

  1. 异步操作:计算属性不能包含异步操作,这时候应该用方法或者watch
  2. 副作用:计算属性不应该有副作用(如修改DOM、发起请求等)
  3. 依赖未声明:如果计算属性依赖的数据没有在data中声明,Vue无法追踪变化
// 错误示范
computed: {
  badComputed() {
    return window.innerWidth // window不是响应式的!
  }
}

八、我的实战经验

在做一个电商项目时,我遇到过这样一个需求:需要根据用户选择的筛选条件动态显示商品列表。最初我用watch来实现,代码变得又长又难维护。后来改用计算属性,代码量减少了60%!

重构前:

data() {
  return {
    products: [],
    filteredProducts: [],
    category: '',
    priceRange: [0, 1000],
    sortBy: 'price'
  }
},
watch: {
  category() {
    this.filterProducts()
  },
  priceRange() {
    this.filterProducts()
  },
  sortBy() {
    this.filterProducts()
  }
},
methods: {
  filterProducts() {
    // 一大段过滤和排序逻辑
  }
}

重构后:

computed: {
  filteredProducts() {
    let result = this.products
    
    // 按类别过滤
    if (this.category) {
      result = result.filter(p => p.category === this.category)
    }
    
    // 按价格范围过滤
    result = result.filter(p => 
      p.price >= this.priceRange[0] && 
      p.price <= this.priceRange[1]
    )
    
    // 排序
    if (this.sortBy === 'price') {
      result = [...result].sort((a, b) => a.price - b.price)
    } else if (this.sortBy === 'sales') {
      result = [...result].sort((a, b) => b.sales - a.sales)
    }
    
    return result
  }
}

代码不仅更简洁,而且性能也更好,因为计算属性会自动缓存结果,只有依赖变化时才会重新计算。

九、总结

计算属性是Vue中一个非常强大的特性,它能够:

  1. 让你的代码更简洁、更易读
  2. 自动缓存计算结果,提高性能
  3. 智能追踪依赖,只在需要时重新计算
  4. 可以读写结合,处理复杂逻辑

记住:当你需要基于现有数据派生新数据时,首先考虑计算属性。它能让你的Vue代码从"能用"升级到"优雅"的水平。

我是小杨,一个喜欢分享的前端开发者。如果这篇文章对你有帮助,别忘了点赞收藏。如果有任何问题,欢迎在评论区留言讨论!

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

不用装插件!3轮对话,我用油猴脚本+AI复刻了掘金闪念笔记,真香!

2025年7月11日 06:50

作者:唐叔在学习

关键词:#油猴脚本 #闪念笔记 #Tampermonkey #AI编程 #浏览器插件开发

大家好,我是你们的唐叔。作为一名技术博主,我平时除了在CSDN上创作内容外,也经常逛掘金寻找灵感。最近发现掘金的一个非常实用的功能——闪念笔记,它可以帮助我们快速记录网页上的灵感和想法。然而,安装整个掘金插件来实现这个功能感觉有点"杀鸡用牛刀"了。作为一个开发者,我决定自己实现这个功能!

技术实现思路

掘金的闪念笔记功能允许用户在浏览网页时快速记录灵感,而实现这个功能,我瞬间想到了使用 油猴 + JavaScript脚本

油猴(Tampermonkey)是一款强大的浏览器插件,它允许用户编写自定义JavaScript脚本来自定义网页行为。使用油猴脚本实现闪念笔记功能有以下优势:

  1. 轻量级,不需要安装完整插件
  2. 跨浏览器兼容
  3. 完全自定义功能
  4. 数据本地存储,隐私性好

开发过程:AI助力三轮迭代

第一轮:基础功能实现

我首先向DeepSeek AI提出了基本需求:

"当前你需要使用油猴插件开发一个脚本,脚本的功能是当浏览网页时,可以标记网页内容,将其记录成笔记,存储到脚本中,每次打开脚本可以查看笔记。类似于掘金的闪念笔记效果。"

AI很快给出了基础实现代码,主要功能包括:

  • 选中文本后显示"添加闪念"按钮
  • 点击按钮将选中内容保存为笔记
  • 查看已保存的笔记列表

在这里插入图片描述

第二轮:跨页面功能优化

基础版本有一个明显缺陷:笔记无法跨页面共享。于是我提出了第二轮优化需求:

"当前插件功能,不支持跨页面显示笔记内容,请实现在A页面记录的笔记,点击显示闪念笔记时,可以在b页面显示A页面记录的笔记。"

AI给出了解决方案,但这次代码不完整,需要进一步优化。

在这里插入图片描述

第三轮:完整代码实现

作为严谨的(懒惰的)开发者,秉持着"AI最严谨"(苦了AI也不能苦了自己)的原则,我要求AI提供完整版本的代码:

"请给出完整版本的代码"

这次AI给出了完整的实现,包括:

  • 使用GM_setValue/GM_getValue实现跨页面数据存储
  • 按域名分类笔记
  • 搜索过滤功能
  • 笔记导出功能

在这里插入图片描述

核心代码解析

以下是完整版的核心代码结构,完整版本的代码见附录的相关资源

// ==UserScript==
// @name         网页闪念笔记(完整版)
// @namespace    https://github.com/yourname
// @version      2.0
// @description  支持跨页面查看和管理的闪念笔记工具
// @author       You
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    // 笔记数据结构
    const STORAGE_KEY = 'FLASH_NOTES_v2';
    let allNotes = GM_getValue(STORAGE_KEY, {});

    // UI组件初始化
    const container = document.createElement('div');
    container.className = 'flash-note-container';
    // ...其他UI组件

    // 核心功能函数
    function saveNote(note) {
        // 保存笔记逻辑
    }

    function refreshNotes() {
        // 刷新笔记显示
    }

    function deleteNote(note) {
        // 删除笔记逻辑
    }

    function exportNotes() {
        // 导出笔记为Markdown
    }

    // 事件监听
    document.addEventListener('mouseup', handleTextSelection);
    // ...其他事件监听

    // 初始化
    initTabs();
    refreshNotes();
})();

总结与思考

通过这个项目,我深刻体会到AI在开发过程中的强大助力:

  1. 效率提升:原本可能需要数小时的工作,在AI帮助下缩短到几分钟
  2. 学习成本降低:不需要精通油猴脚本API,AI可以提供正确实现
  3. 灵感验证:快速验证想法的可行性

当然,AI生成的代码并非完美,需要注意:

  • 需要人工审查和测试
  • 可能需要多轮迭代才能得到理想结果
  • 复杂逻辑仍需开发者自己把控

技术反思:未来可以考虑添加的功能

  1. 云同步支持
  2. 多设备共享
  3. 笔记标签分类
  4. 与Markdown编辑器集成

结语

这个闪念笔记工具已经成为了我日常创作的得力助手。希望这个案例能给大家一些启发:善用AI工具,可以让我们的开发工作事半功倍。如果你也有类似的需求,不妨尝试自己实现一个!

欢迎在评论区分享你的使用体验和改进建议!


相关资源

如果觉得这篇文章有帮助,请点赞收藏支持一下!

我是唐叔,我们下期见~

深入React事件机制:解密“合成事件”与“事件委托”的底层奥秘

作者 AliciaIr
2025年7月11日 00:42

引言:事件,前端交互的“灵魂”

在前端开发的世界里,事件(Event)是用户与页面进行交互的桥梁,是构建动态、响应式用户界面的核心。无论是点击按钮、输入文本,还是滚动页面,这些用户行为都会触发相应的事件,进而驱动页面的状态变化和逻辑执行。理解事件机制,尤其是其底层原理,对于编写高性能、可维护的前端应用至关重要。

然而,对于许多初学者而言,事件机制往往是一个既熟悉又陌生的领域。我们知道如何使用addEventListener来监听事件,也知道event.preventDefault()event.stopPropagation()的作用,但对于事件的捕获、冒泡、事件委托的优势,以及React中独特的“合成事件”(SyntheticEvent)和“事件池”(Event Pooling)等概念,可能还存在一些模糊不清的地方。

本文旨在深入剖析JavaScript原生事件机制的底层原理,并在此基础上,详细解读React如何在其之上构建一套高效、统一的事件系统。我们将从事件的生命周期、事件委托的性能优势,到React合成事件的实现细节,力求将这些知识点讲透彻,帮助读者建立起对React事件机制的全面而深入的理解,从而在实际开发中游刃有余。

准备好了吗?让我们一起踏上这段探索之旅,揭开React事件机制的底层奥秘!

JavaScript原生事件机制:浏览器如何“捕捉”你的每一次交互

在深入React事件机制之前,我们必须先理解JavaScript原生事件机制的工作原理。这是所有前端事件处理的基础,也是React事件系统赖以构建的基石。JavaScript事件机制的核心在于其异步性以及事件的传播阶段。

1. 事件的异步性:非阻塞的用户体验

JavaScript是单线程的,这意味着它在同一时间只能执行一个任务。然而,用户交互(如点击、键盘输入)是随时可能发生的,如果这些交互是同步处理的,那么在处理事件时,页面就会被“卡住”,导致用户界面无响应,严重影响用户体验。为了解决这个问题,JavaScript的事件处理是异步的。

当一个事件被触发时,它并不会立即执行其对应的回调函数。相反,事件及其回调函数会被放入一个“事件队列”(Event Queue)中。JavaScript引擎的主线程会不断地从“事件队列”中取出任务并执行。这种机制确保了即使有大量事件被触发,主线程也能够保持响应,从而提供流畅的用户体验。

底层思考:事件循环(Event Loop)与异步回调

事件的异步性与JavaScript的“事件循环”(Event Loop)机制紧密相关。事件循环是JavaScript运行时环境(如浏览器或Node.js)的核心组成部分,它负责协调任务的执行顺序。简而言之,事件循环的工作流程如下:

  1. 主线程(Call Stack): 负责执行同步任务。当遇到异步任务时,将其交给Web APIs(浏览器提供的API,如setTimeout、DOM事件监听等)处理。
  2. Web APIs: 异步任务在Web APIs中执行,例如一个点击事件被触发后,浏览器会将其放入Web APIs中等待。
  3. 任务队列(Task Queue / Callback Queue): 当Web APIs中的异步任务完成时,其对应的回调函数会被放入任务队列中等待。
  4. 事件循环: 主线程会不断检查调用栈是否为空。如果调用栈为空,事件循环就会从任务队列中取出一个回调函数,将其放入调用栈中执行。

这种机制确保了JavaScript的单线程特性不会阻塞UI渲染,使得事件处理能够以非阻塞的方式进行,从而保证了用户界面的流畅响应。addEventListener中传入的回调函数,Promise.then()中的回调,以及async/await中的异步操作,都是通过事件循环机制来异步执行的。

2. 事件监听:addEventListener()的强大与灵活

在JavaScript中,我们主要通过addEventListener()方法来注册事件监听器。这个方法是DOM Level 2 Events规范的一部分,相比于DOM Level 0事件处理(如<a onclick="doSomething()">),它提供了更强大的功能和更灵活的控制。

addEventListener()的语法:

target.addEventListener(type, listener, [options]);
  • type:表示事件类型的字符串,例如'click''mouseover''keydown'等。
  • listener:事件发生时要调用的函数(回调函数)。
  • options:一个可选对象,用于指定事件监听器的特性。其中最常用的就是capture(或useCapture),它决定了事件是在捕获阶段还是冒泡阶段被处理。

DOM Level 0 vs DOM Level 2:

  • DOM Level 0事件处理: 直接将事件处理函数赋值给DOM元素的事件属性,例如element.onclick = function() {}。这种方式的缺点是,同一个事件类型只能注册一个处理函数,后注册的会覆盖先注册的。

    <button onclick="alert('Hello DOM 0')">点击我</button>
    <script>
      const btn = document.querySelector("button");
      btn.onclick = function() {
        alert("Hello again DOM 0"); // 会覆盖上面的alert
      };
    </script>
    
  • DOM Level 2事件处理: addEventListener()允许为同一个事件类型注册多个处理函数,它们会按照注册的顺序依次执行。这提供了更大的灵活性。

    const btn = document.querySelector("button");
    btn.addEventListener("click", function() {
      alert("Hello DOM 2 - 1");
    });
    btn.addEventListener("click", function() {
      alert("Hello DOM 2 - 2"); // 两个都会执行
    });
    

3. 事件传播:捕获与冒泡的“舞蹈”

当一个事件在DOM元素上触发时,它并不会简单地在那个元素上停止。相反,事件会经历一个传播过程,这个过程分为三个阶段:捕获阶段目标阶段冒泡阶段

底层思考:事件传播的机制

想象一下,你点击了页面上的一个按钮。这个点击事件会从document对象开始,沿着DOM树向下“捕获”,经过<html><body><div>等父元素,直到达到实际被点击的“目标元素”(event.target)。这个过程就是捕获阶段

  1. 捕获阶段(Capturing Phase): 事件从document对象开始,向下传播到目标元素。在这个阶段,如果某个父元素注册了捕获阶段的事件监听器(useCapture设置为true),那么它会先于目标元素接收到事件。

    <div id="outer">
      <button id="inner">点击我</button>
    </div>
    <script>
      const outer = document.getElementById("outer");
      const inner = document.getElementById("inner");
    
      outer.addEventListener("click", function() {
        console.log("Outer Div - 捕获阶段");
      }, true); // true表示在捕获阶段触发
    
      inner.addEventListener("click", function() {
        console.log("Inner Button - 目标阶段");
      }, false); // false表示在冒泡阶段触发 (默认值)
    </script>
    

    当你点击按钮时,控制台会先输出“Outer Div - 捕获阶段”,然后是“Inner Button - 目标阶段”。

  2. 目标阶段(Target Phase): 事件到达实际被点击的元素(event.target)。在这个阶段,目标元素上注册的事件监听器会被执行。

  3. 冒泡阶段(Bubbling Phase): 事件从目标元素开始,向上“冒泡”,沿着DOM树向上传播,经过<div><body><html>等父元素,直到document对象。在这个阶段,如果某个父元素注册了冒泡阶段的事件监听器(useCapture设置为false,这是默认值),那么它会在子元素之后接收到事件。

    <div id="outer">
      <button id="inner">点击我</button>
    </div>
    <script>
      const outer = document.getElementById("outer");
      const inner = document.getElementById("inner");
    
      outer.addEventListener("click", function() {
        console.log("Outer Div - 冒泡阶段");
      }, false); // false表示在冒泡阶段触发 (默认值)
    
      inner.addEventListener("click", function() {
        console.log("Inner Button - 目标阶段");
      }, false); // false表示在冒泡阶段触发 (默认值)
    </script>
    

    当你点击按钮时,控制台会先输出“Inner Button - 目标阶段”,然后是“Outer Div - 冒泡阶段”。

useCapture参数:

addEventListener()的第三个参数useCapture(或options对象中的capture属性)就是用来控制事件监听器是在捕获阶段还是冒泡阶段被触发的。默认值为false,表示在冒泡阶段触发。如果设置为true,则表示在捕获阶段触发。

理解事件的传播机制,尤其是捕获和冒泡阶段,是理解事件委托和React合成事件的关键。

事件委托(Event Delegation):性能优化的“利器”与动态元素的“救星”

事件委托是一种利用事件冒泡机制来优化事件处理的技术。它的核心思想是:将子元素的事件监听器统一注册到它们的父元素(或更上层的祖先元素)上,而不是为每个子元素单独注册监听器。当子元素上的事件触发并冒泡到父元素时,父元素上的监听器会捕获到这个事件,并通过event.target属性判断是哪个子元素触发了事件,从而执行相应的处理逻辑。

1. 性能优化:减少内存消耗与DOM操作

在没有事件委托的情况下,如果页面中有大量相似的元素(例如一个长列表),并且每个元素都需要响应相同的事件(如点击),那么我们就需要为每个元素都注册一个事件监听器。这会导致以下问题:

  • 内存消耗: 每个事件监听器都会占用一定的内存。当元素数量非常大时,内存消耗会显著增加,可能导致页面性能下降。
  • DOM操作: 频繁地添加和移除事件监听器(例如在列表项动态增删时),会涉及到大量的DOM操作,这也会影响页面性能。

事件委托通过将事件监听器集中到父元素上,有效地解决了这些问题:

  • 减少内存消耗: 无论子元素有多少,只需要在父元素上注册一个事件监听器,大大减少了内存占用。
  • 减少DOM操作: 动态增删子元素时,无需为新元素单独注册监听器,也无需移除旧元素的监听器,因为事件监听器始终在父元素上。这简化了DOM操作,提升了性能。

底层思考:事件委托的性能优势

事件委托的性能优势来源于其对内存和DOM操作的优化。每个事件监听器在浏览器内部都会维护一个数据结构,用于存储事件类型、回调函数、目标元素等信息。当监听器数量庞大时,这些数据结构会占用可观的内存。事件委托通过减少监听器的数量,直接降低了这部分内存开销。

此外,DOM操作是浏览器中相对昂贵的操作。频繁地在DOM树上添加或移除事件监听器,会触发浏览器的重排(Reflow)和重绘(Repaint),从而影响页面渲染性能。事件委托将监听器固定在父元素上,避免了这些不必要的DOM操作,使得页面在动态更新时更加流畅。

2. 动态节点的事件处理:应对“未来元素”的挑战

在许多Web应用中,页面内容是动态生成的。例如,通过Ajax请求加载更多数据,然后动态地向列表中添加新的元素。如果为每个新元素都手动注册事件监听器,将会非常繁琐且容易出错。事件委托能够优雅地解决这个问题。

由于事件监听器注册在父元素上,即使子元素是动态添加的,它们的事件仍然会冒泡到父元素,从而被父元素上的监听器捕获并处理。这使得事件处理逻辑与DOM元素的生命周期解耦,大大简化了动态内容的事件管理。

示例:动态添加列表项的事件委托

<ul id="myList">
  <li>Item 1</li>
  <li>Item 2</li>
</ul>
<button id="addItem">添加新项</button>

<script>
  const myList = document.getElementById("myList");
  const addItemBtn = document.getElementById("addItem");

  // 使用事件委托,将点击事件监听器注册到父元素ul上
  myList.addEventListener("click", function(event) {
    // 判断点击的是否是li元素
    if (event.target.tagName === "LI") {
      console.log("点击了列表项:" + event.target.textContent);
    }
  });

  // 动态添加新项
  addItemBtn.addEventListener("click", function() {
    const newItem = document.createElement("li");
    newItem.textContent = "新添加的项 " + (myList.children.length + 1);
    myList.appendChild(newItem);
  });
</script>

即使“新添加的项”是动态生成的,点击它们也能触发父元素myList上的监听器,并正确地处理事件。这在处理无限滚动加载、评论列表等场景时非常有用。

3. 阻止默认行为与事件冒泡:preventDefaultstopPropagation

在事件处理中,我们经常需要控制事件的默认行为或阻止事件的进一步传播。Event对象提供了两个重要的方法来实现这些控制:

  • event.preventDefault() 阻止事件的默认行为。例如,点击<a>标签的默认行为是跳转页面,提交<form>表单的默认行为是刷新页面。调用event.preventDefault()可以阻止这些默认行为的发生。

    document.querySelector("a").addEventListener("click", function(event) {
      event.preventDefault(); // 阻止链接跳转
      console.log("链接被点击,但未跳转");
    });
    
  • event.stopPropagation() 阻止事件在DOM树中的进一步传播(无论是捕获阶段还是冒泡阶段)。这意味着事件不会再传递给父元素或子元素上的其他监听器。

    <div id="outer">
      <button id="inner">点击我</button>
    </div>
    <script>
      const outer = document.getElementById("outer");
      const inner = document.getElementById("inner");
    
      outer.addEventListener("click", function() {
        console.log("Outer Div 被点击");
      });
    
      inner.addEventListener("click", function(event) {
        event.stopPropagation(); // 阻止事件冒泡到outer div
        console.log("Inner Button 被点击");
      });
    </script>
    

    当你点击按钮时,只会输出“Inner Button 被点击”,而不会输出“Outer Div 被点击”。

用户交互的便利体验问题:

stopPropagation在实现一些用户交互功能时非常有用,例如:

  • Toggle按钮: 一个点击后显示/隐藏内容的按钮。如果点击内容区域内部的元素,不希望内容区域关闭,就可以在内容区域内部的点击事件上使用stopPropagation
  • 点击页面空白处关闭弹窗: 弹窗通常会监听document的点击事件,当点击弹窗外部时关闭弹窗。但如果点击弹窗内部,则不希望关闭。这时,在弹窗内部的点击事件上使用stopPropagation,可以阻止事件冒泡到document,从而避免弹窗被关闭。

React事件机制:高效、统一的“合成事件”系统

React并没有直接使用浏览器原生的DOM事件系统,而是实现了一套自己的“合成事件”(SyntheticEvent)系统。这套系统在原生事件之上进行了一层封装和优化,为开发者提供了跨浏览器兼容、性能更优、API更统一的事件处理方式。

1. 事件委托到#root:React的性能优化“秘籍”

React的合成事件系统,在底层巧妙地利用了事件委托的原理。在React应用中,所有的事件(或者说大部分事件)并不会直接绑定到各个DOM元素上,而是统一绑定到应用的最顶层容器(通常是ReactDOM.render()createRoot()渲染的那个DOM节点,例如#root元素)上。

底层思考:React如何实现事件委托?

  1. 事件注册: 当你在JSX中编写onClickonChange等事件处理器时,React并不会立即将这些处理器直接绑定到对应的DOM元素上。相反,React会在应用启动时,为所有支持的事件类型,在#root元素上注册一个统一的事件监听器(通常是在捕获阶段和冒泡阶段各注册一个)。
  2. 事件分发: 当用户在页面上触发一个事件时,这个原生事件会沿着DOM树传播,最终冒泡到#root元素。#root上的统一监听器会捕获到这个原生事件。
  3. 事件封装: React会根据原生事件创建一个“合成事件对象”(SyntheticEvent)。这个合成事件对象是原生事件的跨浏览器包装器,它提供了与原生事件相同的接口(如targetpreventDefaultstopPropagation等),但消除了浏览器之间的兼容性差异。
  4. 事件派发: React会根据合成事件对象的target属性,模拟事件的捕获和冒泡过程,将合成事件派发给对应的React组件层级的事件处理器。这个过程是React在虚拟DOM层面上进行的,与原生DOM事件的传播是独立的。

优势:

  • 性能优化: 类似于原生事件委托,减少了DOM上事件监听器的数量,从而减少了内存消耗和DOM操作。这对于大型、复杂的React应用尤其重要。
  • 跨浏览器兼容性: 合成事件系统抹平了不同浏览器之间原生事件的差异,开发者无需关心兼容性问题,只需使用统一的API进行事件处理。
  • 统一事件处理: 所有的事件都通过React的合成事件系统进行处理,使得事件处理逻辑更加统一和可控。

2. 事件池(Event Pooling):性能优化的“极致”

在React的早期版本中,为了进一步优化性能,React引入了“事件池”(Event Pooling)机制。其核心思想是:事件对象在事件处理函数执行完毕后并不会立即被销毁,而是被放回一个“池子”中,供下一次事件触发时复用。这样可以避免频繁地创建和销毁事件对象,从而减少垃圾回收的压力,提升性能。

底层思考:事件池的工作原理与注意事项

当一个原生事件触发时,React会从事件池中取出一个合成事件对象,用当前原生事件的数据填充它,然后将它传递给事件处理函数。事件处理函数执行完毕后,合成事件对象会被清空并放回事件池。这意味着,在事件处理函数执行完毕后,你不能再异步地访问合成事件对象的属性,因为它们可能已经被清空或被其他事件复用了。

function handleClick(event) {
  console.log(event.type); // 'click'
  setTimeout(() => {
    console.log(event.type); // 在旧版本React中,这里可能为null或undefined
  }, 0);
}

为了解决这个问题,如果你需要在异步操作中访问事件对象的属性,你需要手动地“持久化”事件对象:

function handleClick(event) {
  event.persist(); // 持久化事件对象
  console.log(event.type);
  setTimeout(() => {
    console.log(event.type); // 'click' (在所有版本中都有效)
  }, 0);
}

最新版本中的变化:

值得注意的是,从React 17开始,事件池机制已经被移除。React 17不再复用合成事件对象,而是每次都创建一个新的合成事件对象。这意味着,你不再需要手动调用event.persist()来持久化事件对象了。这个改变主要是为了简化开发者的心智负担,因为现代浏览器和JavaScript引擎的性能已经足够好,事件池带来的性能提升已经不再那么显著,反而可能引入一些难以理解的“陷阱”。

3. 阻止默认行为与事件冒泡:SyntheticEvent的统一API

React的合成事件对象也提供了与原生事件对象类似的preventDefault()stopPropagation()方法,用于控制事件的默认行为和传播。这些方法在合成事件层面上工作,提供了跨浏览器兼容的统一API。

  • event.preventDefault() 阻止合成事件的默认行为。例如,在表单提交时阻止页面刷新。
  • event.stopPropagation() 阻止合成事件在React组件树中的进一步传播。这对于实现一些复杂的交互逻辑非常有用,例如点击弹窗内部不关闭弹窗。

底层思考:SyntheticEvent与原生事件的关联

虽然SyntheticEvent是React对原生事件的封装,但它仍然保留了对原生事件的引用,可以通过event.nativeEvent属性访问到原始的浏览器事件对象。这在某些特殊场景下可能有用,例如需要访问原生事件特有的属性或方法时。

结语:驾驭事件,构建流畅交互

通过本文的深入探讨,我们全面了解了JavaScript原生事件机制的捕获与冒泡、事件委托的性能优势,以及React如何在其之上构建高效、统一的合成事件系统。我们理解了事件的异步性与事件循环的关系,掌握了addEventListener的灵活运用,并深入剖析了事件委托在性能优化和动态元素处理中的重要作用。

更重要的是,我们揭示了React合成事件的底层奥秘:它通过事件委托将所有事件统一绑定到#root元素,从而实现了跨浏览器兼容和性能优化。虽然事件池机制在最新版本中已被移除,但理解其设计思想仍然有助于我们更好地理解React对性能的极致追求。

掌握这些底层知识,你将不再仅仅是事件API的“使用者”,更是能够洞悉事件本质、灵活应对各种交互场景的“驾驭者”。在构建高性能、用户体验友好的React应用时,这些知识将成为你不可或缺的“内功”。

希望本文能为你提供一份宝贵的“武功秘籍”,助你在前端开发的道路上越走越远,成为一名真正的事件处理大师!

Vue v-model 指令详解

作者 梨子同志
2025年7月11日 23:58

什么是 v-model?

v-model 是 Vue 中最常用的指令之一,它实现了表单输入元素与 Vue 实例数据的双向绑定。这意味着:

  • 当用户修改表单元素的值时,Vue 实例的数据会自动更新
  • 当 Vue 实例数据变化时,表单元素的值也会自动更新

基本用法

在原生表单元素上使用

<template>
  <div class="container">
    <!-- 文本输入 -->
    <div class="form-group">
      <label>用户名:</label>
      <input v-model="username" placeholder="输入用户名">
      <p>当前值:{{ username }}</p>
    </div>
    
    <!-- 多行文本 -->
    <div class="form-group">
      <label>个人简介:</label>
      <textarea v-model="bio" placeholder="输入个人简介"></textarea>
      <p class="preview">预览:{{ bio }}</p>
    </div>
    
    <!-- 复选框 -->
    <div class="form-group">
      <label>
        <input type="checkbox" v-model="agreed"> 我同意服务条款
      </label>
      <p v-if="agreed" class="success">已同意条款</p>
    </div>
    
    <!-- 单选按钮 -->
    <div class="form-group">
      <label>选择性别:</label>
      <div class="radio-group">
        <label>
          <input type="radio" value="male" v-model="gender"> 男性
        </label>
        <label>
          <input type="radio" value="female" v-model="gender"> 女性
        </label>
        <label>
          <input type="radio" value="other" v-model="gender"> 其他
        </label>
      </div>
      <p>选择结果:{{ gender }}</p>
    </div>
    
    <!-- 下拉选择 -->
    <div class="form-group">
      <label>选择城市:</label>
      <select v-model="city">
        <option disabled value="">请选择</option>
        <option value="beijing">北京</option>
        <option value="shanghai">上海</option>
        <option value="guangzhou">广州</option>
        <option value="shenzhen">深圳</option>
      </select>
      <p>所选城市:{{ city }}</p>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      username: '',
      bio: '',
      agreed: false,
      gender: '',
      city: ''
    }
  }
}
</script>

<style>
.container {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.form-group {
  margin-bottom: 25px;
  padding: 15px;
  border-radius: 8px;
  background-color: #f8f9fa;
  box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 600;
  color: #333;
}

input[type="text"], 
textarea, 
select {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
  margin-bottom: 10px;
}

input[type="checkbox"], 
input[type="radio"] {
  margin-right: 8px;
}

.radio-group {
  display: flex;
  gap: 15px;
  margin: 10px 0;
}

.preview {
  white-space: pre-wrap;
  background-color: #fff;
  padding: 10px;
  border-radius: 4px;
  border-left: 3px solid #42b983;
}

.success {
  color: #42b983;
  font-weight: 600;
}

p {
  margin: 8px 0;
  color: #555;
}
</style>

v-model 修饰符

Vue 为 v-model 提供了几个有用的修饰符:

1. .lazy

将 input 事件转换为 change 事件(在输入完成时更新)

<!-- 输入完成后才更新数据 -->
<input v-model.lazy="message">

2. .number

自动将用户输入转为数值类型

<input v-model.number="age" type="number">

3. .trim

自动去除用户输入的首尾空白字符

<input v-model.trim="username">

在自定义组件上使用 v-model

v-model 也可用于自定义组件,实现组件与父级数据的双向绑定:

Vue 2 的实现方式

在 Vue 2 中,组件上的 v-model 默认使用 value 属性和 input 事件:

<!-- 父组件 -->
<CustomInput v-model="message" />

<!-- 等价于 -->
<CustomInput :value="message" @input="message = $event" />

子组件实现:

<template>
  <input
    :value="value"
    @input="$emit('input', $event.target.value)"
  >
</template>

<script>
export default {
  props: ['value']
}
</script>

Vue 3 的实现方式

Vue 3 默认使用 modelValue 属性和 update:modelValue 事件:

<!-- 父组件 -->
<CustomInput v-model="message" />

<!-- 等价于 -->
<CustomInput 
  :modelValue="message"
  @update:modelValue="message = $event"
/>

子组件实现:

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  >
</template>

<script>
export default {
  props: ['modelValue']
}
</script>

Vue 3 中的高级用法

多个 v-model 绑定

Vue 3 允许在单个组件上使用多个 v-model:

<UserForm
  v-model:first-name="firstName"
  v-model:last-name="lastName"
  v-model:email="email"
/>

子组件实现:

<template>
  <input :value="firstName" @input="$emit('update:firstName', $event.target.value)">
  <input :value="lastName" @input="$emit('update:lastName', $event.target.value)">
  <input :value="email" @input="$emit('update:email', $event.target.value)">
</template>

<script>
export default {
  props: ['firstName', 'lastName', 'email'],
  emits: ['update:firstName', 'update:lastName', 'update:email']
}
</script>

自定义修饰符

可以为自定义组件创建特定的修饰符:

<CustomInput v-model.capitalize="message" />

子组件实现:

<template>
  <input
    :value="modelValue"
    @input="emitValue($event.target.value)"
  >
</template>

<script>
export default {
  props: {
    modelValue: String,
    modelModifiers: {
      default: () => ({})
    }
  },
  methods: {
    emitValue(value) {
      if (this.modelModifiers.capitalize) {
        value = value.charAt(0).toUpperCase() + value.slice(1)
      }
      this.$emit('update:modelValue', value)
    }
  }
}
</script>

底层实现原理

v-model 本质上是语法糖,它结合了属性绑定和事件监听:

<input v-model="searchText">

<!-- 等价于 -->
<input
  :value="searchText"
  @input="searchText = $event.target.value"
>

对于组件:

<custom-input v-model="searchText"></custom-input>

<!-- 等价于 -->
<custom-input
  :model-value="searchText"
  @update:model-value="searchText = $event"
></custom-input>

最佳实践

  1. 表单验证:结合 v-model 和表单验证库(如 VeeValidate)
  2. 性能优化:对于复杂表单,考虑使用 .lazy 修饰符减少更新频率
  3. 组件设计:为自定义表单组件实现 v-model 接口
  4. 状态管理:在大型应用中,将表单状态存储在 Vuex 或 Pinia 中
  5. 无障碍访问:确保表单元素有正确的 label 和 aria 属性

总结

v-model 是 Vue 中处理表单数据的核心指令,它提供了简洁的双向绑定语法。通过理解其工作原理和各种修饰符,你可以更高效地处理表单交互。在自定义组件中使用 v-model 可以创建高度可复用的表单组件,提升开发效率。

实现 React 多个原生标签子节点渲染

作者 june18
2025年7月11日 23:19

基于文章 简易实现 React 页面初次渲染

本文将实现多个原生标签子节点渲染,主要涉及 render 阶段的修改。

// 多个原生标签子节点
const jsx = (
  <div className="box border">
    <h1 className="border">omg</h1>
    <h2>react</h2>
  </div>
)

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(jsx);

BeginWork 阶段

reconcileChildFibers 函数增加子节点是数组的判断。

// 协调子节点
function createChildReconciler(shouldTrackSideEffects: boolean) {
  function reconcileChildFibers(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChild: any
  ) {
    // 检查 newChild 类型,单个节点
    if (typeof newChild === "object" && newChild !== null) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE: {
          return placeSingleChild(
            reconcileSingleElement(returnFiber, currentFirstChild, newChild)
          );
        }
      }
    }
    
    // 子节点是数组
    if (isArray(newChild)) {
      return reconcileChildrenArray(returnFiber, currentFirstChild, newChild);
    }

    return null;
  }

  return reconcileChildFibers;
}

reconcileChildrenArray 函数协调节点数组。

// 根据 TypeAndProps 创建fiber
export function createFiberFromTypeAndProps(
  type: any,
  key: null | string,
  pendingProps: any
) {
  let fiberTag: WorkTag = IndeterminateComponent;

  if (isStr(type)) {
    // 原生标签
    fiberTag = HostComponent;
  }

  const fiber = createFiber(fiberTag, pendingProps, key);
  fiber.elementType = type;
  fiber.type = type;
  return fiber;
}

// 根据 ReactElement 创建Fiber
export function createFiberFromElement(element: ReactElement) {
  const { type, key } = element;
  const pendingProps = element.props;
  const fiber = createFiberFromTypeAndProps(type, key, pendingProps);
  return fiber;
}

function createChild(returnFiber: Fiber, newChild: any): Fiber | null {
    if (typeof newChild === "object" && newChild !== null) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE: {
          const created = createFiberFromElement(newChild);
          created.return = returnFiber;
          return created;
        }
      }
    }

    return null;
}
  
function reconcileChildrenArray(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChildren: Array<any>
  ) {
    let resultFirstChild: Fiber | null = null; // 头结点
    let previousNewFiber: Fiber | null = null;
    let oldFiber = currentFirstChild;
    let newIdx = 0; // 会在后面多个 for 循环中使用,所以没有定义到 for 循环语句中

    // 初次渲染没有 oldFiber
    if (oldFiber === null) {
      for (; newIdx < newChildren.length; newIdx++) {
        const newFiber = createChild(returnFiber, newChildren[newIdx]);
        
        // null 在 react 中不渲染
        if (newFiber === null) {
          continue;
        }

        // 组件更新阶段,判断在更新前后的位置是否一致,如果不一致,需要移动
        // 和 Vue 不同,react 存储使用的是链表,必须记录 index
        // Vue 存储使用的是数组,天然带下标,不需要记录 index
        newFiber.index = newIdx

        // 第一个节点,不要用 newIdx 判断,因为有可能有 null,而 null 不是有效的 fiber 
        if (previousNewFiber === null) {
          previousNewFiber = newFiber
        } else {
          previousNewFiber.sibling = newFiber
        }
        previousNewFiber = newFiber
      }

      return resultFirstChild
    }

    return resultFirstChild
  }

CompleteWork 阶段

有了兄弟节点,需要遍历。

function appendAllChildren(parent: Element, workInProgress: Fiber) {
  let nodeFiber = workInProgress.child; // 链表结构
  while (nodeFiber !== null) {
    parent.appendChild(nodeFiber.stateNode); // nodeFiber.stateNode是DOM节点
    nodeFiber = nodeFiber.sibling;
  }
}
❌
❌