实战练习 - 使用 RxJS 实作 Flux Pattern

使用 React 作为前端架构的朋友对於 Flux 应该都不陌生,React 也内建了 Flux 让我们可以直接使用,同时也有许多其他的 library 以这个架构为基础设计,并应用在各种前端框架下,如 Redux (for React)、NgRx (for Angular) 和 Vuex (for Vue) 等等。可以见得它是多麽重要的东西,今天我们来尝试实际使用 RxJS 来实作一下这种架构,也藉此多认识一下 Flux Pattern 的重要观念。

简介 Flux Pattern

在现代化的网页 SPA 架构下,我们已经非常习惯将画面上的众多内容拆成许多小的元件,让它们可以各司其职,最後再组合起来,也因此不论是 Angular、React 还是 Vue 这些目前当红的前端架构都以元件化设计为基础,然而当画面越来越复杂时,元件跟元件之间的沟通就成为了一个问题;负责处理资料的元件可能需要将资料一路传到下面好几层负责显示内容的元件,让管理上变得更加麻烦且复杂。

https://ithelp.ithome.com.tw/upload/images/20201017/200206172bavQnrLGN.jpg

因此 Facebook 提出了 Flux 架构,并内建在 React 内,统一了资料的来源,及资料处理流向,让我们能用更加一致的方法去变更资料,以及得知资料变更的结果,并更新在画面上。

Flux Pattern 重要角色

下图是 Flux 的基本资料流程:

https://ithelp.ithome.com.tw/upload/images/20201017/20020617fW5Lo8Rx6a.png

资料来源:https://facebook.github.io/flux/docs/in-depth-overview

Flux Pattern 包含几个重要角色:

  • View:负责画面显示,也就是网页上的各个元件。
  • Action:负责定义要执行的行为,Action 只负责定义行为类型 (type) 和所需的资料 (payload),但不会参与实际变更资料的实作,通常由 View 或其他逻辑程序负责发起这个 Action。
  • Dispatcher:负责分配行为变更资料的方法,Dispatcher 会根据 Action 的内容来对资料进行变更,也就是实际负责让资料被改变的角色。
  • Store:资料来源,也就是 Dispatcher 实际要变更的目标,当 Store 资料被变更时,也需要负责告知 View 资料被异动了,View 才会知道需要更新画面。实际上不止 View 需要知道 Store 内容异动,只要使用到 Store 资料的程序,都应该需要知道资料被改变了,以便进行对应处理。

Flux Pattern 资料流向

从图片中我们也可以看到,资料的流向是单向的。

首先由 View 或其他程序逻辑负责发起 Action 给 Dispatcher

https://ithelp.ithome.com.tw/upload/images/20201017/200206177f0LrWqJox.jpg

Dispatcher 收到 Action 後,再针对 Action 提供的资讯来决定如何更新 Store 资料来源

https://ithelp.ithome.com.tw/upload/images/20201017/20020617wFwd8oDjtk.jpg

当 Store 资料更新後,所有使用到 Store 资料的程序都需要知道资料被更新了,以便进行其他操作

https://ithelp.ithome.com.tw/upload/images/20201017/200206172IcgJ9Hxf4.jpg

透过这样单向资料流的方式,我们能更容易理解资料的流向,而每个角色也只需要负责自己该做的事情:

  • View 只需要负责发起 Action 就好,不用担心资料变更的逻辑,而资料变更後也会由 Store 通知。
  • Action 不用也不用负责资料变更的逻辑,只负责定义提供给 Dispatcher 的资料就好。
  • Dispatcher 只专注在如何根据 Action 类型的不同来更新 Store 资料。
  • Store 专注在提供资料给需要的程序。

乍看之下拆出了很多角色让程序变得更加复杂,但实际上在越来越复杂的逻辑时,各自处理各自的事情在阅读和维护上都会更加容易;另外一种应用是:在程序开发的初期,我们也可以先思考好要处理哪些 Action,以及如何从 Store 取得资料,而不用担心资料该如何变更,可以更快速的先开始工作,等到资料变更的逻辑明确後,再进入 Dispatcher 的开发。

使用 Flux Pattern 的另一个好处是,由於资料来源统一放在 Store 内了,这代表当元件很复杂时,我们可以不用考虑元件跟元件之间该如何传递资讯,只需要统一跟 Store 沟通就好,管理上会更加容易。

https://ithelp.ithome.com.tw/upload/images/20201017/20020617925dASDKRH.jpg

有了基本观念後,就来看看使用 RxJS 如何实作一个简单的 Flux Pattern 吧!

使用 RxJS 实作 Flux Pattern 与 Todo List App

首先先看一下我们预计实作的结果:

https://ithelp.ithome.com.tw/upload/images/20201017/20020617HmUleltyja.jpg

我们要练习的是 RxJS 的实作,因此关於 HTML 与 DOM 物件的操作,已经先准备好了,可以先到以下网址练习:

https://stackblitz.com/edit/mastering-rxjs-flux-pattern-starter

完整的程序码:

https://stackblitz.com/edit/mastering-rxjs-flux-pattern-finished

index.ts 内已经先填好一些程序并注解掉跟 Flux Pattern 有关的操作了,当相关程序完成後,只需要取消注解就可以看到相关的行为。

之後大部分的程序都会在 todo-store 这个目录内实作。

虚拟的元件架构

虽然没有使用如 Angular 等前端框架,但我们依然可以虚拟的想像成有几个元件,让这些元件各自处理各自的事情:

https://ithelp.ithome.com.tw/upload/images/20201017/20020617n2B5EQFHRz.jpg

实作资料来源 - Store

首先我们先开始实作一个资料来源 Store,在 RxJs 内,要得知资料改变再容易不过了,只要建立一个 Observable,之後在需要时就可以直接订阅这个 Observable 来得知内容改变,至於要选用哪种类型的 Observable 呢?

考量到未来 Dispatcher 要更新资料时,我们会需要 Store 内的资料,因此使用 BehaviorSubject,这麽一来不仅可以立刻给予一个初始内容,也可以使用 value 属性随时得知目前资料的内容。因此在练习专案内我们打开 todo-store/todo-store.ts 建立一个 BehaviorSubject

import { BehaviorSubject } from 'rxjs';

export interface TodoState {
  loading: boolean;
  todos: {
    id: number;
    name: string;
    done: boolean;
  }[]
}

export const store$ = new BehaviorSubject<TodoState>({
  loading: false,
  todos: []
});

interface 部分是 TypeScript 语法,让我们先定义好想要的资料模型。

在元件部分,考量到元件产生的时机点不同,订阅 Observable 的时机也不同,如果想要在元件产生的订阅就能取得最新的资料,使用 ReplaySubject 是一个比较好的选择,但在 Store 内使用 BehaviorSubject 比较方便,该怎麽办呢?由於 Strore 外的目标是使用 Observable,而不会直接改变状态,因此我们可以先使用 asObservable() 让 Subject 的 next() 等实作隐藏起来,再搭配 shareReplay() operator 来让外部程序订阅时能得到最新的 store 资料,因此我们打开 todo-store/index.ts 设定外部程序可以使用的内容:

import { shareReplay } from 'rxjs/operators';
import { store$ as storeSubject$ } from './todo-store';

export const store$ = storeSubject$.asObservable().pipe(shareReplay(1));

透过这种方式外部程序在使用 store$ 时,就不用担心呼叫到 next() 来变更资料,也可以在订阅时取得最近一次的事件资料内容罗,例如以下程序 (index.ts):

import { store$ } from './todo-store';

// 订阅 store$ Observable,当资料改变时可即时收到通知
// 透过 map operator,可以只专注在想要的资料上
store$.pipe(map(store => store.todos)).subscribe(todos => {
  // 当 store$ 资料变更後,在此更新画面
});

实作执行动作 - Actions

接着来设计要更新资料的行为,这里预定有三个行为要处理,包含

  • 设定预设的 todo list
  • 新增 todo item 到 todo list 内
  • 变更 todo item 完成状态

我们先打开 todo-store/todo-action-types.ts 设定好要处理的 Action 类型:

export class TodoActionTypes {
  static LoadTodoItems = '[Todo List] Load Todo Items';
  static AddTodoItem = '[Todo List] Add Todo Item';
  static ToggleTodoItem = '[Todo List] Toogle Todo Item'
}

TodoActionTypes 主要是用来定义有哪些 Action 类型可用,以便之後 Dispatcher 透过这些资讯决定要怎麽更新内容。

接着打开 todo-store/todo-actions.ts 来建立几个产生这些 Action 的方法:

import { TodoActionTypes } from "./todo-action-types";

export const loadTodoItemsAction = () => {
  return {
    type: TodoActionTypes.LoadTodoItems,
    payload: null
  };
}

export const addTodoItemAction = (payload) => {
  return {
    type: TodoActionTypes.AddTodoItem,
    payload
  }
}

export const toggleTodoItemAction = (payload) => {
  return {
    type:TodoActionTypes.ToggleTodoItem,
    payload
  }
}

每个方法都会设定目前 Action 的类型,同时提供要传入的资讯 (payload),之後 Dispatcher 可以针对这个 payload 内容来改变 Store 的资料,例如 addTodoItemAction() 可以传入要新增的 todo item 文字,之後 Dispatcher 得知 Action 类型是 TodoActionTypes.AddTodoItem 时,就可以将 payload 设定为新的 todo item 的文字内容。

最後再 todo-store/index.ts 内设定让外部程序可以使用这些建立 Action 的方法:

export * from './todo-actions';

变更资料的实际逻辑 - Dispatcher

在 Dispatcher 这边,我们稍微复制一点 Redux 的概念,加入一个 Reducer 的角色,Dispatcher 会将目前资料以及 Action 传给 Reducer,而 Reducer 负责回传一个新的结果,Dispatcher 再将得到的新结果更新成目前的资料内容。

Reducer 逻辑

先打开 todos-store/todo-reducer.ts 加入预设处理内容:

import { of } from 'rxjs';
import { TodoActionTypes } from './todo-action-types';

// currentState: 目前 store 内的资料
// action: 要执行的 Action
export const todoReducer = (currentState, action) => {
  switch (action.type) {
    // TODO: 针对 action.types 来决定要如何更新资料
    // case TodoActionTypes.XXXX: {
    //   return
    // }
  }
  // 如果没有可以处理的 action type,直接回传原来的内容
  return of(currentState);
};

在这里我们设定 todoReducer 需要回传一个 Observable,以便充分利用 RxJS 的特性,在 todoReducer 内,我们需要针对不同的 Action 来决定要怎麽改变目前的资料,例如当 Action type 为 TodoActionTypes.AddTodoItem 时,将目前资料加入一笔新的 todo item,并包装成 Observable 回传:

import { concat, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { TodoActionTypes } from './todo-action-types';

export const todoReducer = (currentState, action) => {
  switch (action.type) {
    // action.type 为 TodoActionTypes.AddTodoItem
    case TodoActionTypes.AddTodoItem:
      const newState = {
        ...currentState,
        todos: [
          ...currentState.todos,
          {
            id: currentState.todos.length + 1,
            // todo item 的 name 属性就是 action.payload
            name: action.payload,
            done: false
          }
        ]
      };
      return of(newState);
      break;
  }

  // 如果没有可以处理的 action type,直接回传原来的内容
  return of(currentState);
};

包装成 Observable 的好处是除了单纯回传新的资料外,也可以组合成复杂的资料流,例如使用 ajax 呼叫 API,或是先改变 loading 属性当作一次事件,再以新的资料当作第二次事件:

import { concat, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { TodoActionTypes } from './todo-action-types';

export const todoReducer = (currentState, action) => {
  switch (action.type) {
    case TodoActionTypes.AddTodoItem:
      const loadingState = { ...currentState, loading: true };
      // 第一个事件,设定 loading 为 true
      const loadingState$ = of(loadingState);

      const newState = {
        ...currentState,
        todos: [
          ...currentState.todos,
          {
            id: currentState.todos.length + 1,
            name: action.payload,
            done: false
          }
        ],
        loading: false
      };
      // 第二个事件,设定 todos 属性,以及设定 loading 为 false
      // 这里加上 delay(500) 以模拟呼叫 API 的延迟
      const newState$ = of(newState).pipe(delay(500));

      // 最後使用 concat 组合成一个新的资料流
      return concat(loadingState$, newState$);
      break;
  }

  // 如果没有可以处理的 action type,直接回传原来的内容
  return of(currentState);
};

其他两个 Action 也是一样,都是针对资料做处理,就不浪费篇幅,完成程序码在这里:

https://stackblitz.com/edit/mastering-rxjs-flux-pattern-finished

Dispatcher 逻辑

Reducer 只是负责决定新的资料为何,最终变动资料的依然是 Dispatcher 的责任,我们继续把 todo-store/todo-dispather.ts 内容补起来:

export const todoDispatcher = action => {
  from(todoReducer(store$.value, action)).subscribe({
    next: (data: any) => store$.next(data),
    error: data => store$.error(data)
  });
};

todoDispatcher 逻辑很简单,先将 store$ 的资料和 Action 传入 todoReducer 来决定新的资料内容,最终使用 from 包起来可以让 todoReducer 不一定非要回传 Observable 不可,如果没有复杂逻辑,直接回传新的资料也可,但在 todoDispatcher 内用 from 包起来了,所以可以统一使用 Observable 的流程处理资料;得到新的 store 资料後,呼叫 store$.next() 来产生新的 Observable 事件。如此一来只要有订阅 store$ 就可以得知资料的变化啦!

实际使用

我们已经把 Flux Pattern 的 Store、Action 和 Dispatcher 角色相关程序码都完成了,接着就是用 View 的角色实际使用啦!我们可以把 范例专案内 index.ts 的相关注解取消,来看看实际成果。以下举几个使用的例子:

使用 loadTodoItemsAction() 建立取得起始资料的 Action 接着传入给 todoDispatcher,即可将目前 store 资料更新成包含预设资料的内容:

todoDispatcher(loadTodoItemsAction());

当我们要新增一个新的 todo items 时,可以使用 addTodoItemAction() 传入一个文字当作 payload 来建立 Action,然後将此 Action 传入给 todoDispatcher

const todoItemValue = 'Hello World';
todoDispatcher(addTodoItemAction());

要得知目前有所有的 todo items,只需要订阅 todo-storestore$ 即可:

store$.pipe(map(store => store.todos)).subscribe(todos => {
  // 更新画面
});

如果要显示 loading 呢?一样订阅 store$ 并专注在 loading 属性就好:

loading$.subscribe(loading => {
	// 根据 loading 状态显示或隐藏
});

除此一来我们就可以把「读取资料」和「更新资料」各自拆开来处理罗。

本日小结

今天我们学会了 Flux Pattern 的基本知识,并使用 RxJS 完成了一个简易的 Flux Pattern 实作,透过 RxJS 我们可以很容易的把读取资料和更新资料的流程拆开,并且各自处理各自该做的事情。

许多其他前端架构提供的相关 library,概念上也是大同小异,只是让设计方式更加一致而已,因此今天的内容可以多练习几次,把每个行为的资料流向画出来好好理解,未来在使用时,应该就能更加容易上手啦!


<<:  小菜鸡学程序!用 Golang 来跟世界说 Hello 吧!

>>:  Day32:php(1)

Day11-旧网站重写成Vue_2_json抓取资料与渲染

今天要把「关於」的部分写完 首先因为架构是差不多的,所以我打算使用json做成文档然後再抓取渲染 先...

[Day25] 找回密码API – views

哈罗大家好,今天要来撰写我们找回密码API的逻辑,先来看看我的程序码吧~~ 程序码 from dat...

Prepare And Pass Exam With Real Huawei H13-611 Dumps

Start Exam Preparation with Real Huawei H13-611 Du...

[Day23] Angular 简介

呼~~终於写完所有躲在浏览器後面的东西了,现在我们已经架好了主机、布好了 API 程序、装好了 My...

【第二天 - Git 泄漏】

Q1. Git 是什麽? Git 是一个分散式版本控管软件,每个开发者手中都会有完整的一份副本,包含...