技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

使用原生 JavaScript 构建状态管理系统

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/64-使用原生 JavaScript 构建状态管理系统
本文目录9 个章节
  1. 一、为什么需要状态管理
  2. 二、项目目标
  3. 三、发布/订阅 Pub/Sub
  4. 四、Store 核心对象
  5. 五、组件基类和订阅取消
  6. 六、这个实现和 Vue/Pinia 有什么关系
  7. 七、原生实现的边界
  8. 八、总结
  9. 参考资料

使用原生 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) 避免事件名 constructortoString 等与原型属性冲突。更大的系统还需要事件命名约定、异常隔离、日志和类型约束。

四、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 => ({
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    "'": '&#39;',
    '"': '&quot;',
  })[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:每个请求要隔离状态,不能复用跨请求的可变单例。

八、总结

  1. Pub/Sub 解决通知问题,Store 解决状态、读取和写入协议;
  2. action 适合异步编排,mutation 应保持同步、可预测;
  3. 只代理外层 state 无法可靠捕获 items.push() 等深层变化;
  4. 订阅要返回取消函数,组件销毁时必须清理;
  5. 原生实现适合学习设计模式和小型页面,不等于成熟状态库;
  6. Vue 3 简单共享状态可以使用 reactive/composable,复杂应用优先 Pinia;
  7. Proxy、模块、XSS、SSR 和异步竞态都是实际项目必须考虑的边界。

参考资料

原文主题和 Pub/Sub、Store、组件基类教程予以保留;远程演示图因源站不可稳定下载未嵌入,错误文件名、浅层 Proxy、XSS 和缺少取消订阅等问题已修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS