Stories

Detail Return Return

如何優雅地編寫一個高逼格的JS插件? - Stories Detail

在一個風和日麗的早晨,我正悠閒地喝着Coffe,突然領導向我走來,我趕緊熟練地切出VSCode,淡定自若地問:領導,什麼事?領導拍了拍我的肩膀:你上次封裝的方法同事跟我反饋使用起來很不錯啊,你不如做成JS插件給大家用吧。我放下了手中的馬克杯,甩了一下眼前僅剩的幾根劉海:沒問題啊,小Case!隨即開始摸魚....

原型鏈寫法

要開始編寫插件就得先了解JS模塊化,早期的模塊化是利用了函數自執行來實現的,在單獨的函數作用域中執行代碼可以避免插件中定義的變量污染到全局變量,舉個栗子🌰,以下代碼實現了一個簡單隨機數生成的插件:

;(function (global) {
    "use strict";

    var MyPlugin = function (name) {
        this.name = name
    };

    MyPlugin.prototype = {
        say: function () {
            console.log('歡迎你:', this.name)
        },
        random: function (min = 0, max = 1) {
            if (min <= Number.MAX_SAFE_INTEGER && max <= Number.MAX_SAFE_INTEGER) {
                return Math.floor(Math.random() * (max - min + 1)) + min
            }
        }
    };
    
    // 函數自執行將 this(全局下為window)傳入,並在其下面掛載方法
    global.MyPlugin = MyPlugin;
    // 兼容CommonJs規範導出
    if (typeof module !== 'undefined' && module.exports) module.exports = MyPlugin; 
})(this);

直接使用 script 標籤引入該插件,接着 new 一個實例就能使用插件啦:

var aFn = new MyPlugin()

var num = aFn.random(10, 20)
console.log(num) // 打印一個 10~20 之間的隨機數

閉包式寫法

上面的插件使用時如果調用 say 方法,會打印方法中的歡迎字樣,並顯示初始化的 name 值:

var aFn = new MyPlugin('呀哈哈')
aFn.say() // 歡迎你: 呀哈哈

但由於屬性能被直接訪問,插件中的變量就可以隨意修改,這可能是我們不想看到的:

var aFn = new MyPlugin('呀哈哈')
aFn.name = null
aFn.say() // 歡迎你: null

那麼如果要創建私有變量,可以利用JS閉包原理來編寫插件,我們使用工廠模式來創建函數,再舉個栗子🌰,如下代碼實現了一個簡單正則校驗的插件:

; (function (global) {
    "use strict";

    var MyPlugin = function (value) {
        var val = value
        var reg = {
            phone: /^1[3456789]\d{9}$/,
            number: /^-?\d*\.?\d+$/
        };
        return {
            getRegs() {
                return reg
            },
            setRegs(params) {
                reg = { ...reg, ...params }
            },
            isPhone() {
                reg.phone.test(val) && console.log('這是手機號')
                return this
            },
            isNumber() {
                reg.number.test(val) && console.log('這是數字')
                return this
            }
        };
    };

    // 函數自執行將 this(全局下為window)傳入,並在其下面掛載方法
    global.MyPlugin = MyPlugin;
    // 兼容CommonJs規範導出
    if (typeof module !== 'undefined' && module.exports) module.exports = MyPlugin;
})(this);

這時我們再調用插件,其內部的變量是不可訪問的,只能通過插件內部的方法查看/修改

var aFn = new MyPlugin()

console.log( aFn.reg ) // undefined

var reg = aFn.getRegs()
console.log( reg ) // {"phone":{....},"number":{.....}}

上面代碼中我們在 isPhone isNumber 方法的最後都返回了 this,這是為了實現如下的鏈式調用:

var aFn = new MyPlugin(13800138000)

aFn.isPhone().isNumber() // log: > 這是手機號 > 這是數字

仿 JQuery 寫法

這種寫法是仿造JQ實現的一種編寫模式,可以省去調用時new實例化的步驟,並實現類似 $(xxx).someFn(....) 這樣的調用方法,在需要頻繁DOM操作的時候就很適合這麼編寫插件。筆者以前會在小項目中自己實現一些類JQ選擇器操作的功能插件,來避免引入整個JQ,實現插件的核心思路如下:

var Fn = Function(params) {
    return new Fn.prototype.init(params)
}

Fn.prototype = {
    init: function() {}
}

Fn.prototype.init.prototype = Fn.prototype
可以看出核心是對JS原型鏈的極致利用,首先主動對其原型上的init方法進行實例化並返回,init相當於構造函數的效果,而此時返回的實例裏並沒有包含Fn的方法,我們調用時JS自然就會從init的原型對象上去查找,於是最終init下的原型才又指向了Fn的原型,通過這種"套娃"的手法,使得我們能夠不通過實例化Fn又能正確地訪問到Fn下的原型對象。

説了這麼多,還是舉個栗子🌰,以下代碼實現了一個簡單的樣式操作插件:

;(function (global) {
  "use strict";

  var MyPlugin = function (el) {
    return new MyPlugin.prototype.init(el)
  };

  MyPlugin.prototype = {
    init: function (el) {
      this.el = typeof el === "string" ? document.querySelector(el) : el;
    },
    setBg: function (bg) {
      this.el.style.background = bg;
      return this
    },
    setWidth: function (w) {
      this.el.style.width = w;
      return this
    },
    setHeight: function (h) {
      this.el.style.height = h;
      return this
    }
  };

  MyPlugin.prototype.init.prototype = MyPlugin.prototype
  // script標籤引入插件後全局下掛載一個_$的方法
  global._$ = MyPlugin;
})(this || window);

使用演示:

<!-- 頁面元素 -->
<div id="app">hello world</div>

為元素設置背景:

_$('#app').setBg('#ff0')

2022/10/1664903530137.png

為元素設置背景並改變寬高:

_$('#app').setBg('#ff0').setHeight('100px').setWidth('200px')

2022/10/1664903548521.png

工程化插件

假設以後會有多人同時開發的情況,僅靠一個JS維護大型插件肯定是獨木難支,這時候就需要組件化把顆粒度打細,將插件拆分成多個文件,分別負責各自的功能,最終再打包成一個文件引用。如今ES模塊化已經可以輕鬆應對功能拆分了,所以我們只需要一個打包器,Rollup.js 就是不錯的選擇,有了它我們可以更優雅地編寫插件,它會幫我們打包。許多大型框架例如 VueReact 都是用它打包的。

Rollup 是一個用於 JavaScript 的模塊打包器,它將小段代碼編譯成更大更復雜的東西,例如庫或應用程序。官網鏈接

創建一個示例

下面我們一步步實現這個工程化的插件,沒有那麼複雜,先創建一個目錄:

mkdir -p my-project/src

接着運行 npm init 進行項目初始化,一路回車,接着為項目安裝 Rollup

npm install --save-dev rollup

根目錄下創建入口文件 index.js,以及 src下的main.js用於等下測試:

// index.js
import main from './src/main.js';

console.log(main);
// src/main.js
export default 'hello world!';

根目錄下創建 rollup.config.js

import babel from 'rollup-plugin-babel'
// babel:將最終代碼編譯成 es5,我們的開發代碼可以不用處理兼容性。
import commonjs from 'rollup-plugin-commonjs'
import resolve from 'rollup-plugin-node-resolve'
// resolve、commonjs:用於兼容可以依賴 commonjs 規範的包。

export default {
  input: 'index.js',
  output: [
    {
      file: 'dist/main.umd.js',
      format: 'umd',
      name: 'bundle-name',
    },
    {
      file: 'dist/main.es.js',
      format: 'es',
    },
    {
      file: 'dist/main.cjs.js',
      format: 'cjs',
    },
  ],
  plugins: [
    babel({
      exclude: 'node_modules/**',
    }),
    resolve({
      jsnext: true,
      main: true,
      browser: true,
    }),
    commonjs(),
  ],
}

把上面的依賴安裝一下,運行:

npm install --save-dev @babel/core @babel/preset-env rollup-plugin-babel@latest rollup-plugin-node-resolve rollup-plugin-commonjs

修改 package.json,增加一條腳本命令:

.......
"scripts": {
    ......
    "dev": "rollup -c -w"
},

最後運行 npm run dev 看看效果吧:

image.png

示例結果

打包最終文件位置:

image.png

運行 node dist/main.cjs.js

image.png

打包文件格式説明

1. umd

集合了 CommonJSAMDCMDIIFE 為一體的打包模式,看看上面的 hello world 會被打包成什麼:

(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
    typeof define === 'function' && define.amd ? define(factory) :
    (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global["bundle-name"] = factory());
})(this, (function () { 'use strict';

    .....代碼省略.....
    
    return xxxxxxxx;
}));

可以看出導出的文件就是我們前面一直使用的函數自執行開發方式,其中加了各種兼容判斷代碼將在哪個環境下導入。

2. es

現代JS的標準,導出的文件只能使用 ES模塊化 方式導入。

3. cjs

這個是指 CommonJS 規範導出的格式,只可在 Node 環境下導入。

補充:模塊化的發展

  • 早期利用函數自執行實現,在單獨的函數作用域中執行代碼(如 JQuery )
  • AMD:引入 require.js 編寫模塊化,引用依賴必須提前聲明
  • CMD:引入 sea.js 編寫模塊化,特點是可以動態引入依賴
  • CommonJS:NodeJs 中的模塊化,只在服務端適用,是同步加載
  • ES Modules:ES6 中新增的模塊化,是目前的主流

本文前三種插件編寫方式均屬於利用函數自執行(IIFE)實現的插件,同時在向全局注入插件時兼容了 CommonJS 規範,但並未兼容 AMD CMD,是因為目前基本沒有項目會使用到這兩種模塊化。

自動化API文檔

一個 JS 插件如果沒有一份文檔,如同一台精密的儀器沒有説明書。當別人使用你的插件時,他不可能去查看源碼才知道這個插件有哪些方法、用途如何、要傳哪些參數等。

所以這裏我們使用 JSDoc 來創建 API文檔,它使用簡單,只需要在代碼中編寫規範的註釋,即能根據註釋自動生成文檔,一舉多得,十分優雅!

npm install --save-dev jsdoc open

修改 package.json,增加一條腳本命令:

.......
"scripts": {
    ......
    "doc": "jsdoc dist/main.es.js && node server.js"
},

根目錄下創建文件 server.js

var open = require('open');
open(`out/index.html`); // 這是apidoc默認生成的路徑,這裏只是為了自動打開網頁

好了,現在可以使用 npm run doc 命令來生成文檔了,依然是舉個栗子🌰,我們在src目錄下添加一個文件 ArrayDelSome.js

/**
 *
 * @desc 對象數組去重
 * @param {Array} arr
 * @param {String} 對象中相同的關鍵字(如id)
 * @return {Array} 返回新數組,eg: ArrayDelSome([{id: 1},{id: 2},{id: 1}], 'id') -> 返回: [{id: 1},{id: 2}]
 */
function ArrayDelSome(arr, key) {
  const map = new Map()
  return arr.filter((x) => !map.has(x[key]) && map.set(x[key], true))
}

export default ArrayDelSome
本例只演示最基礎的用法,JSDoc有許多類型註釋大家可以自行搜索學習下,不過本例最基本的這幾個註釋依舊是夠用的。

運行 npm run doc,將會打開一個網頁,可以查看我們剛寫的工具函數:

image.png

注意:在生成文檔前需要先進行過 rollup 的打包,且不能開啓去註釋之類的插件,因為上面的例子實際是對 dist/ 目錄下的最終文件進行文檔編譯的。

發佈插件

還沒發佈過npm包?參考這篇文章。

私有源發佈

如果你的公司有私域npm管理源,或者平時喜歡用淘寶源,推薦使用 nrm 進行切換:

npm i nrm -g
  1. 查看源: nrm ls
  2. 添加源: nrm add name http//:xxx.xxx.xxx.xxx:4873/
  3. 刪除源: nrm del name
  4. 使用指定源: nrm use npm

總結

功能較簡單的JS插件我們可以直接採用前三種方式開發,如果涉及DOM操作較多,可以編寫仿JQ的插件更好用,如果插件功能較多,有可能形成長期維護的大型插件,那麼可以採用工程化的方式開發,方便多人協作,配套生成文檔也利於維護。

以上就是文章的全部內容,希望對你有所幫助!如果覺得文章寫得不錯,可以點贊收藏,也歡迎關注,我會持續更新更多前端有用的知識與實用技巧,我是茶無味de一天,希望與你共同成長~

Add a new Comments

Some HTML is okay.