聊聊前端框架 - panchaow/blog GitHub Wiki

前端框架虽然数量众多,采用的架构可能也各有不同,比如MVC、MVVM,但是它们的工作原理是大同小异的。本文不会介绍MVC、MVVM等架构以及它们之间的区别。因为我觉得重要的是这些架构所蕴含的思想,而不是它们具体是如何组织的。

前端要负责的任务其实很简单,就是给用户提供一个友好易用的交互界面,以便用户使用某种资源。当然,随着技术的发展,这个资源不一定放在服务器上了,可能存在用户本地的文件系统里,也可能存在于浏览器的localStorage中。具体地说,前端的工作可以分成几部分:

  1. 将应用的State合理地呈现在页面上
  2. 正确及时地处理用户的交互行为
  3. 与外部资源(比如服务器接口等)交互

这些部分基本上都是不可或缺的。为了完成这些工作,框架需要实现一些功能。

其中,根据应用的State正确渲染出页面应该是框架最为基础的功能。一般来说,我们需要提供一个函数Render(State) -> View来告诉负责计算View的组件该如何从State对应到View。注意,这里的View一般不是DOM树。因为对于DOM的操作是比较繁琐且容易出错的,所以框架会另外提供一种格式来描述View,比如Virtual DOM树。根据Virtual DOM树渲染和更新DOM树的任务交由框架的另外组件来完成。由于State是会发生变化的,因此需要一个机制在State改变时触发重新计算和更新View的流程。由于JavaScript没有提供监听对象改变的途径,因此需要State在改变时通知计算View的组件。有多种方法可以实现这个功能,比如利用accessor property/Proxy来捕获对于State的访问,或者提供特定的用于更新State的函数。下面是这两种方式的使用和区别:

MobX的工作原理和Vue很类似。它们在大部分情况下都是利用accessor property/Proxy来实现功能,只有在小部分情况下,只能提供特定的API以供调用。但是在Vue 3和MobX 5转到使用Proxy后,很多限制都已经不存在了。

使用MobX:

// MobX的工作原理基于accessor property/Proxy
import { autorun, makeObservable, observable } from "mobx";
import { html, render } from "lit-html";

type State = {
  count: number;
};

const state: State = makeObservable(
  {
    count: 0,
  },
  {
    count: observable,
  }
);

const state2View = (state: State) => {
  return html`
    <div>
      <p>count: ${state.count}</p>
    </div>
  `;
};

autorun(() => {
  render(state2View(state), document.getElementById("root")!);
});

// changing state leads to recalculate view and rerender
state.count += 1;

Redux采用的是比较传统的发布-订阅模式,工作原理比较简单。

使用Redux:

import { createStore, Reducer, Action } from "redux";
import { html, render } from "lit-html";

type State = {
  count: number;
};

type ActionTypes = "INCREASE" | "DECREASE";

const initialState: State = {
  count: 0,
};

const reducer: Reducer<State, Action<ActionTypes>> = (
  state = initialState,
  action
) => {
  switch (action.type) {
    case "INCREASE":
      return {
        count: state.count + 1,
      };
    case "DECREASE":
      return {
        count: Math.max(0, state.count - 1),
      };
    default:
      return state;
  }
};

const store = createStore(reducer);

const { getState, dispatch, subscribe } = store;

const state2View = (state: State) => {
  return html`
    <div>
      <p>count: ${state.count}</p>
    </div>
  `;
};

const update = () => {
  render(state2View(getState()), document.getElementById("root")!);
}

subscribe(update);

update();

// changing state leads to recalculate view and rerender
dispatch({
  type: "INCREASE",
});

相比之下,Redux实现的逻辑更容易理解,但是MobX更加方便使用。个人而言,我更喜欢redux这种实现方式,因为代码的逻辑更加清晰,更容易follow。

当然,复杂的应用不可能只有这么一个render函数。如果把所有的计算view的代码都写在函数里,这个函数会变得非常庞大。这样一方面不利于复用,另一方面是会影响效率。所有框架一般都会提供组件或者其他类似的功能让开发者能够将应用分割成几个小的组成部分。组件实例不必要对于无关的State改变做出响应,这对于提高局部更新的效率有很大的帮助。

至于用户的交互,很多框架都提供在View中通过申明式绑定处理函数的支持。至此,框架已经大致可以工作了。

自从接触RxJS了解到流的概念的时候,我在想能不能把流的思想也融合到上述的流程中去。其中,我们很容易就可以看到State流的存在。现在需要做的就转变成了将State流对应到View流。于是,我们需要的Render就变成了Render(State流 -> View流)。这个Render也正是RxJS中的一个operator。按照这个思路写出代码:

import { BehaviorSubject } from "rxjs";
import { map } from "rxjs/operators";
import { html, render } from "lit-html";

interface State {
  count: number;
}

const subject = new BehaviorSubject<State>({
  count: 0,
});

const state2View = (state: State) => {

  const inc = () => {
    console.log(state);
    subject.next({ count: state.count + 1})
  }

  return html`
    <div>
      <p>count: ${state.count}</p>
      <button @click=${inc}>Click</button>
    </div>
  `;
};

subject.pipe(map(state2View)).subscribe((temRes) => {
  render(temRes, document.getElementById("root")!);
});

接下来是关于如何处理用户进行的操作。我们先来看看最经典的纯RxJS是如何做的。

import { fromEvent } from "rxjs";
import { map, mapTo, scan, startWith } from "rxjs/operators";
import { render, html } from "lit-html";

fromEvent(document.getElementById("btn")!, "click")
  .pipe(
    mapTo(1),
    scan((x, y) => x + y),
    startWith(0),
    map((count) => {
      return html`
        <div>
          <p>count: ${count}</p>
        </div>
      `;
    })
  )
  .subscribe((template) => {
    render(template, document.getElementById("count")!);
  });

event$ -> state$ -> view$,可能是最理想的数据流了。这不仅尽可能避免了命令式的代码,而且将相关的代码都组织在一起,逻辑一目了然,特别清晰。这种处理方式的缺点在于:1.开发者可能更习惯声明式地直接为element绑定事件处理函数,而不是先通过selector获得element再进行绑定;2.关于state的处理有些过于pure;3.对于习惯redux的single source tree模式的用户来说,state分布在多个地方这一点可能需要适应。

所以更为常用的方式可能是使用Subject。直接看vue-rx的实例代码:

template:

<button v-stream:click="plus$">+</button>

JavaScript:

import { Subject } from 'rxjs'
import { map, startWith, scan } from 'rxjs/operators'
 
new Vue({
  subscriptions () {
    // declare the receiving Subjects
    this.plus$ = new Subject()
    // ...then create subscriptions using the Subjects as source stream.
    // the source stream emits in the format of `{ event: HTMLEvent, data?: any }`
    return {
      count: this.plus$.pipe(
        map(() => 1),
        startWith(0),
        scan((total, change) => total + change)
      )
    }
  }
})

这种方式解决了一部分的问题。如果再结合一些redux的思想,在事件处理逻辑中dispatch一个action,那数据流就变为了action$ -> state$ -> view$。

⚠️ **GitHub.com Fallback** ⚠️