过去的 v1 时代
距离 BetterScroll v1 版本发布,至今已经 3 年多,由于它在移动端良好的滚动体验与性能以及多种滚动场景的支持,深受社区的青睐。用户也可以基于 BetterScroll 抽象出各种复杂的业务滚动组件,期间依托于 BetterScroll,我们还开源了基于 Vue2.0 的移动端组件库 cube-ui。
目前 BetterScroll 的 star 数已经超过 1.1 万,GitHub 有大约 3.2 万仓库使用了它。 滴滴内部的业务,比如 国内司乘两端、 国外司乘两端等核心业务都大量使用 BetterScroll,它经受住了各种业务场景的考验。
随着大量的业务场景使用以及社区的反馈与建议,v1 版本也暴露了一些问题,主要分为如下四个方面:
- 包体积大,无法按需引用
- 扩展困难,增强功能易侵入核心逻辑
- 测试匮乏,稳定性保证差
- 文档不够友好,社区答疑成本高
v2 将至
先来看下最终的整体 BetterScroll v2 版本的架构图:
从整体架构图可以看出,目前整体 BetterScroll v2 版本除了实现核心滚动外,还额外提供很多插件:
-
picker
gao仿 iOS 原生 Picker 组件 -
mouse-wheel
兼容 PC 鼠标滚轮场景 -
observe-dom
自动探测 scroll 区域 DOM 改变,并且调用 refresh 方法 -
pulldown
监听下拉动作 -
pullup
监听上拉触底动作 -
scrollbar
仿原生浏览器,且样式美观的滚动条 -
slide
实现轮播图交互效果 -
zoom
提供缩放能力 -
nested-scroll
协调双层嵌套的滚动行为 -
infinity
无限滚动列表(多用于大量数据渲染,否则 coreScroll 即可满足需求)
v2 版本的诞生就是为了解决 v1 暴露出来的问题,这里我们将从上面的四个问题分别来揭秘重构过程中的思考与实践。
1、包体积大
v1 的架构设计借鉴于 Vue 2.0 的代码组织方式,但是由于不同的 Feature(
picker、slide、scrollbar 等
) 都是与核心滚动写在一起,导致无法
按需引入。
备注:此处的 按需引入指的是用户可能只需要实现简单的列表滚动效果,却 被迫加载冗余代码,比如所有 Feature 的代码,造成包体积过大的问题。
为了解决这个问题,我们就必须找到一种合理的方式将各个 Feature 代码单独拆分,独立引用,答案就是 插件化方案。那么 v2 版本的一个核心关键点就是 如何设计插件化的机制,我们当时是从下面三个步骤来思考的:
-
核心功能抽象,从核心滚动(CoreScroll)自顶向下地拆分出多个职能单一的类,进而将它们组合在一起构建完整核心逻辑;
由于拆分成细粒度的功能类,考虑到老用户监听事件或者获取属性都是操纵 CoreScroll,我们内部有统一的 事件冒泡层以及 属性代理层,将内部类的事件或者属性都代理到 CoreScroll 上。
-
借鉴 webpack tapable 延伸出来的
hooks
的概念(并不需要 tapable 那么强大),职能类之间通过hooks
(即 EventEmitter 经典的订阅发布者模式增强版) 来处理流程中钩子逻辑; -
借鉴 Vue 2.x 插件注册机制(代码如下),减少老用户的心智负担。
import BScroll from ' @better-scroll/core' import Slide from ' @better-scroll/slide' // 只需注册插件即可,无额外心智负担 BScroll.use(Slide) let bs = new BScroll('.wrapper', { slide: { /* 插件配置项 */ } })
因此 v2 的整体雏形就已经好了,考虑到后期会有很多插件实现不同的业务场景需求,v2 版本采用了
Lerna 来管理多个包,使用
@better-scroll
作为包的命名前缀,这样对于用户来说有更好的辨识度。
TypeScript 的静态类型,加上整个的社区十分成熟丰富的生态,BetterScroll 本身 Feature 已经很多,且未来还会继续增加,综合看非常适合用 TypeScript 进行开发。
TIPS:
Lerna 发包失败始终是开发者(包括作者)绕不过去的话题,目前也有很多 issue 与博客在讨论这个问题,供参考: lerna 发布失败后的解决方案、 lerna issue 1894、 publish 失败问题
2、扩展困难
v1 版本新增 Feature 的时候,有些逻辑代码是与核心滚动代码糅合在一起,造成后期扩展可维护性都会慢慢降低,随之而来的困扰也有包体积无限制的增加。那么如果将 Feature 与 核心滚动 CoreScroll 部分进行彻底分离,将 Feature 做成插件的模式,既能解决包体积的问题,扩展也变得相对容易,迭代的稳定性也变好了。
在 v2 版本中,一个插件的一般实现如下:
class InfinityScroll {
static pluginName = 'infinity'
constructor(public bscroll: BScroll) {
// ...your own logic
}
}
// 假设已经注册了 InfinityScroll
new BScroll('.wrapper', {
infinity: { /* 插件配置项 */ }
// infinity 要与插件的 pluginName 对应上
})
插件 必须拥有一个静态属性 pluginName,这个属性对应的值必须与初始化 BetterScroll 传入的配置对象的 key 对应,否则内部查找不到对应的插件。这个方案充分考虑了开发者使用时候的成本,同时也尽量降低和 v1 版本的差异。
在实现了核心的插件机制后,对于各种 Feature 则是通过一个个插件的形式来丰富 BetterScroll 的整体生态。
3、测试匮乏
在 v1 版本中,测试覆盖率不到 40%,可能也是因为 BetterScroll 在之前是一个巨大的类,编写单元测试也逐渐地困难了起来,这样在后期迭代升级的时候会埋下隐患,这也就是所说的 稳定性保证差。
那么在 v2 版本,为了保证整体功能的稳定性,控制发版质量,我们不但添加了 单元测试,还额外引入了 功能测试做进一步保障。
-
单元测试
之前参与的 cube-ui 的单测是采用
karma + mocha
的方案,不过需要安装 各种插件,还需要做不少配置。已经 0202 年了,最终调研对比发现在现有的 BetterScroll 场景中使用 Jest 作为测试框架是合适的,它本身集成了Mock
、Test Runner
、Snapshot
等强大的功能,基本上算是开箱即用,很好的满足需要。在编写单元测试过程中,用的最多是强大的 manual-mocks 能力。
举个简单的场景来深入浅出地阐述我们对 单元测试的看法以及如何借助 Jest manual-mocks 解决问题。
假如我们的源码文件结构如下:
- src - Core.ts - Helper.ts
Core
与Helper
的代码如下:// Core.ts 代码如下 export default class Core { constructor (helper: Helper) { this.helper = helper } getHammer (type: string) { if (this.helper.isHammer(type)) { return ('Got hammer') } else { return ('No hammer is available') } } } // Helper.ts 代码如下 export default class Helper { isHammer (type: string) { return type === 'hammer' } }
准备工作就绪,现在要开始测试
Core#getHammer
函数,这时我们核心开发成员之间发出了两种不同的声音。方案一:导入
Helper
原始代码(即src/Helper.ts
),让其走全流程;方案二:单元测试应该以函数或者类作为最小的粒度,做法倾向于传统的测试行业的概念,认为
Helper
应该被 mock 掉(使用src/__mocks__/Helper.ts
),换句话来说,Helper
作为另外一个测试单元,它必须保证自己的功能完全正确,但对于Core.ts
的单测,不应该引入原始的Helper
。最后的最后,我们选择了更为严谨的 方案二。
借助于 Jest manual-mocks 的能力,编写测试就变得更愉快与明确了。
-
更改文件结构
src + __mocks__ + Helper.ts + __tests__ + Core.spec.ts Core.ts Helper.ts
加了目录
__mocks__
以及__mocks__/Helper.ts
文件,并且加了测试目录__tests__
与Core.spec.ts
。 -
完善 manual-mocks
// __mocks__/Helper.ts const Helper = jest.fn().mockImplementation(() => { return { isHammer: jest.fn().mockImplementation((type) => { return type === 'MockedHammer' }) } }) export default Helper
-
编写 Core.spec.ts
import Helper from '../Helper.ts' import Core from '../Core.ts' // 使用 '__mocks__/Helper.ts' // 引入的 Helper 就是 mock 处理过的~ jest.mock('../Helper.ts') describe('Core tests', () => { it('should work well with "MockedHammer"', () => { const core = new Core(new Helper() // Mock 过后的 Helper) expect(core.getHammer('MockedHammer')).toBe('Got hammer') // 通过 }) })
从上述可以看出,我们利用 Jest 更改了
Helper.ts
的导出,用的是__mocks__
目录下的,不再是原始的Helper.ts
,这样各个模块自身需要保障自身逻辑正确性,同时对于异常分支的逻辑测试会变得更容易。很有趣, 对吧?
-
-
功能测试
由于 BetterScroll 是一个与浏览器强相关的滚动库,单元测试是用来保证单个模块的 输入输出正确性,所以还需要其他的手段来保证核心滚动、插件等的行为表现符合预期,因此我们就采用了 jest-puppeteer,它的理念就是 Run your tests using Jest & Puppeteer,这里有必要介绍一下 Puppeteer。
Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the DevTools Protocol
用我的工地英语翻译一下就是:
Puppeteer 是一个通过 DevTools 协议控制 Chrome 行为并且提供更优雅的 API 的 Node 类库。
DevTools 这个协议很重要,接下来仍会提及到。
你打开它的官网会发现,它的功能有很多,包括 生成 PDF、 表单、UI 测试、 谷歌插件测试等等,网上也有很多文章介绍如何使用它来做 爬虫。
下面截取核心滚动的功能测试片段代码:
describe('CoreScroll/vertical', () => { beforeAll(async () => { await page.goto('http://0.0.0.0:8932/#/core/default') }) it('should render corrent DOM', async () => { const wrapper = await page.$('.scroll-wrapper') const content = await page.$('.scroll-content') expect(wrapper).toBeTruthy() await expect(content).toBeTruthy() }) it('should trigger eventListener when click wrapper DOM', async () => { let mockHandler = jest.fn() page.once('dialog', async dialog => { mockHandler() await dialog.dismiss() }) // wait for router transition ends await page.waitFor(1000) await page.touchscreen.tap(100, 100) await expect(mockHandler).toHaveBeenCalled() }) })
从上边的示例代码可以看到,Puppeteer 的 API 都是非常语义化的,而且内部的 API 都是返回 Promise。
在逐渐丰富功能测试的时候,还是很愉快的,但是 难题还是不期而遇。
BetterScroll 功能测试强相关联 Touch、Mouse、MouseWheel 等事件,然而此时的 Puppeteer(v1.17.0) 并没有提供全部的接口。
既然 Puppeteer 是一个通过协议控制 Chrome 的类库,那为啥不把它内部的实现先粗略的了解一下呢?
秉着这个想法,在研究了 Puppeteer 的核心实现,最终整理发现,只要理清一条主线,其余的是照葫芦画瓢、参考 DevTools Protocol 文档即可。
下面是简略的流程图。
第一步:利用 node 的 child_process 模块启动
Chromium
;第二步:监听命令行的输出,获取
browserWSEndpoint
,它是一个 URL 地址,传给 WebSocket,这样 Puppeteer 与 Chromium 的双向推送关系就建立了;第三步:实例化 Connection,建立 Session 会话以及 实例化 Browser 类,那么用户操作的都是这个 browser 实例,比如打开一个页面标签(
browser.newPage()
)。在实例化 Connection 的内部,其实有很多细节, DevTools Protocol 就是现成的 API 文档,换句话来说,只要我们按着这个 API 文档通过 WebSocket 给 Chromium 去发消息,就能驱使它作出响应的行为。接下来结合文档以及源码,我们发现只要发送
Input.synthesizePinchGesture
以及Input.synthesizeScrollGesture
消息( 文档在这),就能驱使 Chromium 作出 scroll、 zoom、mouseWheel 等事件交互效果,那么对于 BetterScroll 的各种插件以及核心滚动的功能测试就手到擒来啦!因此,我们对 Puppeteer 做了部分扩展, extendTouch、 extendMouseWheel 以满足功能测试需要。
那么功能测试的写的任务就算可以全部完成啦。
功能测试算是告一段落了,但是 新问题又出现了:跑功能测试,是依赖 examples 下的代码来启动服务,然后在用 Puppeteer 去访问示例代码的服务,最后跑所有的测试用例。也就意味着跑功能测试就需要先把服务准备好,再跑功能测试,这里我们需要一种更为 工程化的手段来解决这个问题!
这个问题的关键是 怎么确保 examples 代码的服务启动再跑功能测试,那么是不是可以从
webpack
下手,尤其是webpackDevServer
。通过研究它的源码实现,发现内部引用的 webpack-dev-middleware,其中有一个 API,叫做waitUntilValid
,接收一个callback
。这个 API 能保证服务已经启动并且 bundle 是可访问的。那么解决方案就如下,在
vue.config.js
注入 webpack 的 配置:module.exports.configureWebpack = { devServer: { before (app, server) { server.middleware.waitUntilValid(() => { // 服务已经 ready,启动 e2e 测试 execa('npm', ['run', 'test:e2e'], { stdio: 'inherit' }) }) } } }
至此,这就是测试部分的探索以及实践,做完这部分,对我们自身而言,有个最大的体会: 工程师的价值在于探索与解决问题。
4、文档不够友好
v1 版本的文档以及示例代码颇受吐槽,尤其是示例部分给了新入坑的小伙伴们很大的心智负担,比如文档内部没有实际代码片段、示例耦合各种无关的 Vue 逻辑。在 v2,这些问题将会得到改善。
首先由于我们的技术栈是 Vue,其周边 VuePress 则是一个很好用的文档框架,它将 Vue、webpack、Markdown 的能力发挥到极致,也能很好的定制主题、实现国际化,并且它插件化的架构设计给 VuePress 带来了很大的灵活性以及扩展能力,所以我们就选型了 VuePress 来完成相关 API 文档化。尽管 VuePress 开箱即用,基本满足我们编写文档的大部分要求,但仍然需要额外的一些扩展。
这里想要实现上面图片的功能,要有 二维码, 组件的代码片段, 要把 examples 目录下的组件真正渲染在 markdown 里面。第一和第三点都特别好实现,VuePress 提供这能力,但是第二点, 在 markdown 同步展示 examples 组件对应的代码,这是个棘手的问题。
那么,深入研究 VuePress 的实现是必要的,VuePress 内部是使用
markdown-it 来编译
md
扩展名的文件。要解决这个问题,看来需要深入研究下 markdown-it 的底层实现,也顺道产出了
markdown-it 源码以及插件的解读系列;发现基于 VuePress 的插件机制可以满足我们定制化的需求,因此写了
extract-code 插件,并约定 markdown 文件只要如下的代码,那么就会被
extract-code
处理。
// 抽取 default.vue 文件的 template 标签内容
<<< @/examples/vue/components/infinity/default.vue?template
// 抽取 default.vue 文件的 script 标签内容
<<< @/examples/vue/components/infinity/default.vue?script
如此一来,我们每次更改 examples 下面的示例代码,文档也会同步更新到对应的部分。
注意:
由于 VuePress 为了加快 markdown 文件的编译速度,内部使用 cache-loader 做缓存,意思是如果 markdown 内容没有发生变化,直接取缓存的内容,虽然示例代码变化,但是对于 markdown 文件来说,内容其实是未改变的。TIPS:
如果你不喜欢代码块的主题,可以研究下大名鼎鼎的 prism,因为 VuePress 的内部就是用这个插件去做高亮的。
总结规划
回顾我们在做 BetterScroll 2.0 版本的大体历程,一路虽有坎坷,但更多的是收获、总结和沉淀。
BetterScroll 2.0 目前经过了 20 多个 alpha 版本,已经发布了 beta 版本,但是却是已经稳定了的版本,内部和社区已经有了大量的下载使用,未来我们会持续做一些事情:
- 优化代码 & 包体积
- 提供更多的插件并且丰富示例 (欢迎 PR 或者提出你们的 ideas)
- 完善文档以及暴露更多的细节
- 测试完善
同时,也会在 BetterScroll 2.0 的基础上产出新版本的组件库,在原本已经优化、提效的基础之上进行二次提效,助力业务。
希望能有越来越多的人使用,同时也有更多的你参与进来,一起共建,让 BetterScroll 的整个生态变得 Better。