2018年,如何写一个现代的JavaScript库?
Posted 前端之巅
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了2018年,如何写一个现代的JavaScript库?相关的知识,希望对你有一定的参考价值。
本文转载自:
https://segmentfault.com/a/1190000016610626
我写过一些开源项目,在开源方面有一些经验,最近开到了阮老师的微博,深有感触,现在一个开源项目涉及的东西确实挺多的,特别是对于新手来说非常不友好。
最近我写了一个 jslib-base,旨在从多方面快速帮大家搭建一个标准的 js 库,本文将已 jslib-base 为例,介绍写一个开源库的知识。
jslib-base 最好用的 js 第三方库脚手架,赋能 js 第三方库开源,让开发一个 js 库更简单,更专业。
所谓代码未动,文档先行,文档对于一个项目非常重要,一个项目的文档包括
README.md
TODO.md
CHANGELOG.md
LICENSE
doc
README 是一个项目的门面,应该简单明了的呈现用户最关心的问题,一个开源库的用户包括使用者和贡献者,所以一个文档应该包括项目简介,使用者指南,贡献者指南三部分。
项目简介用该简单介绍项目功能,使用场景,兼容性的相关知识,这里重点介绍下徽章,相信大家都见过别人项目中的徽章,如下所示:
徽章通过更直观的方式,将更多的信息呈现出来,还能够提高颜值,有一个网站专门制作各种徽章,可以看这里:https://shields.io/#/
TODO 应该记录项目的未来计划,这对于贡献者和使用者都有很重要的意义,下面是 TODO 的例子:
- [X] 已完成
- [ ] 未完成
CHANGELOG 记录项目的变更日志,对项目使用者非常重要,特别是在升级使用版本时,CHANGELOG 需要记录项目的版本,发版时间和版本变更记录:
## 0.1.0 / 2018-10-6
- 新增 xxx 功能
- 删除 xxx 功能
- 更改 xxx 功能
开源项目必须要选择一个协议,因为没有协议的项目是没有人敢使用的,关于不同协议的区别可以看下面这张图(出自阮老师博客),我的建议是选择 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 作为打包工具,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 模块的前世今生》(https://yanhaijing.com/javascript/2015/03/28/js-module/)。
对于浏览器原生,预编译工具和 node,不同环境中的模块化方案也不同;由于浏览器环境不能够解析第三方依赖,所以浏览器环境需要把依赖也进行打包处理;不同环境下引用的文件也不相同,下面通过一个表格对比下。
注意: legacy 模式下的模块系统可以兼容 ie6-8,但由于 rollup 的一个 bug(这个 bug 是我发现的,但 rollup 并不打算修复,╮(╯▽╰)╭哎),legacy 模式下,不可同时使用 export 与 export default。
rollup 是天然支持 tree shaking,tree shaking 可以提出依赖模块中没有被使用的部分,这对于第三方依赖非常有帮助,可以极大的降低包的体积。
举个例子,假设 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 修复
代码的提交应该遵守规范,这里推荐一个我的规范:https://yanhaijing.com/git/2016/02/17/my-commit-message/
没有单元测试的库都是耍流氓,单元测试能够保证每次交付都是有质量保证的,业务代码由于一次性和时间成本可以不做单元测试,但开源库由于需要反复迭代,对质量要求又极高,所以单元测试是必不可少的。
关于单元测试有很多技术方案,其中一种选择是 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 ></script>
<script ></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
- 其他版本信息
### 在线例子
如果有请提供在线例子
### 其他
其他信息
五年弹指一挥间,本文总结了自己做开源项目的一些经验,希望能够帮助大家,所有介绍的内容都可以在 jslib-base 里面找到。
jslib-base 是一个拿来即用脚手架,赋能 js 第三方库开源,快速开源一个标准的 js 库。
最后再送给大家一句话,开源一个项目,重在开始,贵在坚持。
毋庸置疑,这是一个属于人工智能的时代。人工智能正在渗透到各行各业,并且离我们越来越近,新的时代中,我们应该如何利用好新武器?
“ AI 技术内参 ”专栏将为你系统剖析人工智能核心技术,精讲人工智能国际顶级学术会议核心论文,解读技术发展前沿与最新研究成果,分享数据科学家以及数据科学团队的养成秘籍。
现在订阅,有以下福利:
限时优惠¥79,原价 ¥199
每邀请一位好友购买,你可获得 ¥24 现金返现,好友也将获得 ¥612 返现。多邀多得,上不封顶,立即提现。
以上是关于2018年,如何写一个现代的JavaScript库?的主要内容,如果未能解决你的问题,请参考以下文章
赶上 ECMAScript 潮流:用现代 JavaScript 编程