小程序组件设计

目的

  • 解耦:每一个组件可以独立地负责一块功能,而且可以轻松地引入和移除。
  • 复用:不仅有视图的复用,还包括数据和逻辑的复用。
  • 简洁:避免/减少模板代码。
  • 精准:支持不同粒度的控制,应用级/页面级/组件级。

现状

小程序里如果要使用一个组件,通常要干这么几件事情:

  1. 加入组件引用
  2. 在axml中使用组件

接下来,我们会面临两种情况:

  • 如果这个组件的逻辑是完全内置的,且不需要与父组件和其他组件交互,那么它本身就已经满足了解耦的条件,无需更多操作,这种情况是最理想的。
  • 但是现实往往是不如意的,大部分组件只是提供了一些功能,需要从外部获取数据,或者与其他组件交互。这使得我们要写很多重复的模板代码。
<!-- 父组件内 -->
<child data1="data1" data2="data2" onAction1="handleAction1" onAction2="handleAction2" />

为此,我们还需要在父组件中实现相似的事件处理逻辑、相同的数据结构,这就导致父子组件耦合在了一起。 这样的逻辑和数据很难在不同的页面间复用,所以这样的组件最终仅仅实现了视图的复用。

而很多情况下,父组件并不关心这些数据,只是起了一个存储和处理事件的功能。 这时你可能会想到 redux,没错我们有 lux,但是它也有一些弊端。

Lux 的问题

Lux 在小程序中实现了 Vuex,以很低的学习成本给我们带来了很多方便。

然而,小程序和普通的H5应用有一个很大的不同:H5的不同页面之间是隔离的,所以我们的全局 store 在不同的页面间也是完全隔离的;而小程序的 store 是应用级的,因为它只有一个 JS 环境,是被所有页面共享的。

这导致了一个影响比较大的问题,Lux 从设计上不支持页面多开。 在我看来,从一个详情页跳转到另一个详情页,是很正常的需求,所以多开应该是合理的,但是这时候我们一般会说这个需求做不了,建议换一个方案避免多开。😂

还是这个原因,Lux 从设计上不支持组件重复使用。因为 Lux 是在静态 connect 组件到一个 store,所以组件对应的 store 始终是全局 store。同一个组件如果要多次使用,就不能把它的数据和逻辑全部放到 store 中,也就没法避免上面的情况,不得不和父组件耦合起来。

如果组件不能重复使用,那抽取组件就变得没什么意义了。所以我们需要寻找新的方案。

来自 Angular 的灵感

用过 Angular 1 的小伙伴应该会比较熟悉这个场景,只要写一个 HTML 模板,然后绑一个 controller,就成为了一个可交互、带数据绑定的组件了,而且通过 controller 的暴露,还可以让应用的其他部分对这个组件的数据进行操作,起到类似于 redux 的作用(数据的单向流动需要自己保障)。这样实现的组件是完全独立的,引入和移除都不需要过多地修改其他组件或应用。

小程序的一个有功能的组件也可以通过这种方式来实现,只需要:

  • 一个普通的小程序组件,用于实现数据到视图层的绑定关系
  • 一个controller,用于控制数据的变化

这样我们可以把上面的例子变成:

<!-- 父组件内 -->
<child />
// controller.js
const createController = () => ({
  data1,
  data2,
  onAction1() {
    // do stuff
  },
  onAction2() {
    // do stuff
  },
});

组件内部只要能获取到这个 controller,就可以取到相关数据,调用相关的事件处理方法,完全不需要父组件操心。而应用内其他地方只要能获取到 controller,就可以对这个组件进行操作,类似于 Lux 的 dispatch,但是范围更精准了。

那么如何在尽量不侵入其他组件的情况下让这个组件获取到对应的 controller 呢? 一个最简单的方法是,将 controller 挂载到 page上,然后组件要用的时候从page 上,然后组件要用的时候从page 上取下来,因为 $page 是页面的实例,不影响多开,而且组件都可以访问到,获取很方便。为了不影响父组件,可以通过给 controller 实现一个 attach 方法来挂载:

// 在 page 中挂载 controller
const pageOptions = {
  onLoad() {
    controller.attach(this);
  },
};

// 在组件中使用
const compOptions = {
  didMount() {
    const controller = getController(this.$page);
    // 可以挂载到 data 上方便组件内的使用
    this.setData({ controller });
  },
};

挂载的原理就是使用一个唯一的 key 挂载到 $page 上,但是这个 key 不需要被外部感知,在 attach 内部处理即可,减少组件使用者的负担。(开放封闭原则?)

不同粒度的复用

如果要做应用级的复用,我们只需要全局共用一个 controller 即可。 由于 page的隔离,这种挂载到page 的隔离,这种挂载到page 的方式天然支持了页面级的复用。

组件级的复用会复杂一些,我们需要在创建组件的时候生成一个唯一ID,然后在挂载到 $page 的时候加上这个唯一ID,并让组件获取 controller 的时候也带上这个 ID。

const key = '组件唯一key';

const controller = {
  id: getUniqueId(),
  attach(page, id) {
    _.set(page, [key, id], controller);
  },
  // ...
};

function getController(page, id) {
  return _.get(page, [key, id]);
}

数据的同步

通过上面的这些步骤,我们可以实现组件数据和逻辑的剥离,放到一个单独的 controller 中用于在不同的页面、组件内复用。但是还存在一个比较麻烦的问题,就是如何让数据的更新最终反映到视图上。本质上就是要让数据变化时可以通过小程序组件的 setData 来触发更新。

所以我们需要一种机制,能检测到 controller 数据的变化,并在变化后更新组件内部的数据。

这就是一个 reactive 的效果。我们已经有很多现成的工具可以用了,就不造轮子了,比如 Vue、rxjs、Svelte stores。

考虑到学习成本和兼容性,我选择了 Vue 2.x。

用 Vue 来实现 reactive 效果非常方便。

首先,将所有涉及的数据改成 observable:

// 将 controller 用 observable 包起来,完全不影响使用
const controller = Vue.observable({
  // ...
});

然后将所有需要同步更新的数据用 computed 方式实现,配合一个自动同步数据的 mix-in,就可以完美实现数据的同步了:

const computed = {
  data1: () => controller.data1,
  data2: () => controller.data2,
};

const options = {
  mixins: [reactiveMixIn(computed)],
  methods: {
    handleAction1() {
      controller.onAction1();
    },
    handleAction2() {
      controller.onAction2();
    },
  },
};

结语

tinyapp-component.png

至此,我实现了一套新的小程序组件方案。它基本达成了我最初的目标:

  • 侵入性低,通过独立的 controller 控制逻辑,不与其他组件耦合;
  • 复用性高,在不同组件、不同页面应用都不需要重复实现数据结构和方法;
  • 直接操作 controller,调用方法,符合直觉的代码逻辑,无需通过字符串的方法名匹配;
  • Vue 的依赖跟踪使得数据的更新及时且精准,不会造成不必要的更新。

© 2020