关于 React 进化历史 (上)

原文:react-history-intro

刚接触 React 的开发者,常会陷入这样的困惑:面对 Hooks 条条框框的使用限制 ------ 比如只能在函数组件顶层调用、不能在条件语句里嵌套 ------ 很容易觉得这些 API 像是随时间推移,东拼西凑补丁式开发出来的,背后没有一套统一的设计逻辑。

但事实恰恰相反。如果我们回到 React 诞生的起点,顺着它的成长轨迹去探寻每次技术决策的底层逻辑 ------ 为何要创造这样一个框架、每个核心特性诞生时要解决的实际问题 ------ 就会发现,看似零散的设计,其实都串联在一条清晰的发展主线上。

所以接下来将以时间为轴,沿着 React 的历史一步步回溯:看看我们如今习以为常的 JSX 是如何让 UI 描述更直观,VDOM 又是怎样提高操作效率;类组件的组合思想,Hooks 又为何能颠覆传统写法成为主流;最后聊聊 React 19 对迈向服务端化的探索。

本系列可以看作 react-history-intro 的翻译,当然内容做了必要的润色和删减,毕竟翻译首要的目的是自我学习和知识存档。因此采用故事的方式,将发展脉络更好的呈现出来,总共分为上下两篇,包含 7 个章节:

  1. 序章: 为什么会有 React
  2. 第一幕:用 JSX 与 VDOM 清场
  3. 第二幕:类组件的黄金时代与天花板
  4. 第三幕:Hooks 把组件化深入到逻辑层
  5. 第四幕:让复杂应用变得可控
  6. 第五幕:把"数据获取"提升为一等公民
  7. 第六幕:向服务器"迁移"------RSC 与 Server Actions

感兴趣的小伙伴也可以直接阅读原文。ok,下面就直接进入主题吧!

序章: 为什么会有 React

2011 年,Facebook 的广告业务遭遇技术瓶颈:支撑业务的内部框架 BoltJS,虽能解决 90% 的需求,但随着团队规模扩大,剩余 10% 需求暴露出开发一致性差、新人培训效率低、开发者体验不佳等问题。若放任不管,将直接影响产品按预期节奏迭代。

当时广告团队的工程师 Jordan Walke,本就对 BoltJS 这类框架的逻辑不满。他在社区采访中回忆:"初次学编程时,就觉得 MVC 模式的数据绑定与修改方式不适合自己,只是不懂用'状态突变(mutation)''函数式编程(functional programming)'这类术语描述;我写的代码总被认为奇怪,直到学了编程语法基础、掌握术语,才清晰表达出想构建的应用形态。"

带着这份反思,Jordan 以个人项目形式开发 FaxJS,试图解决 BoltJS 及主流框架的共性痛点。不久后 FaxJS 更名为 FBolt(Functional Bolt)------ 这正是后来 React 的雏形,当时已有小团队围绕它展开初步开发。

2012 年,Facebook 以 10 亿美元收购 Instagram。彼时 Instagram 拥有 Android、iOS 移动端应用,却无 Web 版;新团队需开发 Web 端,且必须使用 Facebook 内部技术栈。评估 BoltJS 与 FBolt(早期 React)后,团队最终选择后者,很快发现其迭代快、性能优、开发者接受度高,项目早期甚至有人提议将其开源。

但新的矛盾随之而来:Facebook 内部已形成 BoltJS、React 两套 Web 解决方案。当时 Facebook 刚完成 IPO,广告业务作为核心盈利支柱,此前才迁移到 BoltJS;若再迁 React,不仅需耗时 4 个月,期间还不能接入新需求,业务风险极高,迁移一度被认为 "几乎不可能"。

关键时刻,Facebook CTO 给予了充分的支持:"做正确的技术选择,着眼长远。若产生短期影响,责任由我承担;需要几个月重写,就放手去做。" 最终广告平台顺利完成迁移,效果与 Instagram Web 端开发一致,被认定为 "成功决策"。

在 2013 年的 JSConfUS 大会上,Tom Occhino(汤姆・奥基诺)与 Jordan Walke(乔丹・沃克)共同宣布 React 开源,同时发布了代码和文档。

第一幕:用 JSX 与 VDOM 清场

在最早的时候,React 就洞察到将HTML耦合到JS是一种灵活的设计

JSX 为框架带来了足够的灵活性,不仅能避开用条件渲染和循环等逻辑需要的自定义模板标签;而且,声明式效率极高,帮助开发者对UI快速迭代。

在 React 出现之前,实现类似逻辑的代码可能是这样的:

javascript 复制代码
// This code is expected to live in another file or be a static string of some kind
<div>
  {/* This is pseudo-syntax of a theoretical framework's template code */}
  <some-tag data-if="someVar"></some-tag>
  <some-item-tag data-for="let someItem of someList"></some-item-tag>
</div>

我们必须学习新的语法标签。但 React 可以是这样:

javascript 复制代码
const data = (
  <div>
    {someVar && <some-tag />}
    {someList.map(someItem => (
      <some-item-tag />
    ))}
  </div>
)

这带来的主要好处是:

  1. 编译发生在运行之前,允许错误可以在开发阶段被捕获到
  2. 重用 JavaScript 表达力;无需在另一种字符串语言中重新创造

JSX 本质是函数的语法糖,只支持表达式。在不同端有较好的迁移能力

javascript 复制代码
// The following JSX
function App() {
  return (
    <ul role="list">
      <li>Test</li>
    </ul>
  )
}
// 会被编译成 react.createElement 函数
function App() {
  return React.createElement(
    'ul',
    {
      role: 'list',
    },
    [React.createElement('li', {}, ['Test'])]
  )
}

JSX 才是真正的关心分离

早期,多数人对 JSX 的批评是它打破了"关心分离(separation of concerns)" 。因为基本所有前端项目都是将仓库基于语言进行划分的。

但问题在于:这并不是真正的关心分离。关心分离的核心是指不相关的低耦合,相关的高聚合。而这种基于技术类型的拆分,使相同功能模块中高度关联的CSS、HTML、JS分散在不同文件夹的做法,并不总是能带来想象中便捷,尤其在功能模块比较复杂的情况。

相反,React 团队提出的是,你应该根据功能来拆分代码 这样做的核心优势在于,能让开发者将注意力完全聚焦于单个功能模块内部 ------ 毕竟与该模块相关的代码(HTML、JS、CSS)已高度聚合,无需在分散的文件夹中切换查找,从而实现更贴合实际开发场景的 "关注点分离"。这正是 React 所倡导的理想代码结构,也是 JSX 语法一旦上手便让人青睐的关键原因:它能让 UI 渲染逻辑保持连贯不中断,自然得将与界面相关的 HTML 结构、交互(JS)和样式(CSS)声明在同一处,真正实现了 "相关代码高聚合" 的设计初衷。

响应式 (Reactivity) 框架

响应式框架的核心是当数据发生变化时,框架自动将变化映射的HTML上,无需开发者手动操作DOM。理解这句话之前,我们先来看看 React 之前的 Backbonejs 是如何完成一个 Counter 组件:

html 复制代码
<!-- index.html, shortened for brevity -->
<div id="counter-app"></div>
<script type="text/template" id="counter-template">
  <p>Count: <%= count %></p>
  <button>Add 1</button>
</script>
<script>
  /* app.js */
  $(function () {
    var CounterModel = Backbone.Model.extend({
      defaults: {
        count: 0,
      },
    })
    var CounterView = Backbone.View.extend({
      el: '#counter-app',
      template: _.template($('#counter-template').html()),
      events: {
        'click button': 'increment',
      },
      initialize: function () {
        this.listenTo(this.model, 'change', this.render)
        this.render()
      },
      render: function () {
        var html = this.template(this.model.toJSON())
        this.$el.html(html)
        return this
      },
      increment: function () {
        var currentCount = this.model.get('count')
        this.model.set('count', currentCount + 1)
      },
    })
    var counterModel = new CounterModel()
    new CounterView({ model: counterModel })
  })
</script>

在这个组件里面,我们做这么几件事:

  1. 从标记有 "text/template""的 script 标签读模版
  2. 定义模板使用的数据模型
  3. 手动绑定事件并根据请求将模板重新构建为HTML

大体看下没什么问题,有个细节,increament 内部,用户为了更新count,需要获取DOM节点、执行 +1 逻辑,两者缺一不可;遇到更复杂的情景,很容易在开发过程中对需要操作的DOM节点遗漏。

我们比较下 React 如何完成 Counter 组件:

html 复制代码
<div id="root"></div>
<script type="text/babel">
  var Counter = React.createClass({
    getInitialState: function () {
      return {
        count: 0,
      }
    },
    increment: function () {
      this.setState({
        count: this.state.count + 1,
      })
    },
    render: function () {
      return (
        <div>
          <p>Count: {this.state.count}</p>
          <button onClick={this.increment}>Add 1</button>
        </div>
      )
    },
  })
  ReactDOM.render(<Counter />, document.getElementById('root'))
</script>

相较于 Backbone,React 虽通过 this.setState 显式触发更新,但二者在设计思想上存在根本性转变 ------ 在 React 的 increment 方法中,开发者无需手动操作 DOM,彻底摆脱了传统开发中数据更新与 DOM 操作强耦合的繁琐流程。

React 的 render 方法绝非仅用于生成组件初始模板,它更像是一个具备 "记忆能力" 的跨时间模板:无论何时组件状态发生变化,render 都会基于最新状态自动生成对应的视图描述,开发者只需要通过 JSX 声明数据如何变化、如何被使用,而必要的DOM更新由框架自动完成。这就是响应式框架的核心。

我们通过 JSX + 响应式框架,将关注点放在数据如何驱动UI发生变化,而琐碎的DOM操作交给框架完成,代码组织和开发效率得到了质的提升,这也是我们为什么选择框架,而不再手撸html的原因。

从实际开发角度来看,声明思想 + 响应式的本质,是将 UI 更新视为 "协调(Reconciliation)过程(比较模板的增量更新部分,并进行覆盖) ,而非直接对 DOM 进行修改(DOM Mutation) 。这个想法直接源于Jordan从函数式编程领域学到的知识,在该领域中,数据必须始终是不可变的

想更多了解响应式思想,点击这里

虚拟DOM (VDOM)

虽然 JSX 很好的描述了数据如何驱动模板改变,但每次状态更新,框架需要重新生成(DOM)。这对于大型 DOM 树会产生性能影响。

为了解决这个问题,团队采用了 "虚拟 DOM"(VDOM)的概念。这个虚拟 DOM 是存储在 JavaScript 中的浏览器 DOM 的副本;当 React 在 DOM 中构建一个节点时,它会将该节点复制到自己的 DOM 副本中。

然后,当某个特定组件需要更新 DOM 时,它会与这个虚拟 DOM(VDOM)进行比对,并且只对的需要更新的节点进行重绘。

以上过程,都被React 放在 "协调" 中,通过diff算法来实现 。值得一提的是,即使是早期的 React 版本也已经对虚拟 DOM(VDOM)的大部分差异比较过程进行了优化

第二幕:类组件的黄金时代与天花板

React 在2013年发布了基于类的组件( Hooks 直到 2019 年才发布)。虽然类组件很棒,帮助我们对代码进行模块化,但是也有自身的问题。

组件的一个核心原则是它们能够组合,也就是说,我们可以用现有的组件构建一个新组件

jsx 复制代码
// Existing components
class Button extends React.Component {
  // ...
}
class Title extends React.Component {
  // ...
}
class Surface extends React.Component {
  // ...
}
// Can be reused and merged into a
// newly created broader component
class Card extends React.Component {
  render() {
    return (
      <Surface>
        <Title />
        <Button />
      </Surface>
    )
  }
}

如果没有这种能力,React 在大型应用中会极难扩展。然而上面是组件级别的复用,但对于类组件的内部逻辑我们是没办法复用的。

比如下面这个例子:

jsx 复制代码
class WindowSize extends React.Component {
  state = {
    width: window.innerWidth,
    height: window.innerHeight,
  }
  handleResize = () => {
    this.setState({
      width: window.innerWidth,
      height: window.innerHeight,
    })
  }
  componentDidMount() {
    window.addEventListener('resize', this.handleResize)
  }
  componentWillUnmount() {
    window.removeEventListener('resize', this.handleResize)
  }
  render() {
    // ...
  }
}

上面的 WindowSize 组件用于获取浏览器窗口大小,在改变窗口尺寸时,state的改变会触发组件的重新渲染。

现在假设我们想要在其他组件复用这个逻辑,怎么办呢?如果你学习过面向对象编程 --- 你会意识到有一种很好的方法可以做到这一点:类继承

短期解决方案

不需要改变 WindowSize 的代码,我们可以使用 JavaScript 的内置关键字 extends 允许新的类继承另外一个类的方法和属性。

jsx 复制代码
class MyComponent extends WindowSize {
  render() {
    const { windowWidth, windowHeight } = this.state
    return (
      <div>
        The window width is: {windowWidth}
        <br />
        The window height is: {windowHeight}
      </div>
    )
  }
}

尽管这个简单示例能够正常运行,但存在明显缺陷:当MyComponent的逻辑逐渐复杂时,开发者必须通过super关键字调用基类方法,才能确保基类原有的生命周期行为(如事件监听、状态初始化等)不被中断,一旦遗漏super调用,极易引发逻辑异常或内存泄漏等问题。

jsx 复制代码
class MyComponent extends WindowSize {
  state = {
    // Required with a base class
    ...this.state,
    counter: 0,
  }
  intervalId = null
  componentDidMount() {
    // Required with a base class
    super.componentDidMount()
    this.intervalId = setInterval(() => {
      this.setState(prevState => ({ counter: prevState.counter + 1 }))
    }, 1000)
  }
  componentWillUnmount() {
    // Required with a base class
    super.componentWillUnmount()
    clearInterval(this.intervalId)
  }
  render() {
    const { windowWidth, windowHeight, counter } = this.state
    return (
      <div>
        The window width is: {windowWidth}
        <br />
        The window height is: {windowHeight}
        <br />
        The counter is: {counter}
      </div>
    )
  }
}

为了解决这个问题,许多库采用了一种名为 "高阶组件"(HoC)的模式。

HOC

借助高阶组件,用户避免在其代码库中进行super调用,而是让 props 的形式获取基类的参数:

jsx 复制代码
const withWindowSize = WrappedComponent => {
  return class WithWindowSize extends React.Component {
    state = {
      width: window.innerWidth,
      height: window.innerHeight,
    }
    handleResize = () => {
      this.setState({
        width: window.innerWidth,
        height: window.innerHeight,
      })
    }
    componentDidMount() {
      window.addEventListener('resize', this.handleResize)
    }
    componentWillUnmount() {
      window.removeEventListener('resize', this.handleResize)
    }
    render() {
      return (
        <WrappedComponent
          {...this.props}
          windowWidth={this.state.width}
          windowHeight={this.state.height}
        />
      )
    }
  }
}
class MyComponentBase extends React.Component {
  render() {
    const { windowWidth, windowHeight } = this.props
    return (
      <div>
        The window width is: {windowWidth}
        <br />
        The window height is: {windowHeight}
      </div>
    )
  }
}
const MyComponent = withWindowSize(MyComponentBase)

在 Hook 出现之前,这是复用 React 类组件内部逻辑最好的方式。

缺点是,需要了解父组件会传来什么props,难以支持TypeScript和其他类型检查工具的使用,最终感觉像是一种代码的 hack ,而不是一种属于 React 的 天然、简洁的组合机制。

函数组件

2015 年,React 0.14 发布。 这个版本带来了类组件的替代方案:函数组件。

类组件被 React 团队描述为 "一个带有附加状态容器的渲染函数"。如果我们只移除状态容器而保留渲染函数会怎么样呢?

这意味着,得到下面这段代码

jsx 复制代码
var Aquarium = React.createClass({
  render: function () {
    var fish = getFish(this.props.species)
    return <Tank>{fish}</Tank>
  },
})

如果,将上面的代码更简化一点:

jsx 复制代码
var Aquarium = props => {
  var fish = getFish(props.species)
  return <Tank>{fish}</Tank>
}

虽然这样做很简洁,但也有个致命的缺点:函数组件无法包含自己的状态。

所以,这限制了它在实际应用的能力。早期,大多数仓库为了避免代码风格的分裂,都决定还是坚持使用基于类的组件。

第三幕:Hooks 把组件化深入到逻辑层

React 16.8 正式引入了 Hooks。Hook 解决了函数组件无法保留状态的问题,并且成为未来 React 的基础。

以前的类组件是使用约定的方法和属性来管理状态和副作用

jsx 复制代码
class WindowSize extends React.Component {
  state = {
    width: window.innerWidth,
    height: window.innerHeight,
  }
  handleResize = () => {
    this.setState({
      width: window.innerWidth,
      height: window.innerHeight,
    })
  }
  componentDidMount() {
    window.addEventListener('resize', this.handleResize)
  }
  componentWillUnmount() {
    window.removeEventListener('resize', this.handleResize)
  }
  render() {
    // ...
  }
}

而有了Hooks,以前的的类组件都可以用函数式的方式编写:

jsx 复制代码
function WindowSize() {
	const [size, setSize] = React.useState({
    width: window.innerWidth,
    height: window.innerHeight,
  })
  const {height, width} = size;
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  return (
  	// ...
  )
}

这一变化对组件内部逻辑的二次复用和组合特别有用。

更优雅的处理副作用

让我们看下 class 是如何处置副作用:

jsx 复制代码
class Listener extends React.Component {
	// Requires us to register a method on the `this` boundary
	// to reference in both places
	componentDidMount() {
		window.addEventListener("resize", this.handleResize);
	}
	// There may be many lines between the mount and unmount
	componentWillUnmount() {
		window.removeEventListener("resize", this.handleResize);
	}
	// Methods added to `window` via `addEventListener` needed to use
	// arrow functions, as otherwise `this` would be bound to `window`.
	handleResize = () => {
		// ...
	};
}

副作用发生在 componentWillUnmount 中
副作用的清除则在 componentWillUnmount

注意 对为什么当 handleResize不是箭头函数时,this 会是 window 感到困惑吗?点击这里

对比利用 useEffect 这个 Hook 来注册副作用和清除:

jsx 复制代码
function Listener() {
  useEffect(() => {
    // Method colocated next to the listeners
    const handleResize = () => {
      // ...
    }
    window.addEventListener('resize', handleResize)
    // Cleanup in same scope as the effect
    return () => window.removeEventListener('resize', handleResize)
  }, [])
  // ...
}

useEffect 第一个参数就是副作用函数,清除逻辑由函数 return 的结果提供,执行时机用户不需要关心,由 React 来决定。相比于 Class 更简洁。这就是为什么 Hooks 不 1:1 映射旧的类组件的生命周期, Hooks 能更好的管理和清除副作用。

Hook 的使用限制

所有 Hook 都要遵循一套一致的规则:

  1. 所有钩子都是函数
  2. 函数名称必须以 use 开头
  3. 钩子不能有条件地调用
  4. 它们必须在组件的顶层调用
  5. 不允许动态使用钩子
  6. 传递给钩子的属性不得被修改

无论 Hook 是自定义的还是从 React 导入的,无论是一开始的useState,还是 React18 之后的useActionState------ 都必须遵守这些规则。

jsx 复制代码
// ✅ Allowed usages
function AllowedHooksUsage() {
  const [val, setVal] = React.useState(0)
  const { height, width } = useWindowSize()
  return <>{/* ... */}</>
}
// ❌ Dis-allowed usages
function DisallowedHooksUsage() {
  const obj = {}
  useObj(obj)
  // Not allowed to mutate objects after being passed to a hook
  obj.key = (obj.key ?? 0) + 1
  if (bool) {
    const [val, setVal] = React.useState(0)
  }
  if (other) {
    return null
  }
  // While otherwise valid, can't be after a return
  const { height, width } = useWindowSize()
  for (let i = 0; i++; i < 10) {
    const ref = React.useRef()
  }
  return <>{/* ... */}</>
}
相关推荐
随风一样自由2 小时前
React中实现iframe嵌套登录页面:跨域与状态同步解决方案详解
前端·react.js·前端框架·跨域
一个处女座的程序猿O(∩_∩)O2 小时前
React Native vs React Web:深度对比与架构解析
前端·react native·react.js
@大迁世界2 小时前
紧急:React 19 和 Next.js 的 React 服务器组件存在关键漏洞
服务器·前端·javascript·react.js·前端框架
一个处女座的程序猿O(∩_∩)O3 小时前
React Native 全面解析:跨平台移动开发的利器
javascript·react native·react.js
仙人掌一号17 小时前
梳理SPA项目Router原理和运行机制 [共2500字-阅读时长10min]
前端·javascript·react.js
2401_8604947018 小时前
React Native鸿蒙跨平台开发:error SyntaxError:Unterminated string constant.解决bug错误
javascript·react native·react.js·ecmascript·bug
接着奏乐接着舞19 小时前
react useMeno useCallback
前端·javascript·react.js
北辰alk21 小时前
React Native vs React Web:深度对比与架构解析
react native·react.js
黛色正浓1 天前
【React】极客园案例实践-文章列表模块
javascript·react.js·ecmascript