ReactiveX流式程式設計—從xstream講起

FEOne發表於2019-04-23

ReactiveX流式程式設計

ReactiveX來自微軟,它是一種針對非同步資料流的程式設計。簡單來說,它將一切資料,包括HTTP請求,DOM事件或者普通資料等包裝成流的形式,然後用強大豐富的操作符對流進行處理,使你能以同步程式設計的方式處理非同步資料,並組合不同的操作符來輕鬆優雅的實現你所需要的功能。

為什麼從xstream講起

xstream的作者也是rxjs的深度使用者,但是作者基於一些實踐中考慮而開發這個庫,作者的解釋:WHY WE BUILT XSTREAM

  1. xstream只有26個核心操作符和工廠函式
  2. 只支援模式流
  3. 只有streamlistenerproducer 三個概念,比較好理解
  4. 壓縮後只有30kb大小,日常開發可以輕鬆整合代替部分繁瑣邏輯

xstream簡單上手

import xs from 'xstream'

// Tick every second incremental numbers,
// only pass even numbers, then map them to their square,
// and stop after 5 seconds has passed

var stream = xs.periodic(1000)
  .filter(i => i % 2 === 0)
  .map(i => i * i)
  .endWhen(xs.periodic(5000).take(1))

// So far, the stream is idle.
// As soon as it gets its first listener, it starts executing.

stream.addListener({
  next: i => console.log(i),
  error: err => console.error(err),
  complete: () => console.log('completed'),
})
複製程式碼

核心概念

Stream

代表從事件發生、處理、監聽的一條管道,每個stream都有很多operator類似:map, filter, fold, take
每次呼叫operator都返回一個新的stream;
一般說來stream中的資料是由producer生產的,但是你可以呼叫shamefullySend*系列函式手動發射事件,但是這種方法是反reactive的,作者強烈不推薦使用;按照我的理解,這些方法在多個stream聯合工作的,用來mock某些流的資料時候會比較有用

Listener

監聽者 ,stream的出口,消費管道最終產物;包含有3個方法

  1. next:stream裡每次有管道里產生的資料到流入到這個next方法裡接收
  2. error:stream資料流轉中有異常情況時呼叫
  3. complete:生產者呼叫了stop方法後呼叫
var listener = {
  next: (value) => {
    console.log('The Stream gave me a value: ', value);
  },
  error: (err) => {
    console.error('The Stream gave me an error: ', err);
  },
  complete: () => {
    console.log('The Stream told me it is done.');
  },
}
複製程式碼

Producer

生產者,stream的入口,用來持續產生流的輸入

var producer = {
  start: function (listener) {
    this.id = setInterval(() => listener.next('yo'), 1000)
  },

  stop: function () {
    clearInterval(this.id)
  },

  id: 0,
}

// This fellow delivers a 'yo' next event every 1 second
var stream = xs.create(producer)
複製程式碼

MemoryStream

記憶流 普通stream的記憶版:它會記住最後一次傳送給listener的next方法的資料,這樣後來addListener新增的監聽者能收到記住的這個資料; 這個特性是很有用的,能夠用來儲存應用執行過程中的一些臨時狀態。

Stream的構造-Factories

create

標準的通過producer構造, create(producer)

createWithMemory

標準的通過producer構造memorystream, createWithMemory(producer)

from

從Array|PromiseLike|Observable建立一個stream

of

從字面量建立一個stream,這樣建立的stream會立刻發射所有的引數,並觸發completed

fromPromise

從promise建立一個stream

merge

合併兩個stream成為一個stream,合併的後的資料按照原本的時間線繼續輸出(如下圖)

image.png

combine

這個單純用文字不太好解釋,請看下圖(借用的rxjs裡的combineLatest圖,功能是類似的)

image.png

另外,rxjs中還有個一個類似的zip操作符(xstream中不存在,自己實現),看下圖仔細體會和xstream的combine的不同

image.png

常用的操作符-Operators

map

image.png

mapTo

image.png

filter

image.png

take

image.png

drop

圖片借用的rx裡的skip,是一樣的效果

image.png

fold

圖片借用的rx裡的scan,是一樣的效果

image.png

flatten

這個是操作符就有點複雜了,涉及到了分流的情況,主要功能是將主stream裡返回的支流直接打平,輸出支流裡的資料;整個xstream標準operators(extra下有擴充套件的)裡只有這個操作符有涉及到分流的處理,彈珠(Marble)圖如下

image.png

這裡解釋一下,為什麼b輸出之後,主流程走到第二個tick,開始輸出第二個支流,這是第一個支流的後續輸出都會被廢棄;

實踐一個TODO List

流式思考

假如現在需要我們寫一個簡單的todolist:有一個 input 和一個 button 當我在input輸入內容之後,點選 button 就往todolist集合裡新增一條資料,每條todo行前面有個 checkbox 用來勾選todo的完成狀態,每條todo行後面有一個 del 按鈕,用來刪除這條todo

ok,讓我們開始之前先用  式的方式思考一下這個問題,  式的方式是基於時間線的演進系統動態變化的一個抽象,那麼基於此我們可以很簡單抽閒出 3 條時間線:

image.png

基於此,可以很容易寫出3條stream的程式碼如下:

// 工具函式,方便的建立dom事件流
import fromEvent from 'xstream/extra/fromEvent';

// 從新增按鈕建立的stream
const addTodoBtn$ = fromEvent(addBtnEl, 'click').map(() => inputEl.value).filter(v => v && v !== '');

// 從刪除按鈕觸發的stream
const delTodoBtn$ = fromEvent(document.body, 'click').map((e: Event) => e.target).filter((target: HTMLElement) => target.classList.contains('delTodo')).map((target: HTMLElement) => parseInt(target.dataset.index));

// 從標記完成選項觸發的stream
const toggleTodoInput$ = fromEvent(document.body, 'change').map((e: Event) => e.target).filter((target: HTMLElement) => target.classList.contains('toggleTodo')).map((target: HTMLInputElement) => ({ checked: target.checked, index: parseInt(target.dataset.index) }));
複製程式碼

好了,現在我們有了3條stream:

  • addTodoBtn$在時間線上持續收集 button 的點選,並判斷input框裡是否有輸入有效內容,如果有的話就將輸入的內容作為stream的資料發射出去
  • delTodoBtn$在時間線上持續收集 del 的點選,並將繫結的data-index數值發射出去
  • toggleTodoInput$在時間線上持續收集 checkbox 的點選,並將當前checkbox的選中狀態和data-index一起發射出去

儲存狀態

現在我們有了3條stream,那麼該如何將這些stream與dom的操作對應起來呢?同時還有另外一個問題:傳統的開發過程中,我們需要有一個外部變數類似state這樣用來儲存每次操作後最新的todolist資料集合(副作用); 但是ReactiveX提倡的方式就是要消除副作用,我們需要一點兒技巧來處理這個狀況;

這裡我們思考一下整個操作分兩部分:增量資料、減量資料、更新資料, 而減量資料和更新資料都是基於增量資料來源的基礎上操作的;那麼我們需要定義一個增量資料來源,並且需要能持續保持這個資料來源最後的資料狀態。

讓我們翻翻上面的operators,看看有哪個操作符是可以用來持久儲存區域性變數的? 沒錯,就是 fold 操作符:

// 資料來源
const startUp$ = addTodoBtn$.fold((todos: Todo[], inputValue) => {
  const todo:Todo = { text: inputValue, completed: false };
  todos.push(todo);
  return todos;
}, []);// 初始化空資料列表
複製程式碼

由於這裡的todolist的增量來源只有 button 一個(如果有多個,可以看看 combine 操作符,這裡不展開);

分支流和flatten應用

有了增量資料來源,那麼我們在增量資料來源的每個tick上分出一個減量、更新的 支流 (參看上面的flatten操作符),這樣支流執行的時候拿到的都是資料來源的最新資料;

// 監聽資料來源,並觸發刪除的stream
const delTodos$ = startUp$.map(todos => delTodoBtn$.map(index => {
  console.log(index);
  todos.splice(index, 1);
  return todos;
})).flatten();

// 監聽資料來源,並觸發選中的stream
const toggleTodos$ = startUp$.map(todos => toggleTodoInput$.map(({ checked, index }) => {
  console.log(checked, index);
  if (todos[index]) {
    todos[index].completed = checked;
  }
  return todos;
})).flatten();
複製程式碼

組裝stream

ok,現在我們的資料來源是多個,輸出的都是todolist最新的資料集合狀態,讓我們把這些stream管道組裝起來:

// 組合起來
const todos$ = xs.merge(startUp$, delTodos$, toggleTodos$);

todos$.addListener({
  next: function(todos: Todo[]) {
    console.log(todos);
    renderTodos(todos);
  },
  error: function(e) {
    console.error(e);
  },
  complete: function() {

  }
})

複製程式碼

副作用

在定義完清晰的stream後,我們的實際業務程式碼就是這麼"簡單",由於stream的出口一直都是最新的todolist集合我們實現了類似react的全量渲染;哈哈,實際上這裡還有個不怎麼簡單的副作用方法:

const inputEl = document.getElementById('input') as HTMLInputElement;
const addBtnEl = document.getElementById('addBtn') as HTMLButtonElement;
const todoListEl = document.getElementById('lists');
const initTodos = [];
const renderTodos = function(todos: Todo[]) {
  if (!todos) {
    return;
  }
  todoListEl.innerHTML = '';
  const fragement = document.createDocumentFragment();
  todos.forEach((todo, index) => {
    const liEL = document.createElement('li');
    if (todo.completed) {
      liEL.className = 'completed';
    }
    liEL.innerHTML = `<input type='checkbox' class="toggleTodo" name="toggleTodo" data-index="${index}" ${todo.completed ? 'checked' : null} />
                      <span>${todo.text}</span>
                      <a href='javascript:void(0);' class='delTodo' data-index="${index}">x</a>`;
    fragement.appendChild(liEL);
  });
  todoListEl.appendChild(fragement);
}

複製程式碼

最終效果

ScreenFlow.gif

總結

通過實際例子可以看到這裡有一大段不怎麼優雅的副作用方法,用來操作dom元素,由於我們基於全量渲染的思想,並沒有使用傳統的同步增刪改dom的方式,否則副作用程式碼會更多; 同時由於沒有vdom的加持,此段渲染程式碼純粹只能用來demo展示一下;  至於更優雅的副作用處理和vdom能力,可以期待我後續關於cycle.js的介紹?

專欄其他文章

ReactiveX流式程式設計—從xstream講起
FE One

關注我們的公眾號FE One,會不定期分享JS函數語言程式設計、深入Reaction、Rxjs、工程化、WebGL、中後臺構建等前端知識

相關文章