为啥要用redux-saga

5,078 阅读8分钟

redux-saga上手门槛较高,这对于redux-saga在团队内推广是不利的,对新人来说redux-saga的官方资料没有给出令人信服的理由让我们可以去放弃redux-thunk或者async/await方案。 这篇文章是我的学习过程和了解项目的过程中总结出来的学习笔记,通过分析redux的作用,对比分析redux中处理副作用的方案,说明了redux-saga是什么,有什么用和实现原理。

背景

redux

redux降低了大型项目的维护难度。面向对象中有一种常见的解耦方式: "接口与实现分离"。即通过定义接口,类开发者只需要将接口暴露给业务方,业务方只需要知道接口定义了什么行为就可以了,而不需要知道其背后的实现,而类作者在更新的时候也只需要保证实现对应的接口即可。其集大成的模式就是依赖注入(如Spring和Angular)。redux和这种模式其实非常相似:如果我们把接口理解为action,具体实现是reducer,业务方只能通过action去操作整个store的状态,对业务方来说,也不需要知道reducer的具体实现,更新reducer对业务方来说也是无感的。redux这种编程模式是另种形式的"接口与实现分离"。

"接口与实现分离"的优势相信各位已经很熟悉,就不在这里多说了。

然而这种模式不是无缺陷的。redux被诟病的地方在于代码量太大,在第一次写的时候,写代码速度是不如直接在组件中处理业务逻辑的,因此redux不适合小型或者快速试错的项目。社区提出了一些缓和这一缺点的方案,如redux-action。

副作用

无副作用是指在函数中,除了返回值之外,这个函数不会对调用方产生附加的影响,比如修改函数入参,读取或者修改全局环境变量,写IO,读IO都是副作用。关于无副作用函数的优点这里已经说得很清楚了。

redux官方教程中要求reducer是无副作用的。这是为什么呢?下面两段代码都表示将一个数组全部元素先加1,然后将值大于10的元素全部剔除数组,然后返回这个数组。

function transformArr(arr) {
   // 有副作用
   for (let i = 0; i < arr.length; i++) {
        arr[i] += 1;
        if (arr[i] > 10) {
            arr.splice(i, 1);
        }
    }
    return arr;
}

function transformArr(arr) {
   // 无副作用
   const greaterThen10Arr = arr.map(e => e + 1).filter(e => e > 10);
   return greaterThen10Arr;
}

可以看出,无副作用的代码能够更清晰地表达作者的意图,试想如果你在别人的代码中看到一段对数组的for循环遍历,除了看这个循环对数组做了什么,更让人担惊受怕的是这个循环有没有对数组以外的东西进行了处理,而无副作用的代码只需要看变量名便明白了作者希望得到一个什么样的数组。同样,如果在一个reducer是有副作用的,那么后续维护,你很有可能要回去看这个reducer做了什么,会不会对新增的功能产生影响。这是违背redux接口与实现分离的理念的。

副作用处理方案

redux诞生时,JS社区对如何处理异步的问题还在讨论中,大概是因为这个原因导致redux没有对副作用的处理方案,然而前端不可能做到所有代码无副作用,那么副作用该放哪?下面是社区的方案。

async/await

这是小型项目最简单的方案,如以下代码,在获取用户id后,根据用户id获得对应数据,最后发出action将数据保存到store中。

function fetchData(userId) {
    // 返回一个promise, 内容是数据
}

function fetchUser() {
    // 返回一个promise, 内容是用户信息
}

class Component {
    componentDidMount() {
        const { userInfo: { userId } } = await fetchUser();
        store.dispatch({type: 'UPDATE_USER_ID', payload: userId});
        const { data } = await fetchData(userId);
        store.dispatch({type: 'UPDATE_DATA', payload: data});
    }
}

那问题来了,如果其他组件也需要复用这个获取数据的逻辑该怎么办呢,稍好的方案是建立一个类来实现复用,如下:

class DataHandler {
    static fetchData() {
        const { userInfo: { userId } } = await fetchUser();
        store.dispatch({type: 'UPDATE_USER_ID', payload: userId});
        const { data } = await fetchData(userId);
        store.dispatch({type: 'UPDATE_DATA', payload: data});
    }
}

class ComponentA {
    componentDidMount() {
        DataHandler.fetchData();
    }
}

class ComponentB {
    componentDidMount() {
        DataHandler.fetchData();
    }
}

但是这样就会导致了维护的问题,DataHandler该如何扩展?比如ComponentB希望网络请求结束后,希望对上方第五行中的data进行变换后更新store的其他字段,那你可能需要在DataHandler添加一个给B的专属函数用来处理,长期下来DataHandler就会变得越来越臃肿,最重要是组件对DataHandler是依赖关系,DataHandler的每一次修改都需要检查所有引用DataHandler的组件,DataHandler会越来越变得不可维护。因为我们需要redux。

redux-thunk

redux-thunk用法就不在这里介绍了,主要优点是灵活好用,团队推广难度低,克服DataHandler的缺点,非常适合小型项目使用。

缺点是对不能对异步进行粒度小的控制(下文会说明),不容易测试。需要说的是这篇文章无法也不会去说明redux-saga比redux-thunk的绝对优势,使用哪个完全取决具体场景和团队的选择。

redux-saga

redux-saga最大的缺点是绝对高的理解门槛和缺乏可以公开讨论的使用场景----小型项目根本用不上,大型项目不一定适合在公众场合讨论具体的技术细节,这就导致学习资源很缺乏----除了API使用外。

redux-saga是个非常强大处理副作用的工具。它提供了对异步流程更细粒度的控制,对每个异步流程他可以实现暂停、停止、启动三种状态。此外redux-saga利用了generator,对每个saga,其测试方式可以非常简单。更重要的是其异步处理逻辑放在了saga中,我们可以监听action触发,然后产生副作用。action依然是普通的redux action,不破坏redux对action的定义。

redux-saga如何工作

redux-saga基于generator。我们肯定都认识generator是什么,但是概念很虚,让人看得云里雾里。我们随便拉一个generator例子:

function* gen() {
  const x = yield 1;
  const y = yield 2;
}
const g = gen();
g.next() // { value: 1, done: false }
g.next() // { value: 2, done: false }

这是很简单的generator例子,但我们还是不知道generator可以用来做什么。其实我们可以把gen当成一个任务清单,然后有一个人拿到这个任务清单然后开始执行任务。比如以下代码:

function* gen() {
    const user = {userName: 'xiaoming'};
    const article = {articles: [{title: '文章'}]};
    const x = yield Promise.resolve(user);
    console.assert(x === user); // true
    const y = yield Promise.resolve(article);
    console.assert(y === article); // true
  }
  
 async function worker(gen) {
    const g = gen();
    let task = g.next();
    while(!task.done) {        
        const response = await task.value;
        task = g.next(response);
    }
}

worker(gen);

worker就是执行任务的那个人,gen就是那个任务清单。如果联想一下redux-saga,redux-saga就是那个worker,我们需要做的就是编写任务清单-----编写saga,那saga是什么呢?

saga是什么

saga是个奇怪的名字,下面是字典和论文的定义:

字典:A saga is a long story, account, or sequence of events.

论文:A LLT(Long lived transactions) is a saga if it can be written as a sequence of transactions that can be interleaved with other transactions.

简单解释就是,saga是一个任务列表,任务执行顺序是有序的,每个任务的状态可以被改变。有序好理解,那什么是可以被改变呢?下面代码是项目关于网络请求的代码:

function* request(action: PayloadAction) {
  try {
    yield put(requestActions.start(action));
    const response = yield call(apiRequestAndReturnPromise);
    yield put({type: `${action.type}`_SUCCESS, payload: response});
  } catch (error) {
    yield put({type: `${action.type}`_FAIL, payload: error});
  }
}

function* cancelSendRequestOnAction(abortOn: string | string[], task: any) {
  const { abortingAction } = yield race({
    abortingAction: take(abortOn), // 可以是一个数组
    taskFinished: join(task),
    timeout: call(delay, 10000), // taskFinished doesn't work for aborted tasks
  });
  if (abortingAction) {
    yield cancel(task);
  }
}

function* requestWatcher() {
    const newTask = yield fork(apiRequest, action); // Task 1
    yield fork(cancelSendRequestOnAction, abortOn, newTask); // Task 2
}

这段代码可能会有点绕,可能需要你多看几次。

首先requestWatcher用fork启动了两个异步任务,因为使用了fork,Task 2不会等待Task 1结束,而是会在Task 1开始之后马上执行。

fork返回的newTask是一个saga的任务对象,我们可以对这个任务进行处理,比如取消。

Task 1是发起网络请求获取数据。没有什么特别的。

Task 2是重点,可以解释什么是saga的状态可改变。Task2的意思是,abortingAction、taskFinished、timeout任意一个action触发时,就返回abortingAction,如果abortingAction就取消刚才发起的网络请求任务。

这是一个很有意思的特性,在前端除了网络请求,还有以下的异步行为:Promise、setTimeout、setInterval、点击事件(欢迎补充),那利用redux-saga能简单实现很复杂的交互,特别是在编辑器中的应用。

结论

  • redux可以实现接口与实现分离,async/await处理副作用会破坏这一设计模式;
  • 无副作用的代码质量一般情况下更高;
  • redux-thunk和redux-saga无优劣之分,只有场景的区别;
  • generator本质上应该分出"任务清单"和"worker"两部分,我们把编写好的"任务清单"交给worker,worker负责完成任务清单的工作,而redux-saga就是那个worker;
  • saga是一个任务集合,其任务执行有序,且状态可以被其他任务改变。

PS:热烈欢迎批评