前段时间,将某个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
中更高级的功能诸如时间旅行、服务端渲染我都没用上,何必为了一点儿醋买盘饺子呢?
有了这个案例后,我便有个疑问,各大框架有没有必要使用状态管理库?也就是说,如果没有状态管理库,各大框架如何跨组件共享状态?下来我们将罗列当前流行的前端框架来一一看下,分别是Preact
、SolidJS
、Svelte
、Qwik
、Angular
,最后是React
。
Preact
Preact
是React
的轻量化替代方案,仅有 3KB。
诞生之初,它只是一个手撸React
核心原理的学习仓库,用最少的代码实现了React
的基本功能,而从2015年迭代到今天的v10
版本,Preact
除了高仿了React
的核心能力,还引入了Signals
(信号)来提高性能。它的API
看起来与Vue
最为相似,这篇文章《信仰崩了?Preact 开始采用 Vue3 的响应式设计》吐嘈的很有意思,推荐大家看下。
这几年Signals
成为前端备受关注的话题,许多大佬为之发声,认为是前端框架的未来。Signals
是一个在访问时跟踪依赖、在变更时触发副作用的值容器。其核心特点是自动状态绑定(开发简单)和依赖跟踪(细粒度订阅与更新,最小化重复渲染,保障性能)。
从某种程度上讲,Vue3
的响应式更新本身就是Signals
的一种实现。而目前很多框架也拥抱了Signals
,除了Preact
外,还有Svelte v5
、Solid.js
、Angular
、Qwik
等。
并不是所有实现了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)
与Vue3
的ref(0)
别无二致。在DOM模板里同样不需要加.value
,但在JS
调用时都需要.value
,没有再加一层魔法,符合JS
直觉。
在实际开发中,将signal
的实例放在单独的文件里导出,就与最开始的Vue3
的示例一样了。
使用了
Signals
的好处是,可以细粒度更新DOM
元素,也就是说,当Signals
发生变化时,只有相应的DOM
会更新,并不会触发整个函数组件的重新执行。这种级别的响应式编程,对于一直高仿React
的Preact
来说,应该是个违背祖训的决定,有投奔敌营的嫌疑?
与Vue3
稍有不同,准确地说,Preact
的signal
对标的是Vue3
的shallowRef
,因为它只是个浅层响应。比如,我们将数据从数字修改为一个对象,在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
还有computed
、effect
、batch
等API和useSignal
和useComputed
这两个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
的返回值与React
的useState
比较相似,不过数组第一项count
并不是具体的值,而是个函数,所以在DOM
中使用时是count()
;而第二项setCount
与useState
的别无二致。
SolidJS
在官方文档里是这样内涵React
的:
在不考虑更新的情况下构建组件已经够难的了。Solid 的组件更新是彼此完全独立的。组件函数被调用一次,然后就不再存在。组件的存在是为了组织你的代码,而不是其他。
Svelte
Svelte
与SolidJS
一样,是一款编译时框架,它在构建时将应用程序转换为高效的JavaScript
。
Svelte
提供了一种轻量级的方式来共享状态,通过writable
和readable
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>
);
});
这是个官方的示例,与前面的Vue
、Preact
、Svelte
不同的一点是,在JSX
的DOM
里,Qwik
的signal
并没有用到魔法,仍然写的是count.value
。但事实上,不写.value
一样可以工作。这是因为DOM只是用来显示,在业务上除了显示signal
的值外没有其它的语义,不如加魔法简单明了。
但遗憾的是,Qwik
的Signals
不能外置,也就是说不能独立于组件树存在,就像React
不能把useState
等hooks
外置一样。如果我们把代码修改成这个就会报错:
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
有关,看来一时半会不会有什么变化了。
那么如何跨组件共享状态呢?那就与React
没什么区别了,要么使用Props
传递状态给子组件,要么通过上下文隐式传递。详见官方文档,这里就不赘述了。
Angular
早年Angular
还是很火的,只是当年叫AngularJS
,全面拥抱了TypeScript
装饰器与工程化后的是v2
以上的版本,现在再说AngularJS
,就专指v1
了,而Angular
则特指v2
后的版本。
Angular
是大而全的MVC
框架,正因为太大太全,有人喜欢就有人不满意,后来市场份额逐渐被React
和Vue
蚕食,国内用的同学不多了。
我们来看个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
的脏检查机制,性能必然不如可以细粒度更新DOM
的Signals
。
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)
加了泛型,是因为类型推断的有问题,一时不知道怎么优化了,有解决方案的同学请留言,感谢:
还有个国内大佬撸的 resso 整篇TypeScript
也只有149
行,与Mobx
和Valtio
类似,是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
风格的状态库,无论是Mobx
、Valtio
、use-signals
等,虽然做到了响应式更新,但粒度仍是组件级别,会重复执行函数组件,也就是说,现在的版本大概率都是用useSyncExternalStore
来处理的更新,没有脱离React
的范畴。而Preact
提炼出的@preact/signals-react
和Helux
的 Signal (应该还有其它的库)则跳过了这个机制(虽然也用到了useSyncExternalStore
),直接将数据绑定到视图,实现DOM
粒度更新。用React
的说法就是不符合React
哲学,你用可以,但出了事情我不管~
总结
最近,我将一个Vue3
项目的状态管理库Pinia
移除,改为直接使用Vue3
的ref
。原因是Vue3
本身就能解决跨层级组件的状态共享和更新问题,无需额外的状态管理库。Pinia
的一些高级功能如时间旅行和服务端渲染我也没用到。
从这个案例出发,我探讨了各大前端框架是否有必要使用状态管理库。
各大框架的情况:
- Preact:引入了
Signals
,类似于Vue3
的响应式系统,可以实现细粒度的DOM更新,不需要额外的状态管理库。 - SolidJS:完全拥抱
Signals
,状态也可以外置。 - Svelte:通过
writable
和readable
stores共享状态,v5
增加了Runes
,更简化了状态管理。 - Qwik:支持
Signals
,但Signals
不能独立于组件存在,状态共享需要通过Props
或上下文传递。 - Angular:天然不需要状态管理库,通过服务(
Service
)注入机制共享状态,v17
版加入了Signals
优化性能。 - React:通过
Props
和上下文传递状态,可以手撸简易的状态管理库,如果要使用@preact/signals-react
和Helux
的Signal
等跳过了React
渲染机制以细粒度更新DOM
的话,需要注意有一定风险。
综上,各大框架各有实现状态管理的方式,Vue3
、Preact
、SolidJS
、Svelte
等Signals
的拥趸由于Signals
可以独立于组件外,Angular
可以通过服务共享状态,所以不需要使用状态库,而Qwik
这个例外只能老老实实使用Props
或上下文传递状态,至于React
,因为暴露了useSyncExternalStore
这个API,封装一个简版状态库也非常容易,当然,建议你老老实实使用成熟的库。