微信号:frontshow

介绍:InfoQ大前端技术社群:囊括前端、移动、Node全栈一线技术,紧跟业界发展步伐。

2018年,如何写一个现代的JavaScript库?

2018-11-03 20:15 颜海镜
作者|颜海镜
编辑|覃云

本文转载自:

https://segmentfault.com/a/1190000016610626

我写过一些开源项目,在开源方面有一些经验,最近开到了阮老师的微博,深有感触,现在一个开源项目涉及的东西确实挺多的,特别是对于新手来说非常不友好。

最近我写了一个 jslib-base,旨在从多方面快速帮大家搭建一个标准的 js 库,本文将已 jslib-base 为例,介绍写一个开源库的知识。

jslib-base 最好用的 js 第三方库脚手架,赋能 js 第三方库开源,让开发一个 js 库更简单,更专业。

文档

所谓代码未动,文档先行,文档对于一个项目非常重要,一个项目的文档包括

  • README.md

  • TODO.md

  • CHANGELOG.md

  • LICENSE

  • doc

README.md

README 是一个项目的门面,应该简单明了的呈现用户最关心的问题,一个开源库的用户包括使用者和贡献者,所以一个文档应该包括项目简介,使用者指南,贡献者指南三部分。

项目简介用该简单介绍项目功能,使用场景,兼容性的相关知识,这里重点介绍下徽章,相信大家都见过别人项目中的徽章,如下所示:

徽章通过更直观的方式,将更多的信息呈现出来,还能够提高颜值,有一个网站专门制作各种徽章,可以看这里:https://shields.io/#/

 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 模块的前世今生》(https://yanhaijing.com/javascript/2015/03/28/js-module/)。

对于浏览器原生,预编译工具和 node,不同环境中的模块化方案也不同;由于浏览器环境不能够解析第三方依赖,所以浏览器环境需要把依赖也进行打包处理;不同环境下引用的文件也不相同,下面通过一个表格对比下。

  • 注意: legacy 模式下的模块系统可以兼容 ie6-8,但由于 rollup 的一个 bug(这个 bug 是我发现的,但 rollup 并不打算修复,╮(╯▽╰)╭哎),legacy 模式下,不可同时使用 export 与 export default。

 tree shaking

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 修复

 Git commit 规范

代码的提交应该遵守规范,这里推荐一个我的规范: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 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
- 其他版本信息

### 在线例子
如果有请提供在线例子

### 其他
其他信息
总结

五年弹指一挥间,本文总结了自己做开源项目的一些经验,希望能够帮助大家,所有介绍的内容都可以在 jslib-base 里面找到。

jslib-base 是一个拿来即用脚手架,赋能 js 第三方库开源,快速开源一个标准的 js 库。

最后再送给大家一句话,开源一个项目,重在开始,贵在坚持。

 课程推荐

毋庸置疑,这是一个属于人工智能的时代。人工智能正在渗透到各行各业,并且离我们越来越近,新的时代中,我们应该如何利用好新武器?

“ AI 技术内参 ”专栏将为你系统剖析人工智能核心技术,精讲人工智能国际顶级学术会议核心论文,解读技术发展前沿与最新研究成果,分享数据科学家以及数据科学团队的养成秘籍。

现在订阅,有以下福利:

  1. 限时优惠¥79,原价 ¥199

  2. 每邀请一位好友购买,你可获得 ¥24 现金返现,好友也将获得 ¥612 返现。多邀多得,上不封顶,立即提现。

 
前端之巅 更多文章 React Native重构路线图发布! Storybook 4.0大升级:为RN提供一级支持、引入6个新视图层 如何用浏览器调试器代替console.log() ? Kotlin 1.3发布:支持协程和多平台组件 克军:如何成为一位优秀的前端工程师?
猜您喜欢 创业路上:主导创业中的大大小小事儿 手术知情同意书要不要签,谁能帮帮我? 九合王啸|运动,极客范儿,以及不易被人察觉的人文情怀 资深Python程序员详解爬取喜马拉雅 读书笔记—MySQL提高性能优化总结