前端面试中常会问到和Router、SPA实现多页面的原理,来看看 张庭岑 同学的结合Angular源码的释疑解惑吧~
前言
@angular/common/location
是 @angular/common
导出的一个子模块
import { Location } from '@angular/common'
而在前端, Location 我们一般理解为 [MDN] window.location, 包含当前网址解析的信息, 包含当前页面的导航位置信息以及导航的方法。 在 Angular 等现代前端框架应用内, url 或者说 Location 的变化, 都是表象, 内在的 SPA(单页面应用) 是没有发生变化的, 仅仅是对 Location 的变化做出了一定的响应。 Angular 本身再次封装一个 Location 对象有两个目的:
- 基于跨平台能力考虑, @angular/common/location 就是 Location 对象跨平台的适配器; 但目前这个跨平台的能力不明显, 因为目前所有平台的 runtime 其实都是运行在浏览器中:
export const PLATFORM_BROWSER_ID = 'browser';
export const PLATFORM_SERVER_ID = 'server';
export const PLATFORM_WORKER_APP_ID = 'browserWorkerApp';
export const PLATFORM_WORKER_UI_ID = 'browserWorkerUi';
- Location 包含当前位置信息及导航方法, 受到整个项目的导航方案影响, @angular/common/location 可以作为不同导航方案的适配器:
@Injectable()
export class PathLocationStrategy extends LocationStrategy implements OnDestroy {
@Injectable()
export class HashLocationStrategy extends LocationStrategy implements OnDestroy {}
@angular/common/location
的整体类图如下
所以可以将 @angular/common/location
理解为以 window.history
为模板设计自身的导航 Api, 首先由 LocationStrategy
接口适配不同的路由策略, 再由 PlatformLocation
适配不同的平台。
源码地址见参考文档, 整个模块逻辑简单, 代码量少, 预计阅读时间在 30min 内;
文档解读
首先, @angular/common/location
是一个全局 Service, 至少包含导航位置信息以及导航相关的方法, 除了对这些信息和方法进行跨平台封装外, 它还提供了:
- 一些标准化的工具方法
- 可供监听的导航变化的事件
class Location {
// 静态工具方法
static normalizeQueryParams: (params: string) => string
static joinWithSlash: (start: string, end: string) => string
static stripTrailingSlash: (url: string) => string
// 读取固定状态
path(includeHash: boolean = false): string
getState(): unknown
// 提供标准化的工具方法
isCurrentPathEqualTo(path: string, query: string = ''): boolean
normalize(url: string): string
prepareExternalUrl(url: string): string
// 导航方法的跨平台适配的封装: 基于 window.location 的方法, 让开发者就像在开发 web 应用一样
go(path: string, query: string = '', state: any = null): void
replaceState(path: string, query: string = '', state: any = null): void
forward(): void
back(): void
historyGo(relativePosition: number = 0): void
// 对 url change 事件进行了跨平台封装
onUrlChange(fn: (url: string, state: unknown) => void)
// 订阅所属平台(如浏览器)的 popState 事件
subscribe(onNext: (value: PopStateEvent) => void, onThrow?: (exception: any) => void, onReturn?: () => void): SubscriptionLike
}
源码解读
静态工具方法
normalizeQueryParams
, joinWithSlash
, stripTrailingSlash
在 Angular 自身源码中广泛应用到, 它们只对字符串进行及其简单的处理。 在业务复杂的应用中略显不够, 但也能发挥一些作用。
在读到这些工具方法的时候, 总担心思考极限状况下这些工具方法的运行逻辑是否会出问题, 比如:
Location.normalizeQueryParams('??'); // '??'
Location.joinWithSlash('1','//2'); // '1//2'
然而真正看过源码就会傻眼了, 这些工具方法的并不考虑极限状态, 它们只做最简单的工作, 比如 normalizeQueryParams
只有一行代码:
/**
* Normalizes URL parameters by prepending with `?` if needed.
*
* @param params String of URL parameters.
*
* @returns The normalized URL parameters string.
*/
export function normalizeQueryParams(params: string): string {
return params && params[0] !== '?' ? '?' + params : params;
}
源码地址, 阅读时间约 5min.
Angular 一般用它们来处理平台的输入输出, 所以并不会考虑各种各样不可能出现的输入; 但应用在生产过程中就很容易出问题, 所以只有保证输入不会太离谱的时候, 你可以直接使用它们;
读取固定的位置状态
path(includeHash: boolean = false): string
受到路由策略的影响, 不同的路由策略获取 path 的方法不一样。 但就浏览器平台支持的两种路由策略而言, 获取 path 的方法都是对 window.location 对象的内容进行简单的标准化处理, 逻辑极其简单直接, 就没必要贴源码了。
PathLocationStrategy
返回
location.pathname
+location.search
+location.hash?
这样标准化的格式
HashLocationStrategy
忽略入参 includeHash, 返回完整的 window.location.hash, 并去掉 '#'
getState(): unknown 只要是在浏览器平台, 不论哪个路由策略, getState 方法都是直接返回 [MDN]window.history.state。
提供标准化的工具方法
这些提供标准化的方法在 Angular 的源码中也应用的非常广泛, 它们同样非常简单, 同样没有太多容错。 比如 isCurrentPathEqualTo
这个用于对比 url 是否相等的方法。 Angular 将当前 url 和输入的 url + query 作为字符串进行 ==
判断。 而实际生产过程中, 可能要考虑 queryString
中字段的顺序等等。
normalize(url: string): string
normalize
方法提供了 angular 输出的 url 的标准, 也是 isCurrentPathEqualTo
方法对比的基础, 由于是嵌套调用不是链式调用,所以字符串处理逻辑是从内向外的:
- 如果 url 以
index.html
字符结尾, 那就去除 url 末尾的index.html
; - 如果 url 以 document.baseURI 开头, 那就把开头的 document.baseURI 去掉;
- 如果 url 的 path 部分以
/
结尾, 那就去掉 path 部分结尾的/
normalize(url: string): string {
return Location.stripTrailingSlash(_stripBaseHref(this._baseHref, _stripIndexHtml(url)));
}
isCurrentPathEqualTo(path: string, query: string = ''): boolean
略
isCurrentPathEqualTo(path: string, query: string = ''): boolean {
return this.path() == this.normalize(path + normalizeQueryParams(query));
}
prepareExternalUrl(url: string): string
标准化外部 URL 路径。如果给定的 URL 并非以斜杠( '/' )开头,就会在规范化之前添加一个。如果使用 HashLocationStrategy
则添加哈希;如果使用 PathLocationStrategy
则添加 APP_BASE_HREF
。
PathLocationStrategy
override prepareExternalUrl(internal: string): string {
return joinWithSlash(this._baseHref, internal);
}
HashLocationStrategy
override prepareExternalUrl(internal: string): string {
const url = joinWithSlash(this._baseHref, internal);
return url.length > 0 ? ('#' + url) : url;
}
this._baseHref
就是 APP_BASE_HREF
, 如果没有设置则为空字符串;
而在 HashLocationStrategy
的源码中也有 joinWithSlash(this._baseHref, internal)
这段逻辑。
很明显, 官方文档的描述和官方代码的实现是不匹配的。 一旦在使用 HashLocationStrategy
的项目中注入 base-herf
, 就会造成不必要的错误。 prepareExternalUrl
在 Angular 内部使用是非常广泛的, 在 @angular/common/location
模块的导航方法, 以及 @angular/router
模块执行路由跳转的时候均有使用到。
导航方法的跨平台(跨路由策略)适配的封装
这部分有五个方法, 功能和 [MDN] window.history api 的功能基本一致, 并且底层的 BrowserPlatformLocation
也确实是调用 [MDN] window.history 的 api 实现的 api
go(path: string, query: string = '', state: any = null): void
replaceState(path: string, query: string = '', state: any = null): void
// 下面的三个方法, 都是直接调用了 window.history 的 api 实现的
forward(): void
back(): void
historyGo(relativePosition: number = 0): void
其中 go
对应 [MDN] window.history.pushState, historyGo
对应 [MDN] window.history.go, 其他的 api 和 window.history
上的同名 api 一一对应。
虽然有一一对应的关系, 但是细节方面还是有不一样的地方。
@angular/common/location.go vs window.history.pushState
功能几乎一致, 都是往历史栈中推入一条。
- 但是
go
的路径参数是必填, 而 window.history.pushState 的路径参数是非必填的。 这意味着go
方法就是意味着路由跳转, 它的功能和运行后页面发生的变化时更加确定更加可预期的;@angular/common/location.replaceState
和window.history.replaceState
有这同样的区别; window.history.pushState
运行后将不会触发hashChange
和popState
事件, 这意味着在 Angular 项目中, 如果你直接运行window.history.pushState
去改变页面路径, Angular 将不能进行响应; (你可以在任何一个angular <= 13
的项目中轻易复现这个问题);go
方法则会主动触发路由变化事件;
总的来说 @angular/common/location
将能力专注在导航能力上, 同时为规避 window.history.pushState
的一些缺陷做出了努力;
路由事件监听
onUrlChange(fn: (url: string, state: unknown) => void)
由上文我们可以了解到, 通过 window.history.pushState
改变 url 是 popState
和 hashChange
事件都无法监听到的。 而 @angular/common/location.go 内部也是通过 window.history.pushState
进行跳转的。 那么 Angular 是怎么监听事件作出相应的?
禁止在 Angular 应用中直接调用 window.hisotry.pushState
, 使用 location.go
方法替代, location.go
方法会主动调用所有的监听器
go(path: string, query: string = '', state: any = null): void {
this._locationStrategy.pushState(state, '', path, query);
this._notifyUrlChangeListeners(// 主动调用所有的监听器
this.prepareExternalUrl(path + normalizeQueryParams(query)), state
);
}
而 onUrlChange
方法, 则是往监听列表推入一个新的回调方法, 并返回取消监听的方法;
onUrlChange(fn: (url: string, state: unknown) => void): VoidFunction {
this._urlChangeListeners.push(fn);
if (!this._urlChangeSubscription) {
this._urlChangeSubscription = this.subscribe(v => {
this._notifyUrlChangeListeners(v.url, v.state);
});
}
return () => {
const fnIndex = this._urlChangeListeners.indexOf(fn);
this._urlChangeListeners.splice(fnIndex, 1);
if (this._urlChangeListeners.length === 0) {
this._urlChangeSubscription?.unsubscribe();
this._urlChangeSubscription = null;
}
};
}
subscribe(onNext: (value: PopStateEvent) => void, onThrow?: (exception: any) => void, onReturn?: () => void): SubscriptionLike
location.subscribe
是一个订阅方法, 订阅 'popState' 事件与 'hashChange' 事件的集合。 它内部的主题是 _subject, 这个主题用 rxjs 做如下理解:
merge(
fromEvent('popState'),
fromEvent('hashChange')
)
那么我们有一个疑问了: window.history.pushState
和 location.go
能否触发 location.subscribe
订阅的事件呢? 不可以, 因为它们不触发 popState
和 hashChange
这两个事件;
使用场景总结
你应该避免直接使用 window
, window.location
, window.history
对象, 在 Angular 版本升级, 路由策略更换, 或产品迁移到新的平台的时候, 可以帮助你节省一些修改;
class Location {
// 静态工具方法: 能力简单, 没有容错, 偶尔有用, 建议重写, 谨慎直接使用
static normalizeQueryParams: (params: string) => string
static joinWithSlash: (start: string, end: string) => string
static stripTrailingSlash: (url: string) => string
// 读取固定状态
/**
* 相当于 location.pathname + location.query + location.hash?
* 很多时候你会用到它, 但是如果你要使用 window.location.pathname, @angular/Router 将会更加合适
*/
path(includeHash: boolean = false): string
/**
* 为了未来 N 年或者 Nx10 年, 你的 Angular 应用可能会跑在浏览器意外的平台上, 你应该使用这个方法替代 window.history.state.
* 但是这谁在意呢;
*/
getState(): unknown
// 提供标准化的工具方法
/**
* 非常简单的功能, 你需要知道它, 但是你完全没有必要使用它。
*/
isCurrentPathEqualTo(path: string, query: string = ''): boolean
/**
* 非常简单实用的功能
*/
normalize(url: string): string
/**
* 非常简单使用的功能, 可以用在应用内跳转的超链接上;
* 但是 Router 模块与 RouterLinkComponent 将会更加实用;
*/
prepareExternalUrl(url: string): string
// 导航方法的跨平台适配的封装: 基于 window.location 的方法, 让开发者就像在开发 web 应用一样
/**
* 非常有用, 你应该用它替换所有
* `window.pushState(...)`
* `window.location = "xxx"`
* `window.location.href = "xxx"`
* `window.location.assign("xxx")`
*/
go(path: string, query: string = '', state: any = null): void
/**
* 非常有用, 你应该用它替换所有的 `window.replaceState()`
*/
replaceState(path: string, query: string = '', state: any = null): void
/**
* 非常有用, 你应该用它替换所有的 `window.forward()`
*/
forward(): void
/**
* 非常有用, 你应该用它替换所有的 `window.back()`
*/
back(): void
/**
* 非常有用, 你应该用它替换所有的 `window.go()`
*/
historyGo(relativePosition: number = 0): void
// 对 url change 事件进行了跨平台封装
/**
* 特殊场景必不可少, 比如你想监听 `location.go` 触发的路由变更; 注意使用后要解除监听;
*/
onUrlChange(fn: (url: string, state: unknown) => void)
// 订阅所属平台(如浏览器)的 popState 事件
/**
* 用于监听 url 的变化, 你应该使用这个方法以避免直接使用使用 window 对象进行时间监听;
*/
subscribe(onNext: (value: PopStateEvent) => void, onThrow?: (exception: any) => void, onReturn?: () => void): SubscriptionLike
}