JavaScript SDK 设计指南
一份来自工作实战和个人经验的 JavaScript SDK 设计指南
简介
本指南旨在介绍如何开发跨平台 PC 和移动端多种浏览器运行的 JavaScript SDK,一句话介绍 SDK,SDK 是用户与浏览器之间的连接桥梁。
目录
何为 SDK
SDK 对于开发人员来说的确很常见,webopedia 的解释是:
SDK 是软件开发工具的简称,作为编程工具包,帮助开发者开发特定平台的应用。
典型的 SDK 包括:多项 API 接口、编程工具和文档。
设计哲学
SDK 设计依赖运行设备和调用方式,但 SDK 设计必须遵循原生、简短、高效、简洁、可读和可测试原则。
使用原生 JS 代码编写 SDK, 不推荐使用 Livescript、Coffeescript、Typescript 等编译语言。
原生方式编写 JS 代码会比其他选择更好。
每项 SDK 新版本发布时,确保新版可以兼容旧版和未来待开发的新版本。因此,请记住编写 SDK 文档和代码注释、单元测试和用户场景测试。
SDK 场景
来自第三方 JavaScript 网站的几种场景:
- 嵌入式组件,嵌入网页中的微型交互应用,如:Disqus, Google Maps, Facebook Widget
- 分析统计,收集访问者信息和网站交互情况,如: GA, Flurry, Mixpanel
- web 服务接口包装,用于开发客户端应用,与外部服务通信,如: Facebook Graph API
SDK 引用方式
建议使用异步语法加载脚本,优化网站用户体验,避免干扰网页加载。
异步语法
针对现代浏览器,使用async
属性
传统语法
比较
下图显示异步和传统语法的不同
异步
|----A-----|
|-----B-----------|
|-------C------|
同步
|----A-----||-----B-----------||-------C------|
JS 异步和延迟执行相关解释:
https://developers.google.com/speed/docs/insights/BlockingJS
应当避免或少用阻塞 JS,特别是必须加载完成再执行的外部脚本。
与渲染页面内容相关的脚本需内联在页面中避免额外网络请求,但是内联内容需要很小体积,同时快速高效执行。
与首屏渲染无关的脚本应当在首屏渲染后进行异步或延迟处理。
异步加载问题
当使用异步加载时,不能在页面内立即执行 SDK 函数。
执行结果会报undefined
, 因为函数在脚本未加载完就执行,需要加入点技巧确保脚本成功运行。
事件存储在SDKName.q
队列数组变量中,这样 SDK 可以处理和执行SDKName.q
,重新初始化SDKName
命名空间。
也可以用[].push
方式:
其他引用方式
使用 ES2015 规范的import
import "your-sdk";
模块化引用 Javascript
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 information
function module(name, imports, mod) {
// record module information
window.console.log("found module " + name);
modules[name] = { name: name, imports: imports, mod: mod };
// trigger loading of import dependencies
for (var imp in imports) loadModule(imports[imp]);
// check whether this was the last module to be loaded
// in a given dependency group
loadedModule(name);
}
// function loadModule
// function loadedModule
window.module = module;
})();
SDK 版本管理
请避免使用诸如brand-v
、 brand-v
,brand-v1-v2.js
之类的特殊版本号,会引起 SDK 使用者无法识别最新版本。
可以使用语义化版本号管理 SDK 版本,形式是主版本.次版本.补丁版本
。
v1.0.0
v1.5.0
v2.0.0
这样的版本号很容易在变更日志文章中查找。
我们可以使用多种方式表示 SDK 版本,取决于具体部署服务和设计。
使用 query string 路径
http://.com/sdk.js?v=1.0.0
使用文件夹命名
http://.com/v1.0.0/sdk.js
使用子域名
http://v1..com/sdk.js
建议使用stable
, unstable
, alpha
, latest
, experimental
版本号管理后续开发版本。
http://.com/sdk-stable.js
http://.com/sdk-unstable.js
http://.com/sdk-alpha.js
http://.com/sdk-latest.js
http://.com/sdk-experimental.js
变更日志文档
如果没有更新文档,SDK 使用者不知道 SDK 升级情况。
记住编写变更日志文档记录主要、次要甚至是 bug 修复变更。
如果可以追溯 SDK 接口变化,这会是很好的开发体验。
每个版本可以这样写:
[Added] for new features.
[Changed] for changes in existing functionality.
[Deprecated] for once-stable features removed in upcoming releases.
[Removed] for deprecated features removed in this release.
[Fixed] for any bug fixes.
[Security] to invite users to upgrade in case of vulnerabilities.
此外, commit-message-emoji 可以使用图标解释每次提交的变化
命名空间
SDK 中不要定义多个命名空间,防止名称与其他库冲突。
SDK 代码中使用(function () { ... })()
或 ES6 块{ ... }
包装 SDK 代码。
如何避免命名空间冲突
谷歌统计脚本通过改变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 experience支持通过传参请求命名空间
存储机制
Cookie
不同域名下使用 cookie 情形有点复杂,具体包括子域名和路径
域名http://github.com
/
路径下设置 cookiefirst=value1
在子域名http://sub.github.com
设置 cookie second=value2
不同域下读取情况如下:
http://github.com | http://sub.github.com | |
---|---|---|
first=value1 | ✓ | ✓ |
second=value2 | ✘ | ✓ |
域名http://github.com
设置 cookiefirst=value1
在子域名http://sub.github.com
设置 cookie second=value2
在子域名http://sub.github.com
设置 cookie third=value3
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 是否可写
只用客户端 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
客户端无法写 session,可以咨询服务端团队处理 session
LocalStorage
LS 数据存储没有过期时间,存储限制更大,最小 5M, 而且信息不会发送给服务器。
注意相同域名的不同协议http
和https
之间无法共享数据。
可以通过创建 iframe, 使用postMessage
传递数据。 示例
检测 LS 是否可写
window.localStorage 存在兼容性问题,需要检测 API 是否可用
var testCanLocalStorage = function() {
var mod = "modernizr";
try {
localStorage.setItem(mod, mod);
localStorage.removeItem(mod);
return true;
} catch (e) {
return false;
}
};
SessionStorage
用于存储单次会话数据,标签页关闭后会丢失
var checkCanSessionStorage = function() {
var mod = "modernizr";
try {
sessionStorage.setItem(mod, mod);
sessionStorage.removeItem(mod);
return true;
} catch (e) {
return false;
}
};
事件处理
浏览器有很多事件,如: load
unload
on
off
bind
,
下面是处理不同平台的垫片
DOM Ready
用于确保 SDK 函数执行前整个网页完成 DOM 加载
// 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 树生成时触发,无需等待样式、图片和子 iframe 完成加载, 参考
load事件用于检测整个页面完全加载
element-ready
消息事件
用户在 iframe 和窗口之间进行跨域通信,Mozilla API。
// 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, 使用JSON String
尽管现代浏览器支持结构化复制算法解析参数,但不是全部浏览器都支持。
设备方向变化
检测设备方向变化
window.addEventListener("orientationchange", fn);
获取方向旋转度数
window.orientation; // => 90, -90, 0
检测方向
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/orientation
var orientation =
screen.orientation || screen.mozOrientation || screen.msOrientation;
禁止滚动
web 端可以使用 css overflow: hidden
禁止滚动,但某些移动端不支持,可以用 JS 事件
document.addEventListener("touchstart", function(e) {
e.preventDefault();
}); // prevent scroll
// or
document.body.addEventListener("touchstart", function(e) {
e.preventDefault();
}); // prevent scroll
// use move if you need some touch event
document.addEventListener("touchmove", function(e) {
e.preventDefault();
}); // prevent scroll
数据请求
客户端和服务器数据交互可以使用 ajax 请求,但其实有更好方法。
图像信标
使用图像信标执行 get 请求获取图片
需要加上时间戳防止浏览器缓存
new Image().src = "http://.com/collect?id=1111";
查询字符串有长度限制,比如 1024,取决于浏览器和服务器,可以做如下处理规避
if (length > 2048) {
// do Multiple Post (form)
} else {
// do Image Beacon
}
监听图片加载事件
var img = new Image();
img.src = "http://.com/collect?id=1111";
img.onload = successCallback;
img.onerror = errorCallback;
单 POST 请求
使用原生表单 POST 方法发送键值数据
var form = document.createElement("form");
var input = document.createElement("input");
form.style.display = "none";
form.setAttribute("method", "POST");
form.setAttribute("action", "http://.com/track");
input.name = "username";
input.value = "attacker";
form.appendChild(input);
document.getElementsByTagName("body")[0].appendChild(form);
form.submit();
多 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://.com/page";
iframe.onreadystatechange = function() {
if (iframe.readyState !== "complete") {
return;
}
};
iframe.onload = loadCallback;
body.appendChild(iframe);
移除 iframe 内部边距
iframe 内加载 html
"; 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('
Test
'); doc.close(); // alert "top window url" var iframe =
document.createElement('iframe'); iframe.src = 'javascript:;\'' + encodeURI('
jsonp 脚本
(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
Navigator.sendBeacon()
[Mozilla 文档](documentation
用于 DOM 文档卸载前发送统计和诊断数据,但数据很难确保卸载期间数据发送成功
navigator.sendBeacon("/log", analyticsData);
XHR
XHR 的几个垫片
- window.fetch - window.fetch 方法垫片
- got - 简版 HTTP/HTTPS requests
- microjs - ajax 库列表
参数片识别
注意哈希标识#
后面的数据不会传递给 http 请求。
// Sending a request with a parameter url which contains current url
new Image().src =
"http://yourrequest.com?url=http://github.com/awesome#hueitan";
// actual request will be without #
new Image().src = "http://yourrequest.com?url=http://github.com/awesome";
// Solved, encodeURIComponent(url);
new Image().src =
"http://yourrequest.com?url=" +
encodeURIComponent("http://github.com/awesome#hueitan");
最大连接数
检查浏览器的最大连接数
URI 组成
authority
__________|_________
/ \
userinfo host resource
__|___ ___|___ __________|___________
/ \ / \ / \
username password hostname port path & segment query fragment
__|___ __|__ ______|______ | __________|_________ ____|____ |
/ \ / \ / \ / \ / \ / \ / \
foo://username:[email protected]:123/hello/world/there.html?name=ferret#foo
\_/ \ / \ \ / \__________/ \ \__/
| | \ | | \ |
scheme subdomain \ tld directory \ suffix
\____/ \___/
| |
domain filename
解析 URI
使用原生 URL 解析
var parser = new URL("http://github.com/hueitan");
parser.hostname; // => "github.com"
使用createElement
解析
var parser = document.createElement("a");
parser.href = "http://github.com/hueitan";
parser.hostname; // => "github.com"
调试
模拟多个域名
更改操作系统 host 文件配置模拟不同域名
$ sudo vim /etc/hosts
Add the following entries
# refer to localhost
127.0.0.1 publisher.net
127.0.0.1 sdk.net
开发者工具
可以使用浏览器自带调试工具调试 JS SDK 代码,如: Chrome Developer Tools
Safari Developer Tools
Firebug
开发者工具为 web 开发者提供浏览器和 web 应用内部访问,使用开发者工具可以高效追踪布局问题,设置 JS 断点,找到代码优化的思路。
Console.log
控制台日志可以通过浏览器 APIconsole.log
输入,主要用于测试预期输出日志和其他通用调试。
调试代理工具
有时调试代理工具可以帮助测试 SDK。
调试网络状态、修改 cookie、http 头、缓存设置, 编辑 http 请求和响应,SSL 代理,ajax 请求调试等等。
代理工具列表:
- FiddlerCore
- Charles
- Cellist
BrowserSync
BrowserSync 通过跨多个设备间同步文件变化和交互,实现更快代码调整和测试。
如需测试 SDK 在多设备间的运行结果,BrowserSync 很有用。
调试 Node.js 应用
在谷歌开发者工具中调试 nodejs 代码,要求 Node.js 版本 v6.3.0+
$ node --inspect-brk [script.js]
- BrowserSync 中文
- Official document
- Paul Irish gave a talk in 15 minutes about how to use --inspect
提示与技巧
补充
有时我们不想开发者引用所有 SDK 源,我们只需要发 1 像素请求,例如,加载感谢或结束页面时返回请求。
可以让开发者使用提供的链接地址引入图片文件。
页面可见性 API
标签页之间切检测用户是否在当前页。
visibly.js
visibilityjs
HTTP 文档 Referrer
document.referrer
指的是浏览器 referrer,不是用户操作 referrer。
点击返回按钮,例如: 页面 A -> 页面 B -> 页面 C -> 返回按钮 页面 B, 当前页面 B 的 referrer 指向页面 A,而不是页面 C。
Console.log 垫片
确保调用console
API 不会报错
if (typeof console === "undefined") {
var f = function() {};
console = {
log: f,
debug: f,
error: f,
info: f
};
}
EncodeURI/EncodeURIComponent
理解escape()
encodeURI()
encodeURIComponent()
编码方法之间的差异,文档
encodeURI()
和encodeURIComponent()
方法有 11 个字符编码结果不同,
具体是: # $ & + , / : ; = ? @,相关讨论。
可能不需要 jQuery
如题,网站提供很多有用工具代码
http://blog.garstasio.com/you-dont-need-jquery/
脚本加载回调
和异步脚本加载类似,增加回调事件处理
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;
};
}
// Usage
var canOnlyFireOnce = once(function() {
console.log("Fired!");
});
canOnlyFireOnce(); // "Fired!"
canOnlyFireOnce(); // nada
补充
- throttle/debounce
- rxjs-debounce
设备像素比 DPR
Device pixel ratio - Mobile Web Development
Mobile device pixels - Mobile Web Development
获取样式值
获取行内样式值
This is black color span
获取真实样式值
This is black color span
检测元素是否在视口内
// http://stackoverflow.com/questions/123999/how-to-tell-if-a-dom-element-is-visible-in-the-current-viewport/7557433#7557433
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() */
);
}
检测元素是否可见
var isVisible = function(b) {
var a = window.getComputedStyle(b);
return 0 === a.getPropertyValue("opacity") ||
"none" === a.getPropertyValue("display") ||
"hidden" === a.getPropertyValue("visibility") ||
0 === parseInt(b.style.opacity, 10) ||
"none" === b.style.display ||
"hidden" === b.style.visibility
? false
: true;
};
var element = document.getElementById("box");
isVisible(element); // => false or true
获取视口尺寸
var getViewportSize = function() {
try {
var doc = top.document.documentElement,
g =
(e = top.document.body) &&
top.document.clientWidth &&
top.document.clientHeight;
} catch (e) {
var doc = document.documentElement,
g = (e = document.body) && document.clientWidth && document.clientHeight;
}
var vp = [];
doc &&
doc.clientWidth &&
doc.clientHeight &&
("CSS1Compat" === document.compatMode || !g)
? (vp = [doc.clientWidth, doc.clientHeight])
: g && (vp = [doc.clientWidth, doc.clientHeight]);
return vp;
};
// return as array [viewport_width, viewport_height]
用户跟踪
假设某邪恶广告公司想跟踪用户行为,它会用fingerprinting生成个人定制哈希,
而有道德的公司会通过提示,让用户选择是否使用 cookie。
广告服务提示
数字广告联盟 提供工具帮助人们选择是否使用联盟参与公司的广告服务。
注意 WTF
HTTP 头的 referer 字段拼写错误
HTTP 请求头的 referrer 字段拼写错误,根据维基百科说明:
referer 字段拼写错误起源于计算机科学家
菲利普 哈纳姆-贝克
的提案,提议將该字段加入 HTTP 标准,
RFC 1945
标准文档请求注释
最终收入这个错误拼写, 该文档的合作者罗伊 菲尔丁
表示当时标准的unix拼写检查器
均不能识别 "referrer" 或 "referer"。
Referer 从此成为行业内讨论 HTTP 引用时广泛拼写的单词,其实 referrer 单词并不总是被拼错,某些 web 标准中,如DOM(文档对象模型)
中就使用正确的拼写方式。
JavaScript SDK 模板
初始化模板
(function(window) {
// declare
var myApp = {};
// your sdk init function
myApp.init = function() {
// ...
};
// define your namespace myApp
window.myApp = myApp;
})(window, undefined);
包装模板
(function(window) {
// add your sdk function and method here and there
})(window, undefined);
面向对象模板
(function(window) {
// declare
var myApp = function() {
return;
};
// your sdk init function
myApp.prototype.init = function() {
// ...
};
// define your namespace myApp
window.myApp = new myApp();
})(window, undefined);
参考链接
- http://www.webopedia.com/TERM/S/SDK.html
- https://github.com/github/fetch
- https://remysharp.com/2010/10/08/what-is-a-polyfill
- http://peter.sh/experiments/asynchronous-and-deferred-javascript-execution-explained/
- http://stackoverflow.com/questions/2414750/difference-between-domcontentloaded-and-load-events
- https://medium.com/javascript-scene/must-see-javascript-dev-tools-that-put-other-dev-tools-to-shame-aca6d3e3d925
- https://github.com/noffle/art-of-readme
- https://github.com/dannyfritz/commit-message-emoji
- generate random UUIDs
- 语义化版本管理 2.0
- http://blog.npmjs.org/post/162134793605/why-use-semver
- 理解 URI
译者注
原文链接
- http://hueitan.github.io/javascript-sdk-design/