qrcode

马上订阅,开启修仙之旅

Angular JSONP 详解

一、什么是 JSONP

JSONPJSON with Padding)是数据格式JSON的一种 “使用模式”,可以让网页从别的网域要数据。另一个解决这个问题的新方法是跨来源资源共享

由于同源策略,一般来说位于 server1.example.com 的网页无法与 server2.example.com 的服务器沟通,而HTMLscript 元素是一个例外。利用 script 元素的这个开放策略,网页可以得到从其他来源动态产生的 JSON 数据,而这种使用模式就是所谓的 JSONP。用 JSONP 抓到的数据并不是 JSON,而是任意的 JavaScript,用 JavaScript 解释器运行而不是用 JSON 解析器解析。 —— 维基百科

二、JSONP 跨域原理

AJAX 无法跨域是受到 “同源策略” 的限制,但是带有 src 属性的标签(例如 <script>、<img>、<iframe>)是不受该策略限制的,因此我们可以通过向页面中动态添加 <script> 标签来完成对跨域资源的访问,这也是 JSONP 方案最核心的原理。

通常我们使用 <script> 都是引用的静态资源,其实它也可以用来引用动态资源(php、jsp、aspx 等),后台服务被访问后会返回一个 callback(data) 形式的字符串,由于是字符串,因此在后台的时候不会起到任何作用,但返回浏览器端,放入 <script> 标签之内,就是一个合法的函数调用,实参就是我们所需要的数据。

JSONP 最大的优点就是兼容性非常好,其原理决定了即便在非常古老的浏览器上也能够很好的被实现。但它也有缺点,即只支持 Get 请求,因为是通过 <script> 方式引用资源,相关的参数都显式的包含在 URL 中。

三、Angular JSONP 示例

在 Angular 项目中,要使用 JSONP 实现跨域资源访问,我们需要导入 HttpClientModuleHttpClientJsonpModule 模块:

app.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { HttpClientModule, HttpClientJsonpModule } from "@angular/common/http";

import { AppComponent } from "./app.component";

@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, HttpClientModule, HttpClientJsonpModule],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}

在导入 HttpClientModuleHttpClientJsonpModule 模块之后,我们就可以利用 HttpClient 对象发送请求:

app.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Component } from "@angular/core";
import { HttpClient } from "@angular/common/http";

@Component({
selector: "app-root",
template: `
<button (click)="search('Photo')">Search photo in itunes</button>
`,
styles: [
]
})
export class AppComponent {
baseUrl: string = "https://itunes.apple.com/search";

constructor(public http: HttpClient) {}

search(term: string) {
let searchUrl = `${
this.baseUrl
}?term=${term}&media=music&limit=20`;
return this.http.jsonp<any>(searchUrl, "callback").subscribe(console.dir);
}
}

接下来在启动应用后,我们打开开发者工具,切换到 Network Tab 栏,然后点击页面上的

Search photo in itunes 按钮,这时你会看到以下的请求信息:

1
https://itunes.apple.com/search?term=Photo&media=music&limit=20&callback=ng_jsonp_callback_0

这里我们发现调用 this.http.jsonp() 方法后,Angular 自动在请求的 URL 地址后面添加 callback=ng_jsonp_callback_0 的查询参数。接着在经过一小段时间,控制台输出了相关的数据。

四、Angular JSONP 原理简析

在了解 JSONP 的工作原理之后,再看 Angular 的源码就清晰简单很多。下面我们将以 this.http.jsonp() 方法的调用流程为主线,简单分析一下 Angular JSONP 的实现。

  1. this.http.jsonp(searchUrl, “callback”)
1
2
3
4
5
6
7
jsonp<T>(url: string, callbackParam: string): Observable<T> {
return this.request<any>('JSONP', url, {
params: new HttpParams().append(callbackParam, 'JSONP_CALLBACK'),
observe: 'body',
responseType: 'json',
});
}
  1. this.request(‘JSONP’, url, {…})
1
2
3
4
5
6
7
8
9
10
11
request(first: string|HttpRequest<any>, url?: string, options: {
body?: any,
headers?: HttpHeaders|{[header: string]: string | string[]},
observe?: HttpObserve,
params?: HttpParams|{[param: string]: string | string[]},
reportProgress?: boolean,
responseType?: 'arraybuffer'|'blob'|'json'|'text',
withCredentials?: boolean,
} = {}): Observable<any> {
....
}

通过查看 request() 方法,你会觉得奇怪,没有找到任何与 jsonp 相关的处理逻辑,这是为什么呢?我们马上来分析一下问题,大家应该还记得在 “JSONP 示例” 章节我们除了导入 HttpClientModule 模块之外,我们还导入了 HttpClientJsonpModule 模块,该模块的定义如下:

1
2
3
4
5
6
7
8
@NgModule({
providers: [
JsonpClientBackend,
{provide: JsonpCallbackContext, useFactory: jsonpCallbackContext},
{provide: HTTP_INTERCEPTORS, useClass: JsonpInterceptor, multi: true},
],
})
export class HttpClientJsonpModule {}

HttpClientJsonpModule 模块很简单,模块只声明了 3 个 provider:

  • JsonpClientBackend:JSONP 服务内部实现;
  • JsonpCallbackContext:回调上下文对象;
  • JsonpInterceptor:JSONP 拦截器。

Angular HttpClient 拦截器 这篇文章中,我们已经介绍了拦截器的作用与使用。那是不是我们通过 HttpClient 服务发送的 JSONP 请求被 JsonpInterceptor 拦截器处理了。眼见为实,我们来看一下 JsonpInterceptor 拦截器的定义:

1
2
3
4
5
6
7
8
9
10
11
12
@Injectable()
export class JsonpInterceptor {
constructor(private jsonp: JsonpClientBackend) {}

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (req.method === 'JSONP') {
return this.jsonp.handle(req as HttpRequest<never>);
}
// Fall through for normal HTTP requests.
return next.handle(req);
}
}

上面代码中,关键的部分就是 req.method === 'JSONP' 这个条件语句中的处理逻辑。当发现当前请求的请求方法为 'JSONP' 时,则会把请求代理给 JsonpClientBackend 服务进行处理。

JsonpClientBackend 类及构造函数

1
2
3
4
5
6
@Injectable()
export class JsonpClientBackend implements HttpBackend {
constructor(
private callbackMap: JsonpCallbackContext,
@Inject(DOCUMENT) private document: any) {}
}

其中 HttpBackend 接口的定义如下:

1
2
3
4
5
6
7
export abstract class HttpBackend implements HttpHandler {
abstract handle(req: HttpRequest<any>): Observable<HttpEvent<any>>;
}

export abstract class HttpHandler {
abstract handle(req: HttpRequest<any>): Observable<HttpEvent<any>>;
}

在 JsonpClientBackend 类的构造函数中,我们注入了 JsonpCallbackContext 和 document 对象,其中 JsonpCallbackContext Provider 的配置如下:

1
{ provide: JsonpCallbackContext, useFactory: jsonpCallbackContext },

即使用工厂函数来构造 JsonpCallbackContext 对象:

1
2
3
4
5
6
export function jsonpCallbackContext(): Object {
if (typeof window === 'object') {
return window;
}
return {};
}

前面我们已经简单介绍了 JSONP 的实现原理,这里我们再回顾一下相关处理流程:

  1. 生成唯一的 callback 名称,构造请求的 URL 地址,比如 https://itunes.apple.com/search?term=Photo&media=music&limit=20&callback=ng_jsonp_callback_0
  2. 动态创建 script 标签并为该元素绑定 load 和 error 事件;
  3. 把新建的 script 标签添加到页面上。

好的基本的流程已经梳理清楚,我们再来看一下具体实现:

1
2
3
4
5
6
7
8
9
handle(req: HttpRequest<never>): Observable<HttpEvent<any>> {
// 确保请求方法是'JSONP'和期望的响应类型是JSON
if (req.method !== 'JSONP') {
throw new Error(JSONP_ERR_WRONG_METHOD);
} else if (req.responseType !== 'json') {
throw new Error(JSONP_ERR_WRONG_RESPONSE_TYPE);
}
return new Observable<HttpEvent<any>>((observer: Observer<HttpEvent<any>>) => {...}
}

创建script并设置回调函数

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
return new Observable<HttpEvent<any>>((observer: Observer<HttpEvent<any>>) => {
// let nextRequestId: number = 0;
// private nextCallback(): string { return `ng_jsonp_callback_${nextRequestId++}`; }
const callback = this.nextCallback(); // 生成唯一的callback名称
// callback=JSONP_CALLBACK 转换为 callback=ng_jsonp_callback_0
const url = req.urlWithParams.replace(/=JSONP_CALLBACK(&|$)/, `=${callback}$1`);

// 创建script元素并设定请求的URL地址
const node = this.document.createElement('script');
node.src = url;

// The response object, if one has been received, or null otherwise.
let body: any|null = null;

// Whether the response callback has been called.
let finished: boolean = false;

// Whether the request has been cancelled (and thus any other callbacks)
// should be ignored.
let cancelled: boolean = false;

// 设置响应的回调函数,浏览器环境则是window对象
this.callbackMap[callback] = (data?: any) => {
// Data has been received from the JSONP script. Firstly, delete this callback.
delete this.callbackMap[callback];

// Next, make sure the request wasn't cancelled in the meantime.
if (cancelled) {
return;
}

// Set state to indicate data was received.
body = data;
finished = true;
};
}

load 和 error 回调函数

onLoad 回调函数

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
const onLoad = (event: Event) => {
// Do nothing if the request has been cancelled.
if (cancelled) {
return;
}

// Cleanup the page.
cleanup();

// 请求失败构造响应对象
if (!finished) {
// It hasn't, something went wrong with the request. Return an error via
// the Observable error path. All JSONP errors have status 0.
observer.error(new HttpErrorResponse({
url,
status: 0,
statusText: 'JSONP Error',
error: new Error(JSONP_ERR_NO_CALLBACK),
}));
return;
}

// 请求成功构造响应对象
observer.next(new HttpResponse({
body,
status: 200,
statusText: 'OK',
url,
}));

// Complete the stream, the response is over.
observer.complete();
};

onError 回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const onError: any = (error: Error) => {
// If the request was already cancelled, no need to emit anything.
if (cancelled) {
return;
}
cleanup();

// Wrap the error in a HttpErrorResponse.
observer.error(new HttpErrorResponse({
error,
status: 0,
statusText: 'JSONP Error', url,
}));
};

在 onLoad 和 onError 回调函数中,都调用 cleanup() 函数执行清理操作,该函数的实现如下:

1
2
3
4
5
6
7
8
const cleanup = () => {
// Remove the <script> tag if it's still on the page.
if (node.parentNode) {
node.parentNode.removeChild(node);
}

delete this.callbackMap[callback];
};

定义完成功与异常处理函数,接下来就是为新建的 script 元素绑定对应事件,然后动态地添加该 script 元素:

1
2
3
4
5
6
node.addEventListener('load', onLoad);
node.addEventListener('error', onError);
this.document.body.appendChild(node);

// The request has now been successfully sent.
observer.next({type: HttpEventType.Sent});

此时到这里主要的流程都分析完了,其实还差一步,了解 Observable 对象的同学,估计已经猜到了。是的,没错我们还差可以执行取消订阅的逻辑:

1
2
3
4
5
6
7
8
return () => {
cancelled = true;
// Remove the event listeners so they won't run if the events later fire.
node.removeEventListener('load', onLoad);
node.removeEventListener('error', onError);
// And finally, clean up the page.
cleanup();
};

欢迎小伙伴们订阅前端修仙之路,及时阅读 Angular、RxJS、TypeScript 和 Node.js 最新文章。

qrcode