我原本元旦前想的是好好学习,一月试着写一写 rxjs再写篇笔记。然后变成年前好好学习,然后后变成元宵节前好好学习。
然后最后笔记是今天写出来的。

前阵子React16.8版本里,hooks终于成为了一个正式的特性。可能相比于先前提出的fiber架构,hooks更直接地影响了React的使用者们书写的方法。React是越来越Reactive了,之前从大佬的文章得到启发,想过要试着摸索一套在使用hooks的函数组件中相对可靠的代码风格,折腾了两个月成果不太满意,想想总结下来总比没有好。

从组件类到hooks

在这次React团队向大家介绍hooks时,比较正式地引入了FP里面常提的副作用(side effects)的概念。

Data fetching, setting up a subscription, and manually changing the DOM in React components are all examples of side effects. Whether or not you’re used to calling these operations “side effects” (or just “effects”), you’ve likely performed them in your components before.
举例而言,请求数据、订阅消息源、手动操作DOM节点都是副作用的表现形式。无论你之前是否管他们叫“副作用”,实际上你已经在你之前的组件里写过副作用了。
——Using the Effects Hooks · https:// reactjs.org/docs/hooks- effect.html

某种意义上来说,一个“纯”的函数式组件的唯一任务就是渲染视图。对于给定同样的props,只会输出同样的视图——一如以往版本当中我们用函数组件所做的那样。但大部分的应用的任务显然不是展示页面这么简单。在原本组件类的写法中,通常我们有两种方式来执行副作用:

  1. 在事件回调中执行副作用,如onClick回调中对修改redux的数据状态。
  2. 在组件的生命周期里执行副作用,如在componentDidMount中发异步请求。

而在旧的函数组件里,无从定义组件的生命周期钩子。而监听作用于节点的事件往往是传递父组件所下发的事件回调,其副作用是在父组件当中定义的。



const MyButton = props => <button
    onClick={props.onButtonClick}
>
    click me
</button>;



在React16.8当中,在hooks函数的补充下,函数组件也具备了一套完整的存储组件内部状态、执行副作用的能力。

使用useEffect监听state变化

关于监听组件的生命周期,hooks所给出的解决方案是,你可以使用useEffect(effectCreator, deps)来定义副作用,并通过监听状态变化来决定是否执行副作用。在fiber渲染一个函数组件时,(16.8.4版本为例)当你调用了useEffect,如果监听的数据发生了变化,副作用将被加入副作用队列中并随后执行,并在下一次该副作用执行前或组件生命周期结束前执行effectCreator返回的函数(往往用来清除副作用产生的影响)。



const App = () => {
  const [foo, setFoo] = useState(0);
  const [bar, setBar] = useState(0);

  useEffect(
    () => {
      console.log(`foo is ${foo}`);
      return () => null; // cleanUp, or void
    },
    [foo]
  );

  useEffect(() => {
    let count = 0;
    setInterval(() => setBar(++count), 100);
    setTimeout(() => setFoo(1), 1000);
  }, []);

  return <div>...</div>;
};



输出结果



foo is 0
foo is 1



在例子中,仅当foo值产生变化时,副作用才会执行。在只有bar值发生变化时,函数执行时useEffect认为所监听的数据状态没有变化,副作用不会被执行。简洁漂亮,我们不仅可以用它实现组件类写法中的生命周期钩子,还能更加方便地约束副作用执行的时机。

处理来自事件回调的副作用

正如之前所说的,以往的函数组件和组件类在处理事件回调上,很大的区别在于事件产生的副作用是否依赖组件内部状态、副作用将会修改组件的内部状态。在hooks当中,React提供了useCallback方法,在函数组件内部状态发生改变时更新回调函数的引用。



const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);



这让人想起react开发中常见的写法问题onClick={() => doSomething()}。重新渲染时,绑定到节点上的是一个新的函数,增加了性能开销。要改进这个问题我们有两种很容易得出的方案:
其一,之前我们尝试过可以模仿组件类里的做法,再用一个实例对象来存储事件回调函数所需要的数据状态。



const self = useMemo(() => ({}), []);
useMemo(() => {
  self.a = a;
  self.b = b;
});
const memoizedCallback = useCallback(
  () => {
    doSomething(self.a, self.b);
  },
  [],
);



突然找回了在组件类里写this的感觉。事实上,在刚刚转变到hooks风格时,这不失为一种痛苦较小的过渡方案。

其二,更激进地将事件回调的触发逻辑和主体分离开,



const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b]
);

const [clickCount, onClick] = useReducer(count => ++count, 0);

useMemo(() => {
  if (clickCount) {
    memoizedCallback();
  }
}, [clickCount]);



可见,memorizedCallback仍然可以调用实时的数据状态。我们维护了一个clickCount状态记录点击的次数,每当onClick触发时,clickCount被修改,触发了useMemo(...),进而调用当前的memorizedCallback

写起来更啰嗦了。

而且更麻烦的是,每次事件执行之后都会触发组件的渲染。必须在渲染视图的部分加一个useMemo避免渲染逻辑的重复执行。

引入rxjs

rxjs中,我们关注的不仅仅是数据的当前状态,而是在整个生命周期里,数据状态的变化情况(流)。由于数据流运算在处理复杂异步场景的出色表现,rxjs常被用于描述复杂的交互动画效果。但是,在组件类里使用rxjs确实比较繁琐,Subscription的订阅和取消在不同的生命周期里完成,比较难进一步封装。

而在hooks补充下的函数组件里,开发者可以更加灵活地自定义hook,自己按需封装可复用的逻辑。

例如,我们可以封装一个用来声明Subject值的函数,而不用每次手动处理创建和销毁。



const useBehaviorSubject = <T>(initValue: T) => {
    const subject = useMemo(() => new BehaviorSubject(initValue), []);

    useEffect(() => () => subject.complete(), []);
    return subject;
};



同样的方法也可以用来封装Subscription

在封装了useBehaviorSubject之后,我们就可以直接在函数组件中声明我们所需要使用到的BehaviorSubject,今儿开始进行数据流运算。



const subject = useBehaviorSubject(''); // BehaviorSubject<string>



state,也用Observable

在一开始学习rxjs的开发思路时,我跑去看了cycle.js,想窥探一二reactive的门道。cycle.js整个开发的风格都建立在reactive思路上,非常漂亮,甚至在输出视图的时候也是一个Observable。但是这也意味着需要对项目已有的老代码全部改用reactive的这一套,过渡成本其实挺大的。很酷,但是不敢用。

而现有的 在组件类中使用rxjs的写法,也往往要求在组件间通信时统一使用Observable类型的数据,减少在stateObservable之间重复转换的成本。不难发现,许多开发者认为,要在react中使用rxjs,往往不得不需要把整个数据结构都转变成rxjs的风格

而有了hooks之后,要封装一个从数据流中读取到state,或将state的变化转换为数据流对象变得简单了起来。例如,



// typescript
export const useObservable = <T>(maker: () => Observable<T>, initValue: T) => {
    let value: T, setValue: React.Dispatch<React.SetStateAction<T>>;
    const [initialState, subscription] = useMemo(() => {
        let initialState = initValue;
        const source = maker();
        let setter = (v: T) => {
            if (!setValue) {
                initialState = v;
            } else {
                setValue(v);
                setter = setValue;
            }
        };
        const subscription = source.subscribe(v => setter(v));

        return [initialState, subscription] as [T, Subscription];
    }, []);
    [value, setValue] = useState(initialState);

    useEffect(() => () => subscription.unsubscribe(), []);

    return value;
};



对于一个 Observable对象,可能在订阅的同时就连续多次发出值。在初次渲染阶段我们需要将最新的值作为 initialState,避免在组件初次渲染之后立即再进行一次渲染导致多余的性能开销。

在封装好了useObservable之后,我们就可以在函数组件里用它来从Observable中读取数据,以触发组件的渲染。



const str = useObservable(() => Observable.of('hello world'), '');



同样的,我们也可以在每次渲染时监听数据的状态,将它们输入到数据流里。



// typescript
export const useObservableFrom = <T>(inputs: T) => {
    const subject$ = useBehaviorSubject(inputs);
    useMemo(() => subject$.next(inputs), [inputs]);
    return useMemo(() => subject$.asObservable(), []);
};



注:一般情况下,我们用变量名以 $结尾的变量表示一个 Observable值,用变量名以 $$结尾的变量表示一个 Subscription

举例而言,在渲染列表时,父组件向子组件传递的props通常是一个非Observable值。当我们想要用rxjs描述子组件内部的数据逻辑时,我们需要先讲父组件代入的props转变为一个Observable对象。

此时我们可以使用useObservableFrom在子组件内部简单地进行数据转换,



class _ListItem extends PureComponent {
  constructor(props) {
    supre(props);
    this.item$ = new BehaviorSubject(props.item);
  }
  componentWillReceiveProps(nextProps) {
    this.item$.next(nextProps.item);
  }
  componentWillUnmount() {
    this.item$.complete();
  }
  // ...
}

// 或者
const ListItem = React.memo(props => {
  const item$ = useObservableFrom(props.item);
  // ...
});



可以看到,在当前版本的函数组件中,引入rxjs的代价不再像之前那么昂贵,对现有的项目侵入性更小,你可以在没有必要使用rxjs的一些组件里很方便地转换为原有的代码风格。

使用rxjs+Context处理跨组件通信

一开始,我们使用redux来管理组件间共同的状态。随后在v16.3版本里,react提供了新版本的Context API。(流行的说法是,在轻量级场景下替代redux的任务)相较于reduxContext API多了一些优势,

  • 修改状态的方式更加灵活,不局限于reducer。
  • 一个组件可以同时访问多个Context。这意味着一些仅需要在局部组件间通信的数据,不需要存储到一个全局的redux当中。

但与此同时,Context API在使用过程中也存在一些麻烦,

  • 一个组件访问多个Conetxt时,Consumer的嵌套难免让人回想起callback hell似的三角形代码。
  • 由于没有类似reduxmapStateToProps,有时一个组件仅依赖于Context当中的一部分数据,但Context其他数据的变化也会导致子组件的重复渲染。
const FromGx3 = React.createContext(null);

const GrandGrandGrandParent = () => {
  const [state, setState] = useState({title: '', content: ''});
  return <FromGx3.Provider value={state}>

  </FromGx3.Provider>
};

// ...

const Child = () => {
  return <FromGx3.Consumer>
    {
      ({content}) => <FromGx2.Consumer>
        {
          user => <div>
            <p>{user.name}</p>
            <p>{content}</p>
          </div>
        }
      </FromGx2.Consumer>
    }
  </FromGx3.Consumer>
};



上述例子中,对于GrandGrandGrandParent组件里下发的状态,在Child组件里只使用了content字段,而当title字段发生变化时,Child组件里定义的div节点也会重新渲染。

但如果我们下发的是一个引用不变的Observable实例,子组件在访问数据流的过程中可以更加明确地声明自己需要的数据,控制来自context的影响。

同时,在useContext的帮助下,我们也可以在函数组件内使用hook函数获取context值,不再需要使用Consumer的层层嵌套来完成。



import { map, distinctUntilChanged } from 'rxjs/operators';

const FromGx3 = React.createContext(null);

const GrandGrandGrandParent = () => {
  const state$ = useBehaviorSubject({title: '', content: ''});
  return <FromGx3.Provider value={state$}>

  </FromGx3.Provider>
};

// ...

const Child = () => {
  const state$ = useContext(FromGx3);
  const content = useObservable(() => state$.pipe(
      map(state => state.content), // `pluck`操作符可以完成类似的任务
      distinctUntilChanged(), // 仅当值发生变化时发出数据
    ), '');
  const user = useContext(FromGx2);

  return <div>
    <p>{user.name}</p>
    <p>{content}</p>
  </div>;
};



当然,这还并不是rxjs擅长的任务。

例子:使用rxjs处理异步逻辑

不止一篇关于rxjs的文章这样描述:rxjs适用于处理复杂UI交互的场景。事实如此,在一些涉及多项异步输入(ajax请求返回、定时事件、节点状态、用户操作输入)的场景下,使用rxjs描述更加直观简便。

举例而言,在一个聊天室场景,如果同一条内容被连续复述超过10次,那么就向用户提示“人类的本质就是复读机”。使用rxjs可以让这一逻辑描述得十分简单



useListener(() => {
  return msg$
    .pipe(
      pairwise(), // 存储最近两次发出的值
      map(([prev, current]) => prev === current), // 判断内容与上一条相同
      bufferCount(9, 1), // 将最近9次发出的值存储在一个数组里
      filter(last9 => !last9.includes(false)) // 最近的9条发言每一条都和上一条一致
    )
    .subscribe(() => console.log("人类的本质就是复读机"));
});



其中 useListener在生命周期结束时自动关闭 Subscription

维护消息队列的逻辑封装在了操作符(operator)里,代码本身可以更直观地反应开发者所描述的业务逻辑。

又比如,当我们在网站的页面上想要写一个展示toast提示的浮层,我们使用了css动画让toast在展示过后渐隐。在一条消息完全透明之后,将它从节点树里移除。




react中如何封装一个request请求_数据


我们只需要


const ToastView = () => {
  const { toast$ } = useContext(CommentInputContext); // 一个Observable<string>,表示应用中发出的每一次toast
  const [onAnimationEnd, animationEnd$] = useEventHandler();

  const toasts = useObservable(
    () =>
      merge(toast$, animationEnd$).pipe(
        scan((toasts, value, uniq) => {
          if (typeof value === "string") {
            return [...toasts, { content: value, uniq }];
          } else {
            return toasts.slice(1);
          }
        }, []),
        startWith([])
      ),
    []
  );

  return (
    <div className="toast-view-container">
      {toasts.map(t => (
        <div className="toast-box" key={t.uniq} onAnimationEnd={onAnimationEnd}>
          {t.content}
        </div>
      ))}
    </div>
  );
};


其中useEventHandler会返回一个回调函数和Subject对象,回调函数每次调用时,Subject对象会发出函数获取的第一个参数。


// typescript
const useEventHandler = <Event>() => {
    const subject = useMemo(() => new Subject<Event>(), []);
    const callback = useCallback((e: Event) => subject.next(e), []);
    useEffect(() => () => subject.complete(), []);
    return [callback, subject] as [typeof callback, Subject<Event>];
};


目前存在的问题

刚开始尝试rxjshooks的配合的时候比较上头,想着可以把整个项目的所有数据状态都用rxjs封装,只在视图层将Observable转换成state。然而对于在简单的场景下,直接使用useState来管理状态绰绰有余。对于web应用大部分并没有复杂交互的场景而言,使用rxjs带来的提升并不明显,甚至徒增繁琐。

另一方面,在文中提到使用hook描述数据流、描述副作用的例子,(事实上包括大部分自定义hook)在每次渲染过程中都会导致多次调用useMemouseEffect等钩子。理论上可以用一个高阶组件来隔离数据流定义和视图渲染层,但目前还没摸索到合适优雅的封装形式,有待进一步探索。

总体而言,rxjs没有成为完美解决数据管理问题的银弹,但这并不妨碍它在复杂异步场景下的出色表现。在hook的帮助下,我们现在可以以更低的代价引入rxjs,并在项目中按需要灵活切换两种不同的编程风格——这多半还不是最优雅的做法,但确实很方便。

  • 本想把组件的输入和副作用都抽象出来,越搞越觉得自己在模仿Cycle.js
  • 吐槽一则: Mobx的思路很reactive,但写起来很vue。如果副作用的控制可以进行数据流运算就更酷了。
  • 文中用到的关于使用rxjshook函数我放在 fugo - npm 了。(名字是因为最近在看jojo(真的好看))。