使用原生 JavaScript 构建状态管理系统
原文通过一个完成清单示例,使用 Pub/Sub、Proxy、Store、actions、mutations 和组件基类构建状态管理系统。这个练习仍然适合学习设计模式;但它不应替代成熟的 Redux、MobX、Vuex 或 Pinia。本文尽可能保留原来的项目结构,同时修正原代码中的事件取消、深层状态、文件名、异步 action 和模块化问题。
一、为什么需要状态管理
小型页面可以把状态放在组件内部;当多个组件需要共享同一份状态时,如果直接把状态挂到 DOM、window 或互相调用,就会产生隐式依赖和难以追踪的更新。
一个简单状态系统通常包含:
- 单一来源:状态集中保存;
- 读取:组件从 store 读取当前状态;
- 写入协议:通过 mutation/action 或命名方法改变状态;
- 订阅:状态变化后通知视图;
- 调试边界:知道是谁、何时、为什么改变了状态。
“状态优先”不等于所有数据都必须放进全局 store。局部表单、弹窗开关和临时输入仍然更适合放在组件内部。
二、项目目标
我们构建一个不依赖 Vue、React 或打包器的完成清单:
用户提交表单
↓
store.dispatch('addItem')
↓
action 调用 store.commit()
↓
mutation 修改 state
↓
Pub/Sub 发布 stateChange
↓
多个组件重新 render
浏览器原生 ES Module 需要使用:
<script type="module" src="./src/js/main.js"></script>
直接双击 index.html 可能遇到模块 CORS 或文件协议限制,应通过本地 HTTP 服务器运行。Proxy 也不支持 IE11,不能通过一个简单 polyfill 完整补齐 Vue 3 所需的 Proxy 语义。
三、发布/订阅 Pub/Sub
Pub/Sub 允许一部分代码订阅具名事件,另一部分代码发布事件并传递 payload。餐厅中“前菜清理完成”的通知可以看成一个事件:多个厨师订阅同一事件,在收到通知后分别准备主菜。
原文使用数组存储回调,这可以工作,但没有取消订阅能力,组件反复创建和销毁后容易产生重复回调。下面使用 Set 并返回 unsubscribe 函数:
// src/js/lib/pubsub.js
export default class PubSub {
constructor() {
this.events = Object.create(null)
}
subscribe(event, callback) {
if (!this.events[event]) {
this.events[event] = new Set()
}
this.events[event].add(callback)
return () => {
this.events[event]?.delete(callback)
}
}
publish(event, data) {
const subscribers = this.events[event]
if (!subscribers) return []
// 复制 Set,避免回调执行期间取消订阅影响当前遍历
return [...subscribers].map(callback => callback(data))
}
}
Object.create(null) 避免事件名 constructor、toString 等与原型属性冲突。更大的系统还需要事件命名约定、异常隔离、日志和类型约束。
四、Store 核心对象
4.1 为什么原来的浅层 Proxy 不够
原文只给 state 外层 Proxy 增加 set:
state.items.push(item)
这类嵌套修改发生在 items 数组上,未必会触发外层 state 的 set,因此组件可能收不到 stateChange。此外,原文的 commit 通过 Object.assign(self.state, newState) 合并状态,但示例 mutation 又返回整个 state,边界不清晰。
下面的教学实现递归代理嵌套对象/数组,并在 mutation 期间批量通知一次:
// src/js/store/store.js
import PubSub from '../lib/pubsub.js'
function isObject(value) {
return value !== null && typeof value === 'object'
}
function createDeepProxy(value, onChange, cache = new WeakMap(), path = []) {
if (!isObject(value)) return value
if (cache.has(value)) return cache.get(value)
const proxy = new Proxy(value, {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
return isObject(result)
? createDeepProxy(result, onChange, cache, [...path, key])
: result
},
set(target, key, nextValue) {
const previousValue = target[key]
const success = Reflect.set(target, key, nextValue, target)
if (success && !Object.is(previousValue, nextValue)) {
onChange({
type: 'set',
path: [...path, key],
previousValue,
nextValue,
})
}
return success
},
deleteProperty(target, key) {
const existed = Object.prototype.hasOwnProperty.call(target, key)
const previousValue = target[key]
const success = Reflect.deleteProperty(target, key)
if (success && existed) {
onChange({
type: 'delete',
path: [...path, key],
previousValue,
})
}
return success
},
})
cache.set(value, proxy)
return proxy
}
export default class Store {
constructor(params = {}) {
this.actions = params.actions || Object.create(null)
this.mutations = params.mutations || Object.create(null)
this.events = new PubSub()
this.status = 'resting'
this.committing = 0
this.changes = []
this.state = createDeepProxy(
params.state || {},
change => this.handleStateChange(change),
)
}
subscribe(event, callback) {
return this.events.subscribe(event, callback)
}
handleStateChange(change) {
this.changes.push(change)
if (this.committing > 0) return
console.warn('请通过 mutation 修改 state')
this.publishStateChange()
}
publishStateChange() {
const changes = this.changes.splice(0)
this.events.publish('stateChange', {
state: this.state,
changes,
})
}
commit(mutationKey, payload) {
const mutation = this.mutations[mutationKey]
if (typeof mutation !== 'function') {
console.error(`Mutation "${mutationKey}" 不存在`)
return false
}
this.status = 'mutation'
this.committing++
try {
// mutation 直接修改 state,不需要返回并重新 assign 整个 state
return mutation(this.state, payload)
} finally {
this.committing--
if (this.committing === 0) {
this.publishStateChange()
this.status = 'resting'
}
}
}
dispatch(actionKey, payload) {
const action = this.actions[actionKey]
if (typeof action !== 'function') {
console.error(`Action "${actionKey}" 不存在`)
return false
}
this.status = 'action'
const result = action(this, payload)
if (result && typeof result.then === 'function') {
return result.finally(() => {
this.status = 'resting'
})
}
this.status = 'resting'
return result
}
}
这个 Store 是学习用实现,不是 Vue 的响应式系统:
- 它没有依赖收集和组件级更新;
- 只有订阅
stateChange的组件会重新 render; - Proxy 深度代理仍有运行时成本;
- 同一对象通过多个路径访问时,路径记录等边界需要更完善的实现;
- 生产环境还要考虑严格模式、异常、时间旅行、持久化和 SSR 请求隔离。
4.2 action、mutation 和 state
目录结构:
src/js/
├── components/
│ ├── count.js
│ ├── list.js
│ └── status.js
├── lib/
│ ├── component.js
│ └── pubsub.js
├── store/
│ ├── actions.js
│ ├── index.js
│ ├── mutations.js
│ ├── state.js
│ └── store.js
└── main.js
原文一处创建的是 mutation.js,另一处导入 mutations.js,文件名会导致模块加载失败。统一为 mutations.js。
// src/js/store/state.js
export default {
items: [
'I made this',
'Another thing',
],
}
// src/js/store/mutations.js
export default {
addItem(state, item) {
state.items.push(item)
},
clearItem(state, { index }) {
state.items.splice(index, 1)
},
}
mutation 应保持同步、可预测,并集中描述状态如何改变。异步请求、重试和业务编排放在 action:
// src/js/store/actions.js
export default {
addItem(store, item) {
store.commit('addItem', item)
},
async addItemFromServer(store, item) {
const response = await fetch('/api/items', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ item }),
})
const savedItem = await response.json()
store.commit('addItem', savedItem)
},
clearItem(store, payload) {
store.commit('clearItem', payload)
},
}
// src/js/store/index.js
import actions from './actions.js'
import mutations from './mutations.js'
import state from './state.js'
import Store from './store.js'
export default new Store({
actions,
mutations,
state,
})
这套 action/mutation 约定类似 Vuex,但它不是 Vuex,也没有 Vuex 的模块、插件、devtools 和严格模式。
五、组件基类和订阅取消
原文组件基类在构造函数中订阅 stateChange,但没有取消订阅。修正版保存 unsubscribe 函数:
// src/js/lib/component.js
export default class Component {
constructor({ store, element } = {}) {
this.element = element
this.render = this.render || (() => {})
this.unsubscribe = store
? store.subscribe('stateChange', () => this.render())
: () => {}
}
destroy() {
this.unsubscribe()
}
}
5.1 清单组件
// src/js/components/list.js
import Component from '../lib/component.js'
import store from '../store/index.js'
export default class List extends Component {
constructor() {
super({
store,
element: document.querySelector('.js-items'),
})
}
render() {
if (store.state.items.length === 0) {
this.element.innerHTML = '<p class="no-items">还没有完成事项</p>'
return
}
this.element.innerHTML = `
<ul class="app__items">
${store.state.items.map((item, index) => `
<li>
<span>${escapeHtml(item)}</span>
<button data-index="${index}" aria-label="删除此事项">×</button>
</li>
`).join('')}
</ul>
`
this.element.querySelectorAll('button').forEach(button => {
button.addEventListener('click', () => {
store.dispatch('clearItem', {
index: Number(button.dataset.index),
})
})
})
}
}
function escapeHtml(value) {
return String(value).replace(/[&<>'"]/g, character => ({
'&': '&',
'<': '<',
'>': '>',
"'": ''',
'"': '"',
})[character])
}
原文直接把用户输入插入 innerHTML,会产生 XSS 风险。实际项目优先使用 textContent 创建文本节点,或像示例一样进行可靠转义;不要把未经清洗的用户输入拼进 HTML。
5.2 数量和状态组件
// src/js/components/count.js
import Component from '../lib/component.js'
import store from '../store/index.js'
export default class Count extends Component {
constructor() {
super({
store,
element: document.querySelector('.js-count'),
})
}
render() {
const count = store.state.items.length
this.element.textContent = `已完成 ${count} 项`
}
}
// src/js/components/status.js
import Component from '../lib/component.js'
import store from '../store/index.js'
export default class Status extends Component {
constructor() {
super({
store,
element: document.querySelector('.js-status'),
})
}
render() {
this.element.textContent = store.state.items.length === 0
? '暂无事项'
: `${store.state.items.length} 项已完成`
}
}
5.3 入口文件
// src/js/main.js
import store from './store/index.js'
import Count from './components/count.js'
import List from './components/list.js'
import Status from './components/status.js'
const formElement = document.querySelector('.js-form')
const inputElement = document.querySelector('#new-item-field')
const count = new Count()
const list = new List()
const status = new Status()
count.render()
list.render()
status.render()
formElement.addEventListener('submit', event => {
event.preventDefault()
const value = inputElement.value.trim()
if (!value) return
store.dispatch('addItem', value)
inputElement.value = ''
inputElement.focus()
})
window.addEventListener('beforeunload', () => {
count.destroy()
list.destroy()
status.destroy()
})
六、这个实现和 Vue/Pinia 有什么关系
原文使用 Proxy 和 Pub/Sub,可以帮助理解“状态变化—通知视图”的基本链路,但 Vue 3 的响应式系统不只是一个 Proxy set:它还包含依赖收集、effect、computed、scheduler、数组/集合 instrumentation 和组件更新边界。
6.1 Vue 3 的轻量共享状态
状态很简单时,可以用 reactive 和 composable:
// stores/counter.ts
import { computed, reactive, readonly } from 'vue'
const state = reactive({ count: 0 })
export function useCounter() {
const double = computed(() => state.count * 2)
function increment() {
state.count++
}
return {
state: readonly(state),
double,
increment,
}
}
对外暴露 readonly 可以避免组件任意修改状态,把写入集中到命名方法。Nuxt 4/SSR 场景不要把用户请求相关的状态放在所有请求共享的模块级单例中,应按请求创建 store 或遵循框架的状态约定。
6.2 Vue 3.5 推荐 Pinia
大型 Vue 应用通常使用 Pinia:
// stores/todo.ts
import { defineStore } from 'pinia'
export const useTodoStore = defineStore('todo', {
state: () => ({
items: [] as string[],
}),
getters: {
count: state => state.items.length,
},
actions: {
addItem(item: string) {
this.items.push(item)
},
},
})
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
Pinia 的 state、getters 和 actions 与本文的 state、派生值和 action/mutation 有相似之处,但 Pinia 直接依托 Vue 响应式,并提供 devtools、插件、类型推断和组合式 store。Vuex 3 适用于 Vue 2,Vuex 4 适用于 Vue 3;新 Vue 项目通常优先 Pinia。
七、原生实现的边界
- Proxy:现代浏览器支持良好,但 IE11 不支持;
- ES Module:需要
type="module"和 HTTP 服务; - 深层状态:浅层 Proxy 不能自动捕获嵌套变化,递归代理要处理循环引用、身份和性能;
- 通知粒度:全局
stateChange会让所有组件重新 render,规模大时应按依赖或 selector 更新; - 异步竞态:请求完成顺序可能与发起顺序不同,需要 request ID、AbortController 或状态机;
- 内存泄漏:订阅必须可取消,组件销毁时要释放监听器;
- 安全:不要把不可信字符串直接写入
innerHTML; - SSR:每个请求要隔离状态,不能复用跨请求的可变单例。
八、总结
- Pub/Sub 解决通知问题,Store 解决状态、读取和写入协议;
- action 适合异步编排,mutation 应保持同步、可预测;
- 只代理外层 state 无法可靠捕获
items.push()等深层变化; - 订阅要返回取消函数,组件销毁时必须清理;
- 原生实现适合学习设计模式和小型页面,不等于成熟状态库;
- Vue 3 简单共享状态可以使用 reactive/composable,复杂应用优先 Pinia;
- Proxy、模块、XSS、SSR 和异步竞态都是实际项目必须考虑的边界。
参考资料
- Vue 状态管理
- Vue 响应式深入
- Vue provide/inject
- Pinia 核心概念
- Pinia State
- Pinia Actions
- MDN Proxy
- MDN JavaScript modules
原文主题和 Pub/Sub、Store、组件基类教程予以保留;远程演示图因源站不可稳定下载未嵌入,错误文件名、浅层 Proxy、XSS 和缺少取消订阅等问题已修正。