没有状态管理库,各大框架如何跨组件共享状态?

2,242 阅读10分钟

前段时间,将某个Vue3项目的状态管理库Pinia删了。

原来代码可能是这样的:

import { ref } from "vue";
import { defineStore } from "pinia";

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  function increment() {
    count.value++
  }

  return { count, increment }
})

现在修改为:

import { ref } from 'vue'

export const count = ref(0)

export function increment() {
   count.value++
}

为什么呢?

因为我意识到,我引入状态管理库解决的是跨层级组件之间的状态共享状态更新问题,但在Vue3里完全不需要啊,我只要按文件拆分出来对应的状态数据与action,在各个组件里引入就可以工作了,而Pinia中更高级的功能诸如时间旅行、服务端渲染我都没用上,何必为了一点儿醋买盘饺子呢?

有了这个案例后,我便有个疑问,各大框架有没有必要使用状态管理库?也就是说,如果没有状态管理库,各大框架如何跨组件共享状态?下来我们将罗列当前流行的前端框架来一一看下,分别是PreactSolidJSSvelteQwikAngular,最后是React

Preact

PreactReact的轻量化替代方案,仅有 3KB。

诞生之初,它只是一个手撸React核心原理的学习仓库,用最少的代码实现了React的基本功能,而从2015年迭代到今天的v10版本,Preact除了高仿了React的核心能力,还引入了Signals(信号)来提高性能。它的API看起来与Vue最为相似,这篇文章《信仰崩了?Preact 开始采用 Vue3 的响应式设计》吐嘈的很有意思,推荐大家看下。

这几年Signals成为前端备受关注的话题,许多大佬为之发声,认为是前端框架的未来。Signals是一个在访问时跟踪依赖、在变更时触发副作用的值容器。其核心特点是自动状态绑定(开发简单)和依赖跟踪(细粒度订阅与更新,最小化重复渲染,保障性能)。

从某种程度上讲,Vue3的响应式更新本身就是Signals的一种实现。而目前很多框架也拥抱了Signals,除了Preact外,还有Svelte v5Solid.jsAngularQwik等。

并不是所有实现了Signals的框架都能够共享状态,关键在于框架本身是否允许独立于组件创建Signals。而Preact是可以的。

以下是个简单的示例:

import { signal } from "@preact/signals";

const count = signal(0);

function Counter() {
  return <span>count is {count}</span>;
}

function Add() {
  const increment = () => {
    count.value++;
  };
  return <button onClick={increment}>Add</button>;
}

export function App() {
  return (
    <>
      <h1>Vite + Preact</h1>
      <div class="card">
        <Add />
        <Counter />
      </div>
    </>
  );
}

可以看出,这里的signal(0)Vue3ref(0)别无二致。在DOM模板里同样不需要加.value,但在JS调用时都需要.value,没有再加一层魔法,符合JS直觉。

在实际开发中,将signal的实例放在单独的文件里导出,就与最开始的Vue3的示例一样了。

使用了Signals的好处是,可以细粒度更新DOM元素,也就是说,当Signals发生变化时,只有相应的DOM会更新,并不会触发整个函数组件的重新执行。这种级别的响应式编程,对于一直高仿ReactPreact来说,应该是个违背祖训的决定,有投奔敌营的嫌疑?

Vue3稍有不同,准确地说,Preactsignal对标的是Vue3shallowRef,因为它只是个浅层响应。比如,我们将数据从数字修改为一个对象,在Vue中很简单:

<script setup lang="ts">
  import { ref } from 'vue';

  const count = ref({data: 0})
</script>

<template>
  <div>
    <button type="button" @click="count.data++">Add</button>
    <p>count is {{ count.data }}</p>
  </div>

</template>

但在Preact里,点按钮完全没有反应:

const count = signal({data: 0});

function Counter() {
  return <span>count is {count.value.data}</span>;
}

function Add() {
  const increment = () => {
    count.value.data++;
  };
  return <button onClick={increment}>Add</button>;
}

只有为signal重新赋值(使用.value修改)才可以触发变更,喜欢Vue的同学会觉得奇怪,但在React风格看来再正常不过,数据不可变嘛。

const increment = () => {
  count.value = {data: count.value.data + 1};
};

Preact还有computedeffectbatch等API和useSignaluseComputed这两个hooks,有兴趣的同学详见官方文档

SolidJS

SolidJS使用JSX来描述组件,所以看起来与React好像没什么区别。但内在则是翻来覆地的变化。

它放弃了虚拟DOM,是编译时框架,所以性能卓越。抛开这个不谈,SolidJS也使用了Signals来响应式更新。所以它的整体代码风格看起来与现在的Preact更像是失散多年的亲兄弟?

我们来看一个外置Signals以共享状态的样例:

import { createSignal } from 'solid-js'

const [count, setCount] = createSignal(0)

function Counter() {
  return <span>count is {count()}</span>
}

function Add(){
  return <button onClick={() => setCount((count) => count + 1)}>Add</button>
}

function App() {
  return (
    <>
      <h1>Vite + Solid</h1>
      <div>
        <Add />
        <Counter />
      </div>
    </>
  )
}

createSignal的返回值与ReactuseState比较相似,不过数组第一项count并不是具体的值,而是个函数,所以在DOM中使用时是count();而第二项setCountuseState的别无二致。

SolidJS官方文档里是这样内涵React的:

在不考虑更新的情况下构建组件已经够难的了。Solid 的组件更新是彼此完全独立的。组件函数被调用一次,然后就不再存在。组件的存在是为了组织你的代码,而不是其他。

Svelte

SvelteSolidJS一样,是一款编译时框架,它在构建时将应用程序转换为高效的JavaScript

Svelte提供了一种轻量级的方式来共享状态,通过writablereadable stores,可以在多个组件之间共享和响应状态变化。

下面我们来看个示例(v4)。

store.ts,注意到对数据的变更是调用update函数:

import { writable } from "svelte/store";

export const count = writable(0);

export const increment = () => {
  count.update((n) => n + 1);
};

Add.svelte

<script lang="ts">
  import { increment } from "./store";
</script>

<button on:click={increment}> Add </button>

App.svelte中使用store定义的值,需要使用$符号,算是Svelte特色的编译标志:

<script lang="ts">
  import Add from "./lib/Add.svelte";
  import { count } from "./lib/store";

  count.subscribe((value) => {
    console.log(value);
  });
</script>

<main>
  <h1>Vite + Svelte</h1>
  <div class="card">
    <Add />
    <p>
      count is {$count}
    </p>
  </div>
</main>

Svelte v5版本加入了Runes(符文),也就是一组函数式符号,无需额外引入,有点儿像Vue3的宏。普通的代码变成了这样:

<script>
let count = $state(0);
</script>

<button on:click={() => count++}>
  click
</button>
{count}

魔法越来越多。

Qwik

Qwik是由Angular 框架的作者之一Misko Hevery带头开发的一个新型的 Web 框架。Qwik 的目标是提供快速启动交互性,它的一个显著特点是它的可恢复性(惰性执行 JS 操作和无 hydration 操作)。Qwik做到了更优于islands架构的极致性能,无论网站的复杂性如何,它都能够提供快速的页面加载时间。

Misko Hevery大佬在2023年年底发文表示Signals是前端框架的未来,引起前端社区激烈的讨论。Qwik中自然是包含了Signals的:

import { component$, useSignal } from '@builder.io/qwik';

export default component$(() => {
  const count = useSignal(0);

  return (
    <button onClick$={() => count.value++}>
      Increment {count.value}
    </button>
  );
});

这是个官方的示例,与前面的VuePreactSvelte不同的一点是,在JSXDOM里,Qwiksignal并没有用到魔法,仍然写的是count.value。但事实上,不写.value一样可以工作。这是因为DOM只是用来显示,在业务上除了显示signal的值外没有其它的语义,不如加魔法简单明了。

但遗憾的是,QwikSignals不能外置,也就是说不能独立于组件树存在,就像React不能把useStatehooks外置一样。如果我们把代码修改成这个就会报错:

const count = useSignal(0);

export default component$(() => {
  return (
    <button onClick$={() => count.value++}>
      Increment {count}
    </button>
  );
});

报错信息是:

Error: Code(20): Calling a 'use*()' method outside 'component$(() => { HERE })' is not allowed. 'use*()' methods provide hooks to the 'component$' state and lifecycle, ie 'use' hooks can only be called synchronously within the 'component$' function or another 'use' method.
For more information see: https://qwik.builder.io/docs/components/tasks/#use-method-rules

Signals的角度讲,它只是一种依赖订阅的模式,与组件按理说是解耦的。这个帖子有关于『渲染树外状态』的讨论,可能与技术原因(涉及SSR)和兼容React有关,看来一时半会不会有什么变化了。 image.png

那么如何跨组件共享状态呢?那就与React没什么区别了,要么使用Props传递状态给子组件,要么通过上下文隐式传递。详见官方文档,这里就不赘述了。

Angular

早年Angular还是很火的,只是当年叫AngularJS,全面拥抱了TypeScript装饰器与工程化后的是v2以上的版本,现在再说AngularJS,就专指v1了,而Angular则特指v2后的版本。

Angular是大而全的MVC框架,正因为太大太全,有人喜欢就有人不满意,后来市场份额逐渐被ReactVue蚕食,国内用的同学不多了。

我们来看个Demo的目录结构,一个组件代码中拆分出来HTML、CSS、JS/TS 三类单独的文件,与Vue是截然相反的两个极端:

src
|-- app
|   |-- add
|   |   |-- add.component.css
|   |   |-- add.component.html
|   |   |-- add.component.spec.ts
|   |   `-- add.component.ts
|   |-- app.component.css
|   |-- app.component.html
|   |-- app.component.spec.ts
|   |-- app.component.ts
|   |-- app.config.ts
|   |-- app.routes.ts
|   `-- counter
|       |-- counter.component.css
|       |-- counter.component.html
|       |-- counter.component.spec.ts
|       |-- counter.component.ts
|       |-- counter.service.spec.ts
|       `-- counter.service.ts
|-- assets
|-- favicon.ico
|-- index.html
|-- main.ts
`-- styles.css

4 directories, 20 files

时至今日,Angular仍以类组件和装饰器语法为主:

import { Component } from '@angular/core';
import { CounterService } from './counter.service';

@Component({
  selector: 'app-counter',
  standalone: true,
  imports: [],
  templateUrl: './counter.component.html',
  styleUrl: './counter.component.css',
})
export class CounterComponent {
  constructor(private counterService: CounterService) {}

  count = this.counterService.count;

  addCount() {
    this.counterService.increment();
  }
}

Angular提供了一种称为服务(Service)注入的机制,允许跨组件共享服务。这意味着组件可以与服务交互以访问和更新状态,从而实现有效的跨组件通信。

对应到上面的代码里就是CounterService,它的内容如下:

import { Injectable, effect, signal } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class CounterService {
  constructor() {
    effect(() => {
      console.log(`The count is: ${this.count()}`);
    });
  }

  count = signal(0);

  increment() {
    this.count.update((value) => value + 1);
  }
}

这种数据共享,本质上是将Service单例化了。任何依赖注入这个Service的组件都可以非常容易拿到数据,或者进行变更。

所以,Angular天然就不需要状态管理库,除非你需要预测性、持久化、调试工具等复杂的功能,那些就不在本文讨论范围内了。

需要指出的是,上述例子使用了signal,也就是说,Angular在最近的v17版本里也加入了Signal以优化性能。其中,counter.component.html使用这个signal来显示数值:

<p>{{count()}}</p>

如果不使用signal,那么Service从写法上其实更简单:

export class CounterService {
  count = 0;

  increment() {
    this.count += 1;
  }
}

在组件的构建函数中依赖注入CounterService时,将之声明为public

export class CounterComponent {
  constructor(public counterService: CounterService) {}

  addCount() {
    this.counterService.increment();
  }
}

这样,counter.component.html就能直接使用counterService,随着count的变化,页面也会响应式变化:

<p>{{counterService.count}}</p>

只不过原来这种处理依赖于Angular的脏检查机制,性能必然不如可以细粒度更新DOMSignals

React

React如果不使用状态管理库,怎么解决跨层级组件之间的状态共享和状态更新?常见的思路还是上面说的,使用Props传递状态给子组件,或者通过上下文(Context)隐式传递。

秀点的话,就是可以手撸一个简版的状态管理库。因为React开放了useSyncExternalStore API,所以较以前容易了许多。从名字上看,就知道这是API可以使用外部Store。

以前转发的这篇神光大佬的《手写一个 zustand,只要60行》,大家可以看看,核心处理很简单。

我这里也来一个jotai的:

import { useSyncExternalStore } from "react";

type Listener = () => void;
type UnListener = () => void;

type SimpleAtomValue = string | number | boolean | null | undefined;
type AtomValue =
  | SimpleAtomValue
  | SimpleAtomValue[]
  | Record<string, Exclude<any, Function>>;

type SetStateAction<Value> = Value | ((prev: Value) => Value);
type SetState<Value> = (newState: SetStateAction<Value>) => void;

interface Atom<T> {
  getSnapshot(): T;
  setState: SetState<T>;
  subscribe(listener: Listener): UnListener;
}

export function atom<T extends AtomValue>(initialState: T): Atom<T> {
  let state = initialState;
  let listeners = new Set<Listener>();
  const setState: SetState<T> = (newState) => {
    if (typeof newState === "function") {
      state = newState(state);
    } else {
      state = newState;
    }
    listeners.forEach((listener) => listener());
  };
  return {
    getSnapshot: () => state,
    setState,
    subscribe(listener: Listener) {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
  };
}

export function useAtom<T extends AtomValue>(atom: Atom<T>): [T, SetState<T>] {
  const state = useSyncExternalStore(atom.subscribe, atom.getSnapshot);
  return [state, atom.setState];
}

使用起来:

import { useAtom, atom } from "./utils";

const counter = atom<number>(0);

function Counter() {
  const [count] = useAtom(counter);
  return <span>count is {count}</span>
}

function Add() {
  const [, setCount] = useAtom(counter);
  return <button onClick={() => setCount((count) => count + 1)}>Add</button>
}

atom<number>(0)加了泛型,是因为类型推断的有问题,一时不知道怎么优化了,有解决方案的同学请留言,感谢: image.png

还有个国内大佬撸的 resso 整篇TypeScript也只有149行,与MobxValtio类似,是Proxy风格的,有兴趣的可以学习下:

import resso from 'resso';

const store = resso({ count: 0, text: 'hello' });

function App() {
  const { count } = store; // data used in UI → must destructure at top first 🥷
  return (
    <>
      {count}
      <button onClick={() => ++store.count}>+</button>
    </>
  );
}

值得一提的是,React这种Signals风格的状态库,无论是MobxValtiouse-signals等,虽然做到了响应式更新,但粒度仍是组件级别,会重复执行函数组件,也就是说,现在的版本大概率都是用useSyncExternalStore来处理的更新,没有脱离React的范畴。而Preact提炼出的@preact/signals-reactHeluxSignal (应该还有其它的库)则跳过了这个机制(虽然也用到了useSyncExternalStore),直接将数据绑定到视图,实现DOM粒度更新。用React的说法就是不符合React哲学,你用可以,但出了事情我不管~

总结

最近,我将一个Vue3项目的状态管理库Pinia移除,改为直接使用Vue3ref。原因是Vue3本身就能解决跨层级组件的状态共享和更新问题,无需额外的状态管理库。Pinia的一些高级功能如时间旅行和服务端渲染我也没用到。

从这个案例出发,我探讨了各大前端框架是否有必要使用状态管理库。

各大框架的情况:

  1. Preact:引入了Signals,类似于Vue3的响应式系统,可以实现细粒度的DOM更新,不需要额外的状态管理库。
  2. SolidJS:完全拥抱Signals,状态也可以外置。
  3. Svelte:通过writablereadable stores共享状态,v5增加了Runes,更简化了状态管理。
  4. Qwik:支持Signals,但Signals不能独立于组件存在,状态共享需要通过Props或上下文传递。
  5. Angular:天然不需要状态管理库,通过服务(Service)注入机制共享状态,v17版加入了Signals优化性能。
  6. React:通过Props和上下文传递状态,可以手撸简易的状态管理库,如果要使用@preact/signals-reactHeluxSignal等跳过了React渲染机制以细粒度更新DOM的话,需要注意有一定风险。

综上,各大框架各有实现状态管理的方式,Vue3PreactSolidJSSvelteSignals的拥趸由于Signals可以独立于组件外,Angular可以通过服务共享状态,所以不需要使用状态库,而Qwik这个例外只能老老实实使用Props或上下文传递状态,至于React,因为暴露了useSyncExternalStore这个API,封装一个简版状态库也非常容易,当然,建议你老老实实使用成熟的库。