翻译背景
前段时间因为业务需要设计开发了服务于公司内部的Javascript-SDK,开发之前的调研阶段发现这篇文章,感觉写的很好,方方面面都有提到,与其说是SDK设计指南,实际上对类库、框架、开源项目设计都有指导作用。其中参考的很多网站和技术博客都极具价值,基本囊括了一个开源项目从开始到最后上线发布所有需要注意的所有技术细节和流程。网上有关设计开发的文档极少,所以决定把这篇文章翻译成中文。虽然借助了很多翻译工具,也请教了周围英语比较好的同事,但是鉴于水平有限,篇幅太长,难免有所疏漏或者翻译不准确的地方欢迎大家留言指出。
原文地址:http://sdk-design.js.org/
Javascript-SDK设计指南
介绍
本指南为您介绍了在台式机和移动网络在不同的平台和浏览器( < 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------|
异步和延迟脚本执行解释
异步延迟.jpg异步的问题
当你使用异步加载的时候,将会出现,页面中的函数无法正常调用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 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
本文参考
What is Software Development Kit
A window.fetch JavaScript polyfill.
POST Request
Semantic VersioningVersioning 2.0.0
HTTP API design guide extracted from work on the Heroku Platform API
Understanding URIs
URI Parsing with JavaScript
Modernizr: the feature detection library for HTML5/CSS3
HTML5 Web Storage
Check if third-party cookies are enabled
Introduction to Analytics.js - Universal Analytics Web Tracking
Facebook Conversion Tracking Pixel
What is the maximum length of a URL
YOU MIGHT NOW NEED JQUERY
What is a Polyfill?
Asynchronous and deferred JavaScript execution explained
generate random UUIDs
DOMContentLoaded and Load Event
Must See JavaScript Dev Tools That Put Other Dev Tools to Shame
(inspired by http-api-design)
网友评论
欢迎订阅《技术之里》https://toutiao.io/subject/56074