JavaScript SDK 設計指南

花墩闖爺發表於2017-03-30

原文地址:http://sdk-design.js.org/

介紹

本指南為您介紹了在桌上型電腦和行動網路在不同的平臺和瀏覽器( < 99.99 %我可能會跳過一些瀏覽器)開發的JavaScript SDK ,對於那些非瀏覽器開發的支援(硬體,嵌入式,節點/ IO JS )被排除在本文件之外,在未來予以考慮。
因為我沒有找到一個關於設計JavaScript SDK的比較好的文件,所以我在這裡收集並記下了我個人的經驗。這份文件已經寫了好幾個月,有一點我們需要知道,JavaScript的SDK-設計不僅僅是設計SDK本身,這也是有關於開發者與裝置瀏覽器中間的聯絡。我們寫的越多,越會更多的思考我們真正關心的是不同平臺和瀏覽器之間的效能和相容問題。你可以根據情況自由的更改或者完全放棄我在文章裡列出的建議。

什麼是SDK

我知道它確實是很普通很常見。一般是一些軟體工程師為特定的軟體包、軟體框架、硬體平臺、作業系統等建立應用軟體時的開發工具的集合。通常一個SDK包含一個或多個API,程式設計工具和檔。

設計理念

這取決於你的SDK用來幹什麼的,但是它必須具備原生的,短,速度快,乾淨,可讀可測試特性。用原生javascript寫,不要用像Livescript, Coffeescript, Typescript和其它的編譯語言。必須有更好的方法來編寫自己的javascript原生程式碼比別人更快。請不要在你的SDK裡用JQuery,除非它非常有必要。你可以使用其它的類似jQuery的庫,譬如zetpo.js,用於DOM操作,如果你需要用到HTTP Ajax請求,可以使用另外一種輕量庫像window.fetch。

每一次的SDK版本釋出,確保它不僅適用於舊版本而且適應於未來的新版本。所以,記得為你的SDK寫文件,程式碼要寫註釋,同時做好單元測試和使用者場景測試。

適應範圍

基於《Third-Party JavaScript》這本書。在何種情況下,你應該為你的應用設計一個JavaScript SDK?

  • 嵌入式元件 – 嵌入在出釋出者的網頁中的互動式應用程式(Disqus, Google Maps, Facebook Widget)。
  • 分析與資料 – 蒐集網站訪問者以及其與網站互動的資料資訊。(GA, Flurry, Mixpanel)
  • web服務API封裝 -對於發展與外部Web服務通訊的客戶端應用程式。(Facebook的圖形API)

在什麼情況下,我們應該在JavaScript環境中使用SDK呢?大家可以想想還有其它情沒?

引入SDK

建議你採用非同步載入指令碼的方式。我們要優化網站的使用者體驗,所以不希望我們的SDK庫阻塞其它主要程式。
非同步載入

(function() {vars=document.createElement('script');s.type='text/javascript';s.async=true;s.src='http://xxx.com/sdk.js';varx=document.getElementsByTagName('script')[0];x.parentNode.insertBefore(s, x);})();**

在新的現代瀏覽器(chrome)你可以使用

<script asyncsrc="http://xxx.com/sdk.js"></script>

傳統載入方法

<script type="text/javascript"src="http://xxx.com/sdk.js"></script>

對比:

下面是簡單的圖形顯示非同步載入和傳統同步載入方式之間的區別

非同步:

|----A-----|
      |-----B-----------|
            |-------C------|

同步:

|----A-----||-----B-----------||-------C------|

非同步和延遲指令碼執行解釋

非同步的問題

當你使用非同步載入的時候,將會出現,頁面中的函式無法正常呼叫SDK方法的情況。

<script>
  (function () {
    var s =document.createElement('script');
    s.type='text/javascript';
    s.async=true;
    s.src='http://xxx.com/sdk.js';
    var x =document.getElementsByTagName('script')[0];
    x.parentNode.insertBefore(s, x);
  })();
  // execute your script immediately hereSDKName('some arguments');
</script>

結果會報undefined錯誤,因為SDKName()在指令碼載入之前執行了。所以我們應該使用點技巧讓指令碼正確執行。把事件儲存在SDKName.q陣列裡,SDK初始化的時候執行SDKName.q。

<script>
  (function () {
    // add a queue event here
    SDKName = SDKName ||function () {
    (SDKName.q=SDKName.q|| []).push(arguments);
  };
  var s =document.createElement('script');
  s.type='text/javascript';
  s.async=true;
  s.src='http://xxx.com/sdk.js';
  var x =document.getElementsByTagName('script')[0];
  x.parentNode.insertBefore(s, x);
})();
  // execute your script immediately hereSDKName('some arguments');
 </script>

或者用 [ ].push

<script>
  (function () {
   // add a queue event here
   SDKName =window.SDKName|| (window.SDKName= []);
   var s =document.createElement('script');
  s.type='text/javascript';
  s.async=true;
  s.src='http://xxx.com/sdk.js';
  var x =document.getElementsByTagName('script')[0];
  x.parentNode.insertBefore(s, x);
})();
// execute your script immediately hereSDKName.push(['some arguments']);
</script>

其他方式

還有其它不同方式載入指令碼

Import in ES2015

import"your-sdk";

模組載入

這裡有完整的原始碼和非常棒的教程. Loading JavaScript Modules

module('sdk.js',['sdk-track.js', 'sdk-beacon.js'],function(track, beacon) {
// sdk definitions, split into local and global/exported   definitions// local definitions// exports
});
// you should contain this "module" method
(function () {
var modules = {}; // private record of module data// modules   are functions with additional   informationfunctionmodule(name,imports,mod) {
// record module informationwindow.console.log('found module '+name);
modules[name] = {name:name, imports: imports, mod: mod};
// trigger loading of import dependenciesfor (var imp in imports) loadModule(imports[imp]);
// check whether this was the last module to be loaded// in a given dependency grouploadedModule(name);
}

// function loadModule// function   loadedModulewindow.module=module;
})();

SDK版本

避免使用自己的特例作為版本名稱像

標識-v<時間戳>.js 標識-v<日期>.js 標識-v1-v2.js
它可能導致使用SDK的開發者很混亂不知道哪個是最新版本。
使用 Semantic Versioning (語義化版本規範)去定義SDK的版本號以”大.小.補丁”形式。
版本以v1.0.0 v1.5.0 v2.0.0的形式,會讓使用者搜尋跟蹤日誌檔案更容易。
通常情況下,我們會有不同的方式去宣告SDK的版本,這取決於具體針對的業務和設計。

使用查詢字串路徑

http://xxx.com/sdk.js?v=1.0.0

使用資料夾命名

http://xxx.com/v1.0.0/sdk.js

使用主機名或者子域名

http://v1.xxx.com/sdk.js

為了以後版本的升級迭代,建議用stable unstable alpha latest experimental 版本。

http://xxx.com/sdk-stable.js

http://xxx.com/sdk-unstable.js


http://xxx.com/sdk-alpha.js


http://xxx.com/sdk-latest.js


http://xxx.com/sdk-experimental.js

更新日誌檔案

你應該注意到如果你升級你的SDK卻沒通知使用者,使用者不會知道。記得寫更新日誌來記錄無論是主要、次要甚至bug修復等修改。這將是一個好的開發經驗,我們能快速的跟蹤到SDK某個API的修改。所以保持更新日誌 – Keep a Changelog, Github Repo

每個版本的日誌應該有:

[新增] 新功能.
[更新] 修改現有的更能
[廢棄] 在即將釋出的版本中刪除某個功能.
[刪除] 在這個版本中刪除棄用的功能.
[修正] bug修復
[安全] 邀請使用者對安全進行升級

名稱空間

在你的SDK裡只定義一個全域性名稱空間,並且不要用太過通用的名字,避免和其它類庫名發生衝突。SDK的主體用(function () { … })()包裹。這種做法越來越普遍的應用於各種流行的javascript類庫譬如jQuery,Node.js等等。這種建立私有的名稱空間的技術很重要,有助於避免各種類庫之間命名的衝突。

為了避免名稱空間衝突

學習Google Analytics的做法,你可以通過改變 ga的值來定義你自己的名稱空間。

(function(i,s,o,g,r,a,m) {i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o) [0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google- analytics.com/analytics.js','ga');

下面的是 openX的做法,支援通過給地址傳遞引數定義名稱空間。

<script src="http://your_domain/sdk?namespace=yourcompany"></script>

儲存機制

cookie

使用cookie就會面臨複雜的作用域範圍問題,而且涉及到子域和路徑問題。

比如在路徑 path=/下, cookie first=value1 在域名 http://github.com下, 另外一個 cookie second=value2 在域名 http://sub.github.com下

http://github.com http://sub.github.com
first=value1
second=value2

有個 cookie first=value1 在 http://github.com下, cookie second=value2 在 http://github.com/path1 另外一個 cookie third=value3 在 http://sub.github.com下,

http://github.com http://github.com/path1 http://sub.github.com
first=value1
second=value2
third=value3

檢查 Cookie 可讀寫

給定一個域 (預設當前主機域名), 檢查cookie是否可讀寫。

var checkCookieWritable = function(domain) {
try {
    // Create cookie
    document.cookie = 'cookietest=1' + (domain ? '; domain=' + domain : '');
    var ret = document.cookie.indexOf('cookietest=') != -1;
    // Delete cookie
    document.cookie = 'cookietest=1; expires=Thu, 01-Jan-1970 00:00:01 GMT' + (domain ? '; domain=' + domain : '');
    return ret;
} catch (e) {
    return false;
}
};

檢查第三方 Cookie 可讀寫

檢查第三方cookie僅僅通過客戶端js是辦不到的,需要伺服器端配合。

寫 讀 刪除 Cookie 程式碼

程式碼片段寫/讀/刪除cookie的指令碼。

var cookie = {
write: function(name, value, days, domain, path) {
    var date = new Date();
    days = days || 730; // two years
    path = path || '/';
    date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
    var expires = '; expires=' + date.toGMTString();
    var cookieValue = name + '=' + value + expires + '; path=' + path;
    if (domain) {
        cookieValue += '; domain=' + domain;
    }
    document.cookie = cookieValue;
},
read: function(name) {
    var allCookie = '' + document.cookie;
    var index = allCookie.indexOf(name);
    if (name === undefined || name === '' || index === -1) return '';
    var ind1 = allCookie.indexOf(';', index);
    if (ind1 == -1) ind1 = allCookie.length;
    return unescape(allCookie.substring(index + name.length + 1, ind1));
},
remove: function(name) {
    if (this.read(name)) {
        this.write(name, '', -1, '/');
    }
}
};

Session

js寫不了session,需要伺服器端寫。
一個頁面的session會一直儲存著只要瀏覽器是開著的即使頁面重新載入。開啟一個新頁面會生成一個新的session。子視窗會和父視窗共享一個session。

LocalStorage

儲存的資料沒有時間限制。儲存資料量大(至少5MB)並且資訊不會傳送到伺服器。而且同一個域名從http和https訪問localStorage是不共享的。你可以在你的網頁上建立個iframe,然後用postMessage方法去傳值到父頁面。HOW TO?

檢查 LocalStorage 可寫

window.localStorage 並不是任何瀏覽器都支援,SDK在用之前要檢查是否可用。

var testCanLocalStorage = function() {
var mod = 'modernizr';
 try {
     localStorage.setItem(mod, mod);
     localStorage.removeItem(mod);
     return true;
 } catch (e) {
   return false;
 }
};

SessionStorage

針對一個 session 的資料儲存(當使用者關閉瀏覽器視窗後,資料會被刪除).

檢查 SessionStorage 可寫

var checkCanSessionStorage = function() {
var mod = 'modernizr';
try {
sessionStorage.setItem(mod, mod);
sessionStorage.removeItem(mod);
return true;
} catch (e) {
return false;
}
}

事件

在客戶端瀏覽器有很多事件載入、解除安裝、繫結等會存在相容問題。polyfills是個解決不同平臺事件繫結的不錯的解決方案。

Document Ready

確保整個頁面完成載入了再執行SDK方法。

// handle IE8+
function ready (fn) {
if (document.readyState != 'loading') {
    fn();
} else if (window.addEventListener) {
    // window.addEventListener('load', fn);
    window.addEventListener('DOMContentLoaded', fn);
} else {
    window.attachEvent('onreadystatechange', function() {
        if (document.readyState != 'loading')
            fn();
        });
}
}

DOMContentLoaded - 所有DOM解析完會觸發整個事件 不需要等到樣式表、圖片等載入完。

load 頁面完整載入。

Message Event

這裡是實現iframe和父頁面之間的資料通訊, 這裡有文件 API documentation.

// in the iframe
parent.postMessage("Hello"); // string

// ==========================================
// in the iframe's parent
// Create IE + others compatible event handler
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod == "attachEvent" ? "onmessage" : "message";
// Listen to message from child window
eventer(messageEvent,function(e) {
// e.origin , check the message origin
console.log('parent received message!:  ',e.data);
},false);

傳送的資料是字串, 對於使用更高階的json字串. 不是所有的瀏覽器對支援 Structured Clone Algorithm on the parameter, (引數的結構化克隆)。

Orientation Change 橫屏事件

檢測裝置橫屏

window.addEventListener('orientationchange', fn);

獲取旋轉方向和角度

window.orientation; // => 90, -90, 0

Screen portrait-primary(豎屏正方向), portrait-secondary(豎屏反方向), landscape-primary(橫屏正方向), landscape-secondary (橫屏反方向)(Experimental)

// https://developer.mozilla.org/en-US/docs/Web/API/Screen/orientation
var orientation = screen.orientation || screen.mozOrientation || screen.msOrientation;

Request

我們的SDK和伺服器之間通訊通過Ajax請求,因為我們知道我們可以使用jQuery的Ajax 方法。但是有更好的方案來實現它。

圖片預載入

通過建立一個Image物件預載入一張圖片。為了防止瀏覽器快取記得加上時間戳。

(new Image()).src = 'http://xxxxx.com/collect?id=1111';

要注意通過GET方式傳輸引數最大長度是2048個位元組(取決於不同的瀏覽器和伺服器)。這裡要做一些處理如果超過長度。

if (length > 2048) {
// do Multiple Post (form)
} else {
// do Image Beacon
}

你可能遇到問題在使用encodeURI 還是 encodeURIComponent的時候,最好理解它們的區別。 See below.

對於影像載入成功/錯誤回撥

var img = new Image();
img.src = 'http://xxxxx.com/collect?id=1111';
img.onload = successCallback;
img.onerror = errorCallback;

單個 Post 請求

普通表單傳送一個對應元素和值

var form = document.createElement('form');
var input = document.createElement('input');

form.style.display = 'none';
form.setAttribute('method', 'POST');
form.setAttribute('action', 'http://xxxx.com/track');

input.name = 'username';
input.value = 'attacker';

form.appendChild(input);
document.getElementsByTagName('body')[0].appendChild(form);

form.submit();

多個 Post 請求

服務通常比較複雜,需要通過POST方法傳送更多資料。

function requestWithoutAjax( url, params, method ){

params = params || {};
method = method || "post";

// function to remove the iframe
var removeIframe = function( iframe ){
    iframe.parentElement.removeChild(iframe);
};

// make a iframe...
var iframe = document.createElement('iframe');
iframe.style.display = 'none';

iframe.onload = function(){
    var iframeDoc = this.contentWindow.document;

    // Make a invisible form
    var form = iframeDoc.createElement('form');
    form.method = method;
    form.action = url;
    iframeDoc.body.appendChild(form);

    // pass the parameters
    for( var name in params ){
        var input = iframeDoc.createElement('input');
        input.type = 'hidden';
        input.name = name;
        input.value = params[name];
        form.appendChild(input);
    }

    form.submit();
    // remove the iframe
    setTimeout( function(){
        removeIframe(iframe);
    }, 500);
};

document.body.appendChild(iframe);
}
requestWithoutAjax('url/to', { id: 2, price: 2.5, lastname: 'Gamez'});

Iframe

當你在需要在頁面中生成內容時候,你可以通過iframe嵌入。

var iframe = document.createElement('iframe');
var body = document.getElementsByTagName('body')[0];

iframe.style.display = 'none';
iframe.src = 'http://xxxx.com/page';
iframe.onreadystatechange = function () {
if (iframe.readyState !== 'complete') {
    return;
}
};
iframe.onload = loadCallback;

body.appendChild(iframe);

清除iframe的邊框,內部margin值。

<iframe src="..."
 marginwidth="0"
 marginheight="0"
 hspace="0"
 vspace="0"
 frameborder="0"
 scrolling="no">
</iframe>

iframe中插入html

<iframe id="iframe"></iframe>

<script>
  var html_string= "content <script>alert(location.href); </script>";
  document.getElementById('iframe').src = "data:text/html;charset=utf-8," + escape(html_string);
  // alert data:text/html;charset=utf-8.....
  // access cookie get ERROR

  var doc = document.getElementById('iframe').contentWindow.document;
  doc.open();
  doc.write('<body>Test<script>alert(location.href);</script></body>');
  doc.close();
  // alert "top window url"

  var iframe = document.createElement('iframe');
  iframe.src = 'javascript:;\\\\'' + encodeURI('<html><body>    <script>alert(location.href);</body></html>') + '\\\\'';
  // iframe.src = 'javascript:;"' + encodeURI((html_tag).replace(/\\\\"/g, '\\\\\\\\\\\\"')) + '"';
  document.body.appendChild(iframe);
  // alert "about:blank"
</script>

jsonp

這種情況下,你的伺服器需要響應JavaScript 程式碼,並讓瀏覽器執行它,僅僅通過js指令碼連結。

(function () {
  var s = document.createElement('script');
  s.type = 'text/javascript';
  s.async = true;
  s.src = '/yourscript? some=parameter&callback=jsonpCallback';
  var x = document.getElementsByTagName('script')[0];
x.parentNode.insertBefore(s, x);
  })();

關於jsonp你需要了解:

  • JSONP 只能通過GET請求。
  • JSONP 缺少錯誤處理機制, 意味著你不能檢測程式碼是否404還是500等狀態。
  • JSONP 請求是非同步的。
  • 當心 CSRF 攻擊。
  • 跨域通訊。指令碼響應端(伺服器端)不需要關心CORS。

XMLHttpRequest

自己寫XMLHttpRequest不是個好主意,因為你要浪費很多時間去做IE或者其它瀏覽器的相容。這裡提供一些現成的解決方案供大家參考:

1 - window.fetch - A window.fetch JavaScript polyfill.
2 - got - Simplified HTTP/HTTPS requests
3 - microjs - list of ajax lib
4 – more

Maximum Number of Connection

檢查不同瀏覽器的最大連線數 browserscope

除錯

模擬多個域

你不需要註冊多個域名來模擬域,在本地搭建個虛擬伺服器,繫結host的方式就可以:

$ sudo vim /etc/hosts

新增以下條目

#refer to localhost 
127.0.0.1 publisher.net
127.0.0.1 sdk.net

然後你就可以訪問該頁面http://publisher.net和http://sdk.net

Developer Tools

用瀏覽器自帶的除錯工具,Chrome Developer Tool 、Safari Developer Tools、Firebug都是不錯的選擇。

開發工具也簡稱為工具。

工具提供Web開發者深進入瀏覽器和Web應用程式的內部。使用工具來有效地追蹤佈局問題,將JavaScript打斷點,並獲得程式碼優化的建議。

控制檯日誌

用於測試和輸出文字和其他一般的除錯, 控制檯日誌可通過瀏覽器的API log()輸出顯示。有各種各樣的方法和格式輸出你的資訊,瞭解更多API: Console API.

除錯代理

代理在你除錯SDK的很多時候都很有用。 修改cookies, headers, cache, 編輯 http request/response, SSL Proxying, ajax 除錯等等。
這裡推薦一些代理工具:

BrowserSync

Browsersync能讓瀏覽器實時、快速響應您的檔案更改(html、js、css、sass、less等)並自動重新整理頁面。更重要的是 Browsersync可以同時在PC、平板、手機等裝置下進項除錯。它真的很有幫助如果你需要跨平臺測試你的SDK)。

提示和小技巧

Console Logs Polyfill(Polyfilling 是由 RemySharp 提出的一個術語,它是用來描述複製缺少的 API 和API 功能的行為)

這不是一個真正的polyfill,只是保證在呼叫console.log API的時候不丟擲錯誤。

if (typeof console === "undefined") { var f = function() {}; console = { log: f, debug: f, error: f, info: f };}

EncodeURI or EncodeURIComponent

理解三者的不同 escape()、encodeURI()、encodeURIComponent()
here.
記住使用 encodeURI()和encodeURIComponent()有11個字元不同。 它們是: # $ & + , / : ; = ? @ more discussion

你可能真的不需要JQuery

正如標題所說, 你可能真的不需要JQuery。如果你正在找一些公共的程式碼那下面這些會很有用:- AJAX EFFECTS, ELEMENTS, EVENTS, UTILS

你不需要 jQuery

Free yourself from the chains of jQuery by embracing and understanding the modern Web API and discovering various directed libraries to help you fill in the gaps.

http://blog.garstasio.com/you-dont-need-jquery/
有用的 Tips
Selecting Elements
DOM Manipulation

回撥函式載入指令碼

類似於 非同步載入指令碼 增加回撥函式。

function loadScript(url, callback) { 
  var script = document.createElement('script'); 
  script.async = true; script.src = url; 
  var entry = document.getElementsByTagName('script')[0]; entry.parentNode.insertBefore(script, entry); 
  script.onload = script.onreadystatechange = function () { var rdyState = script.readyState; 
  if (!rdyState || /complete|loaded/.test(script.readyState)) { 

  callback(); // detach the event handler to avoid memory leaks     in IE (http://mng.bz/W8fx) 
  script.onload = null;
  script.onreadystatechange = null; } };
}

執行一次函式

這裡展示瞭如何實現函式只執行一次。

每當你想有一個只執行一次的函式。通常這些函式是以事件監聽的方式,很難管理。當然如果很容易管理,你只需要刪除監聽事件,但是這是個理想的狀態,很多時候你只需要允許一個函式執行一次。下面的程式碼可以實現:

// Copy from DWB
// http://davidwalsh.name/javascript-once
   function once(fn, context) { 
    var result; return function() { 
      if(fn) { 
          result = fn.apply(context || this, arguments); 
          fn = null;
       } 
    return result; };
}
// Usagevar 
  canOnlyFireOnce = once(function() { console.log('Fired!');});
  canOnlyFireOnce(); // "Fired!"canOnlyFireOnce(); // nada

獲取樣式

獲取行間樣式

<span id="black" style="color: black"> 
  This is black color span 
</span>
<script> document.getElementById('black').style.color; // => black</script>

獲取真正的樣式

<style>
      #black { color: red !important;}
</style>
<span id="black" style="color: black">
   This is black color span 
</span>
<script> 
  document.getElementById('black').style.color; // => black 
// real var black = document.getElementById('black');   
  window.getComputedStyle(black, null).getPropertyValue('color'); // => rgb(255, 0, 0)
</script>

ref:https://developer.mozilla.org/en-US/docs/Web/API/Window/getComputedStyle

檢測當前視窗

瞭解更多: here

function isElementInViewport (el) { 
  //special bonus for those using jQuery
 if (typeof jQuery === "function" && el instanceof jQuery) { 
    el = el[0];
 }
 var rect = el.getBoundingClientRect(); 
return (
 rect.top >= 0 &&
 rect.left >= 0 && 
  rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /*or $(window).height() */ 
  rect.right <= (window.innerWidth || document.documentElement.clientWidth) /*or $(window).width() */ 
);
}

模板

有些人要求提供一些SDK的模板這裡有一些列子給大家:

TEMPLATE.md

書/相關注意

Third-Party JavaScript
JQuery Plugin
LightningJS

相關文章