8102年如何寫一個現代的JavaScript庫
我寫過一些 ofollow,noindex">開源專案 ,在開源方面有一些經驗,最近開到了阮老師的微博,深有感觸,現在一個開源專案涉及的東西確實挺多的,特別是對於新手來說非常不友好

最近我寫了一個 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可以提出依賴模組中沒有被使用的部分,這對於第三方依賴非常有幫助,可以極大的降低包的體積
舉個例子,假設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庫
最後再送給大家一句話,開源一個專案, 重在開始,貴在堅持
最後推薦下我的新書《React狀態管理與同構實戰》,深入解讀前沿同構技術,感謝大家支援