本文作者:IMWeb jerryOnlyZRJ 原文出处:IMWeb社区 未经同意,禁止转载
最近在 H5 开发与 APP 客户端工程师的联调过程中, 经常需要实现一些常用的移动端事件封装成接口提供给客户端,例如用户的单击 tap 事件、双击事件、长按事件以及拖动事件。但由于浏览器默认只提供了 touchstart
、touchmove
、touchend
三个原生事件,在实际的开发过程中,我们常用的解决方案便是通过监听touchstart
和 touchend
事件配合定时器来实现我们的自定义移动端事件,为了实现常用自定义事件的复用,我们对其进行了封装,并提供方便用户使用的工具函数,这也是我们实现 mt-events 的初衷。
mt-events 全名是 Mobile Terminal Events。最初我们对这个库的定位是希望封装一些常用的移动端事件来方便用户进行更为便捷的移动端开发,例如双击事件、长按事件、滑动事件等等。后来,随着项目的迭代,mt-events 的功能更倾向往前端事件绑定工具的趋势发展,因为我们集成了事件委托等,您可以像使用 JQuery 的 on 方法那样使用我们的 mt-events,更加便捷事件绑定和委托,让移动端事件如原生事件般友好。 这是我们项目的 Github 地址:https://github.com/jerryOnlyZRJ/mobile-events 。
接下来,我们将带您体验一款工具库的搭建流程,ES6 新特性 Map、Proxy、Reflect 以及 WeakMap 在我们的工具库中发挥的作用,以及我们开源的工具库mt-events所拥有的魅力。
先看看 mt-events 这款工具库具有哪些特性:
那么,我们又该如何使用它呢?这里提供了两种引用工具库的方式,最常用的当然是从 HTML 里使用 script引入:
<script src="http://mtevents.jerryonlyzrj.com/mtevents.min.js"></script>
然后,我们的工具函数 mtEvents 将会被挂载在 window对象上,您可以在浏览器的开发者工具里的 console 面板输入并执行 mtEvents,如果打印出如下文本说明您已经成功引入我们的工具库了:
或者您是 VUE 等前端框架的开发者,您也可以通过 npm 依赖的方式引入我们的工具,我们的工具库会跟随您们的 VUE 文件被打包进 bundle 里。
首先,将我们的工具库以上线依赖的形式安装:
npm i mt-events --save
然后就可以在我们的 .vue 等文件里直接引入使用:
//test.vue
<script>
const mtEvents = require('mt-events')
export default {
...,
mounted(){
mtEvents('#bindTarget', 'click', e => console.log('click'))
}
}
</script>
具体的使用方法,您可以参照我们 Github 为您提供的用户文档哦~
没有经过测试的代码不具备任何说服性。相信大家在浏览别人开源的工具库代码时,都能在根目录下见到一个名为 test 的文件夹,其中就放置着项目的测试文件。特别对于工具库来说,测试更是一个不可或缺的环节。
市面上的测试工具种类繁多,例如 Jest,Karma,Mocha,Tape等,并不需要局限与哪一款,下面我们对这几种框架进行了一些对比。
综上所述,Jest 开箱即用;若需要为大型项目配备足以快速上手的框架,建议使用Karma;Mocha 用的人最多,社区最成熟,灵活,可配置性强易拓展;Tape 最精简,提供最基础的东西最底层的API。
下面我们举个例子如何使用 Jest:
$ npm i jest -D
// jest.config.js # 在 jest.config.js 配置测试用例路径,以及覆盖率输出文档的目录等等信息
module.exports = {
testURL: 'http://localhost',
testMatch: ['<rootDir>/test/*.js'], // 测试文件匹配路径(拿到根目录下test文件夹里的所有JS文件)
coverageDirectory: '<rootDir>/test/coverage', // 测试覆盖率文档生成路径
coverageThreshold: { // 测试覆盖率通过阈值
global: {
branches: 90,
functions: 90,
lines: 90,
statements: 90
}
}
}
// package.json
{
...,
"scripts": {
...,
"test": "jest"
}
}
// test/index.js # 编写测试用例
describe('test dbtap events', () => { # 测试组
test('test 1+1', () => { # 测试用例
expect(1+1).toBe(2) # 断言
})
})
$ npm t
这就是配置测试的基本流程。
在团队开发的工作中,代码维护所占的时间比重往往大于新功能的开发。因此制定符合团队的代码规范是至关重要的,这样不仅仅可以很大程度地避免基本语法错误,也保证了代码的可读性,方便维护。
程序是写给人读的,只是偶尔让计算机执行一下。 --Donald Knuth
众所周知,eslint 是一个开源的 JavaScript代码检查工具,可以用来校验我们的代码,给代码定义一个规范,团队成员按照这个代码规范进行开发,这保证了代码的规范。使用 eslint 可以带来很多好处,可以帮助我们避免一些低级错误,可能一个小小的语法问题,让您定位了很久才发现问题所在,而且在团队合作的过程中,可以保证大家都按照同一种风格去开发,这样更方便大家看懂彼此的代码,提高开发效率。
另外,eslint 的初衷是为了让开发者创建自己的代码检测规则,使其可以在编码过程中发现问题,扩展性强。为了方便使用,eslint 也内置了一些规则,也可以在这基础上去增加自定义规则。
eslint --init
在开发阶段我们经常会使用一些语法糖像ES6的新特性来方便我们的开发,或者 ES6 Modules 来衔接我们的模块化工作,但是有些新特性是 Node.js 或者浏览器还未能支持的,所以我们需要对开发代码进行编译及打包,为了提炼自动化工程,我们可以选择许多优良的自动化构建工具,例如前端巨头 Webpack,或是流式构建工具 Gulp,亦或是具有优良 Tree-shaking 特性的Rollup,每款构建工具都有自己的闪光点,我们可以根据业务需求选择最合适的构建工具。 构建工具做的事情就是将一系列流程用代码去实现,自动化地去执行一系列复杂的操作,最终实现将源代码转换成可以执行的 JavaScript、CSS、HTML 代码。构建工具层出不穷,例如 Grunt,Gulp,Webpack,Rollup 等等。下面我们对这几种工具进行一些对比。
我们的 mt-events 项目选择了 Rollup 和 Webpack 两款构建工具是因为我们需要对“同构”后的JS代码裁剪分支,因此我们需要利用 Rollup 优良的 Tree-shaking 特性;并且为了上线 min.js 文件的压缩打包,我们使用 Webpack 来方便我们的构建工作。
项目的维护工作是延伸项目生命周期的最关键手段,阅读别人的源码相信对大家来说都是一件费力的事情,特别是当原作者不在您身边或者无法给您提供任何信息的时候,那就更是悲从中来。所以,书写完善的注释是开发过程中需要养成的良好习惯。为了提升代码的可维护性,我们都会在主干代码上完善我们的注释,并且,市面上有一款工具,它能够自动将我们的注释转化成 API 文档,生成可视化页面,听起来是很神奇吧,先别着急,听我娓娓道来。
这款工具名为 JSDoc,它是一款根据 Javascript文件中注释信息,生成 JavaScript应用程序或库、模块的 API 文档的工具。JSDoc 分析的源代码是我们书写的符合 Docblock 格式的代码注释,它会智能帮我们生成美观的 API 文档页面,我们要做的,只是简单的跑一句jsdoc
命令就可以了。
下面是 mt-events 的 API 文档页面(很美观不是吗?这些都是JSDoc自动生成的):
简约的风格让人看起来心旷神怡,想想如果有后来的维护者想要快速了解您的项目的大体架构和具体方法的功能,献上这样一份开发者文档可不是要比直接丢给他一份源代码要来的好得多对吧。
为了确保线上的代码不被污染,我们配置了eslint
,所以在团队里每位成员push代码之前,都需要进行一次lint和test,这样才能确保线上代码的整洁性和有效性,但是这一繁琐的工作能否自动化去完成呢?
解决方案就是使用git钩子来实现自动化lint和test:
package.json
里添加我们的钩子命令:
在mt-events项目里,我们在commit钩子上执行lint,在push钩子上执行test,配置如下:
{ ..., "scripts": { ..., "precommit": "lint-staged", "prepush": "npm t" } }
package.json
里添加相关配置即可。mt-events示例:
首先,先安装lint-staged依赖:
npm install -D lint-staged
接着,在package里添加相应配置:
{ ..., "lint-staged": { "core/*.js": [ // lint监听的变更文件 "prettier --write", // 自动格式化所有代码 "eslint --fix", // lint并自动修改不符合规范的代码 "git add" // 将所有的修改添加进暂存池 ] } }
配上钩子之后,我们就能看到这样的额输出结果了:
每次我们在本地跑完构建生成了上线文件之后,我们都需要通过scp
或者rsync
等方式上传到我们的服务器上,每次如果都需要手动执行相关命令完成上线操作肯定是违背了我们工程自动化的思想,为了实现自动化部署,我们可以使用持续集成工具来协助我们完成上线操作。
市面上成熟的持续集成工具也不少,但是口碑最盛的也当属 Travis CI 和 Jenkins 了。作为Github的标配,Travis CI 在开源领域有着不可颠覆的地位,如果我们是在Github上对项目进行版本控制管理,选择这款工具自然再合适不过了。Jenkins因为内容较多,这里就不做过多介绍了,本文的重点,主要是谈谈Travis CI在我们的自动化工程中该如何运用。
其实Travis CI的使用方法可以简单的概括为3步,就像官网首页的那样图片介绍的一样:
.travis.yml
的配置文件其实难点也就是 .travis.yml
配置文件的书写和具体持续集成的梳理的,先 po 一张我们项目的配置文件:
language: node_js # 项目语言,node 项目就按照这种写法就OK了
node_js:
- 8.11.2 # 项目环境
cache: # 缓存 node_js 依赖,提升第二次构建的效率
directories:
- node_modules
before_install: # 这些是我们加密密钥后自动生成,两行命令的作用就是得到一个有效密钥
- openssl aes-256-cbc -K $encrypted_81d1fc7fdfa5_key -iv $encrypted_81d1fc7fdfa5_iv
-in mtevents_travis_key.enc -out mtevents_travis_key -d
- chmod 600 mtevents_travis_key
after_success: # 构建成功后的自定义操作
- npm run codecov # 生成 Github 首页的 codecov 图标
- scp -i mtevents_travis_key -P $DEPLOY_PORT -o stricthostkeychecking=no -r dist/mtevents.min.js
$DEPLOY_USER@$DEPLOY_HOST:/usr/local/nginx/html #将生成的上线文件 scp 到服务器
先梳理一下持续集成的流程,首先,我们更新开源项目然后 push,Travis 会监听到我们的 push 操作并自动拉取项目代码到 Travis 的虚拟机上,执行构建流程。思路就是这样,其实我们使用 Shelljs 也能实现一个简单的持续集成工具。
通常,我们在CI大型项目例如网站、Web APP 之类的项目时,更多地会使用 rsync
命令代替我们暴力的 scp
,因为 scp
会上传所有的文件,而 rsync
自带 diff 功能,所以功能如其名,它的作用就是“同步”变更文件,这样能极大提升我们的CI效率。但是由于我们的工具库项目只有一个 min.js 文件,所以 scp
就已经足够解决问题了。
每个开源项目都需要配置一份合适的开源许可证来告知所有浏览过我们的项目的用户他们拥有哪些权限,具体许可证的选取可以参照阮一峰前辈绘制的这张图表:
那我们又该怎样为我们的项目添加许可证了?其实 Github 已经为我们提供了非常简便的可视化操作: 我们平时在逛 github 网站的时候,发现不少项目都在 README.md 中添加徽标,对项目进行标记和说明,这些小图标给项目增色不少,不仅简单美观,而且还包含清晰易懂的信息。
当我们花费了很多精力去构建完善我们的项目后,希望有更多的人来关注以及使用我们的项目。此时我们如何更好地向其他人展示自己的项目呢?给自己的项目添加一些好看的徽标是一种不错的选择,让人耳目一新。
点开 mt-events 的README文件,您可以看到在开头部分有很多漂亮的小图标,很多大型项目都会使用这些小图标来装饰自己的项目,既能展示项目的一些主要信息,也能体现项目的专业性。
那么,我们又该如何为我们自己的开源项目添加这样的小图标呢?GitHub 小图标的官方网站是 http://shields.io/ ,可以在上面选择喜欢的徽标来为自己的项目润色,常见的徽标主要有持续集成状态,代码测试覆盖率,项目版本信息,项目下载量,开源协议类型,项目语言等,下面根据我们项目简单罗列几个图标讲一讲如何生成。
.travis.yml
配置文件,告诉 Travis CI 怎样对您的项目进行编译或测试,具体配置关注上一个模块。
辛辛苦苦把项目的测试覆盖率提高到了100%,不把它show出来肯定很憋屈吧。如果您希望在您的Github上添加项目测试覆盖率小图标,这里我们推荐使用 codecov 这套解决方案(图片来自官网截图)。
您要做的,只是像在Travis CI里添加项目那样把您需要跑收集测试覆盖率的项目添加进codecov的仪表盘,然后在您的项目里安装codecov依赖:
$ npm install codecov --save-dev
codecov的原理就是在您执行完项目测试之后,它会自动去寻找并收集项目内的测试覆盖率文档,然后呈现在页面上,并生成小图标,所以,您只要在项目测试之后执行codecov
命令就行了。因为我们的codedev是安装在本地,所以我们需要进入package.json
内配置一下我们的codecov执行命令:
// package.json
{
...,
"scripts": {
...,
"codecov": "codecov"
}
}
现在,您终于知道我们的.travis.yml
配置文件里的npm run codecov
是做什么用了的吧~
接下来,就可以把我们的效果图添加进Github首页了。
mt-events
├── core # 源代码文件夹
│ ├── event.js # 自定义事件处理句柄生成器,包含长按,双击,滑动,拖拽事件
│ ├── index.js # mtEvents 类以及绑定,移除事件方法
│ ├── proxy.js # 事件代理 Proxy 生成器
│ ├── touch.js # 模拟浏览器原生 touch 事件,供test使用,未对外发布
│ ├── weakmap.js # 建立用户定义回调与事件绑定元素的弱引用,预防内存泄漏
├── dist
│ ├── mtevents.min.js # mt-events 工具库最终生成的 JS 上线压缩文件
├── docs
│ ├── developer # 为开发者提供的mt-events开发文档,使用命令`$npm run docs`即可生成
│ ├── user # 为用户提供的mt-events的中英文使用文档
├── lib # 上线待构建代码临时文件夹
│ ├── event.js
│ ├── index-Browser.js # 上线压缩JS源文件
│ ├── index-npm.js # npm package入口文件
│ ├── proxy.js
│ ├── weakmap.js
├── test
│ ├── coverage # 测试覆盖率参考文件
│ ├── index.js # 测试用例
├── .travis.yml # Travis-ci配置文件
├── jest.config.js # Jest 配置文件
├── package.json
├── rollup.config.js # rollup 配置文件
├── webpack.config.js # webpack配置文件
这是一份平平无奇的项目目录,大家一定能看到很多熟悉的字眼,我们都对其中的文件的用途进行了解释说明,具体关键细节和重点,我们会在后文中提炼出来。
构建: webpack4 Rollup
测试工具: Jest
持续集成: Travis CI
API 文档生成工具: JSDoc
代码规范: eslint prettier lint-staged
项目版本控制工具: git
Rollup 已被许多主流的 JavaScript库使用,它对代码模块使用新的标准化格式,这些标准都包含在 JavaScript的 ES6 版本中,这可以让您自由无缝地使用您需要的 lib 中最有用的独立函数。Rollup 还帮助 mt-events实现了简单的“同构”,通过区分用户的引用方式,我们将上线文件区分为 index-npm.js 和 index-Browser.js 文件,既可以通过 script在 HTML 引入,也可以使用 npm 方式 require 依赖。
除了使用 ES6 模块,Rollup 独树一帜的 Tree Shaking 特性,可以静态分析导入模块,移除冗余,帮助我们完成了代码无用分支的裁剪:
// index.js
if (process.env.PLATFORM === 'Browser') {
window.mtEvents = mtEventsFun
} else {
module.exports = mtEventsFun
}
// rollup.config.js
export default {
entry: './core/index.js',
output: {
file: `lib/index-${platform}.js`,
format: 'cjs'
},
plugins: [
replace({
"process.env.PLATFORM": JSON.stringify(platform)
}),
copy({
'./core/events.js': 'lib/events.js',
'./core/proxy.js': 'lib/proxy.js',
'./core/weakmap.js': 'lib/weakmap.js',
})
]
};
// package.json 根据传入的参数生成对应的 index-npm.js 和 index-Browser.js 文件
// 在相应的 index-${platform}.js 文件移除没用到的代码
{
"build:browser": "rollup --config --platform Browser",
"build:npm": "rollup --config --platform npm"
}
// index-npm.js
{
module.exports = mtEventsFun;
}
// index-Browser.js
{
window.mtEvents = mtEventsFun;
}
随着项目迭代的过程,依赖人工去回归测试容易出错和遗漏,为了保证 mt-events 库的质量,以及实现自动化测试,我们引入了 Jest,因为它集成了 JSDOM,用它模拟我们的事件库在浏览器环境中执行的效果再合适不过了。并且 Jest 容易上手,开箱即用,几乎零配置,功能全面。
但是在测试的开始阶段就遇到了一个问题,在浏览器原生移动端事件中,并没有一个像 click() 那样的方法可以供我们直接调用来模拟事件触发,这个问题又该如何解决呢?
利用挂载在全局的 TouchEvent 构造函数,我们尝试着创建用户的 touch 事件,最终实践证明,这个方法可行,下方便是我们模拟touch事件的核心代码:
// touch.js
createTouchEvent (type) {
return new window.TouchEvent(type, {
bubbles: true,
cancelable: true
})
}
dispatchTouchEvent (eventTarget, event) {
if (typeof eventTarget === 'string') {
eventTarget = document.querySelector(eventTarget)
}
eventTarget.dispatchEvent(event)
return eventTarget
}
下面是我们使用 Jest 测试代码的覆盖率及结果:
根据前文提到的配置,我们就可以在Travis CI首页看到我们的项目的持续集成结果:
线上的min.js文件也同时被更新到最新的版本了。
mt-events 源码都是按照 ES6 代码规范来写,下面从几个方面来体验 mt-events 源码的魅力:
如此奇葩的数据类型看起来似乎很陌生,但我敢保证您之前一定有见过,只是没注意到它罢了,而且是多年以前我们最经常打交道的老朋友。还记得 JQuery 里面的$
符号嘛?您一定用过这种写法去获取元素 $("#myDom")
,也用过挂在 $
上的 ajax 方法来发送请求就像这样:$.ajax(...)
,是不是被我这么一说忽然发现,之前最常用的 $
居然既是个函数又是个对象,很少见这样的情况对吧,其实实现原理很简单,只需要把类实例的原型挂载到 Function 上就搞定了,之所以这么做,是为了让用户绑定事件时,直接使用mtEvents这个 Function 就可以了,就不需要再去拿到 mtEvents 上的 bind 方法了,能够优化体验。具体实现代码如下:
// index.js
let mtEvents = new MTEvents()
const mtEventsPrototype = Object.create(MTEvents.prototype)
const mtEventsFun = mtEvents.bind.bind(mtEvents)
Object.setPrototypeOf(mtEventsFun, mtEventsPrototype)
Object.keys(mtEvents).map(keyItem => {
mtEventsFun[keyItem] = mtEvents[keyItem]
})
在自定义事件中,我们是通过同时监听 touchstart
和 touchend
两个事件来判断用户触发的事件类型,并且在指定的位置执行用户传入的回调。那么,当用户需要移除之前绑定的事件时,我们又该如何处理呢?用户传入的肯定是需要执行的回调,而不是我们绑定在元素上的事件回调。
这时候,我们就需要对用户传入的执行回调和我们绑定在事件监听上的回调建立映射关系了,这样我们就可以依据用户传入的执行回调找到我们所需要移除的事件绑定回调函数了。对于映射关系,我们首先想到的肯定就是对象了,但是在传统的 JS 里,对象的键只能是字符串,但是我们需要让它是一个函数,这回就该想到我们 ES6 里新增的数据类型 Map 了,他的键可以不限于字符串,正合我意。
我们定义 userCallback2Handler 为一个 Map,将用户自定义的 callback 与事件处理器 eventHandler 绑定起来,相应的 remove 的时候也是根据 callback 来进行移除事件绑定,自定义事件中也是同理。
WeakMap 就是为了解决这个问题而诞生的,它的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。 --摘自 阮一峰《ECMAScript 6 入门》
weakmap.js 的意义在于建立 DOM 元素与对应 callback 的弱引用,在移除 DOM 元素时绑定在该元素上的回调也会被 GC 回收,这样就能起到防止内存泄漏的作用。
// weakmap.js
/**
* weakMapCreator WeakMap生成器
* @param {HTMLElement} htmlElement DOM元素
* @param {Function} callback 事件监听回调
* @return {WeakMap} WeakMap实例
*/
function weakMapCreator (htmlElement, callback) {
let weakMap = new WeakMap()
weakMap.set(htmlElement, callback)
return weakMap
}
在开发的过程中我们发现,为了实现事件委托相关操作,我们经常要书写重复的代码,为了降低代码的重复率,我们想到了使用 ES6 里的 Proxy 和 Reflect 对事件回调进行代理,在这过程中执行事件委托相关操作。
在 proxy.js 源码中,定义了事件委托处理的方法:_delegateEvent,以及事件委托 Proxy 生成器:delegateProxyCreator,这样在执行事件监听回调时,经过我们的事件委托 Proxy,进行相应的事件委托处理,这样不仅可以大大减少代码重复率,使代码看起来更加精简美观,同时这样定位问题 bug 也变得简单很多,只需要从根源处去定位 bug 即可。
/**
* _delegateEvent 事件代理处理
* @param {String(Selector) | HTMLElement} bindTarget 事件绑定元素
* @param {String(Selector)} delegateTarget 事件代理元素
* @param {Object} target 原生事件对象上的target对象,即(e.target)
* @return {Object | null} 如果存在代理,则调用此方法,事件发生在代理对象上则返回代理对象
*/
function _delegateEvent (bindTarget, delegateTarget, target) {
if (!delegateTarget) return null
const delegateTargets = new Set(document.querySelectorAll(delegateTarget))
while (target !== bindTarget) {
if (delegateTargets.has(target)) {
return target
} else {
target = target
}
}
return null
}
/**
* delegateProxyCreator 事件代理Proxy生成器
* @param {String(Selector) | HTMLElement} bindTarget 事件绑定元素
* @param {String(Selector)} delegateTarget 事件代理元素
* @param {Object} target 原生事件对象
* @param {Function} callback proxy拦截回调
* @return {Function} 过Proxy的callback
*/
function delegateProxyCreator (bindTarget, delegateTarget, e, callback) {
const handler = {
apply (callback, ctx, args) {
const target = _delegateEvent(bindTarget, delegateTarget, e.target)
if ((delegateTarget && target) || !delegateTarget) {
return Reflect.apply(...arguments)
}
}
}
return new Proxy(callback, handler)
}