@angular/common/location 源码阅读

1,918 阅读9分钟

前端面试中常会问到和Router、SPA实现多页面的原理,来看看 张庭岑 同学的结合Angular源码的释疑解惑吧~

前言

@angular/common/location@angular/common 导出的一个子模块

import { Location } from '@angular/common'

而在前端, Location 我们一般理解为 [MDN] window.location, 包含当前网址解析的信息, 包含当前页面的导航位置信息以及导航的方法。 在 Angular 等现代前端框架应用内, url 或者说 Location 的变化, 都是表象, 内在的 SPA(单页面应用) 是没有发生变化的, 仅仅是对 Location 的变化做出了一定的响应。 Angular 本身再次封装一个 Location 对象有两个目的:

  1. 基于跨平台能力考虑, @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';
  1. Location 包含当前位置信息及导航方法, 受到整个项目的导航方案影响, @angular/common/location 可以作为不同导航方案的适配器:
@Injectable()
export class PathLocationStrategy extends LocationStrategy implements OnDestroy {
@Injectable()
export class HashLocationStrategy extends LocationStrategy implements OnDestroy {}

@angular/common/location 的整体类图如下

pic1_UML图_1652158748633.jpg

所以可以将 @angular/common/location 理解为以 window.history 为模板设计自身的导航 Api, 首先由 LocationStrategy 接口适配不同的路由策略, 再由 PlatformLocation 适配不同的平台。

源码地址见参考文档, 整个模块逻辑简单, 代码量少, 预计阅读时间在 30min 内;

文档解读

首先, @angular/common/location 是一个全局 Service, 至少包含导航位置信息以及导航相关的方法, 除了对这些信息和方法进行跨平台封装外, 它还提供了:

  1. 一些标准化的工具方法
  2. 可供监听的导航变化的事件
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 方法对比的基础, 由于是嵌套调用不是链式调用,所以字符串处理逻辑是从内向外的:

  1. 如果 url 以 index.html 字符结尾, 那就去除 url 末尾的 index.html;
  2. 如果 url 以 document.baseURI 开头, 那就把开头的 document.baseURI 去掉;
  3. 如果 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

功能几乎一致, 都是往历史栈中推入一条。

  1. 但是 go 的路径参数是必填, 而 window.history.pushState 的路径参数是非必填的。 这意味着 go 方法就是意味着路由跳转, 它的功能和运行后页面发生的变化时更加确定更加可预期的; @angular/common/location.replaceStatewindow.history.replaceState 有这同样的区别;
  2. window.history.pushState 运行后将不会触发 hashChangepopState 事件, 这意味着在 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 是 popStatehashChange 事件都无法监听到的。 而 @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.pushStatelocation.go 能否触发 location.subscribe 订阅的事件呢? 不可以, 因为它们不触发 popStatehashChange 这两个事件;

使用场景总结

你应该避免直接使用 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
}

参考文档

Angular 中文文档

Angular location 模块源码

[MDN] window.location

[MDN] window.history