阅读视图

发现新文章,点击刷新页面。

记住这张时间线图,你再也不会乱用 useEffect / useLayoutEffect

useEffect 和 useLayoutEffect 的区别:别背定义,按“什么时候上屏”来选

以前一直写vue,现在写react了,写react代码的时候有时候会碰到一个选择题:

  • 这个副作用用 useEffect 还是 useLayoutEffect
  • 为什么我用 useEffect 量 DOM 会闪一下?
  • Next.js 里 useLayoutEffect 为什么会给我一个 warning?

这俩 Hook 的差别,说穿了就一句:它们跑在“上屏(paint)”的前后


一句话结论(先拿走)

  • 默认用 useEffect:不会挡住浏览器绘制。
  • 只有在“必须读布局/写布局且不能闪”的时候用 useLayoutEffect:它会在浏览器 paint 之前同步执行。

如果你脑子里只留两句话,就留这两句。


它们到底差在哪:在浏览器 paint 的前后

把 React DOM 的一次更新粗暴拆成四步,你就不会混了:

flowchart LR
  A[render 计算 JSX] --> B[commit 写入 DOM]
  B --> C[useLayoutEffect 同步执行]
  C --> D[浏览器 paint 上屏]
  D --> E[useEffect 执行]

  classDef info fill:#cce5ff,stroke:#0d6efd,color:#004085
  classDef warning fill:#fff3cd,stroke:#ffc107,color:#856404

  class C warning
  class E info
  • useLayoutEffectDOM 已经变了,但还没 paint。它会阻塞本次 paint。
  • useEffect页面已经 paint 了。它不会阻塞上屏(但也意味着你在里面改布局可能会“先错后改”,肉眼看到就是闪)。

注意我在说的是“commit 后”的那个时间点,不是 render 阶段。


一个很真实的例子:测量 DOM 决定位置(useEffect 会闪)

比如你做一个 Tooltip:初始不知道自己宽高,得先 render 出来,然后用 getBoundingClientRect() 量一下,再把位置修正。

如果你用 useEffect

  • 第一次 paint:Tooltip 先用默认位置上屏
  • effect 里量完 -> setState
  • 第二次 paint:位置修正

用户看到的就是“闪一下”。如果你用 useLayoutEffect,修正发生在 paint 之前,第一帧就是对的。

下面这段代码可以直接在 React DOM 里跑(为了不违反 Hooks 规则,我写成两个组件,用 checkbox 切换时会 remount):

import React, { useEffect, useLayoutEffect, useRef, useState } from "react";

type TooltipPosition = {
  anchorRef: React.RefObject<HTMLButtonElement | null>;
  tipRef: React.RefObject<HTMLDivElement | null>;
  left: number;
};

function calcLeft(anchor: HTMLButtonElement, tip: HTMLDivElement) {
  const a = anchor.getBoundingClientRect();
  const t = tip.getBoundingClientRect();
  return Math.round(a.left + a.width / 2 - t.width / 2);
}

function useTooltipPositionWithEffect(): TooltipPosition {
  const anchorRef = useRef<HTMLButtonElement | null>(null);
  const tipRef = useRef<HTMLDivElement | null>(null);
  const [left, setLeft] = useState(0);

  useEffect(() => {
    const anchor = anchorRef.current;
    const tip = tipRef.current;
    if (!anchor || !tip) return;
    setLeft(calcLeft(anchor, tip));
  }, []);

  return { anchorRef, tipRef, left };
}

function useTooltipPositionWithLayoutEffect(): TooltipPosition {
  const anchorRef = useRef<HTMLButtonElement | null>(null);
  const tipRef = useRef<HTMLDivElement | null>(null);
  const [left, setLeft] = useState(0);

  useLayoutEffect(() => {
    const anchor = anchorRef.current;
    const tip = tipRef.current;
    if (!anchor || !tip) return;
    setLeft(calcLeft(anchor, tip));
  }, []);

  return { anchorRef, tipRef, left };
}

function TooltipFrame({ pos }: { pos: TooltipPosition }) {
  return (
    <>
      <button ref={pos.anchorRef} style={{ marginLeft: 120 }}>
        Hover me
      </button>

      <div
        ref={pos.tipRef}
        style={{
          position: "fixed",
          top: 80,
          left: pos.left,
          padding: "8px 10px",
          borderRadius: 8,
          background: "#111827",
          color: "#fff",
          fontSize: 12,
          whiteSpace: "nowrap",
        }}
      >
        I am a tooltip
      </div>
    </>
  );
}

function DemoUseEffect() {
  return <TooltipFrame pos={useTooltipPositionWithEffect()} />;
}

function DemoUseLayoutEffect() {
  return <TooltipFrame pos={useTooltipPositionWithLayoutEffect()} />;
}

export function Demo() {
  const [layout, setLayout] = useState(false);

  return (
    <div style={{ padding: 40 }}>
      <label style={{ display: "block", marginBottom: 12 }}>
        <input
          type="checkbox"
          checked={layout}
          onChange={(e) => setLayout(e.target.checked)}
        />{" "}
        用 useLayoutEffect(勾上后更不容易闪)
      </label>

      {layout ? <DemoUseLayoutEffect /> : <DemoUseEffect />}
    </div>
  );
}

真实项目里你可能还会处理 resize、内容变化(ResizeObserver)、字体加载导致的宽度变化等;但对理解这两个 Hook 的差别,上面这个例子够用了。


怎么选:我自己用的“决策口诀”

1)只要不读/写布局,就用 useEffect

典型场景:

  • 请求数据、上报埋点
  • 订阅/取消订阅(WebSocket、EventEmitter)
  • document.titlelocalStorage 同步
  • 给 window/document 绑事件

这些东西不需要卡在 paint 之前完成,useEffect 更合适。

2)你要读布局(layout read)并且会影响第一帧渲染,就用 useLayoutEffect

典型场景:

  • getBoundingClientRect() / offsetWidth / scrollHeight 这种
  • 计算初始滚动位置、同步滚动
  • 需要避免视觉抖动的“测量 -> setState”
  • focus / selection(输入框聚焦、光标定位)对首帧体验敏感

一句话:“不想让用户看到中间态”

3)别在 useLayoutEffect 里干重活

因为它会阻塞 paint:

  • 你在里面做重计算,页面就掉帧
  • 你在里面频繁 setState,可能放大卡顿

如果你只是“想早点跑一下”,但并不依赖布局,别用它。


Next.js / SSR 里那个 warning 怎么回事

在服务端渲染(SSR)时:

  • useEffect 本来就不会执行(它只在浏览器跑)
  • useLayoutEffect 也不会执行,但 React 会提示你:它在服务端没意义,可能导致你写出“依赖布局但 SSR 不存在布局”的代码

如果你写的是“浏览器才有意义的 layout effect”,又不想看到 warning,常见做法是包一层:

import { useEffect, useLayoutEffect } from "react";

export const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

然后把需要 layout 的地方用 useIsomorphicLayoutEffect


容易踩的坑(顺手说两句)

  • Strict Mode 下 effect 会在开发环境额外执行一次useEffectuseLayoutEffect 都一样,别拿这个现象判断线上行为。
  • “我在 useEffect 里 setState 为什么会闪?”:因为你改的是布局相关内容,第一帧已经 paint 了。
  • 不要把数据请求塞进 useLayoutEffect:它既不需要 paint 前完成,还可能拖慢上屏。

简单总结一下

  • useEffect:大多数副作用的默认选择。
  • useLayoutEffect:只在“必须卡在 paint 前解决”的那一小撮场景里用。

真要说区别,其实就是一句:你愿不愿意为了“第一帧正确”去挡住 paint


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

我只是给Typescript提个 typo PR,为什么还要签协议?

第一次给大公司的开源项目(Typescript)提 PR,提交完 GitHub 就弹出一条评论,让你签什么 CLA:

@microsoft-github-policy-service agree

image.png 什么玩意儿?我就改了个拼写错误,还要签协议?

CLA 是什么

CLA,全称 Contributor License Agreement,翻译过来叫"贡献者许可协议"。

简单说,就是一份法律文件,你签了之后,就授权项目方可以合法使用你贡献的代码。

为什么需要这东西

想象一个场景:

张三给某开源项目提了个 PR,合并了。过了两年,张三突然跳出来说:"这段代码是我写的,你们用了我的代码,侵犯我版权,赔钱!"

项目方一脸懵:代码是你自己提交的啊?

张三:提交归提交,我没说授权你们用啊。

听起来像碰瓷,但法律上还真不好说。毕竟代码确实是张三写的,版权默认归作者。

CLA 就是为了堵这个漏洞。你签了 CLA,就相当于白纸黑字写清楚了:

  • 这代码是我自己写的(不是抄的)
  • 我授权你们用、改、分发
  • 以后不会反悔找你们麻烦

CLA 里具体写了啥

以微软的 CLA 为例,核心条款就这几条:

1. 原创声明

你保证提交的代码是你自己写的。如果包含别人的代码,要标注清楚来源和许可证。

2. 版权授权

你授予项目方永久的、全球范围的、免版税的版权许可。说白了就是:他们可以随便用,不用给你钱,也不用每次都问你。

3. 专利授权

如果你的代码涉及专利(虽然大多数情况下不会),你也授权他们使用。

4. 雇主确认

如果你是在工作中写的代码,公司可能对代码有知识产权。这种情况下,你得先拿到公司的许可才能签 CLA。

签了会怎样

签 CLA 不会让你:

  • 失去代码的版权(版权还是你的)
  • 不能在别处使用这段代码
  • 承担什么法律责任

签 CLA 只是说:

  • 项目方可以合法使用你的贡献
  • 你不会秋后算账

不同项目的 CLA

不是所有开源项目都要签 CLA,主要是大公司的项目:

公司 需要 CLA
微软
Google
Meta
Apache 基金会
个人项目 通常不需要

个人维护的开源项目一般不搞这套,太麻烦。但大公司不行,法务部不允许有法律风险敞口。

怎么签

以 GitHub 上的微软项目为例:

  1. 提交 PR
  2. 机器人会自动评论,让你签 CLA
  3. 回复:@microsoft-github-policy-service agree
  4. 搞定

就这么简单。签一次就行,以后再给同一个组织提 PR 就不用重复签了。

如果你是代表公司贡献代码,需要加上公司名:

@microsoft-github-policy-service agree company="你的公司名"

一些细节

Q:我就改了个 typo,也要签?

是的。哪怕只改了一个字符,也是贡献,也要签。

Q:签了 CLA,代码版权归谁?

版权还是你的。CLA 只是授权,不是转让。

Q:能撤回吗?

理论上你不能撤回已经合并的代码的授权。但你可以随时停止贡献。

Q:CLA 和开源许可证什么关系?

开源许可证(MIT、Apache 等)是项目对外的授权,告诉使用者可以怎么用这个项目。

CLA 是贡献者对项目的授权,告诉项目方可以怎么用贡献者的代码。

两个方向不一样。

小结

CLA 这东西,说白了就是大公司的法务需求。对贡献者来说,签一下也没什么损失,就是授权项目方合法使用你的代码。

第一次遇到可能有点懵,但理解了它的目的,就知道这是正常流程,不是什么坑。

签就完了。


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

JavaScript 为什么选择原型链?从第一性原理聊聊这个设计

JavaScript 为什么选择原型链?从第一性原理聊聊这个设计

学 JavaScript 的时候,原型链是个绑不过去的坎。很多人(包括我)的第一反应是:这玩意儿怎么这么别扭?为什么不像 Java、C++ 那样用类继承?

后来了解了 JavaScript 的诞生背景,才发现原型链不是"别扭的设计",而是在特定约束下的合理选择。今天从第一性原理的角度,聊聊 JavaScript 为什么选择了原型链。


先回到 1995 年

1995 年 5 月,Brendan Eich 在 Netscape 公司用 10 天时间写出了 JavaScript 的第一个版本(当时叫 Mocha)。

10 天,这个时间约束很关键。

当时 Netscape 急着在浏览器里加一门脚本语言,用来做简单的表单验证、页面交互。管理层给 Eich 的要求是:

  1. 语法要像 Java(因为 Netscape 和 Sun 有合作,Java 正火)
  2. 要简单(目标用户是业余开发者,不是专业程序员)
  3. 要快(10 天内搞定原型)

Eich 本来想把 Scheme(一门函数式语言)塞进浏览器,但管理层否了——语法太怪,不像 Java。于是他搞了个混合体:

  • 语法:像 Java
  • 函数:像 Scheme(一等公民、闭包)
  • 对象系统:像 Self(原型继承)

为什么对象系统选了 Self 而不是 Java?这就要从第一性原理说起。


第一性原理:对象系统的本质需求是什么?

不管用什么方式实现,对象系统要解决的核心问题就两个:

  1. 代码复用:多个对象共享相同的行为
  2. 创建对象:能方便地造出新对象

类继承和原型继承都能解决这两个问题,但方式不同。

类继承的思路

类继承把世界分成两层:实例

类(Class)= 模板、蓝图
    ↓ 实例化
实例(Instance)= 具体对象

你先定义一个类,描述"这类对象长什么样、有什么方法",然后用 new 从类创建实例。

这套东西在静态语言里运作良好,但有个问题:概念多

类继承需要你理解:类、实例、构造函数、接口、抽象类、虚函数、多重继承、菱形继承问题……一套学下来,不轻松。

原型继承的思路

原型继承只有一层:对象

对象 → 对象 → 对象 → ... → null

没有"类"这个概念,只有对象。要创建新对象?从现有对象复制一份,改改就行。要共享行为?让多个对象指向同一个原型对象。

Self 语言的设计者 David Ungar 和 Randall Smith 在 1987 年的论文里说:

"Prototypes are more concrete than classes because they are examples of objects rather than descriptions of format and initialization."

(原型比类更具体,因为原型是对象的实例,而类只是格式和初始化的描述。)

说白了:原型是活的对象,类是抽象的描述


为什么 JavaScript 选择了原型?

回到 1995 年的约束条件,原型继承的优势就很明显了:

1. 实现更简单

类继承需要一套复杂的类型系统:类的定义、继承关系的解析、方法查找表的构建……

原型继承只需要:

  • 每个对象有个 __proto__ 指针,指向它的原型
  • 访问属性时,顺着指针往上找

10 天时间,选哪个?

Eich 后来回忆说:"选择原型继承意味着解释器可以非常简单,同时保留面向对象的特性。"

2. 动态性更强

JavaScript 是动态语言,对象可以随时增删属性。原型继承天然支持这种动态性:

// 随时给原型加方法,所有实例立刻能用
Array.prototype.first = function() {
  return this[0];
};

[1, 2, 3].first(); // 1

类继承在静态语言里很自然,但在动态语言里反而别扭——类定义完了还能改吗?方法能动态添加吗?处理起来麻烦。

3. 概念更少

类继承需要区分"类"和"实例",原型继承只有"对象"。

对于 1995 年的目标用户(网页设计师、业余开发者)来说,概念越少越好。


原型继承的核心:委托(Delegation)

原型继承有时候也叫委托继承,这个词更能说明它的工作方式。

当你访问一个对象的属性时:

  1. 先在对象自身找
  2. 找不到,委托给原型对象
  3. 还找不到,继续委托给原型的原型
  4. 直到 null
flowchart LR
    A["obj.foo"]:::start --> B{"obj 有 foo?"}
    B -->|有| C["返回 obj.foo"]:::success
    B -->|没有| D{"obj.__proto__ 有 foo?"}
    D -->|有| E["返回原型的 foo"]:::success
    D -->|没有| F["继续往上找..."]
    F --> G["直到 null,返回 undefined"]:::error

    classDef start fill:#cce5ff,stroke:#0d6efd,color:#004085
    classDef success fill:#d4edda,stroke:#28a745,color:#155724
    classDef error fill:#f8d7da,stroke:#dc3545,color:#721c24

这跟类继承的"复制"模型不同。类继承是在创建实例时把行为"复制"到实例上(或者通过虚函数表间接访问)。原型继承是运行时动态查找,真正的"按需委托"。

委托的好处

内存效率高:方法只在原型上存一份,所有实例共享。

function Dog(name) {
  this.name = name;
}
Dog.prototype.bark = function() {
  console.log('Woof!');
};

const dog1 = new Dog('A');
const dog2 = new Dog('B');

dog1.bark === dog2.bark; // true,同一个函数

运行时可修改:原型改了,所有实例立刻生效。

Dog.prototype.bark = function() {
  console.log('汪汪!');
};

dog1.bark(); // 汪汪!(立刻变了)

这种动态性在静态类继承里很难实现。


原型链的设计权衡

原型继承不是完美的,它做了一些权衡。

放弃了什么

静态类型检查:没有类,就没法在编译时检查类型。JavaScript 是动态类型语言,这是设计选择的一部分。

封装性较弱:原型上的东西都是公开的,没有 private/protected 的原生支持(ES2022 才加了私有字段 #)。

继承关系不明显:类继承的 extends 一眼就能看出继承关系,原型链要顺着 __proto__ 找。

得到了什么

极致的灵活性:对象可以随时改,原型可以动态换。

简单的心智模型:只有对象,没有类/实例的二元论。

运行时效率:对于 1995 年的浏览器来说,原型链的实现比类系统轻量得多。


后来的故事:class 语法糖

ES6(2015 年)加了 class 关键字,看起来像类继承:

class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    console.log(`${this.name} makes a sound`);
  }
}

class Dog extends Animal {
  bark() {
    console.log('Woof!');
  }
}

但这只是语法糖,底层还是原型链:

console.log(typeof Animal); // "function"
console.log(Dog.prototype.__proto__ === Animal.prototype); // true

class 让代码更清晰,但没有改变 JavaScript 的对象模型。理解原型链,才能理解 class 背后发生了什么。


总结

JavaScript 选择原型链,不是随意的决定,而是在特定约束下的合理选择:

约束 原型继承的优势
10 天开发时间 实现简单,解释器轻量
目标用户是业余开发者 概念少,只有"对象"
动态语言特性 天然支持运行时修改
浏览器性能有限 内存效率高,方法共享

从第一性原理看,对象系统的本质是"代码复用 + 对象创建"。原型继承用最简单的方式解决了这两个问题:

  • 代码复用:对象委托给原型,原型上的方法共享
  • 对象创建:复制现有对象,改改就行

类继承更严谨、更适合大型静态系统。原型继承更灵活、更适合动态脚本语言。JavaScript 选对了。


参考资料


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB
❌