阅读 32484

从 13K 的前端开源项目我学到了啥?

近期我们团队的小伙伴小池同学分享了 “BetterScroll 2.0 发布:精益求精,与你同行” 这篇文章到团队内部群,看到了 插件化 的架构设计,阿宝哥突然来了兴趣,因为之前阿宝哥在团队内部也做过相关的分享。既然已经来了兴趣,那就决定开启 BetterScroll 2.0 源码的学习之旅。

接下来本文的重心将围绕 插件化 的架构设计展开,不过在分析 BetterScroll 2.0 插件化架构之前,我们先来简单了解一下 BetterScroll

BetterScroll 源码学习脑图 1.0

一、BetterScroll 简介

BetterScroll 是一款重点解决移动端(已支持 PC)各种滚动场景需求的插件。它的核心是借鉴的 iscroll 的实现,它的 API 设计基本兼容 iscroll,在 iscroll 的基础上又扩展了一些 feature 以及做了一些性能优化。

BetterScroll 1.0 共发布了 30 多个版本,npm 月下载量 5 万,累计 star 数 12600+。那么为什么升级 2.0 呢?

做 v2 版本的初衷源于社区的一个需求:

  • BetterScroll 能不能支持按需加载?

来源于:BetterScroll 2.0 发布:精益求精,与你同行

为了支持插件的按需加载,BetterScroll 2.0 采用了 插件化 的架构设计。CoreScroll 作为最小的滚动单元,暴露了丰富的事件以及钩子,其余的功能都由不同的插件来扩展,这样会让 BetterScroll 使用起来更加的灵活,也能适应不同的场景。

下面是 BetterScroll 2.0 整体的架构图:

(图片来源:https://juejin.im/post/6868086607027650573)

该项目采用的是 monorepos 的组织方式,使用 lerna 进行多包管理,每个组件都是一个独立的 npm 包:

与西瓜播放器一样,BetterScroll 2.0 也是采用 插件化 的设计思想,CoreScroll 作为最小的滚动单元,其余的功能都是通过插件来扩展。比如长列表中常见的上拉加载和下拉刷新功能,在 BetterScroll 2.0 中这些功能分别通过 pull-uppull-down 这两个插件来实现。

插件化的好处之一就是可以支持按需加载,此外把独立功能都拆分成独立的插件,会让核心系统更加稳定,拥有一定的健壮性。好的,简单介绍了一下 BetterScroll,接下来我们步入正题来分析一下这个项目中一些值得我们学习的地方。

二、开发体验方面

2.1 更好的智能提示

BetterScroll 2.0 采用 TypeScript 进行开发,为了让开发者在使用 BetterScroll 时能够拥有较好的智能提示,BetterScroll 团队充分利用了 TypeScript 接口自动合并的功能,让开发者在使用某个插件时,能够有对应的 Options 提示以及 bs(BetterScroll 实例)能够有对应的方法提示。

2.1.1 智能插件 Options 提示

2.1.2 智能 BetterScroll 实例方法提示

接下来,为了后面能更好地理解 BetterScroll 的设计思想,我们先来简单介绍一下插件化架构。

三、插件化架构简介

3.1 插件化架构的概念

插件化架构(Plug-in Architecture),是一种面向功能进行拆分的可扩展性架构,通常用于实现基于产品的应用。插件化架构模式允许你将其他应用程序功能作为插件添加到核心应用程序,从而提供可扩展性以及功能分离和隔离。

插件化架构模式包括两种类型的架构组件:核心系统(Core System)和插件模块(Plug-in modules)。应用逻辑被分割为独立的插件模块和核心系统,提供了可扩展性、灵活性、功能隔离和自定义处理逻辑的特性。

图中 Core System 的功能相对稳定,不会因为业务功能扩展而不断修改,而插件模块是可以根据实际业务功能的需要不断地调整或扩展。 插件化架构的本质就是将可能需要不断变化的部分封装在插件中,从而达到快速灵活扩展的目的,而又不影响整体系统的稳定。

插件化架构的核心系统通常提供系统运行所需的最小功能集。插件模块是独立的模块,包含特定的处理、额外的功能和自定义代码,来向核心系统增强或扩展额外的业务能力。 通常插件模块之间也是独立的,也有一些插件是依赖于若干其它插件的。重要的是,尽量减少插件之间的通信以避免依赖的问题。

3.2 插件化架构的优点

  • 灵活性高:整体灵活性是对环境变化快速响应的能力。由于插件之间的低耦合,改变通常是隔离的,可以快速实现。通常,核心系统是稳定且快速的,具有一定的健壮性,几乎不需要修改。
  • 可测试性:插件可以独立测试,也很容易被模拟,不需修改核心系统就可以演示或构建新特性的原型。
  • 性能高:虽然插件化架构本身不会使应用高性能,但通常使用插件化架构构建的应用性能都还不错,因为可以自定义或者裁剪掉不需要的功能。

介绍完插件化架构相关的基础知识,接下来我们来分析一下 BetterScroll 2.0 是如何设计插件化架构的。

四、BetterScroll 插件化架构实现

对于插件化的核心系统设计来说,它涉及三个关键点:插件管理、插件连接和插件通信。下面我们将围绕这三个关键点来逐步分析 BetterScroll 2.0 是如何实现插件化架构。

4.1 插件管理

为了统一管理内置的插件,也方便开发者根据业务需求开发符合规范的自定义插件。BetterScroll 2.0 约定了统一的插件开发规范。 BetterScroll 2.0 的插件需要是一个类,并且具有以下特性:

1.静态的 pluginName 属性;

2.实现 PluginAPI 接口(当且仅当需要把插件方法代理至 bs);

3.constructor 的第一个参数就是 BetterScroll 实例 bs,你可以通过 bs 的 事件 或者 钩子 来注入自己的逻辑。

这里为了直观地理解以上的开发规范,我们将以内置的 PullUp 插件为例,来看一下它是如何实现上述规范的。PullUp 插件为 BetterScroll 扩展上拉加载的能力。

顾名思义,静态的 pluginName 属性表示插件的名称,而 PluginAPI 接口表示插件实例对外提供的 API 接口,通过 PluginAPI 接口可知它支持 4 个方法:

  • finishPullUp(): void:结束上拉加载行为;
  • openPullUp(config?: PullUpLoadOptions): void:动态开启上拉功能;
  • closePullUp(): void:关闭上拉加载功能;
  • autoPullUpLoad(): void:自动执行上拉加载。

插件通过构造函数注入 BetterScroll 实例 bs,之后我们就可以通过 bs 的事件或者钩子来注入自己的逻辑。那么为什么要注入 bs 实例?如何利用 bs 实例?这里我们先记住这些问题,后面我们再来分析它们。

4.2 插件连接

核心系统需要知道当前有哪些插件可用,如何加载这些插件,什么时候加载插件。常见的实现方法是插件注册表机制。核心系统提供插件注册表(可以是配置文件,也可以是代码,还可以是数据库),插件注册表含有每个插件模块的信息,包括它的名字、位置、加载时机(启动就加载,或是按需加载)等。

这里我们以前面提到的 PullUp 插件为例,来看一下如何注册和使用该插件。首先你需要使用以下命令安装 PullUp 插件:

$ npm install @better-scroll/pull-up --save
复制代码

成功安装完 pullup 插件之后,你需要通过 BScroll.use 方法来注册插件:

import BScroll from '@better-scroll/core'
import Pullup from '@better-scroll/pull-up'

BScroll.use(Pullup)
复制代码

然后,实例化 BetterScroll 时需要传入 PullUp 插件的配置项。

new BScroll('.bs-wrapper', {
  pullUpLoad: true
})
复制代码

现在我们已经知道通过 BScroll.use 方法可以注册插件,那么该方法内部做了哪些处理?要回答这个问题,我们来看一下对应的源码:

// better-scroll/packages/core/src/BScroll.ts
export const BScroll = (createBScroll as unknown) as BScrollFactory
createBScroll.use = BScrollConstructor.use
复制代码

BScroll.ts 文件中, BScroll.use 方法指向的是 BScrollConstructor.use 静态方法,该方法的实现如下:

export class BScrollConstructor<O = {}> extends EventEmitter {
  static plugins: PluginItem[] = []
  static pluginsMap: PluginsMap = {}

  static use(ctor: PluginCtor) {
    const name = ctor.pluginName
    const installed = BScrollConstructor.plugins.some(
      (plugin) => ctor === plugin.ctor
    )
    // 省略部分代码
    if (installed) return BScrollConstructor
    BScrollConstructor.pluginsMap[name] = true
    BScrollConstructor.plugins.push({
      name,
      applyOrder: ctor.applyOrder,
      ctor,
    })
    return BScrollConstructor
  }
}
复制代码

通过观察以上代码,可知 use 方法接收一个参数,该参数的类型是 PluginCtor,用于描述插件构造函数的特点。PluginCtor 类型的具体声明如下所示:

interface PluginCtor {
  pluginName: string
  applyOrder?: ApplyOrder
  new (scroll: BScroll): any
}
复制代码

当我们调用 BScroll.use(Pullup) 方法时,会先获取当前插件的名称,然后判断当前插件是否已经安装过了。如果已经安装则直接返回 BScrollConstructor 对象,否则会对插件进行注册。即把当前插件的信息分别保存到 pluginsMap({}) 和 plugins([]) 对象中:

另外调用 use 静态方法后,会返回 BScrollConstructor 对象,这是为了支持链式调用:

BScroll.use(MouseWheel)
  .use(ObserveDom)
  .use(PullDownRefresh)
  .use(PullUpLoad)
复制代码

现在我们已经知道 BScroll.use 方法内部是如何注册插件的,注册插件只是第一步,要使用已注册的插件,我们还需要在实例化 BetterScroll 时传入插件的配置项,从而进行插件的初始化。对于 PullUp 插件,我们通过以下方式进行插件的初始化。

new BScroll('.bs-wrapper', {
  pullUpLoad: true
})
复制代码

所以想了解插件是如何连接到核心系统并进行插件初始化,我们就需要来分析一下 BScroll 构造函数:

// packages/core/src/BScroll.ts
export const BScroll = (createBScroll as unknown) as BScrollFactory

export function createBScroll<O = {}>(
  el: ElementParam,
  options?: Options & O
): BScrollConstructor & UnionToIntersection<ExtractAPI<O>> {
  const bs = new BScrollConstructor(el, options)
  return (bs as unknown) as BScrollConstructor &
    UnionToIntersection<ExtractAPI<O>>
}
复制代码

createBScroll 工厂方法内部会通过 new 关键字调用 BScrollConstructor 构造函数来创建 BetterScroll 实例。因此接下来的重点就是分析 BScrollConstructor 构造函数:

// packages/core/src/BScroll.ts
export class BScrollConstructor<O = {}> extends EventEmitter {
  constructor(el: ElementParam, options?: Options & O) {
    const wrapper = getElement(el)
    // 省略部分代码
    this.plugins = {}
    this.hooks = new EventEmitter([...])
    this.init(wrapper)
  }
  
  private init(wrapper: MountedBScrollHTMLElement) {
    this.wrapper = wrapper
    // 省略部分代码
    this.applyPlugins()
  }
}
复制代码

通过阅读 BScrollConstructor 的源码,我们发现在 BScrollConstructor 构造函数内部会调用 init 方法进行初始化,而在 init 方法内部会进一步调用 applyPlugins 方法来应用已注册的插件:

// packages/core/src/BScroll.ts
export class BScrollConstructor<O = {}> extends EventEmitter {  
  private applyPlugins() {
    const options = this.options
    BScrollConstructor.plugins
      .sort((a, b) => {
        const applyOrderMap = {
          [ApplyOrder.Pre]: -1,
          [ApplyOrder.Post]: 1,
        }
        const aOrder = a.applyOrder ? applyOrderMap[a.applyOrder] : 0
        const bOrder = b.applyOrder ? applyOrderMap[b.applyOrder] : 0
        return aOrder - bOrder
      })
      .forEach((item: PluginItem) => {
        const ctor = item.ctor
				// 当启用指定插件的时候且插件构造函数的类型是函数的话,再创建对应的插件
        if (options[item.name] && typeof ctor === 'function') {
          this.plugins[item.name] = new ctor(this)
        }
      })
  }
}
复制代码

applyPlugins 方法内部会根据插件设置的顺序进行排序,然后会使用 bs 实例作为参数调用插件的构造函数来创建插件,并把插件的实例保存到 bs 实例内部的 plugins({}) 属性中。

到这里我们已经介绍了插件管理和插件连接,下面我们来介绍最后一个关键点 —— 插件通信。

4.3 插件通信

插件通信是指插件间的通信。虽然设计的时候插件间是完全解耦的,但实际业务运行过程中,必然会出现某个业务流程需要多个插件协作,这就要求两个插件间进行通信; 由于插件之间没有直接联系,通信必须通过核心系统,因此核心系统需要提供插件通信机制

这种情况和计算机类似,计算机的 CPU、硬盘、内存、网卡是独立设计的配置,但计算机运行过程中,CPU 和内存、内存和硬盘肯定是有通信的,计算机通过主板上的总线提供了这些组件之间的通信功能。

同样,对于插件化架构的系统来说,通常核心系统会以事件总线的形式提供插件通信机制。提到事件总线,可能有一些小伙伴会有一些陌生。但如果说是使用了 发布订阅模式 的话,应该就很容易理解了。这里阿宝哥不打算在展开介绍发布订阅模式,只用一张图来回顾一下该模式。

对于 BetterScroll 来说,它的核心是 BScrollConstructor 类,该类继承了 EventEmitter 事件派发器:

// packages/core/src/BScroll.ts
export class BScrollConstructor<O = {}> extends EventEmitter {  
  constructor(el: ElementParam, options?: Options & O) {
    this.hooks = new EventEmitter([
      'refresh',
      'enable',
      'disable',
      'destroy',
      'beforeInitialScrollTo',
      'contentChanged',
    ])
    this.init(wrapper)
  }
}
复制代码

EventEmitter 类是由 BetterScroll 内部提供的,它的实例将会对外提供事件总线的功能,而该类对应的 UML 类图如下所示:

讲到这里我们就可以来回答前面留下的第一个问题:“那么为什么要注入 bs 实例?”。因为 bs(BScrollConstructor)实例的本质也是一个事件派发器,在创建插件时,注入 bs 实例是为了让插件间能通过统一的事件派发器进行通信。

第一个问题我们已经知道答案了,接下来我们来看第二个问题:”如何利用 bs 实例?“。要回答这个问题,我们将继续以 PullUp 插件为例,来看一下该插件内部是如何利用 bs 实例进行消息通信的。

export default class PullUp implements PluginAPI {
  static pluginName = 'pullUpLoad'
  constructor(public scroll: BScroll) {
    this.init()
  }
}
复制代码

在 PullUp 构造函数中,bs 实例会被保存到 PullUp 实例内部的 scroll 属性中,之后在 PullUp 插件内部就可以通过注入的 bs 实例来进行事件通信。比如派发插件的内部事件,在 PullUp 插件中,当距离滚动到底部小于 threshold 值时,触发一次 pullingUp 事件:

private checkPullUp(pos: { x: number; y: number }) {
  const { threshold } = this.options
  if (...) {
      this.pulling = true
      // 省略部分代码
      this.scroll.trigger(PULL_UP_HOOKS_NAME) // 'pullingUp'
  }
}
复制代码

知道如何利用 bs 实例派发事件之后,我们再来看一下在插件内部如何利用它来监听插件所感兴趣的事件。

// packages/pull-up/src/index.ts
export default class PullUp implements PluginAPI {
  static pluginName = 'pullUpLoad'
  constructor(public scroll: BScroll) {
    this.init()
  }

  private init() {
    this.handleBScroll()
    this.handleOptions(this.scroll.options.pullUpLoad)
    this.handleHooks()
    this.watch()
  }
}
复制代码

在 PullUp 构造函数中会调用 init 方法进行插件初始化,而在 init 方法内部会分别调用不同的方法执行不同的初始化操作,这里跟事件相关的是 handleHooks 方法,该方法的实现如下:

private handleHooks() {
  this.hooksFn = []
  // 省略部分代码
  this.registerHooks(
    this.scroll.hooks,
    this.scroll.hooks.eventTypes.contentChanged,
    () => {
      this.finishPullUp()
    }
  )
}
复制代码

很明显在 handleHooks 方法内部,会进一步调用 registerHooks 方法来注册钩子:

private registerHooks(hooks: EventEmitter, name: string, handler: Function) {
  hooks.on(name, handler, this)
  this.hooksFn.push([hooks, name, handler])
}
复制代码

通过观察 registerHooks 方法的签名可知,它支持 3 个参数,第 1 个参数是 EventEmitter 对象,而另外 2 个参数分别表示事件名和事件处理器。在 registerHooks 方法内部,它就是简单地通过 hooks 对象来监听指定的事件。

那么 this.scroll.hooks 对象是什么时候创建的呢?在 BScrollConstructor 构造函数中我们找到了答案。

// packages/core/src/BScroll.ts
export class BScrollConstructor<O = {}> extends EventEmitter {
  constructor(el: ElementParam, options?: Options & O) {
    // 省略部分代码
    this.hooks = new EventEmitter([
      'refresh',
      'enable',
      'disable',
      'destroy',
      'beforeInitialScrollTo',
      'contentChanged',
    ]) 
  }
}
复制代码

很明显 this.hooks 也是一个 EventEmitter 对象,所以可以通过它来进行事件处理。好的,插件通信的内容就先介绍到这里,下面我们用一张图来总结一下该部分的内容:

介绍完 BetterScroll 插件化架构的实现,最后我们来简单聊一下 BetterScroll 项目工程化方面的内容。

五、工程化方面

在工程化方面,BetterScroll 使用了业内一些常见的解决方案:

  • lerna:Lerna 是一个管理工具,用于管理包含多个软件包(package)的 JavaScript 项目。
  • prettier:Prettier 中文的意思是漂亮的、美丽的,是一个流行的代码格式化的工具。
  • tslint:TSLint 是可扩展的静态分析工具,用于检查 TypeScript 代码的可读性,可维护性和功能性错误。
  • commitizen & cz-conventional-changelog:用于帮助我们生成符合规范的 commit message。
  • husky:husky 能够防止不规范代码被 commit、push、merge 等等。
  • jest:Jest 是由 Facebook 维护的 JavaScript 测试框架。
  • coveralls:用于获取 Coveralls.io 的覆盖率报告,并在 README 文件中添加一个不错的覆盖率按钮。
  • vuepress:Vue 驱动的静态网站生成器,它用于生成 BetterScroll 2.0 的文档。

因为本文的重点不在工程化,所以上面阿宝哥只是简单罗列了 BetterScroll 在工程化方面使用的开源库。如果你对 BetterScroll 项目也感兴趣的话,可以看看项目中的 package.json 文件,并重点看一下项目中 npm scripts 的配置。当然 BetterScroll 项目还有很多值得学习的地方,剩下的就等大家去发掘吧,欢迎感兴趣的小伙伴跟阿宝哥一起交流与讨论。

六、参考资源