我的源码阅读之路:redux源码剖析(上)

1,203 阅读18分钟
原文链接: zhuanlan.zhihu.com

前言

用过react的小伙伴对redux其实并不陌生,基本大多数的React应用用到它。一般大家用redux的时候基本都不会单独去使用它,而是配合react-redux一起去使用。刚学习redux的时候很容易弄混淆redux和react-redux,以为他俩是同一个东西。其实不然,redux是javascript应用程序的可预测状态容器,而react-redux则是用来连接这个状态容器与react组件。可能前端新人对这两者还是觉得很抽象,打个比方说,在一个普通家庭中,妈妈在家里都是至高无上的地位,掌握家中经济大权,家里的经济流水都要经过你的妈妈,而你的爸爸则负责从外面赚钱然后交给你的妈妈。这里把你的妈妈类比成redux,而你的爸爸可以类比成react-redux,而外面的大千世界则是react组件。相信这样的类比,大家对这react和react-redux的有了一个初步认识。本篇文章介绍的主要内容是对redux的源码的分析,react-redux的源码分析将会在我的下一篇文章中,敬请期待!各位小伙们如果觉得写的不错的话,麻烦多多点赞收藏关注哦!

redux的使用

在讲redux的源码之前,我们先回顾一下redux是如何使用的,然后我们再对照着redux的使用去阅读源码,这样大家的印象可能会更加深刻点。先贴上一段demo代码:

const initialState={
  cash:200,

}
const reducer=(state=initialState,action)=>{
  const {type,payload} = action;
  switch(type){
    case 'INCREMENT':
      return Object.assign({},state,{
        cash:state.cash+payload
      });
    case 'DECREMENT':
      return Object.assign({},state,{
        cash:state.cash-payload
      });
    default :
      return state;
  }
}

const reducers=Redux.combineReducers({treasury:reducer});

//创建小金库
const store=Redux.createStore(reducers);

//当小金库的现金发生变化时,打印当前的金额
store.subscribe(()=>{
  console.log(`余额:${store.getState().treasury.cash}`);
});

//小明爸爸发了工资300块上交
store.dispatch({
  type:'INCREMENT',
  payload:300
});
//小明拿着水电费单交100块水电费
store.dispatch({
  type:'DECREMENT',
  payload:100
});

上面这段代码是一个非常典型的redux的使用,跟大家平时在项目里用的不太一样,可能有些小伙伴们不能理解,其实react-redux只不过在这种使用方法上做了一层封装。等当我们弄清楚redux的使用,再去看react-redux源码便会明白了我们在项目里为何是那种写法而不是这种写法。

说到redux的使用,不免要说一下action、reducer和store三者的关系。记得当初第一次使用redux的时候,一直分不清这三者的关系,感觉这三个很抽象很玄学,相信不少小伙伴们跟我一样遇到过同样的情况。其实并不难,我还是用文章开头打的比方还解释这三者的关系。

现在保险箱(store)里存放200块大洋。到月底了,小明的爸爸的单位发了工资总计300块大洋,拿到工资之后第一件的事情就是上交,毫无疑问的,除非小明爸爸不要命了。小明的爸爸可以直接将这300块大洋放到家里的保险箱里面吗?显然是不可以的,所以小明的爸爸得向小明的爸爸提交申请,而这个申请也就是我们所说的action。这个申请(action)包括操作类型和对应的东西,申请类型就是存钱(INCREMENT),对应的东西就是300块大洋(payload)。此时小明的妈妈拿到这个申请之后,将根据这个申请执行对应的操作,这里就是往保险箱里的现金里放300块大洋进去,此时小明的妈妈干的事情就是reducer干的事情。当300块大洋放完之后,小明的妈妈就通知家里的所有人现在的小金库的金额已经发生了变化,现在的余额是500块。当小明的爸爸收到这个通知之后,心的一块大石头也就放下来了。过了一会,小明回来了,并且拿着一张价值100块的水电费的催收单。于是,小明想小明妈妈申请交水电费,小明妈妈从保险库中取出来100块给了小明,并通知了家里所有人小金库的金额又发生了变化,现在余额400块。

通过上面的例子,相信小伙们对三者的关系有了一个比较清晰的认识。现在我们已经理清楚了action、reducer和store三者的关系,并且也知道了redux是如何使用的了,现在将开始我们得源码阅读之旅。

redux项目结构

本篇文章是基于redux的4.0.0版本做的源码分析,小伙伴们在对照源码的时候,千万别弄错了。整个redux项目的源码的阅读我们只需要关注src的目录即可。


这里主要分为两大块,一块为自定义的工具库,另一块则是redux的逻辑代码。先从哪块开始阅读呢?我个人建议先阅读自定义的工具库这块。主要有这么两个原因:第一个,这块代码比较简单,容易理解,大家更能进入阅读的状态;第二个,redux逻辑代码会用到这些自定义工具,先搞懂这些,对后续逻辑代码的阅读做了一个很好的铺垫。下面我们正式开始我们的源码阅读之旅。

utils

actionTypes.js

const ActionTypes = {
  INIT:
    '@@redux/INIT' +
    Math.random()
      .toString(36)
      .substring(7)
      .split('')
      .join('.'),
  REPLACE:
    '@@redux/REPLACE' +
    Math.random()
      .toString(36)
      .substring(7)
      .split('')
      .join('.')
}

export default ActionTypes

这段代码很好理解,就是对外暴露两个action类型,没什么难点。但是我这里想介绍的是Number.prototype.toString方法,估计应该有不少小伙伴们不知道toString是可以传参的,toString接收一个参数radix,代表数字的基数,也就是我们所说的2进制、10进制、16进制等等。radix的取值范围也很容易得出来,最小进制就是我们得二进制,所以redix>=2。0-9(10个数字)+a-z(26个英文字母)总共36个,所以redix<=36。总结一下2<=radix<=36,默认是10。基于这个特性我们可以写一个获取指定长度的随机字符串的长度:

//获取指定长度的随机字符串
function randomString(length){
  let str='';
  while(length>0){
    const fragment= Math.random().toString(36).substring(2);
    if(length>fragment.length){
      str+=fragment;
      length-=fragment.length;
    }else{
      str+=fragment.substring(0,length);
      length=0;
    }
  }
  return str;
}

isPlainObject.js

export default function isPlainObject(obj) {
  if (typeof obj !== 'object' || obj === null) return false

  let proto = obj
  while (Object.getPrototypeOf(proto) !== null) {
    proto = Object.getPrototypeOf(proto)
  }

  return Object.getPrototypeOf(obj) === proto
}

isPlainObject.js也很简单,仅仅只是向外暴露了一个用于判断是否简单对象的函数。什么简单对象?应该有一些小伙伴不理解,所谓的简单对象就是该对象的proto等于Object.prototype,用一句通俗易懂的话就是:

凡不是new Object()或者字面量的方式构建出来的对象都不是简单对象

下面看一个例子:

class Fruit{
  sayName(){
    console.log(this.name)
  }
}

class Apple extends Fruit{
  constructor(){
    super();
    this.name="苹果"
  }
}

const apple = new Apple();
const fruit = new Fruit();
const cherry = new Object({
  name:'樱桃'
});
const banana = {
  name:'香蕉'
};

console.log(isPlainObject(apple));//false
console.log(isPlainObject(fruit));//false
console.log(isPlainObject(cherry));//true
console.log(isPlainObject(banana));//true

这里可能会有人不理解isPlainObject(fruit)===false,如果对这个不能理解的话,自己后面要补习一下原型链的相关知识,这里fruit.proto.proto才等价于Object.prototype。

warning.js

export default function warning(message) {
  if (typeof console !== 'undefined' && typeof console.error === 'function') {
    console.error(message)
  }
  try {
    throw new Error(message)
  } catch (e) {} 
}

这个也很简单,仅仅是打印一下错误信息。不过这里它的console居然加了一层判断,我查阅了一下发现console其实是有兼容性问题,ie8及其以下都是不支持console的。哎,不仅感叹一句!

如果说马赛克阻碍了人类文明的进程,那ie便是阻碍了前端技术的发展。

逻辑代码

到这里我已经完成对utils下的js分析,很简单,并没有大家想象的那么难。仅仅从这几个简单的js中,就牵引出好几个我们平时不太关注的知识点。如果我们不读这些源码,这些容易被忽视的知识点就很难被捡起来,这也是为什么很多大佬建议阅读源码的原因。我个人认为,阅读源码,理解原理是次要的。学习大佬的代码风格、一些解决思路以及对自己知识盲点的点亮更为重要。废话不多说,开始我们下一个部分的代码阅读,下面的部分就是整个redux的核心部分。

index.js

import createStore from './createStore'
import combineReducers from './combineReducers'
import bindActionCreators from './bindActionCreators'
import applyMiddleware from './applyMiddleware'
import compose from './compose'
import warning from './utils/warning'
import __DO_NOT_USE__ActionTypes from './utils/actionTypes'

function isCrushed() {}

if (
  process.env.NODE_ENV !== 'production' &&
  typeof isCrushed.name === 'string' &&
  isCrushed.name !== 'isCrushed'
) {
  warning(
    "You are currently using minified code outside of NODE_ENV === 'production'. " +
      'This means that you are running a slower development build of Redux. ' +
      'You can use loose-envify (https://github.com/zertosh/loose-envify) for browserify ' +
      'or DefinePlugin for webpack (http://stackoverflow.com/questions/30030031) ' +
      'to ensure you have the correct code for your production build.'
  )
}

export {
  createStore,
  combineReducers,
  bindActionCreators,
  applyMiddleware,
  compose,
  __DO_NOT_USE__ActionTypes
}

index.js是整个redux的入口文件,尾部的export出来的方法是不是都很熟悉,每个方法对应了一个js,这也是后面我们要分析的。这个有两个点需要讲一下:

第一个,__DO_NOT_USE__ActionTypes。 这个很陌生,平时在项目里面我们是不太会用到的,redux的官方文档也没有提到这个,如果你不看源码你可能就不知道这个东西的存在。这个干嘛的呢?我们一点一点往上找,找到这么一行代码:

import __DO_NOT_USE__ActionTypes from './utils/actionTypes'

这个引入的js不就是我们之前分析的utils的其中一员吗?里面定义了redux自带的action的类型,从这个变量的命名来看,这是帮助开发者检查不要使用redux自带的action的类型,以防出现错误。

第二个,函数isCrushed。 这里面定义了一个函数isCrushed,但是函数体里面并没有东西。第一次看的时候很奇怪,为啥要这么干?相信有不少小伙伴们跟我有一样的疑问,继续往下看,紧跟着后面有一段代码:

if (
  process.env.NODE_ENV !== 'production' &&
  typeof isCrushed.name === 'string' &&
  isCrushed.name !== 'isCrushed'
) {
  warning(
    "You are currently using minified code outside of NODE_ENV === 'production'. " +
      'This means that you are running a slower development build of Redux. ' +
      'You can use loose-envify (https://github.com/zertosh/loose-envify) for browserify ' +
      'or DefinePlugin for webpack (http://stackoverflow.com/questions/30030031) ' +
      'to ensure you have the correct code for your production build.'
  )
}

看到process.env.NODE_ENV,这里就要跟我们打包时用的环境变量联系起来。当process.env.NODE_ENV==='production'这句话直接不成立,所以warning也就不会执行;当process.env.NODE_ENV!=='production',比如是我们的开发环境,我们不压缩代码的时候typeof isCrushed.name === 'string' && isCrushed.name !== 'isCrushed'也不会成立;当process.env.NODE_ENV!=='production',同样是我们的开发环境,我们进行了代码压缩,此时isCrushed.name === 'string' && isCrushed.name !== 'isCrushed'就成立了,可能有人不理解isCrushed函数不是在的吗?为啥这句话就不成立了呢?其实很好理解,了解过代码压缩的原理的人都知道,函数isCrushed的函数名将会被一个字母所替代,这里我们举个例子,我将redux项目的在development环境下进行了一次压缩打包。代码做了这么一层转换:

未压缩

function isCrushed() {}
if (
  process.env.NODE_ENV !== 'production' &&
  typeof isCrushed.name === 'string' &&
  isCrushed.name !== 'isCrushed'
)

压缩后

function d(){}"string"==typeof d.name&&"isCrushed"!==d.name

此时判断条件就成立了,错误信息就会打印出来。这个主要作用就是防止开发者在开发环境下对代码进行压缩。开发环境下压缩代码,不仅让我们

createStore.js

函数createStore接受三个参数(reducer、preloadedState、enhancer),reducer和enhancer我们用的比较多,preloadedState用的比较少。第一个reducer很好理解,这里就不过多解释了,第二个preloadedState,它代表着初始状态,我们平时在项目里也很少用到它,主要说一下enhancer,中文名叫增强器,顾名思义就是来增强redux的,它的类型的是Function,createStore.js里有这么一行代码:

if (typeof enhancer !== 'undefined') {
    if (typeof enhancer !== 'function') {
      throw new Error('Expected the enhancer to be a function.')
    }

    return enhancer(createStore)(reducer, preloadedState)
  }

这行代码展示了enhancer的调用过程,根据这个调用过程我们可以推导出enhancer的函数体的架子应该是这样子的:

function enhancer(createStore) {
    return (reducer,preloadedState) => {
         //逻辑代码
        .......
    }
 }

常见的enhancer就是redux-thunk以及redux-saga,一般都会配合applyMiddleware一起使用,而applyMiddleware的作用就是将这些enhancer格式化成符合redux要求的enhancer。具体applyMiddleware实现,下面我们将会讲到。我们先看redux-thunk的使用的例子:

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/index';

const store = createStore(
  rootReducer,
  applyMiddleware(thunk)
);

看完上面的代码,可能会有人有这么一个疑问“createStore函数第二个参数不是preloadedState吗?这样不会报错吗?” 首先肯定不会报错,毕竟官方给的例子,不然写个错误的例子也太大跌眼镜了吧!redux肯定是做了这么一层转换,我在createStore.js找到了这么一行代码:

if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
    enhancer = preloadedState
    preloadedState = undefined
  }

当第二个参数preloadedState的类型是Function的时候,并且第三个参数enhancer未定义的时候,此时preloadedState将会被赋值给enhancer,preloadedState会替代enhancer变成undefined的。有了这么一层转换之后,我们就可以大胆地第二个参数传enhancer了。

说完createStore的参数,下面我说一下函数createStore执行完之后返回的对象都有什么?在createStore.js最下面一行有这一行代码:

return {
    dispatch,
    subscribe,
    getState,
    replaceReducer,
    [?observable]: observable
  }

他返回了有这么几个方法,其中前三个最为常用,后面两个在项目基本上不怎么用,接下来我们去一一剖析。

定义的一些变量

let currentState = preloadedState //从函数createStore第二个参数preloadedState获得
let currentReducer = reducer  //从函数createStore第一个参数reducer获得
let currentListeners = [] //当前订阅者列表
let nextListeners = currentListeners //新的订阅者列表
let isDispatching = false

其中变量isDispatching,作为锁来用,我们redux是一个统一管理状态容器,它要保证数据的一致性,所以同一个时间里,只能做一次数据修改,如果两个action同时触发reducer对同一数据的修改,那么将会带来巨大的灾难。所以变量isDispatching就是为了防止这一点而存在的。

dispatch

function dispatch(action) {
    if (!isPlainObject(action)) {
      throw new Error(
        'Actions must be plain objects. ' +
          'Use custom middleware for async actions.'
      )
    }

    if (typeof action.type === 'undefined') {
      throw new Error(
        'Actions may not have an undefined "type" property. ' +
          'Have you misspelled a constant?'
      )
    }

    if (isDispatching) {
      throw new Error('Reducers may not dispatch actions.')
    }

    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    const listeners = (currentListeners = nextListeners)
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i]
      listener()
    }

    return action
  }

函数dispatch在函数体一开始就进行了三次条件判断,分别是以下三个: - 判断action是否为简单对象 - 判断action.type是否存在 - 判断当前是否有执行其他的reducer操作

当前三个预置条件判断都成立时,才会执行后续操作,否则抛出异常。在执行reducer的操作的时候用到了try-finally,可能大家平时try-catch用的比较多,这个用到的还是比较少。执行前isDispatching设置为true,阻止后续的action进来触发reducer操作,得到的state值赋值给currentState,完成之后再finally里将isDispatching再改为false,允许后续的action进来触发reducer操作。接着一一通知订阅者做数据更新,不传入任何参数。最后返回当前的action。

getState

function getState() {
    if (isDispatching) {
      throw new Error(
        'You may not call store.getState() while the reducer is executing. ' +
          'The reducer has already received the state as an argument. ' +
          'Pass it down from the top reducer instead of reading it from the store.'
      )
    }

    return currentState
  }

getState相比较dispatch要简单许多,返回currentState即可,而这个currentState在每次dispatch得时候都会得到响应的更新。同样是为了保证数据的一致性,当在reducer操作的时候,是不可以读取当前的state值的。说到这里,我想到之前一次的面试经历:

面试官:执行createStore函数生成的store,可不可以直接修改它的state?

我:可以。(普罗大众的第一反应)

面试官:你知道redux怎么做到不能修改store的state吗?

我:额......(处于懵逼状态)

面试官:很简单啊!重写store的set方法啊!

那会没看过redux的源码,就被他忽悠了!读完redux源码之后,靠!这家伙就是个骗子!自己没读过源码还跟我聊源码,无语了!当然,我自己也有原因,学艺不精,被忽悠了。我们这里看了源码之后,getState函数返回state的时候,并没有对currentState做一层拷贝再给我们,所以是可以直接修改的。只是这么修改的话,就不会通知订阅者做数据更新。得出的结论是:

store通过getState得出的state是可以直接被更改的,但是redux不允许这么做,因为这样不会通知订阅者更新数据。

subscribe

function subscribe(listener) {
    if (typeof listener !== 'function') {
      throw new Error('Expected the listener to be a function.')
    }

    if (isDispatching) {
      throw new Error(
        'You may not call store.subscribe() while the reducer is executing. ' +
          'If you would like to be notified after the store has been updated, subscribe from a ' +
          'component and invoke store.getState() in the callback to access the latest state. ' +
          'See https://redux.js.org/api-reference/store#subscribe(listener) for more details.'
      )
    }

    let isSubscribed = true //表示该订阅者在订阅状态中,true-订阅中,false-取消订阅

    ensureCanMutateNextListeners()
    nextListeners.push(listener)

    return function unsubscribe() {
      if (!isSubscribed) {
        return
      }

      if (isDispatching) {
        throw new Error(
          'You may not unsubscribe from a store listener while the reducer is executing. ' +
            'See https://redux.js.org/api-reference/store#subscribe(listener) for more details.'
        )
      }

      isSubscribed = false

      ensureCanMutateNextListeners()
      const index = nextListeners.indexOf(listener)
      nextListeners.splice(index, 1)
    }
  }

在注册订阅者之前,做了两个条件判断: - 判断监听者是否为函数 - 是否有reducer正在进行数据修改(保证数据的一致性)

接下来执行了函数ensureCanMutateNextListeners,下面我们看一下ensureCanMutateNextListeners函数的具体实现逻辑:

function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      nextListeners = currentListeners.slice()
    }
  }

逻辑很简单,判断nextListeners和currentListeners是否为同一个引用,还记得dispatch函数中有这么一句代码以及定义变量时一行代码吗?

// Function dispatch
const listeners = (currentListeners = nextListeners)
// 定义变量
let currentListeners = []
let nextListeners = currentListeners

这两处将nextListeners和currentListeners引用了同一个数组,另外定义变量时也有这么一句话代码。而ensureCanMutateNextListeners就是用来判断这种情况的,当nextListeners和currentListeners为同一个引用时,则做一层浅拷贝,这里用的就是Array.prototype.slice方法,该方法会返回一个新的数组,这样就可以达到浅拷贝的效果。

函数ensureCanMutateNextListeners作为处理之后,将新的订阅者加入nextListeners中,并且返回取消订阅的函数unsubscribe。函数unsubscribe执行时,也会执行两个条件判断: - 是否已经取消订阅(已取消的不必执行) - 是否有reducer正在进行数据修改(保证数据的一致性)

通过条件判断之后,讲该订阅者从nextListeners中删除。看到这里可能有小伙伴们对currentListeners和nextListeners有这么一个疑问?函数dispatch里面将二者合并成一个引用,为啥这里有啥给他俩分开?直接用currentListeners不可以吗?这里这样做其实也是为了数据的一致性,因为有这么一种的情况存在。当redux在通知所有订阅者的时候,此时又有一个新的订阅者加进来了。如果只用currentListeners的话,当新的订阅者插进来的时候,就会打乱原有的顺序,从而引发一些严重的问题。

replaceReducer

function replaceReducer(nextReducer) {
    if (typeof nextReducer !== 'function') {
      throw new Error('Expected the nextReducer to be a function.')
    }

    currentReducer = nextReducer
    dispatch({ type: ActionTypes.REPLACE })
  }

这个函数是用来替换reducer的,平时项目里基本很难用到,replaceReducer函数执行前会做一个条件判断: - 判断所传reducer是否为函数

通过条件判断之后,将nextReducer赋值给currentReducer,以达到替换reducer效果,并触发state更新操作。

observable

/**
   * Interoperability point for observable/reactive libraries.
   * @returns {observable} A minimal observable of state changes.
   * For more information, see the observable proposal:
   * https://github.com/tc39/proposal-observable
   */

这里没贴代码,因为这块代码我们不需要掌握。这个observable函数,并没有调用,即便暴露出来我们也办法使用。所以我们就跳过这块,如果有兴趣的话,可以去作者给的github的地址了解一下。


讲完这几个方法之后,还有一个小细节需要说一下,createStore函数体里有这样一行代码。

dispatch({ type: ActionTypes.INIT })

为啥要有这么一行代码?原因很简单,假设我们没有这样代码,此时currentState就是undefined的,也就我说我们没有默认值了,当我们dispatch一个action的时候,就无法在currentState基础上做更新。所以需要拿到所有reducer默认的state,这样后续的dispatch一个action的时候,才可以更新我们的state。


结语

由于知乎对文章的篇幅限制,《我的源码阅读之路:redux源码剖析(上)》就讲到createStore就为止,后续代码剖析,请看我的下篇文章《我的源码阅读之路:redux源码剖析(下)》