Skip to main content

Redux Source Code Analysis

Free2017-08-27#JS#redux原理#redux源码分析#redux souce analysis#redux内部原理#redux与vuex

Over 700 lines, not very hard to understand

Preface

A library with very concise API design, has some exquisite small tricks and functional programming flavor

I. Structure

src/
│  applyMiddleware.js
│  bindActionCreators.js
│  combineReducers.js
│  compose.js
│  createStore.js
│  index.js
│
└─utils/
        warning.js

index exposes all APIs:

export {
  createStore,      // Key
  combineReducers,  // reducer combination helper
  bindActionCreators,   // wrap dispatch
  applyMiddleware,  // middleware mechanism
  compose           // Bonus, function composition util
}

The two most core things are createStore and applyMiddleware, status equivalent to core and plugin

II. Design Philosophy

Core idea is the same as Flux:

(state, action) => state

Reflected in source code (createStore/dispatch()):

try {
  isDispatching = true
  // Recalculate state
  // (state, action) => state Flux basic idea
  currentState = currentReducer(currentState, action)
} finally {
  isDispatching = false
}

Pass currentState and action into top-level reducer, calculated layer by layer through reducer tree to get new state

No dispatcher concept, each action comes, starts from top-level reducer and flows through entire reducer tree, each reducer only focuses on action it's interested in, creates a small piece of state, state tree corresponds to reducer tree, when reducer calculation process ends, get new state, discard previous state

P.S. For more Redux design philosophy (roles of action, store, reducer and how to understand), please see Redux

III. Tricks

minified Detection

function isCrushed() {}

// min detection, warn if using min in non-production environment
if (
  process.env.NODE_ENV !== 'production' &&
  typeof isCrushed.name === 'string' &&
  isCrushed.name !== 'isCrushed'
) {
  // warning(...)
}

Code obfuscation changes isCrushed's name, used as detection basis

Non-interfering throw

// Small detail, can trace call stack when enabling all exception breakpoints, doesn't affect if not enabled
// Can also be kept in production environment
try {
    throw new Error('err')
} catch(e) {}

Compare with async throw trick used in velocity:

/!!! Trick, async throw, doesn't affect logic flow
setTimeout(function() {
    throw error;
}, 1);

Both don't affect logic flow, non-interfering throw's advantage is not losing call stack and other context information, specifically:

This error was thrown as a convenience so that if you enable "break on all exceptions" in your console, it would pause the execution at this line.

master-dev queue

This trick doesn't have a very suitable name (master-dev queue was also casually named, but quite vivid), let's call it mutable queue:

// 2 queues, current can't be directly modified, need to sync from next, like master and dev relationship
// Used to ensure listener execution process is not interfered
// If subscribe() when listener queue is executing, newly registered listener takes effect next time
let currentListeners = []
let nextListeners = currentListeners

// Use nextListeners as backup, only modify next array each time
// Sync before flush listener queue
function ensureCanMutateNextListeners() {
  if (nextListeners === currentListeners) {
    nextListeners = currentListeners.slice()
  }
}

Writing and reading need some extra operations:

// Write
ensureCanMutateNextListeners();
updateNextListeners();

// Read
currentListeners = nextListeners;

Equivalent to opening a new dev branch when writing (if doesn't exist), merge dev to master and delete dev branch when reading

Very suitable for listener queue scenario:

// Write (subscribe/unsubscribe)
function subscribe(listener) {
  // Not allowed to parachute in
  ensureCanMutateNextListeners()
  nextListeners.push(listener)

  return function unsubscribe() {
    // Not allowed to jump off
    ensureCanMutateNextListeners()
    const index = nextListeners.indexOf(listener)
    nextListeners.splice(index, 1)
  }
}

// Read (flush queue execute all listeners)
// Sync two listener arrays
// flush listener queue process not interfered by subscribe/unsubscribe
const listeners = currentListeners = nextListeners
for (let i = 0; i < listeners.length; i++) {
  const listener = listeners[i]
  listener()
}

Can analogize to driving scenario:

nextListeners is waiting room, take all people from waiting room before driving, close waiting room
After car leaves, if someone wants to get on (subscribe()), open a new waiting room (slice())
People enter waiting room first, taken away next trip, not allowed to parachute in
Getting off is the same, if car hasn't stopped, first record who wants to get off through waiting room, won't take him next trip, not allowed to jump off

Very interesting trick, similar to git workflow

compose util

function compose(...funcs) {
  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

Used to implement function composition:

compose(f, g, h) === (...args) => f(g(h(...args)))

Core is reduce (i.e., reduceLeft), specific process as follows:

// Array reduce API
arr.reduce(callback(accumulator, currentValue, currentIndex, array)[, initialValue])

// Input -> Output
[f1, f2, f3] -> f1(f2(f3(...args)))

1. Do ((a, b) => (...args) => a(b(...args)))(f1, f2)
  Get accumulator = (...args) => f1(f2(...args))
2. Do ((a, b) => (...args) => a(b(...args)))(accumulator, f3)
  Get accumulator = (...args) => ((...args) => f1(f2(...args)))(f3(...args))
  Get accumulator = (...args) => f1(f2(f3(...args)))

Notice two orders:

Parameter evaluation from inside out: f3-f2-f1 i.e., from right to left
Function call from outside in: f1-f2-f3 i.e., from left to right

applyMiddleware part uses this order, in parameter evaluation process bind next (from right to left), in function call process next() tail trigger (from left to right). So middleware looks quite strange:

// Middleware structure
let m = ({getState, dispatch}) => (next) => (action) => {
  // todo here
  return next(action);
};

Has its reasons

Fully Utilize Own Mechanism

Initially quite puzzled point:

function createStore(reducer, preloadedState, enhancer) {
  // Calculate first state
  dispatch({ type: ActionTypes.INIT })
}

Obviously could be more direct, like store.init(), why must go through dispatch itself? Actually has 2 purposes:

  • Special type used in combineReducer as reducer return value legality check, as a simple action use case

  • And marks that current state is initial, uncalculated by reducer

When checking reducer legality, directly threw this initial action in to execute twice, saved one action case, additionally saved initial environment identifier variable and extra store.init method

Fully utilized own dispatch mechanism, quite clever approach

IV. applyMiddleware

This part of source code is challenged most, looks quite confusing, somewhat difficult to understand

Look at middleware structure again:

// Middleware structure
//                fn1                 fn2         fn3
let m = ({getState, dispatch}) => (next) => (action) => {
  // todo here
  return next(action);
};

Why must use such ugly higher-order function?

function applyMiddleware(...middlewares) {
  // Inject {getState, dispatch} to every middleware, strip fn1
  chain = middlewares.map(middleware => middleware(middlewareAPI))
  // fn = compose(...chain) is reduceLeft chained combination from left to right
  // fn(store.dispatch) pass original dispatch in, as last next
  // Parameter evaluation process inject next from right to left, strip fn2, get series of (action) => {} standard dispatch combination
  // When calling tampered dispatch, pass action from left to right
  // Action first flows through all middleware according to next chain order, last ring is original dispatch, enter reducer calculation process
  dispatch = compose(...chain)(store.dispatch)
}

Focus on how fn2 is stripped:

// Parameter evaluation process inject next from right to left, strip fn2 dispatch = compose(...chain)(store.dispatch)

As comments:

  • fn = compose(...chain) is reduceLeft chained combination from left to right

  • fn(store.dispatch) passes original dispatch in, as last next (innermost parameter)

  • Previous step parameter evaluation process inject next from right to left, strip fn2

Use reduceLeft parameter evaluation process bind next

Then look at call process:

  • When calling tampered dispatch, pass action from left to right

  • action first flows through all middleware according to next chain order, last ring is original dispatch, enter reducer calculation process

So each layer of higher-order function in middleware structure has specific purpose:

fn1 Accept middlewareAPI injection
fn2 Accept next bind
fn3 Implement dispatch API (receive action)

applyMiddleware will be refactored, clearer version see pull request#2146, core logic is like this, refactoring might consider whether to do break change, whether to support boundary cases, whether readable enough (many people focus on these few lines of code, related issue/pr at least dozens) etc., Redux maintenance team is quite cautious, this part's confusion was questioned many times before deciding to refactor

V. Source Code Analysis

Git address: https://github.com/ayqy/redux-3.7.0

P.S. Comments are detailed enough. Although latest is 3.7.2 now, won't have big differences, 4.0 might have a wave of long-planned changes

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment