【第1421期】2018年如何写一个现代的JavaScript库
前言
又到周末了,据说今天厦门有iweb分享会。今日早读文章由@颜海镜投稿分享。
@颜海镜,知名技术博主,开源达人,《React状态管理与同构实战》作者
正文从这开始~~
我写过一些开源项目,在开源方面有一些经验,最近看到了阮老师的微博(好吧确实是几年前看到的了),深有感触,现在一个开源项目涉及的东西确实挺多的,特别是对于新手来说非常不友好
最近我写了一个jslib-base,旨在从多方面快速帮大家搭建一个标准的js库,本文将以jslib-base为例,介绍写一个开源库的知识
jslib-base 最好用的js第三方库脚手架,赋能js第三方库开源,让开发一个js库更简单,更专业
文档
所谓代码未动,文档先行,文档对于一个项目非常重要,一个项目的文档包括
README.md
TODO.md
CHANGELOG.md
LICENSE
doc
README.md
README是一个项目的门面,应该简单明了的呈现用户最关心的问题,一个开源库的用户包括使用者和贡献者,所以一个文档应该包括项目简介,使用者指南,贡献者指南三部分
项目简介用该简单介绍项目功能,使用场景,兼容性的相关知识,这里重点介绍下徽章,相信大家都见过别人项目中的徽章,如下所示
徽章通过更直观的方式,将更多的信息呈现出来,还能够提高颜值,有一个网站专门制作各种徽章,可以看这里
这里有一个README的完整的例子
TODO.md
TODO应该记录项目的未来计划,这对于贡献者和使用者都有很重要的意义,下面是TODO的例子
- [X] 已完成
- [ ] 未完成
CHANGELOG.md
CHANGELOG记录项目的变更日志,对项目使用者非常重要,特别是在升级使用版本时,CHANGELOG需要记录项目的版本,发版时间和版本变更记录
## 0.1.0 / 2018-10-6
- 新增xxx功能
- 删除xxx功能
- 更改xxx功能
LICENSE
开源项目必须要选择一个协议,因为没有协议的项目是没有人敢使用的,关于不同协议的区别可以看下面这张图(出自阮老师博客),我的建议是选择MIT或者BSD协议
doc
开源项目还应该提供详细的使用文档,一份详细文档的每个函数介绍都应该包括如下信息:
函数简单介绍
函数详细介绍
函数参数和返回值(要遵守下面的例子的规则)
- param {string} name1 name1描述
- return {string} 返回值描述
举个例子(要包含代码用例)
// 代码
特殊说明,比如特殊情况下会报错等
构建
理想的情况如下:
库开发者美滋滋的写ES6+的代码;
库使用者能够运行在浏览器(ie6-11)和node(0.12-10)中
库使用者能够使用AMD或CMD模块方案
库使用者能够使用webpack、rollup或fis等预编译工具
理想很丰满,现实很。。。,如何才能够让开发者和使用者都能够开心呢,jslib-base通过babel+rollup提供了解决方案
编译
通过babel可以把ES6+的代码编译成ES5的代码,babel经历了5到6的进化,下面一张图总结了babel使用方式的变迁
本文不讨论babel的进化史(后面会单独开一片博文介绍),而是选择最现代化的babel-preset-env方案,babel-preset-env可以通过提供提供兼容环境,而决定要编译那些ES特性
其原理大概如下,首先通过ES的特性和特性的兼容列表计算出每个特性的兼容性信息,再通过给定兼容性要求,计算出要使用的babel插件
首先需要安装babel-preset-env
$ npm i --save-dev babel-preset-env
然后新增一个.babelrc文件,添加下面的内容
{
"presets": [
["env",
{
"targets": {
"browsers": "last 2 versions, > 1%, ie >= 6, Android >= 4, iOS >= 6, and_uc > 9",
"node": "0.10"
},
"modules": false,
"loose": false
}]
]
}
targets中配置需要兼容的环境,关于浏览器配置对应的浏览器列表,可以从browserl.ist上查看
modules表示编出输出的模块类型,支持”amd”,”umd”,”systemjs”,”commonjs”,false这些选项,false表示不输出任何模块类型
loose代表松散模式,将loose设置为true,能够更好地兼容ie8以下环境,下面是一个例子(ie8不支持Object.defineProperty)
// 源代码
const aaa = 1;
export default aaa;
// loose false
Object.defineProperty(exports, '__esModule', {
value: true
});
var aaa = 1;
exports.default = 1;
// loose true
exports.__esModule = true;
var aaa = 1;
exports.default = 1;
babel-preset-env解决了语法新特性的兼容问题,如果想使用api新特性,在babel中一般通过babel-polyfill来解决,babel-polyfill通过引入一个polyfill文件来解决问题,这对于普通项目很实用,但对于库来说就不太友好了
babel给库开发者提供的方案是babel-transform-runtime,runtime提供类似程序运行时,可以将全局的polyfill沙盒化
首先需要安装babel-transform-runtime
$ npm i --save-dev babel-plugin-transform-runtime
在.babelrc增加下面的配置
"plugins": [
["transform-runtime", {
"helpers": false,
"polyfill": false,
"regenerator": false,
"moduleName": "babel-runtime"
}]
]
transform-runtime,支持三种运行时,下面是polyfill的例子
// 源代码
var a = Promise.resolve(1);
// 编译后的代码
var _promise = require('babel-runtime/core-js/promise');
var a = _promise.resolve(1); // Promise被替换为_promise
虽然虽然可以优雅的解决问题,但是引入的文件非常之大,比如只用了ES6中数组的find功能,可能就会引入一个几千行的代码,我的建议对于库来说能不用最好不用
打包
编译解决了ES6到ES5的问题,打包可以把多个文件合并成一个文件,对外提供统一的文件入口,打包解决的是依赖引入的问题
rollup vs webpack
我选择的rollup作为打包工具,rollup号称下一代打包方案,其有如下功能
依赖解析,打包构建
仅支持ES6模块
Tree shaking
webpack作为最流行的打包方案,rollup作为下一代打包方案,其实一句话就可以总结二者的区别:库使用rollup,其他场景使用webpack
为什么我会这么说呢?下面通过例子对比下webpack和rollup的区别
假设我们有两个文件,index.js和bar.js,其代码如下
bar.js对外暴漏一个函数bar
export default function bar() {
console.log('bar')
}
index.js引用bar.js
import bar from './bar';
bar()
下面是webpack的配置文件webpack.config.js
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
}
};
下面来看一下webpack打包输出的内容,o(╯□╰)o,别着急,我们的代码在最下面的几行,上面这一大片代码其实是webpack生成的简易模块系统,webpack的方案问题在于会生成很多冗余代码,这对于业务代码来说没什么问题,但对于库来说就不太友好了
注意:下面的代码基于webpack3,webpack4增加了scope hoisting,已经把多个模块合并到一个匿名函数中
/******/
(function(modules) { // webpackBootstrap
/******/ // The module cache
/******/
var installedModules = {};
/******/
/******/ // The require function
/******/
function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/
if (installedModules[moduleId]) {
/******/
return installedModules[moduleId].exports;
/******/
}
/******/ // Create a new module (and put it into the cache)
/******/
var module = installedModules[moduleId] = {
/******/
i: moduleId,
/******/
l: false,
/******/
exports: {}
/******/
};
/******/
/******/ // Execute the module function
/******/
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/
module.l = true;
/******/
/******/ // Return the exports of the module
/******/
return module.exports;
/******/
}
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/
__webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/
__webpack_require__.c = installedModules;
/******/
/******/ // define getter function for harmony exports
/******/
__webpack_require__.d = function(exports, name, getter) {
/******/
if (!__webpack_require__.o(exports, name)) {
/******/
Object.defineProperty(exports, name, {
/******/
configurable: false,
/******/
enumerable: true,
/******/
get: getter
/******/
});
/******/
}
/******/
};
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/
__webpack_require__.n = function(module) {
/******/
var getter = module && module.__esModule ?
/******/
function getDefault() { return module['default']; } :
/******/
function getModuleExports() { return module; };
/******/
__webpack_require__.d(getter, 'a', getter);
/******/
return getter;
/******/
};
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/
__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/
__webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/
return __webpack_require__(__webpack_require__.s = 0);
/******/
})
/************************************************************************/
/******/
([
/* 0 */
/***/
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony import */
var __WEBPACK_IMPORTED_MODULE_0__bar__ = __webpack_require__(1);
Object(__WEBPACK_IMPORTED_MODULE_0__bar__["a" /* default */ ])()
/***/
}),
/* 1 */
/***/
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
/* harmony export (immutable) */
__webpack_exports__["a"] = bar;
function bar() {
//
console.log('bar')
}
/***/
})
/******/
]);
下面来看看rollup的结果,rollup的配置和webpack类似
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle2.js',
format: 'cjs'
}
};
下面看看rollup的产出,简直完美有没有,模块完全消失了,rollup通过顺序引入到同一个文件来解决模块依赖问题,rollup的方案如果要做拆包的话就会有问题,因为模块完全透明了,但这对于库开发者来说简直就是最完美的方案
'use strict';
function bar() {
//
console.log('bar');
}
bar();
模块化方案
在ES6模块化之前,JS社区探索出了一些模块系统,比如node中的commonjs,浏览器中的AMD,还有可以同时兼容不同模块系统的UMD,如果对这部分内容感兴趣,可以看我之前的一篇文章《JavaScript模块的前世今生》
对于浏览器原生,预编译工具和node,不同环境中的模块化方案也不同;由于浏览器环境不能够解析第三方依赖,所以浏览器环境需要把依赖也进行打包处理;不同环境下引用的文件也不相同,下面通过一个表格对比下
浏览器(script,AMD,CMD) | 预编译工具(webpack,rollup,fis) | Node | |
---|---|---|---|
引用文件 | index.aio.js | index.esm.js | index.js |
模块化方案 | UMD | ES Module | commonjs |
自身依赖 | 打包 | 打包 | 打包 |
第三方依赖 | 打包 | 不打包 | 不打包 |
注意: legacy模式下的模块系统可以兼容ie6-8,但由于rollup的一个bug(这个bug是我发现的,但rollup并不打算修复,╮(╯▽╰)╭哎),legacy模式下,不可同时使用 export 与 export default
tree shaking
rollup是天然支持tree shaking,tree shaking可以剔除依赖模块中没有被使用的部分,这对于第三方依赖(node_moduels依赖)非常有帮助,可以极大的降低包的体积
举个例子,假设index.js只是用了第三方包is.js中的一个函数isString,没有treeshaking会将is.js全部引用进来
而使用了treeshaking的话则可以将is.js中的其他函数剔除,仅保留isString函数
规范
无规矩不成方圆,特别是对于开源项目,由于会有多人参与,所以大家遵守一份规范会事半功倍
编辑器规范
首先可以通过.editorconfig来保证缩进、换行的一致性,目前绝大部分浏览器都已经支持,可以看这里
下面的配置设置在js,css和html中都用空格代替tab,tab为4个空格,使用unix换行符,使用utf8字符集,每个文件结尾添加一个空行
root = true
[{*.js,*.css,*.html}]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
insert_final_newline = true
[{package.json,.*rc,*.yml}]
indent_style = space
indent_size = 2
代码风格
其次可以通过eslint来保证代码风格一致,关于eslint的安装和配置这里不再展开解释了,在jslib-base中只需要运行下面的命令就可以进行代码校验了,eslint的配置文件位于config/.eslintrc.js
$ npm run lint
设计规范
eslint只能够保证代码规范,却不能保证提供优秀的接口设计,关于函数接口设计有一些指导规则
参数数量
函数的参数个数最多不要超过5个
可选参数
可选参数应该放到后面
可选参数数量超过三个时,可以使用对象传入
可选参数,应该提供默认值
参数校验与类型转换
必传参数,如果不传要报错
对下列类型要做强制检验,类型不对要报错(object, array, function)
对下列类型要做自动转换(number, string, boolean)
对于复合类型的内部数据,也要做上面的两个步骤
对于number转换后如果为NaN,要做特殊处理(有默认值的赋值为默认值,无默认值的要报错)
参数类型
参数尽量使用值类型(简单类型)
参数尽量不要使用复杂类型(避免副作用)
使用复杂类型时,层级不要过深
使用复杂数据类型时,应该进行深拷贝(避免副作用)
函数返回值
返回值可返回操作结果(获取接口),操作是否成功(保存接口)
返回值的类型要保持一致
返回值尽量使用值类型(简单类型)
返回值尽量不要使用复杂类型(避免副作用)
版本规范
版本应该遵守开源社区通用的语义化版本
版本号格式:x.y.z
x 主版本号,不兼容的改动
y 次版本号,兼容的改动
z 修订版本号,bug修复
Git commit规范
代码的提交应该遵守规范,这里推荐一个我的规范
测试
没有单元测试的库都是耍流氓,单元测试能够保证每次交付都是有质量保证的,业务代码由于一次性和时间成本可以不做单元测试,但开源库由于需要反复迭代,对质量要求又极高,所以单元测试是必不可少的
关于单元测试有很多技术方案,其中一种选择是mocha+chai,mocha是一个单元测试框架,用来组织、运行单元测试,并输出测试报告;chai是一个断言库,用来做单元测试的断言功能
由于chai不能够兼容ie6-8,所以选择了另一个断言库——expect.js,expect是一个BDD断言库,兼容性非常好,所以我选择的是mocha+expect.js
关于BDD与TDD的区别这里不再赘述,感兴趣的同学可以自行查阅相关资料
有了测试的框架,还需要写单元测试的代码,下面是一个例子
var expect = require('expect.js');
var base = require('../dist/index.js');
describe('单元测试', function() {
describe('功能1', function() {
it('相等', function() {
expect(1).to.equal(1);
});
});
});
然后只需运行下面的命令,mocha会自动运行test目录下面的js文件
$ mocha
mocha支持在node和浏览器中测试,但上面的框架在浏览器下有一个问题,浏览器没法支持require(‘expect.js’),我用了一个比较hack的方法解决问题,早浏览器中重新定义了require的含义
<script src="../../node_modules/mocha/mocha.js"></script>
<script src="../../node_modules/expect.js/index.js"></script>
<script>
var libs = {
'expect.js': expect,
'../dist/index.js': jslib_base
};
var require = function(path) {
return libs[path];
}
</script>
下面是用mocha生成测试报告的例子,左边是在node中,右边是在浏览器中
可持续集成
没有可持续集成的库都是原始人,如果每次push都能够自动运行单元测试就好了,这样就省去了手动运行的繁琐,好在travis-ci已经为我们提供了这个功能
用GitHub登录travis-ci,就可以看到自己在GitHub上的项目了,然后需要打开下项目的开关,才能够打开自动集成功能
第二步,还需要在项目中添加一个文件.travis.yml,内容如下,这样就可以在每次push时自动在node 4 6 8版本下运行npm test命令,从而实现自动测试的目的
language: node_js
node_js:
- "8"
- "6"
- "4"
其他内容
开源库希望得到用户的反馈,如果对用户提的issue有要求,可以设置一个模版,用来规范github上用户反馈的issue需要制定一些信息
通过提供.github/ISSUE_TEMPLATE文件可以给issue提供模版,下面是一个例子,用户提issue时会自动带上如下的提示信息
### 问题是什么
问题的具体描述,尽量详细
### 环境
- 手机: 小米6
- 系统:安卓7.1.1
- 浏览器:chrome 61
- jslib-base版本:0.2.0
- 其他版本信息
### 在线例子
如果有请提供在线例子
### 其他
其他信息
jsmini
jsmini是基于jslib-base的一系列库,jsmini的理念是小而美,并且无第三方依赖,开源了很多能力,能够 助力库开发者
总结
五年弹指一挥间,本文总结了自己做开源项目的一些经验,希望能够帮助大家,所有介绍的内容都可以在jslib-base里面找到
jslib-base是一个拿来即用脚手架,赋能js第三方库开源,快速开源一个标准的js库
最后再送给大家一句话,开源一个项目,重在开始,贵在坚持
关于本文
作者:@颜海镜
原文:
https://yanhaijing.com/javascript/2018/08/17/2020-js-lib/
最后,为你推荐
【第1239期】关于Google发布的JS代码规范,你需要了解什么?
【第1213期】优雅的提交你的 Git Commit Message
他曾分享过