JSBridge的原理

转载自掘金:https://juejin.im/post/5abca877f265da238155b6bc

关于 JSBridge,绝大多数同学最早遇到的是微信的 WeiXinJSBridge(现在被封装成 JSSDK),各种 Web 页面可以通过 Bridge 调用微信提供的一些原生功能,为用户提供相关的功能。其实,JSBridge很早就出现在软件开发中,在一些桌面软件中很早就运用了这样的形式,多用在通知、产品详情、广告等模块中,然后这些模块中,使用的是 WebUI,而相关按钮点击后,调用的是Native功能。现在移动端盛行,不管是Hybrid应用,还是React-Native都离不开JSBridge,当然也包括在国内举足轻重的微信小程序。那么,JSBridge到底是什么?它的出现是为了什么?它究竟是怎么实现的?在这篇文章中,会在移动混合开发的范畴内,将给大家带来 JSBridge 的深入剖析。

图1
图2

1 前言

有些童鞋听到JSBridge这个名词,就是觉得非常高上大,有了它Web和Native可以进行交互,就像『进化药水』,让Web摇身一变,成为移动战场的『上将一名』。其实并非如此,JSBridge 其实真是一个很简单的东西,更多的是一种形式、一种思想。

2 JSBridge 的起源

为什么是 JSBridge ?而不是 PythonBridge 或是 RubyBridge ?

当然不是因为 JavaScript 语言高人一等(虽然斯坦福大学已经把算法导论的语言从 Java 改成 JavaScript,小得意一下,嘻嘻),主要的原因还是因为 JavaScript 主要载体 Web 是当前世界上的最易编写、最易维护、最易部署的UI构建方式。工程师可以用很简单的HTML标签和CSS样式快速的构建出一个页面,并且在服务端部署后,用户不需要主动更新,就能看到最新的 UI 展现。

因此,开发维护成本更新成本较低的 Web 技术成为混合开发中几乎不二的选择,而作为Web技术逻辑核心的JavaScript也理所应当肩负起与其他技术『桥接』的职责,并且作为移动不可缺少的一部分,任何一个移动操作系统中都包含可运行JavaScript的容器,例如WebView和JSCore。所以,运行JavaScript不用像运行其他语言时,要额外添加运行环境。因此,基于上面种种原因,JSBridge 应运而生。PhoneGap(Codova 的前身)作为 Hybrid 鼻祖框架,应该是最先被开发者广泛认知的 JSBridge 的应用场景;而对于 JSBridge的应用在国内真正兴盛起来,则是因为杀手级应用微信的出现,主要用途是在网页中通过JSBridge设置分享内容。移动端混合开发中的JSBridge,主要被应用在两种形式的技术方案上:

  1. 基于 Web 的 Hybrid 解决方案:例如微信浏览器、各公司的 Hybrid 方案
  2. 非基于 Web UI 但业务逻辑基于 JavaScript 的解决方案:例如 React-Native

【注】:微信小程序基于 Web UI,但是为了追求运行效率,对 UI 展现逻辑和业务逻辑的 JavaScript 进行了隔离。因此小程序的技术方案介于上面描述的两种方式之间。

3 JSBridge 的用途

JSBridge 简单来讲,主要是 给 JavaScript 提供调用 Native 功能的接口,让混合开发中的『前端部分』可以方便地使用地址位置、摄像头甚至支付等 Native 功能。
既然是『简单来讲』,那么 JSBridge 的用途肯定不只『调用 Native 功能』这么简单宽泛。实际上,JSBridge 就像其名称中的『Bridge』的意义一样,是 Native 和非 Native 之间的桥梁,它的核心是 构建 Native 和非 Native 间消息通信的通道,而且是 双向通信的通道。

图3

所谓 双向通信的通道:

  1. JS 向 Native 发送消息 : 调用相关功能、通知 Native 当前 JS 的相关状态等。
  2. Native 向 JS 发送消息 : 回溯调用结果、消息推送、通知 JS 当前 Native 的状态等。

这里有些同学有疑问了:消息都是单向的,那么调用 Native 功能时 Callback 怎么实现的?对于这个问题,在下一节里会给出解释。

在JavaScript中使用mixin

mixin:多重继承,也称为extend

mixin(混合)的本质就是将一个对象的属性拷贝到另一个对象上,其实就是对象的融合。

Q: 这个和原型链的继承有什么区别?
A: mixin是直接在混合后的对象本身的属性上查找的,而不是在prototype上查找的

Q: 这个和Object.assigin()有什么区别?
A: 虽然Object.assigin()也在对象属性进行合并,但Object.assigin()不会复制原型链上的属性,
而mixin会将原型链上的属性一并复制过去(for … in …)

demo:

1
2
3
4
5
6
7
8
9
10
// mixin.js
export default mixin (source, target) {
for (let key in source) {
if (source[key] !== target[key]) {
target[key] = source[key];
}

return target;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// index.js
import mixin from 'mixin';

const Book = {
type: 'book',
log () {
console.log(this);
}
};

const Tech_book = mixin (Book, {
name: 'tech_book',
log () {
Book.log.call(this);
}
});

Tech_book.log();
// this: {
type: 'book',
name: 'tech_book',
log: f log()
}

注: mixin.js 扩展参数的写法:

1
2
3
4
5
6
7
8
9
// mixin.js
export default mixin (...objs) {
return objs.reduce((dest, src) => {
for (var key in src) {
dest[key] = src[key]
}
return dest;
});
};

wap适配iPhone X方案实践

关于异形屏的安全区以及苹果官方的文档,网上有很详尽的博客,这里不再累述,本文偏实践。阅读本文前,可点击了解safe-Area概念

适配iphone X方案:

1. 实现原理:

> 首先我们需要了解一个属性:constant(safe-area-inset-bottom)/env(safe-area-inset-bottom);这个的含义是,安全区底部距离屏幕底部的距离。因此,非异形屏下,这个值为0/或不解析;异形屏下,这个值为真实的距离,比如34px啥的。

接下来,我们需要做的有三点:

  1. 找到有底部banner的页面
  2. 将底部banner的父容器从bottom: 0; 升级成 bottom: 0;bottom: constant(safe-area-inset-bottom); bottom: env(safe-area-inset-bottom); // bottom: 0;是兼容非异形屏,bottom: constant(safe-area-inset-bottom);是为了让banner上移到安全区内。
  3. 给 banner:after添加纯色遮罩bottom: 0; height: 0; height: constant(safe-area-inset-bottom); height: env(safe-area-inset-bottom); background: #fff;
    (注:iOS11支持constant语法,future版本支持env语法)

然后我们发现底部的banner确实是在安全区内了,而且底部有纯色遮罩覆盖,不会有穿透效果。

然而。。。。我们是否忘记了,底部banner上移了,那页面里原有的内容区是不是被盖住了一部分?
所以,我们需要再给body:after加上height: 0; height: $safeArea(bottom);把body也撑起一个高度,使得内容区不会被上移了的banner遮住。

2. 实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/* _config.mcss */
$safeAreaHeight = ($height, $isImportant){
@if $isImportant {
height: $height !important;
height: t('calc( constant(safe-area-inset-bottom) + ' + $height + ')') !important;
height: t('calc( env(safe-area-inset-bottom) + ' + $height + ')') !important;
}
@else {
height: $height;
height: t('calc( constant(safe-area-inset-bottom) + ' + $height + ')');
height: t('calc( env(safe-area-inset-bottom) + ' + $height + ')');
}
}

$safeAreaTop = ($height, $isImportant){
@if $isImportant {
top: $height !important;
top: t('calc( constant(safe-area-inset-top) + ' + $height + ')') !important;
top: t('calc( env(safe-area-inset-top) + ' + $height + ')') !important;
}
@else {
top: $height;
top: t('calc( constant(safe-area-inset-top) + ' + $height + ')');
top: t('calc( env(safe-area-inset-top) + ' + $height + ')');
}
}

$safeAreaLeft = ($left, $isImportant){
@if $isImportant {
left: $left !important;
left: t('calc( constant(safe-area-inset-left) + ' + $left + ')') !important;
left: t('calc( env(safe-area-inset-left) + ' + $left + ')') !important;
}
@else {
left: $left;
left: t('calc( constant(safe-area-inset-left) + ' + $left + ')');
left: t('calc( env(safe-area-inset-left) + ' + $left + ')');
}
}

$safeAreaRight = ($right, $isImportant){
@if $isImportant {
right: $right !important;
right: t('calc( constant(safe-area-inset-right) + ' + $right + ')') !important;
right: t('calc( env(safe-area-inset-right) + ' + $right + ')') !important;
}
@else {
right: $right;
right: t('calc( constant(safe-area-inset-right) + ' + $right + ')');
right: t('calc( env(safe-area-inset-right) + ' + $right + ')');
}
}

$safeAreaBottom = ($height, $isImportant){
@if $isImportant {
bottom: $height !important;
bottom: t('calc( constant(safe-area-inset-bottom) + ' + $height + ')') !important;
bottom: t('calc( env(safe-area-inset-bottom) + ' + $height + ')') !important;
}
@else {
bottom: $height;
bottom: t('calc( constant(safe-area-inset-bottom) + ' + $height + ')');
bottom: t('calc( env(safe-area-inset-bottom) + ' + $height + ')');
}

}
1
2
3
4
5
6
7
/* base.mcss */
body:after {
$safeAreaHeight(0px, true);
display: block;
content: '';
width: 100%;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* module.mcss */
/* iPhone X适配 */
.f-safeArea {
$safeAreaBottom(0px, true);
}
.f-safeArea:after {
$safeAreaHeight(0px, true);
display: block !important;
content: '' !important;
position: fixed !important;
left: 0 !important;
bottom: 0 !important;
width: 100% !important;
background: #fff !important;
}
1
2
<!-- common.ftl -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">

封装一个复制组件

应用场景: WEB/WAP 工程中有一些复制文本的需求,目的是为了减少用户的操作步骤。既然该功能比较常用,那就封装成组件吧。

API

1. document.execCommand

当一个HTML文档切换到设计模式(designMode)时,文档对象暴露execCommand方法,该方法允许运行命令来操纵可编辑区域的内容。

1
2
3
4
5
6
7
/**
*若bool = false 则表示document.execCommand方法不被当前浏览器支持或未被启用
*@property {String} aCommandName 命令的名称,如:copy/cut/paste【必选】
*@property {Boolean} aShowDefaultUI 是否展示用户界面,默认false【可选】
*@property {String} aValueArgument 一些命令(例如insertImage)需要额外的参数(insertImage需要提供插入image的url),默认null【可选】
**/
bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument);

2. ClipboardEvent.clipboardData(注:实验中的功能)

ClipboardEvent.clipboardData 属性保存了一个 DataTransfer 对象,这个对象可用于:

  1. 描述哪些数据可以由 cut 和 copy 事件处理器放入剪切板,通常通过调用 setData(format, data) 方法;
  2. 获取由 paste 事件处理器拷贝进剪切板的数据,通常通过调用 getData(format) 方法
1
data = ClipboardEvent.clipboardData

Q: 那既然ClipboardEvent.clipboardData是实验中的功能,那兼容性就需要考虑了,那在IE低版本下如何实现将内容传给剪切板呢?
A:通过插入input节点,再通过input.select()方法选中内容的方式去hack,这总hack方式浏览器的兼容性更好。

基于Regular实现copy组件

Component - HTML:

1
2
3
4
<div class="command-container">
<div class="content {extendClass_content}" r-modal={content}>{content}</div>
<a class="u-btn u-btn_copy {extendClass_copy}" href="javascript:void(0)" on-tap={this.copy()}>{buttonTxt || '复制'}</a>
</div>

Component - JS:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
define([
'pro/widget/BaseComponent',
'pro/components/toast/toast',
'text!./copy.html',
], function(BaseComponent, toast, tpl) {
var Copy = BaseComponent.extend({
template: tpl,
config: function(data) {
this.supr(data);
},
copy: function() {
try {
this.clipCopy();
} catch(e) {
//低版本浏览器通过input方式hack复制兼容
this.inputCopy();
}
},
clipCopy: function() {
var data = this.data;
document.addEventListener('copy', function copy(evt) {
//将需要复制的内容传给系统的剪切板
evt.clipboardData.setData('text/plain', data.content);
evt.preventDefault();
});
var isCopySuc = document.execCommand('copy');
document.removeEventListener('copy', 'copy');
this.callback(isCopySuc);
},
inputCopy: function() {
var data = this.data;
var input = document.createElement('input');
input.style.display = 'none';
input.setAttribute('value', data.content);
document.body.appendChild(input);
input.select();
var isCopySuc = document.execCommand('copy');
document.body.removeChild(input);
this.callback(isCopySuc);
},
callback: function(isCopySuc) {
var data = this.data;
if (data.onSuccess && isCopySuc) { //copy组件实例复制成功回调
data.onSuccess();
return;
}
if (data.onFail && !isCopySuc) { //copy组件实例复制失败回调
data.onFail();
return;
}
// 若copy组件实例无相关回调,触发默认的toast
var message = isCopySuc? '复制成功': '不支持复制,请手动复制';
toast.toast({message: message, singleMsg: true});
}
});

return Copy;
});

兼容性:IE9+, AOS4.1+, iOS7.1+ (部分待验证)

copy组件使用文档:

Usage

1
<Copy content={ content }></Copy>

Props

参数 类型 默认值 说明
content String 【必选】复制的内容
extendClass_content String 【可选】内容框自定义class
extendClass_copy String 【可选】复制按钮自定义class
buttonTxt String 【可选】复制按钮自定义文字
onSuccess Function 【可选】复制成功回调函数
onFail Function 【可选】复制失败回调函数

跨域

本文主要涉及三种跨域方法:JSONP、CORS、postMessage。

Q:为什么会出现跨域问题?
A:出于浏览器的同源策略限制,浏览器会拒绝跨域请求。
*注:严格的说,浏览器并不是拒绝所有的跨域请求,实际上拒绝的是跨域的读操作。浏览器的同源限制策略是这样执行的:

  • 通常浏览器允许进行跨域写操作(Cross-origin writes),如链接,重定向;
  • 通常浏览器允许跨域资源嵌入(Cross-origin embedding),如 img、script 标签;
  • 通常浏览器不允许跨域读操作(Cross-origin reads)。*

Q:什么情况才算作跨域?
A:非同源请求,均为跨域。名词解释:同源 —— 如果两个页面拥有相同的协议(protocol),端口(port)和主机(host),那么这两个页面就属于同一个源(origin)。
img01

Q:为什么有跨域需求?
A:场景 —— 工程服务化后,不同职责的服务分散在不同的工程中,往往这些工程的域名是不同的,但一个需求可能需要对应到多个服务,这时便需要调用不同服务的接口,因此会出现跨域。

如何实现跨域

通常,最常用的跨域方式有以下三种:JSONP、CORS、postMessage。

JSONP

单纯地为了实现跨域请求而创造的一个 trick。
【实现原理】
虽然因为同源策略的影响,不能通过XMLHttpRequest请求不同域上的数据(Cross-origin reads)。但是,在页面上引入不同域上的js脚本文件却是可以的(Cross-origin embedding)。因此在js文件载入完毕之后,触发回调,可以将需要的data作为参数传入。
【实现方式(需前后端配合)】

1
2
3
4
5
6
<script type="text/javascript">
function dosomething(data){
//处理获得的数据
}
</script>
<script src="http://example.com/data.php?callback=dosomething"></script>

1
2
3
4
5
<?php
$callback = $_GET['callback'];//得到回调函数名
$data = array('a','b','c');//要返回的数据
echo $callback.'('.json_encode($data).')';//输出
?>

【JSONP的优缺点】
优点:兼容性好(兼容低版本IE)
缺点:1.JSONP只支持GET请求; 2.XMLHttpRequest相对于JSONP有着更好的错误处理机制