近期咱们团队的小伙伴小池同窗分享了 “BetterScroll 2.0 发布:精益求精,与你同行” 这篇文章到团队内部群,看到了 插件化 的架构设计,阿宝哥忽然来了兴趣,由于以前阿宝哥在团队内部也作过相关的分享。既然已经来了兴趣,那就决定开启 BetterScroll 2.0 源码的学习之旅。vue
接下来本文的重心将围绕 插件化 的架构设计展开,不过在分析 BetterScroll 2.0 插件化架构以前,咱们先来简单了解一下 BetterScroll。node
BetterScroll 是一款重点解决移动端(已支持 PC)各类滚动场景需求的插件。它的核心是借鉴的 iscroll 的实现,它的 API 设计基本兼容 iscroll,在 iscroll 的基础上又扩展了一些 feature 以及作了一些性能优化。github
BetterScroll 1.0 共发布了 30 多个版本,npm 月下载量 5 万,累计 star 数 12600+。那么为何升级 2.0 呢?typescript
作 v2 版本的初衷源于社区的一个需求:shell
- BetterScroll 能不能支持按需加载?
为了支持插件的按需加载,BetterScroll 2.0 采用了 插件化 的架构设计。CoreScroll 做为最小的滚动单元,暴露了丰富的事件以及钩子,其他的功能都由不一样的插件来扩展,这样会让 BetterScroll 使用起来更加的灵活,也能适应不一样的场景。npm
下面是 BetterScroll 2.0 总体的架构图:json
(图片来源:juejin.cn/post/686808…性能优化
该项目采用的是 monorepos 的组织方式,使用 lerna 进行多包管理,每一个组件都是一个独立的 npm 包:
与西瓜播放器同样,BetterScroll 2.0 也是采用 插件化 的设计思想,CoreScroll 做为最小的滚动单元,其他的功能都是经过插件来扩展。好比长列表中常见的上拉加载和下拉刷新功能,在 BetterScroll 2.0 中这些功能分别经过 pull-up
和 pull-down
这两个插件来实现。
插件化的好处之一就是能够支持按需加载,此外把独立功能都拆分红独立的插件,会让核心系统更加稳定,拥有必定的健壮性。好的,简单介绍了一下 BetterScroll,接下来咱们步入正题来分析一下这个项目中一些值得咱们学习的地方。
BetterScroll 2.0 采用 TypeScript 进行开发,为了让开发者在使用 BetterScroll 时可以拥有较好的智能提示,BetterScroll 团队充分利用了 TypeScript 接口自动合并的功能,让开发者在使用某个插件时,可以有对应的 Options 提示以及 bs(BetterScroll 实例)可以有对应的方法提示。
接下来,为了后面能更好地理解 BetterScroll 的设计思想,咱们先来简单介绍一下插件化架构。
插件化架构(Plug-in Architecture),是一种面向功能进行拆分的可扩展性架构,一般用于实现基于产品的应用。插件化架构模式容许你将其余应用程序功能做为插件添加到核心应用程序,从而提供可扩展性以及功能分离和隔离。
插件化架构模式包括两种类型的架构组件:核心系统(Core System)和插件模块(Plug-in modules)。应用逻辑被分割为独立的插件模块和核心系统,提供了可扩展性、灵活性、功能隔离和自定义处理逻辑的特性。
图中 Core System 的功能相对稳定,不会由于业务功能扩展而不断修改,而插件模块是能够根据实际业务功能的须要不断地调整或扩展。 插件化架构的本质就是将可能须要不断变化的部分封装在插件中,从而达到快速灵活扩展的目的,而又不影响总体系统的稳定。
插件化架构的核心系统一般提供系统运行所需的最小功能集。插件模块是独立的模块,包含特定的处理、额外的功能和自定义代码,来向核心系统加强或扩展额外的业务能力。 一般插件模块之间也是独立的,也有一些插件是依赖于若干其它插件的。重要的是,尽可能减小插件之间的通讯以免依赖的问题。
介绍完插件化架构相关的基础知识,接下来咱们来分析一下 BetterScroll 2.0 是如何设计插件化架构的。
对于插件化的核心系统设计来讲,它涉及三个关键点:插件管理、插件链接和插件通讯。下面咱们将围绕这三个关键点来逐步分析 BetterScroll 2.0 是如何实现插件化架构。
为了统一管理内置的插件,也方便开发者根据业务需求开发符合规范的自定义插件。BetterScroll 2.0 约定了统一的插件开发规范。 BetterScroll 2.0 的插件须要是一个类,而且具备如下特性:
1.静态的 pluginName 属性;
2.实现 PluginAPI 接口(当且仅当须要把插件方法代理至 bs);
3.constructor 的第一个参数就是 BetterScroll 实例 bs
,你能够经过 bs 的 事件 或者 钩子 来注入本身的逻辑。
这里为了直观地理解以上的开发规范,咱们将之内置的 PullUp 插件为例,来看一下它是如何实现上述规范的。PullUp 插件为 BetterScroll 扩展上拉加载的能力。
顾名思义,静态的 pluginName
属性表示插件的名称,而 PluginAPI 接口表示插件实例对外提供的 API 接口,经过 PluginAPI 接口可知它支持 4 个方法:
插件经过构造函数注入 BetterScroll 实例 bs
,以后咱们就能够经过 bs 的事件或者钩子来注入本身的逻辑。那么为何要注入 bs 实例?如何利用 bs 实例?这里咱们先记住这些问题,后面咱们再来分析它们。
核心系统须要知道当前有哪些插件可用,如何加载这些插件,何时加载插件。常见的实现方法是插件注册表机制。核心系统提供插件注册表(能够是配置文件,也能够是代码,还能够是数据库),插件注册表含有每一个插件模块的信息,包括它的名字、位置、加载时机(启动就加载,或是按需加载)等。
这里咱们之前面提到的 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({}) 属性中。
到这里咱们已经介绍了插件管理和插件链接,下面咱们来介绍最后一个关键点 —— 插件通讯。
插件通讯是指插件间的通讯。虽然设计的时候插件间是彻底解耦的,但实际业务运行过程当中,必然会出现某个业务流程须要多个插件协做,这就要求两个插件间进行通讯; 因为插件之间没有直接联系,通讯必须经过核心系统,所以核心系统须要提供插件通讯机制。
这种状况和计算机相似,计算机的 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 使用了业内一些常见的解决方案:
由于本文的重点不在工程化,因此上面阿宝哥只是简单罗列了 BetterScroll 在工程化方面使用的开源库。若是你对 BetterScroll 项目也感兴趣的话,能够看看项目中的 package.json
文件,并重点看一下项目中 npm scripts 的配置。固然 BetterScroll 项目还有不少值得学习的地方,剩下的就等你们去发掘吧,欢迎感兴趣的小伙伴跟阿宝哥一块儿交流与讨论。