
我修好了我的博客——如何设计一个全新的博客框架
作者分享了个人博客框架迭代历程:从Hexo迁移到Astro的尝试,到发现Elog平台带来的启发,最终思考将Notion作为内容管理系统的新方案。文章重点描述了作者对博客系统的核心需求——解决创作同步问题,以及在探索NotionNext等解决方案过程中遇到的技术限制和思考。
本文探讨了redux的发布-订阅模式的实现,强调其与原生useContext和useReducer的区别。redux通过subscription实例实现按需刷新,避免无关组件的更新。文章详细介绍了发布-订阅模式的基本概念、与观察者模式的对比,以及redux中的实现,包括Provider组件和Hook API的用法,如useSelector和useDispatch等,展示了redux如何解耦组件与状态更新的过程。
在研究原生的useContext
和useReducer
方案时我就感到非常疑惑,数据对象更新导致的组件刷新是由react控制的,这导致了在状态增多后会产生意外刷新的问题。
但是redux明明采用了相近的思路,是如何避免整个对象刷新带来的意外组件更新的呢?
redux的核心就是发布-订阅模式,我们回忆一下redux的基本结构和流程:
可以看见,问题的关键就在于订阅了状态的组件按需刷新这一点,前两点通过原生的useContext
+ useReducer
其实都可以实现,但只有redux能实现按需刷新,不会刷新无关组件。
那这是怎么做到的呢?我们先从发布-订阅模式入手,看看redux是如何实现该模式的。
发布-订阅模式天然具有两个对象:发布者和订阅者,比如在视频网站中,up主就是发布者,关注了up的用户就是订阅者,up主发布视频后关注了该up的所有人都会收到推送。
利用OOP,我们可以定义以下两个接口来表示发布者和订阅者需要实现的基础能力。
// 订阅者
interface ISubscribe {
// 注册对IPublish实例的监听,并添加事件处理函数
subscribe: (publisher: IPublish, callback: (payload: ...any) => void) => void;
// 取消对IPublish实力的监听
cancel: (publisher: IPublish) => void;
}
// 发布者
interface IPublish {
// 通知所有订阅了该发布者的订阅者实例
notify: (message: ...any) => void;
}
在具体实现方面,订阅者比较简单,只需要向发布者注册和注销监听即可;发布者一方可以维护一个线性表来保存所有订阅了该消息的订阅者实例。具体实现上可以参考下面的(伪)代码:
class Publisher implements IPublish {
subscribers: Map<ISubscribe, (...any) => void> = new Map();
addListener(subscriber: ISubscribe, callback?: (...any) => void) {
subsicribers[subscriber] = callback;
}
removeListener(subscriber: ISubscriber) {
// remove
}
notify(...message: any) {
subscribers.values.forEach(fn => fn(message));
}
}
上面的代码中我们创建了一个哈希表来存储订阅者,主要是为了方便管理订阅者(添加和删除),在发送消息时直接调用表中存储的回调来通知订阅者,实现了一个最基础的发布-订阅模式。
上面的实现看起来一眼观察者模式对吧,我们的Publisher就是Subject(被观察者),Subscriber就是Observer(观察者),Publisher状态变化时就会去主动通知Subscriber,就像下面这样。
但真正的发布-订阅模式不是这样的,与观察者模式直接相连的方式不同,发布-订阅模式最直观的区别就是发布者和订阅者之间维护了一层类似消息队列的东西,负责分发消息,像下面这样。
还是拿刚刚B站视频订阅举例子,在up更新视频后给观众推送订阅消息时,并不是up自己主动地给每个关注了自己的人发消息,而是告诉了一个中间人(broker)“我更新了视频”,然后这个中间人再向订阅了这个消息的人推送这条消息“xx up更新了视频,快来看吧”。
虽然它们的结果是相同的,但他们中间的实现过程有着最本质的区别:发布者和订阅者之间完全不需要有直接联系,是完全解耦的,而不像观察者模式一样是松耦合的(基于接口抽象)。
因此我们上面的例子可以说完全不是发布-订阅模式,只是观察者模式;而接口描述只是描述了这个模型的能力,而二者又是几乎相同的。
在应用场景上,二者虽然结果相同,但面向的应用场景和规模是不同的:
在redux中,我们把整个更新机制看作发布-订阅模式的实现,其中dispatch action的一方看作发布者,mapState使用状态的一方看作订阅者。二者是完全解耦的,符合定义要求。
react-redux是redux与react的视图连接层,负责将redux store中的数据更新传递给React,以准确更新UI。
在使用 connect()
的经典react-redux模型中,观察和订阅的实现要稍微复杂一点。准确地说,redux中的Subscription
实例既可以发布,也可以订阅。
Subscription
,并在它们更新时得到通知在上面的流程图中有一个小细节需要注意,那就是Subscription
的来源:
Provider
组件的Context时会自动生成一个Subscription
实例,它包含了我们的store,并使用redux store的subscribe(callback)
订阅了最顶层的消息,即在整个store发生更新时得到通知,其逻辑和其他层级的subscribe
是一致的connect()
包裹组件时,它除了生成一个HOC外还会生成一个Subscription
实例,它将订阅当前Context中的Subscription
对象,并更新当前Context的Subscription
对象为自己为什么要选择嵌套的Provider结构?确保更新一层一层地传递,而不是直接更新整个树
以下是简化后的subscription实现代码:
// Subscriotion.js
const nullListeners = { notify() {} };
// 监听集合是一个双向链表
function createListenerCollection() {
// 也就是React里的unstable_batchedUpdates
// 来自司徒正美微博:unstable_batchedUpdates会把子组件的forceUpdate干掉,防止组件在一个批量更新中重新渲染两次
const batch = getBatch();
let first = null;
let last = null;
return {
clear() {
first = null;
last = null;
},
// 通知订阅者更新
notify() {
batch(() => {
let listener = first;
while (listener) {
// 这个callback的本质就是让组件本身forceUpdate
listener.callback();
listener = listener.next;
}
});
},
// 订阅
subscribe(callback) {
let isSubscribed = true;
// 把last赋值为新的
let listener = (last = {
callback,
next: null,
prev: last
});
// 如果存在前一个,就把前一个的next指向当前(最后一个)
if (listener.prev) {
listener.prev.next = listener;
} else {
// 否则它就是第一个
first = listener;
}
// 返回退订函数
return function unsubscribe() {
// ...退订逻辑
};
}
};
}
export default class Subscription {
constructor(store, parentSub) {
// redux store
this.store = store;
// 父级的Subscription实例
this.parentSub = parentSub;
// 退订函数
this.unsubscribe = null;
// 监听者
this.listeners = nullListeners;
this.handleChangeWrapper = this.handleChangeWrapper.bind(this);
}
// 添加嵌套的订阅者
addNestedSub(listener) {
// 首先先将当前的Subscription实例绑定到父级
// 绑定的同时会初始化listeners
this.trySubscribe();
return this.listeners.subscribe(listener);
}
// 通知子级
notifyNestedSubs() {
this.listeners.notify();
}
// 当父级Subscription的listeners通知时调用
handleChangeWrapper() {
// 这个是new出实例的时候加上的,感觉有点秀
if (this.onStateChange) {
this.onStateChange();
}
}
trySubscribe() {
// 不会重复绑定
if (!this.unsubscribe) {
this.unsubscribe = this.parentSub
? this.parentSub.addNestedSub(this.handleChangeWrapper)
: // subscribe是redux里的方法,在redux state改变的时候会调用
this.store.subscribe(this.handleChangeWrapper);
// 创建新的listeners,每个connect的组件都会有listeners
this.listeners = createListenerCollection();
}
}
// 退订
tryUnsubscribe() {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
this.listeners.clear();
this.listeners = nullListeners;
}
}
}
<Provider>
组件Provider只实现了两个简单的功能:
Subscription
实例,订阅redux store的消息事件Subscription
实例和store的Context,供其包裹的组件使用状态其简单的代码实现如下:
// components/Provider.js
function Provider({ store, context, children }) {
// useMemo仅在store变化时再重新返回
const contextValue = useMemo(() => {
const subscription = new Subscription(store);
// 通知订阅这个subscription的子级刷新
subscription.onStateChange = subscription.notifyNestedSubs;
return {
store,
// 将此subscription传入context方便子级订阅
subscription
};
}, [store]);
// 缓存上次的state
const previousState = useMemo(() => store.getState(), [store]);
useEffect(() => {
const { subscription } = contextValue;
// 在这里是订阅的reudx store的subscribe事件
subscription.trySubscribe();
if (previousState !== store.getState()) {
subscription.notifyNestedSubs();
}
return () => {
subscription.tryUnsubscribe();
subscription.onStateChange = null;
};
}, [contextValue, previousState, store]);
// 传入的context或者react-redux自带的
const Context = context || ReactReduxContext;
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
我们关注到以下细节:
ContextValue
其实包含了store
和Subscription实例,而Subscription实例内部的store只是用于订阅store的改变事件connect
API 与 HOCconnect
API几乎已经被废弃了,官方也推荐使用Hook API替代。但connect
API仍然是可用的,而且理解它对理解redux的原理也是有帮助的。在没有 connect
的情况下,我们可以通过 subscribe
和redux手动产生连接,不过这种方式下store通过dispatch产生的任何更改都会通知所有订阅者,我们需要手动处理数据选择和渲染优化等问题,代码如下:
import { store } from 'src/store'
export default classs Counter extends React.Component {
constructor(props){
super(props);
this.state = {
counter: store.getState().counter,
}
;
componentDidMount(){
this.unsubscribue = store.subscribe(() => {
this.setstate({
counter: store.getState().counter,
);
});
}
componentWilillUnmount(){
this.unsubscribue();
}
render() {
return (
<div>
<h2>counter:{this.state.counter}</h2>
<button onClick={()=>this.increment()}>+1</button>
<button onClick={()=>this.decrement()}>-1</button>
</div>
)
}
increment(){
store.dispatch(increment());
}
decrement(){
store.dispatch(decrement());
}
}
connect()
函数的作用就是生成一个高阶组件(HOC)来包裹要调用store的组件,来自动化刚刚所提到的订阅操作,这个HOC主要负责以下工作:
将state和dispatch(mapDispatchToProps
)写入被包裹组件的props中,以便组件调用
mapStateToProps(state, ownProps) ⇒ nextProps
:将state和组件props组合成新的propsmapDispatchToProps(actions) => props
:决定将哪些action传给组件将store更新和视图更新解耦,判断当前组件是否需要刷新,避免不需要的刷新
notify
通知所有订阅了它的Subscription实例,并触发onStateChange
回调Connect组件中checkForUpdates
用于处理onStateChange
回调事件,它负责处理数据的刷新,判断当前组件是否需要刷新
实际上 connect()
也是使用了 subscribe()
API,并且打包了如 selector 等特性,一个简化版的实现如下代码所示(并没有实现渲染优化):
import { StoreContext } from './context';
// 定义一个connect函数,并接收两个参数
export const connect = function (mapStateToProps, mapActionToProps) {
// 返回值是一个函数,接收一个组件,并返回一个组件
return function (Cpn) {
class WraperCpn extends PureComponent {
static contextType = storeContext;
constructor(props, context) {
super(props, context);
// 在组件创建的时候,初始化组件状态数据
this.storeState = mapStateToProps(context);
}
componentDidMount() {
// 在组件挂载后,订阅redux中的数据
this.unsubscribe = this.context.subscribe(() => {
this.setState({
storeState: mapStateToProps(this.context)
})
})
}
componentWillUnmount() {
// 在组件销毁的时候,取消组件订阅的redux
this.unsubscribe();
}
render() {
return <Cpn
{...this.props}
{...mapStateToProps(this.context)}
{...mapActionToProps(this.context.dispatch)}></Cpn>
}
}
return WraperCpn;
}
}
在Hook API这边,具体实现则完全不同,没有想 connect()
那样手写 subscribe + setState()
,而是使用了一个原生Hook useSyncExternalStore
。
useSyncExternalStore
这是一个React 18引入的新API,在此前可以用过 use-sync-external-store
这个包来调用。
其作用就是与外部的「发布-订阅模式」的store实例产生联结,其API如下:
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
function TodosApp() {
const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);
// ...
}
它可以和任何外部实现了对应方法的数据对接,最主要的场景之一就是和store对接,除了react-redux,其他库如zustand等也使用了这个api。
在redux中使用这个Hook而不是直接使用 subscribe
和 setState
的主要原因有两个:
setState
操作的更新是异步并发的,并可能被时间片切段,这导致了「状态撕裂」问题connect()
的HOC带来的嵌套Subscription结构,使用异步API导致的状态撕裂会进一步加剧stale-props的问题从上面的场景也可以看出,这个hook并非 setState
的简单语法糖,而是着眼于 sync
这个关键词,深入到reconcile过程中去解决React18状态异步更新带来的「撕裂」问题,尽量让整个页面获得一致的props。
这里就不分析源码了,着重讲讲在reconcile的每个过程中这个hook做了什么:
为什么 connect()
API 可以直接获取
useSelector
const result: any = useSelector(selector: Function, equalityFn?: Function)
selector Hook的底层实现是基于上述的 useSyncExternalStorage
,在概念上与connect中的mapStateToProps
类似,都是按需获取store中的部分状态。
mapStateToProps
一样,useSelector
会在store更新后触发运行,但在订阅的数据没有更新时不会触发组件刷新,而是使用缓存值由于useSelector
是一个Hook,它也具有一些mapState
不具备的特性
useSelector
采用严格引用相等来判断更新,因此建议对每一个原子状态单独设置selector而不是合成在一个对象中,在react-redux v7中使用react批量更新会将这些Selector放在一起执行(只会执行一次)useDispatch
const dispatch = useDispatch()
useDispatch
相对于useSelector
更加简单,它只是返回store.dispatch
。
只要传入Provider的store没有改变,dispatch的函数引用地址就应该是稳定不变的,但useMemo
和useCallback
这样的记忆化Hook还是会要求把dispatch
放进deps数组中。
useStore
在任何时候都应该尽量避免使用这个Hook来获取状态
const store = useStore()
这个 hook 返回一个 Redux store 引用,该 store 与传递给 <Provider>
组件的 store 相同。
不应该频繁使用这个 hook。宁愿将 useSelector()
作为主要选择。然而,对于少量需要访问 store 的场景而言,例如替换 reducer,这个 hook 很有用。
作者: ChlorineC
创建于: 2023-08-23 16:53:00
更新于: 2025-01-11 21:00:00
版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
作者分享了个人博客框架迭代历程:从Hexo迁移到Astro的尝试,到发现Elog平台带来的启发,最终思考将Notion作为内容管理系统的新方案。文章重点描述了作者对博客系统的核心需求——解决创作同步问题,以及在探索NotionNext等解决方案过程中遇到的技术限制和思考。
本文探讨了前端开发的复杂性,提倡回归以后端为中心的架构,强调使用简单的工具和技术,如模板引擎、jQuery、alpine.js和htmx,来简化开发流程。作者认为,现代前端工具链的复杂性逐渐偏离了简化开发的初衷,建议在不引入过多复杂性的情况下,利用Web Components等标准技术来实现可复用组件,从而提高开发效率。
本文总结了作者在前端开发领域的学习与成长历程,从大二的项目学习到2023年的实习经历,强调了通过项目实践和面试驱动学习的重要性。作者探讨了前端技术的广度与深度,认为前端并未“死亡”,而是面临着技术的两极分化,未来的发展方向包括拓展Web技术栈和探索全栈开发。文章还提出了大前端技术栈的横向与纵向拓展策略,鼓励前端工程师不断学习与适应新技术。
在React中,useRef和useEffect常用于管理组件的可变值和副作用,但在响应DOM元素挂载时,使用useCallback作为ref的回调函数更为有效。useCallback确保在元素挂载和卸载时被调用,避免了因useRef导致的生命周期回调未触发的问题。文中还介绍了其他几种优化Node操作的方法,如使用useState、useStateRef和useRefWithCallback,以提高性能和管理复杂性。