手写Redux(一):实现Redux

677 阅读16分钟

在React中,组件和组件之间通过props传递数据的规范,极大地增强了组件之间的耦合性,而context类似全局变量一样,里面的数据能被随意接触就能被随意修改,每个组件都能够改context里面的内容会导致程序的运行不可预料。

Redux是一个独立专门用于做状态管理的JS库,帮助开发出行为稳定可预测的、易于测试的应用程序,通过react-redux,可集中式管理React应用中多个组件共享的状态。

本系列的两篇文章带你手写一个Redux,以及结合React实现自己的react-redux。

手写Redux(一):实现Redux

手写Redux(二):实现React-redux

1 如何修改状态?

说到如何管理React应用的状态数据,最直接的想法应该是设置一个全局的状态对象(state),各个组件可以读取、修改该对象中状态数据,下面我们通过一个具体的例子演示一下,使用create-react-app新建一个项目my-redux,修改public/index.html中的body

<body>
  <div id='title'></div>
  <div id='content'></div>
</body>

清除src/index.js里面所有的代码,添加下面代码,改代码中包含了我们应用的状态,以及对状态的操作方法:

const appState = {
  title: {
    text: 'my-redux',
    color: 'red',
  },
  content: {
    text: '如何实现自己的redux?',
    color: 'blue'
  }
}

function renderApp (appState) {
  renderTitle(appState.title)
  renderContent(appState.content)
}
function renderTitle (title) {
  const titleDOM = document.getElementById('title')
  titleDOM.innerHTML = title.text
  titleDOM.style.color = title.color
}
function renderContent (content) {
  const contentDOM = document.getElementById('content')
  contentDOM.innerHTML = content.text
  contentDOM.style.color = content.color
}

renderApp(appState)

浏览器打开http://localhost:3000,显示如下页面:

image.png

以上代码通过将页面属性与状态变量绑定,实现了状态的统一管理,但是这种方案存在一个重大的隐患,渲染数据的时候,使用的是一个共享状态appState,该变量没有任何封装保护,每个人都可以修改它:

// 这个方法中修改了 appState 的内容,比如:appState.title = null
// 出现问题的时候 debug 起来就非常困难
doSomthingMore()
// ...
renderApp(appState)

renderApp(appState)之前执行了一大堆函数操作,你根本不知道它们会对appState做什么事情,renderApp(appState)的结果无法得到保障。一旦共享数据可以任意修改,所有对共享状态的操作都是不可预料的,出现问题的时候debug起来就非常困难,这就是老生常谈的尽量避免全局变量。

组件之间需要共享数据数据可能被任意修改导致不可预料的结果之间存在着矛盾。

解决方案: 提高数据修改的门槛,组件之间可以共享数据、修改数据。但是这个数据并不能直接改,只能执行某些允许的修改,而且你修改过程必须大张旗鼓,不能悄悄的改。

这里我们定义一个方法叫dispatch,通过这个方法专门负责数据的修改:

function dispatch (action) {
  switch (action.type) {
    case 'UPDATE_TITLE_TEXT':
      appState.title.text = action.text
      break
    case 'UPDATE_TITLE_COLOR':
      appState.title.color = action.color
      break
    default:
      break
  }
}

所有对数据的操作必须通过dispatch函数,它接受一个参数action对象,里面必须包含一个type字段来声明你到底想干什么。dispatchswtich里面会识别这个type字段,能够识别出来的操作才会执行对appState的修改,这样就能管理所有对状态数据的操作。

比如上面的dispatch它只能识别两种操作,

  1. UPDATE_TITLE_TEXT :用actiontext字段去更新appState.title.text
  2. UPDATE_TITLE_COLOR:用actioncolor字段去更新appState.title.color

可以看到,action里面除了type是必须的以外,其他字段都是可以自定义的,任何的模块如果想要修改 appState.title.text,必须 大张旗鼓 地调用dispatch

dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本
dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改标题颜色

这样做有什么好处?

// 里面可能通过 dispatch 修改标题颜色
doSomthingMore()
// ...
renderApp(appState)

我们不需要担心renderApp(appState)之前的那堆方法会对appState做什么奇怪的操作,因为我们规定不能直接修改appState,它们对appState的修改必须只能通过dispatch。而dispatch的实现只能修改 title.text 和 title.color,通过引入dispatch方法,组件间共享数据的方式发生了如下变化:

image.png 我们再也不用担心共享数据状态的修改的问题,我们只要把控了dispatch,所有的对appState的修改就无所遁形,毕竟 只有一根箭头 指向appState了。

2 抽像 store 监听数据变化

现在,我们有了appStatedispatch,把它们集中到一个地方,给这个地方起个名字叫做store,然后构建一个函数createStore,用来专门生产这种statedispatch的集合:

function createStore (state, stateChanger) {
  const getState = () => state
  const dispatch = (action) => stateChanger(state, action)
  return { getState, dispatch }
}

其中各个参数的含义与作用:

  • state: 表示应用程序状态数据;
  • stateChanger: 描述应用程序状态会根据action发生什么变化,相当于上文 dispatch 代码里面的内容;
  • getState: 用于获取state数据,把state对象返回;
  • dispatch: 用于修改数据,接收action,把stateaction一并传给stateChanger

通过createStore,我们可以这样渲染页面:

const appState = {
  title: {
    text: 'my-redux',
    color: 'red',
  },
  content: {
    text: '如何实现自己的redux?',
    color: 'blue'
  }
}

function stateChanger (state, action) {
  switch (action.type) {
    case 'UPDATE_TITLE_TEXT':
      state.title.text = action.text
      break
    case 'UPDATE_TITLE_COLOR':
      state.title.color = action.color
      break
    default:
      break
  }
}

// renderApp()、createStore()方法,此处省略,参照上文
// ...

// 创建store,包含获取状态的getState,和操作状态的dispatch
const store = createStore(appState, stateChanger)

renderApp(appState) // 首次渲染
store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本
store.dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改标题颜色
renderApp(appState) // dispatch 修改完状态 appState 后,需要手动调用renderApp()再次触发渲染

上面的代码有一个问题,通过dispatch修改数据的时候只是数据发生了变化,如果不手动调用renderApp(),页面是不会发生变化的,我们希望数据变化的时候程序能够自动触发重新渲染。

这个问题可以通过观察者模式“监听”数据变化,然后重新渲染页面:

function createStore (state, stateChanger) {
  const listeners = []
  // 调用subscribe,可以将监听器传入内部的listeners数组,监听数据变化
  const subscribe = (listener) => listeners.push(listener)
  const getState = () => state
  const dispatch = (action) => {
    stateChanger(state, action)
    // 每当 dispatch 的时候,遍历并且执行监听器
    // 即当数据变化时候进行一些方法回调,比如说重现渲染
    listeners.forEach((listener) => listener())
  }
  return { getState, dispatch, subscribe }
}

createStore里面定义了一个数组listeners和一个新的方法subscribe,可以通过 store.subscribe(listener)的方式给subscribe传入一个监听方法,这个函数会被push到listeners数组当中;修改dispatch,每次当它被调用的时候,除了会调用stateChanger进行数据的修改,还会遍历listeners数组里面的方法,然后一个个地去调用;监听回调方法可以通过subscribe注册进listeners数组。

完成以上修改后,在store中注册renderApp()回调方法,当数据变化时重新渲染,便会自动调用该方法:

// 其他方法及变量参考上文
// ...

const store = createStore(appState, stateChanger)
// 注册重新渲染回调方法,即当数据变化时候进行重现渲染
store.subscribe(() => renderApp(store.getState()))

renderApp(appState) // 首次渲染
store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本
store.dispatch({ type: 'UPDATE_CONTENT_COLOR', color: 'blue' }) // 修改标题颜色
// renderApp(appState) // 不再需要手动触发重新渲染了

3 纯函数的概念

在开始下面的内容前,先看下函数式编程里面非常重要的概念 —— 纯函数(Pure Function)。 纯函数的概念可参考这篇文章:纯函数是什么?怎么合理运用纯函数?,为防止链接失效,我将其中的内容简单罗列如下。 纯函数本身是一个函数,同时满足以下两点:

  1. 函数的返回结果只依赖于它的参数,相同的输入,总是会的到相同的输出;
  2. 执行过程中没有任何副作用。

下面说下这两点:

3.1 函数的返回结果只依赖于它的参数

let a = 1;
function xAdd(x) {
    return x + a;
};
xAdd(1); //2

上面这个函数就不是一个纯函数,因为在程序执行的过程中,变量a发生改变,执行xAdd(1)时得到的输出不同。

function sum(x, y) {
    return x + y;
};
sum(1,2); //3

这个例子中,符合相同的输入得到相同的输出这个概念,sum是一个纯函数。

3.2 执行过程中没有任何副作用

到底什么是副作用?这里的副作用指的是函数在执行过程中产生了 外部可观察变化,比如:

  • 发起HTTP请求
  • 操作DOM
  • 修改外部数据
let a = 1;
function func() {
    a = 'b';
};
func();
console.log(a); // b

我们运行了func函数,外部的变量a的值发生了改变,这就是产生了所谓的副作用,所以func不是一个纯函数。

function func2() {
    let a = 1;
    a = 'a';
    return a
};
func(); // a

函数fun2不会对产生外部可观察变化,也就不会产生副作用,它就是一个纯函数。

3.3 纯函数的好处

  1. 更容易进行测试,结果只依赖输入,测试时可以确保输出稳定;
  2. 更容易维护和重构,我们可以写出质量更高的代码;
  3. 更容易调用,我们不用担心函数会有什么副作用;
  4. 结果可以缓存,因为相同的输入总是会得到相同的输出。

3.4 纯函数运用的经典案例

  1. 数组的很多基本方法都是纯函数,例如map,forEach,filter,reduce等等;
  2. redux中三大原则之一使用纯函数来执行修改,其中就运用了reducer来描述 action 如何改变 state tree;
  3. Lodash 是一个一致性、模块化、高性能的 JavaScript 实用工具库,纯函数代表。

4 共享结构对象解决性能问题

4.1 整体刷新的性能问题

第二节中实现的createStore仍然有比较严重的性能问题,我们通过subscribe注册了重新渲染的回调函数,

store.subscribe(() => renderApp(store.getState())) // 监听数据变化

可通过dispatch修改状态中的Title文本数据,从而触发渲染,

store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本

但是,renderApp的实现,同时渲染了Title组件和Content组件,Content组件的状态数据没有发生变化,但还是触发了一次渲染:

function renderApp (appState) {
  renderTitle(appState.title)
  renderContent(appState.content)
}

这里提出的解决方案是,在每个渲染函数执行渲染操作之前先做个判断,判断传入的 新数据旧数据 是不是相同,相同的话就不渲染了。 修改renderApp:

function renderApp(newAppState, oldAppState = {}) { // 防止 oldAppState 没有传入,所以加了默认参数 oldAppState = {}
  if (newAppState === oldAppState) return // 数据没有变化就不渲染了
  console.log('render app...')
  renderTitle(newAppState.title, oldAppState.title)
  renderContent(newAppState.content, oldAppState.content)
}
function renderTitle (newTitle, oldTitle = {}) {
  if (newTitle === oldTitle) return // 数据没有变化就不渲染了
  console.log('render title...')
  const titleDOM = document.getElementById('title')
  titleDOM.innerHTML = newTitle.text
  titleDOM.style.color = newTitle.color
}
function renderContent (newContent, oldContent = {}) {
  if (newContent === oldContent) return // 数据没有变化就不渲染了
  console.log('render content...')
  const contentDOM = document.getElementById('content')
  contentDOM.innerHTML = newContent.text
  contentDOM.style.color = newContent.color
}

使用新的renderApp()

const store = createStore(appState, stateChanger)
let oldState = store.getState() // 缓存旧的 state
store.subscribe(() => {
  const newState = store.getState() // 数据可能变化,获取新的 state
  renderApp(newState, oldState) // 把新旧的 state 传进去渲染
  oldState = newState // 渲染完以后,新的 newState 变成了旧的 oldState,等待下一次数据变化重新渲染
})

刷新页面,查看日志输出,发现以下两个dispatch并没有触发页面重新渲染:

store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本
store.dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改标题颜色

原因是这样的,if条件中判断newAppState === oldAppState比较的是对象引用,而用于比较的对象的引用始终没有发生变化(类似的问题还有深拷贝和浅拷贝的比较)。分析renderApp代码发现:

  • renderApp比较的是:newAppState === oldAppState
  • renderTitle比较的是:newAppState.title === oldAppState.title
  • renderContent比较的是:newAppState.content === oldAppState.content

分析stateChanger代码发现,仅仅修改了以下两处:

  1. state.title.text = action.text
  2. state.title.color = action.color

因此,上述三处if判断恒等于true,直接return,没有进行后面的渲染。

4.2 共享结构的对象

通过上一节的分析,我们发现重新渲染回调没有生效的原因是,我们 直接修改了状态对象中的属性,未修改对象自身, 导致新老状态对象比较时,对象引用是一样的,是同一个对象renderXXX函数认为没有发生变化,不需要刷新。

解决方法是,禁止直接修改原来的对象,一旦你要修改某些东西,你就得把修改路径上的所有对象复制一遍,生成一个新的对象,例如,我们不写下面的修改代码:

appState.title.text = 'spring-boot'

而是新建一个appState,新建appState.title,新建appState.title.text

let newAppState = { // 新建一个 newAppState
  ...appState, // 复制 appState 里面的内容
  title: { // 用一个新的对象覆盖原来的 title 属性
    ...appState.title, // 复制原来 title 对象里面的内容
    text: 'spring-boot' // 覆盖 text 属性
  }
}
return newAppState

image.png

  • appStatenewAppState引用不一样,需要刷新;
  • appState.conetntnewAppState.conetnt引用一样,不需要刷新;
  • appState.titlenewAppState.title引用不一样,需要刷新。

按照上面的思路,改造stateChanger

function stateChanger (state, action) {
  switch (action.type) {
    case 'UPDATE_TITLE_TEXT':
      return { // 构建新的对象并且返回
        ...state,
        title: {
          ...state.title,
          text: action.text
        }
      }
    case 'UPDATE_TITLE_COLOR':
      return { // 构建新的对象并且返回
        ...state,
        title: {
          ...state.title,
          color: action.color
        }
      }
    default:
      return state // 没有修改,返回原来的对象
  }
}

因为stateChanger不会修改原来对象了,而是返回对象,所以我们需要修改一下createStorestate = stateChanger(state, action)覆盖原来的state

function createStore (state, stateChanger) {
  const listeners = []
  // 调用subscribe,可以将监听器传入内部的listeners数组,监听数据变化
  const subscribe = (listener) => listeners.push(listener)
  const getState = () => state
  const dispatch = (action) => {
    state = stateChanger(state, action)  // 覆盖原对象
    // 每当 dispatch 的时候,遍历并且执行监听器
    // 即当数据变化时候进行一些方法回调,比如说重现渲染
    listeners.forEach((listener) => listener())
  }
  return { getState, dispatch, subscribe }
}

刷新页面,查看日志,我们成功地把不必要的页面渲染优化掉了: image.png

5 reducer的概念

经过了前面几节的内容,我们有了很通用的createStorestateChanger,可以优化一下,将appStatestateChanger合并到一起,删除appState对象:

function stateChanger (state, action) {
  if (!state) {
    return {
      title: {
        text: 'my-redux',
        color: 'red',
      },
      content: {
        text: '如何实现自己的redux?',
        color: 'blue'
      }
    }
  }
  switch (action.type) {
    case 'UPDATE_TITLE_TEXT':
      return {
        ...state,
        title: {
          ...state.title,
          text: action.text
        }
      }
    case 'UPDATE_TITLE_COLOR':
      return {
        ...state,
        title: {
          ...state.title,
          color: action.color
        }
      }
    default:
      return state
  }
}

stateChanger现在既充当了获取初始化数据的功能,也充当了生成更新数据的功能,如果有传入state 就生成更新数据,否则就是初始化数据。

更新createStore方法:

function createStore (stateChanger) {
  let state = null
  const listeners = []
  // 调用subscribe,可以将监听器传入内部的listeners数组,监听数据变化
  const subscribe = (listener) => listeners.push(listener)
  const getState = () => state
  const dispatch = (action) => {
    state = stateChanger(state, action)  // 覆盖原对象
    // 每当 dispatch 的时候,遍历并且执行监听器
    // 即当数据变化时候进行一些方法回调,比如说重现渲染
    listeners.forEach((listener) => listener())
  }
  dispatch({}) // 初始化 state
  return { getState, dispatch, subscribe }
}

createStore内部的state不再通过参数传入,而是一个局部变量let state = nullcreateStore的最后会手动调用一次dispatch({})dispatch内部会调用stateChanger,这时候的state是 null,所以这次的dispatch其实就是初始化数据了。createStore内部第一次的dispatch导致state初始化完成,后续外部的dispatch就是修改数据的行为了。修改store的获取方式:

const store = createStore(stateChanger)

我们给stateChanger这个方法起一个更加形象的名字:reducer,为什么叫reducer呢?因为这个函数实现了以下功能:

state + action = newState

是不是和MapReduce里的reduce是一个意思哈?而且这个reducer还是个纯函数。

修改下参数名:

function createStore (reducer) {
  let state = null
  const listeners = []
  // 调用subscribe,可以将监听器传入内部的listeners数组,监听数据变化
  const subscribe = (listener) => listeners.push(listener)
  const getState = () => state
  const dispatch = (action) => {
    state = reducer(state, action)  // 覆盖原对象
    // 每当 dispatch 的时候,遍历并且执行监听器
    // 即当数据变化时候进行一些方法回调,比如说重现渲染
    listeners.forEach((listener) => listener())
  }
  dispatch({}) // 初始化 state
  return { getState, dispatch, subscribe }
}

至此,我们给reducer下个定义,并明确其作用:

定义: createStore接受一个叫reducer的函数作为参数,这个函数规定是一个纯函数,它接受两个参数,一个是state,一个是action

作用: 如果没有传入state或者state是 null,那么它就会返回一个初始化的数据。如果有传入 state的话,就会根据action来修改数据,但其实它并没有修改原始state对象,而是要通过上节所说的把修改路径的对象都复制一遍,然后产生一个新的对象返回。如果它不能识别你的action,它就不会产生新的数据,而是把state原封不动地返回(default分支)。 reducer是不允许有副作用的。你不能在里面操作 DOM,也不能发 Ajax 请求,更不能直接修改state,它要做的仅仅是初始化和计算新的state。 现在我们可以用这个createStore来构建不同的store了,只要给它传入符合上述的定义的reducer即可:

function themeReducer (state, action) {
  if (!state) return {
    themeName: 'Red Theme',
    themeColor: 'red'
  }
  switch (action.type) {
    case 'UPATE_THEME_NAME':
      return { ...state, themeName: action.themeName }
    case 'UPATE_THEME_COLOR':
      return { ...state, themeColor: action.themeColor }
    default:
      return state
  }
}
const store = createStore(themeReducer)

6 总结

在上面的几节内容中,

  1. 我们从一个简单的例子开始,提出一个可以被不同模块任意修改共享的数据状态,其操作都是不可预料的,出现问题的时候debug起来就非常困难,需要避免全局变量;
  2. 于是我们提高了修改数据的门槛:必须通过dispatch执行某些允许的修改操作;
  3. 我们抽取了一个createStore方法,其返回的store对象中包含了获取状态的方法getState和修改状态的方法dispatch;然后,通过观察者模式设置监听回调函数,在状态翻身变化时指定触发页面渲染;
  4. 针对渲染过程中,未发生状态的组件无效渲染的性能问题,通过引入“共享结构的对象”的概念,优化了其中无效的渲染;
  5. 引入rudex中的reducer,作为纯函数,负责初始化state、根据stateaction计算具有共享结构的新state对象。

6.1 完整代码

public/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>React App</title>
  </head>
  <body>
    <div id='title'></div>
    <div id='content'></div>
  </body>
</html>

src/index.js

const appState = {
  title: {
    text: 'my-redux',
    color: 'red',
  },
  content: {
    text: '如何实现自己的redux?',
    color: 'blue'
  }
}

function stateChanger (state, action) {
  if (!state) {
    return {
      title: {
        text: 'my-redux',
        color: 'red',
      },
      content: {
        text: '如何实现自己的redux?',
        color: 'blue'
      }
    }
  }
  switch (action.type) {
    case 'UPDATE_TITLE_TEXT':
      return {
        ...state,
        title: {
          ...state.title,
          text: action.text
        }
      }
    case 'UPDATE_TITLE_COLOR':
      return {
        ...state,
        title: {
          ...state.title,
          color: action.color
        }
      }
    default:
      return state
  }
}

function renderApp(newAppState, oldAppState = {}) { // 防止 oldAppState 没有传入,所以加了默认参数 oldAppState = {}
  if (newAppState === oldAppState) return // 数据没有变化就不渲染了
  console.log('render app...')
  renderTitle(newAppState.title, oldAppState.title)
  renderContent(newAppState.content, oldAppState.content)
}
function renderTitle (newTitle, oldTitle = {}) {
  if (newTitle === oldTitle) return // 数据没有变化就不渲染了
  console.log('render title...')
  const titleDOM = document.getElementById('title')
  titleDOM.innerHTML = newTitle.text
  titleDOM.style.color = newTitle.color
}
function renderContent (newContent, oldContent = {}) {
  if (newContent === oldContent) return // 数据没有变化就不渲染了
  console.log('render content...')
  const contentDOM = document.getElementById('content')
  contentDOM.innerHTML = newContent.text
  contentDOM.style.color = newContent.color
}

function createStore (reducer) {
  let state = null
  const listeners = []
  // 调用subscribe,可以将监听器传入内部的listeners数组,监听数据变化
  const subscribe = (listener) => listeners.push(listener)
  const getState = () => state
  const dispatch = (action) => {
    state = reducer(state, action)  // 覆盖原对象
    // 每当 dispatch 的时候,遍历并且执行监听器
    // 即当数据变化时候进行一些方法回调,比如说重现渲染
    listeners.forEach((listener) => listener())
  }
  dispatch({}) // 初始化 state
  return { getState, dispatch, subscribe }
}

const store = createStore( stateChanger)
let oldState = store.getState() // 缓存旧的 state
store.subscribe(() => {
  const newState = store.getState() // 数据可能变化,获取新的 state
  renderApp(newState, oldState) // 把新旧的 state 传进去渲染
  oldState = newState // 渲染完以后,新的 newState 变成了旧的 oldState,等待下一次数据变化重新渲染
})

renderApp(appState) // 首次渲染
store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本
store.dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改标题颜色
// renderApp(appState) // dispatch 不需要再次出触发渲染