65.9K
CodeProject 正在变化。 阅读更多。
Home

具有复杂路由和导航的 Angular 面包屑

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (18投票s)

2019年3月6日

CPOL

24分钟阅读

viewsIcon

90539

downloadIcon

1957

一个Angular示例应用程序,讨论了具有高级路由策略、导航场景、实际工作流等的面包屑(最新更新包含Angular 9 CLI和ASP.NET Core 3.1网站)。

引言

任何严肃网站上面包屑的UI结构看起来都很简单。但由于相关的路由复杂性和导航多样性,其底层代码逻辑、操作规则和导航工作流一点也不简单。本文将演示一个功能齐全的面包屑示例应用程序,并讨论实现和测试问题的解决方案。

可以通过上述链接下载的示例应用程序是Angular文档《路由与导航》中原始的英雄示例的修改版本。我不想从头开始创建我的示例应用程序,那样是重复造轮子。英雄示例涵盖了大多数路由模式和类型,因此可以作为添加面包屑功能的基础源代码。然而,它不足以演示具有复杂导航场景和工作流完整性的实际面包屑。修改任务包括添加更多页面及相应的导航路由、更改UI结构和样式、使用自定义替代方案修复活动路由链接问题、更新经过身份验证的会话创建和持久化的代码逻辑等等。熟悉原始英雄示例的读者可以将其与我的修改版本(即本示例应用程序)进行比较,以了解更多详细信息。

读者在阅读本文和使用示例应用程序时,还应牢记以下有关面包屑的注意事项。

  1. 通常由这些结构发起的导航类型
    • 在浏览器地址栏中输入URL
    • 来自任何定义的菜单或子菜单的链接
    • 任何页面上的内联链接
    • 页面顶部的面包屑
    • 浏览器后退和前进按钮
    • 浏览器后退或前进历史记录选择
    • 浏览器刷新按钮
  2. Angular路由器功能和路由策略
    • 路由器元素层次结构
    • 路由的配置和顺序
    • URL片段、矩阵和查询参数
    • 活动路由链接和样式
    • 路由守卫
    • 主路由和次要路由
    • 懒加载或动态加载路由

本文的主要章节和主题也列在此处。读者可以跳转到任何章节或位置直接查看内容。

构建和运行示例应用程序

下载的源代码包含两种不同的Visual Studio解决方案/项目类型:ASP.NET Core 3.1和旧版ASP.NET 5。建议使用前者运行示例应用程序。如果您需要,后者也完全可用。node.js(推荐版本12.16.x LTS或更高版本)的全局安装是必需的,以便将Angular及相关库下载到您的本地机器。请查看node.js文档了解详细信息。Angular CLI的全局安装是可选的,因为应用程序可以使用本地等效项。

您可以检查C:\Program Files (x86)\Microsoft SDKs\TypeScript文件夹中Visual Studio可用的TypeScript版本。ASP.NET Core和ASP.NET 5类型的示例应用程序都在SM.NgExDialog.Sample.csproj文件的TypeScriptToolsVersion节点中将Visual Studio的TypeScript版本设置为3.7。如果您没有安装版本3.7,请从Microsoft网站下载安装包

Breadcrumb_AspNetCore_NgCli

  1. 您需要在本地机器上使用Visual Studio 2019(版本16.4.x)。.NET Core 3.1 SDK包含在Visual Studio安装和更新中。

  2. 下载源代码文件并将其解压到您的本地工作区。

  3. 进入本地工作区的物理位置,依次双击SM.Breadcrumb.Web\AppDev文件夹下的npm_install.batng_build_local.bat文件。如果您已在机器上全局安装了Angular 9 CLI,则可以运行ng_build.bat,而不是ng_build_local.bat

    注意:每次更改TypeScript/JavaScript代码后,可能都需要执行ng build命令(或运行ng_build_local.bat),而只有当node-module包有任何更新时(如果Visual Studio没有自动更新node-module包),才需要执行npm install

  4. 使用Visual Studio 2019打开解决方案,并使用Visual Studio重新构建解决方案。

  5. 单击IIS Express工具栏命令(或按F5)启动示例应用程序。

Breadcrumb_AspNet5_NgCli

  1. 下载源代码文件并将其解压到您的本地工作区。

  2. 进入本地工作区的物理位置,依次双击SM.Breadcrumb.Web\AppDev文件夹下的npm_install.batng_build_local.bat文件。如果您已在机器上全局安装了Angular 9 CLI,则可以运行ng_build.bat,而不是ng_build_local.bat(另请参阅设置Breadcrumb_AspNetCore_NgCli项目的相同说明)。

  3. 使用Visual Studio 2019或2017(Visual Studio 2015 Update 3或更高版本也适用)打开解决方案并重新构建解决方案。

  4. 单击IIS Express工具栏命令(或按F5)启动示例应用程序。

Breadcrumb_Scripts_AnyPlatform_Ng9.zip文件只包含客户端脚本源代码文件夹和文件。如果您使用非Microsoft平台系统,可以将纯Angular源代码文件复制到您自己的网站项目,使用npm和Angular CLI命令执行必要的设置,然后在您的系统上运行示例应用程序。

如果您对Angular组件、模板或CSS样式进行了任何更改,请务必清除浏览器缓存,如果需要,请重新构建Angular CLI捆绑包,然后刷新页面或重新启动应用程序。

当应用程序运行时,您可以使用任何导航方式浏览页面。以下是导航到英雄详情相关页面的截图示例。

面包屑结构和规则

示例应用程序的面包屑结构基于路由层次结构和用例。下文将最佳地说明相应面包屑的主要路由配置。此图不包括“未找到页面”主路由和“联系”次要路由,它们可以从任何其他路由导航。

根据上图所示的路由,我们可以进一步描述面包屑的规则

  1. 面包屑由分层路由分支组成。每个分支代表一系列具有相关功能的页面。示例应用程序中有三个主要面包屑分支

    • 英雄
    • 危机中心
    • Admin
  2. 基本项是那些始终作为每个面包屑显示列表中的开头项存在的项。示例应用程序中的基本项是Home。从新分支开始的任何导航都应该用开头的基本项(或多个项)重新构建面包屑。例如

    • Home > Hero List > Hero Detail
    • Home > Admin Dashboard > Manage Crises
  3. 面包屑项可以仅为内联命令设置,不一定由菜单项激活。这种面包屑可能用于多个分支和路由,例如“任务板”项

    • Home > Task Board > Hero List
    • Home > Task Board > Crisis List

    或者,是“未找到页面”的终端路由

    • Home > Hero List > Page Not Found
    • Home > Crisis List > Crisis Detail > Page Not Found
  4. 特定分支中面包屑列表中的项应具有层次位置

    • 同一项从不出现两次或更多次。
    • 任何父项从不显示在其子项之后。
    • 不允许出现循环项列表。
  5. 对于为子路由构建的任何子菜单或选项卡,同级项的序列基于路由激活的顺序。但是,默认项应始终在首位。例如,ProfileAwardsCrisis HistoryHero Detail的同级项,其中Profile是默认项。面包屑可以这样构建

    • Home > Hero List > Hero Profile > Awards > Crisis History
    • Home > Hero List > Hero Profile > Crisis History > Awards
  6. 如果从一个分支重定向到另一个分支中的页面(跨分支导航),目标页面的面包屑项将被追加到现有分支项列表。另一路由分支中的当前页面应是终端路由或仅具有向下导航路由。路由分支之间也不应存在循环工作流。以下是一个案例示例

    • 当前面包屑:Home > Admin Dashboard > Manage Heroes
    • 点击页面上的View Hero Detail链接。
    • 其他同级路由的链接可用,但Back to Hero List按钮已移除。
    • 可能的新面包屑:Home > Admin Dashboard > Manage Heroes > Hero Profile > Crisis History

    请参阅跨分支导航部分了解更多详细信息。

  7. 在面包屑项列表中,没有必要将Login(路由守卫组件)显示为可导航或可执行项,尽管它可以在页面渲染时作为终端只读项追加。

  8. 任何带有次要路由的导航都将从面包屑项列表中排除。次要路由页面独立于任何主路由页面或面包屑。在示例应用程序中,当您点击左侧菜单上的“联系”时,次要路由页面会打开并保留在那里,直到您发送联系消息或取消它为止。

  9. 当使用面包屑命令导航时,任何带有路由所需的URL矩阵或查询参数的下一个导航请求不应丢失。请查看处理URL参数面包屑导航命令部分中的详细信息。

  10. 无论何时点击浏览器的后退前进刷新按钮,甚至右键点击浏览器的后退前进按钮并从会话导航历史下拉列表中选择任何项,面包屑项及其顺序都会保持不变或重新加载。请参阅浏览器固有的导航部分了解详细信息。

面包屑的HTML模板

breadcrumb.component.html文件只包含几行代码。

<ul class="breadcrumb">
    <li *ngFor="let bcItem of breadcrumbList; 
     let $index = index; let $last = last" title="{{bcItem.labelName}}">
        <a *ngIf="!$last" [ngClass]="{'active': $last}" class="cursor-pointer"
            (click)="openPageWithBreadcrumb($index)">{{bcItem.labelName}}</a>
        <span *ngIf="$last" class="active">{{bcItem.labelName}}</span>
    </li>
</ul>

代码片段执行三个主要任务

  1. 迭代并渲染来自breadcrumbList数组的面包屑项,并带有相应的索引,这些索引被传递给openPageWithBreadcrumb()方法以进行导航操作。

  2. 使用$last变量有条件地设置可执行或只读项的样式。列表中的最后一项始终是只读或禁用的。

  3. 通过设置title属性向项目添加工具提示。

    注意:工具提示可能不适用于移动设备上的浏览器。

此外,breadcrumb.component.css文件中的breadcrumb CSS类指定了当文本溢出并出现省略号字符(“…”)时,项的最大显示宽度。

.breadcrumb li {
    - - -
    max-width: 15rem;
    text-overflow: ellipsis;
    -ms-text-overflow: ellipsis
}

您可以在第一个屏幕截图上看到省略号示例

面包屑项对象

BreadcrumbItem是核心对象,用于接收为每个激活路由配置的数据对象中的数据,然后在处理过程中存储数据。该对象的实例将被添加到数组breadcrumbList中,并渲染到UI结构中。

BreadcrumbItem类在breadcrumb.component.ts文件中定义。

export class BreadcrumbItem {
    key: string = undefined;
    labelName: string = undefined;
    path: string = '';   
    terminalOnly?: boolean = undefined; 
    afterBaseOnly?: boolean = undefined;
    pathParamList?: Array<any> = [];
    queryParams?: any = undefined;
    fragment?: string = undefined;
}

前五个属性的值由路由配置中输入的静态数据填充。以下是admin路由所有子路由的面包屑数据设置示例。

{
    path: 'crises',
    component: ManageCrisesComponent,
    data: { breadcrumbItem: { key: 'ManageCrises', labelName: 'Manage Crises'} }
},
{
    path: 'heroes',
    component: ManageHeroesComponent,
    data: { breadcrumbItem: { key: 'ManageHeroes', labelName: 'Manage Heroes'} }
},
{
    path: '',
    component: AdminDashboardComponent,
    data: { breadcrumbItem: { key: 'AdminDashboard', labelName: 'Admin Dashboard'} }
}

最后三个属性用于存储和传递从激活路由处理逻辑动态添加的参数。带有面包屑传递参数的主题将在处理URL参数部分讨论。

设置基本面包屑项

示例应用程序中的基本面包屑项Home将在启动网站或点击任何主路由分支的主菜单项,或点击Home菜单本身时加载。

  • 点击菜单项会触发SideMenuComponent中的manuItemVisited事件。任何不以基本面包屑项开头的菜单项都不会调用menuItemClicked方法,例如用于链接到Contact页面的次要路由的Contract菜单。

    @Output() menuItemVisited: EventEmitter<boolean> = new EventEmitter<boolean>();
    menuItemClicked() { 
        this.menuItemVisited.emit(true);
    }
  • AppComponentSideMenuComponent的父组件)中,onMenuItemVisited()方法将全局变量isMenuAction设置为true

    onMenuItemVisited($event) {
        glob.caches.isMenuAction = true;
    } 
  • BreadcrumbComponent中,基本项根据路由器NavigationEnd事件中的isMenuAction标志进行设置和加载。

    bcInitItem: BreadcrumbItem = {
        key: 'Home',
        labelName: 'Home',
        path: '/home'
    };
    
    ngOnInit() {
        let pThis: any = this;
        this.router.events.pipe
         (filter(value => value instanceof NavigationEnd)).subscribe((value: any) => {    
            - - -
            //Any primary route side-menu action will add base breadcrumb items.
            if (glob.caches.isMenuAction) {
                glob.caches.isMenuAction = false;
                pThis.loadBaseBreadcrumbList();
            }                        
        });
    }
    
    loadBaseBreadcrumbList() {
        this.breadcrumbList.length = 0;
        this.breadcrumbList.push(this.bcInitItem);
    }

如果在任何情况下只需要在当前激活路由的项之前显示基本面包屑项,可以将afterBaseOnly属性设置为当前激活路由的breadcrumbItem对象实例。请参阅不可执行的仅限终端的面包屑项部分中登录页面的示例。

一些应用程序需要一个以上的面包屑基本项,有些甚至需要针对特定分支的不同面包屑基本项。实施应根据上述工作流程轻松调整。

更新面包屑项列表

breadcrumbList数组在每个激活路由操作的NavigationEnd事件中动态更新。

添加新项

任何需要关联面包屑的主路由都应在路由配置文件中将breadcrumbItem对象设置为数据对象的一个属性(参见上一节面包屑项对象)。然后,可以通过对当前激活路由及其层次结构树中的第一个子路由进行递归操作,将breadcrumbItem实例添加到breadcrumbList数组中。无需使用for循环水平迭代子路由,因为无论该级别有多少同级项,每个导航操作都首先指向相应的目标路由。此外,次要路由被排除在渲染面包屑之外,尽管它可以是同一级别中多个子路由的元素。

如果您对路由层次结构树的“第一个子项”场景感到好奇,这里显示了激活路由crisis-detail的路由器状态树详细信息,其中':id'path

…/ClientApp/app/breadcrumb/breadcrumb.component.ts文件中的BreadComponent.refreshBreadcrumbs()方法执行将新项添加到面包屑的逻辑。这些过程可以通过伪代码列表更好地解释

  • 激活路由被传递给refreshBreadcrumbs()方法。

  • 如果第一个子路由不为null,则将其设置为当前路由并继续,否则退出方法。

  • 如果data.breadcrumbItem对象实例未定义,则通过传递当前路由递归调用refreshBreadcrumbs()方法。

  • 重复调用,直到找到具有data.breadcrumbItem对象实例的路由。

  • 获取路由的breadcrumbItem并动态更新所需属性的值,例如path

  • 将更新后的breadcrumbItem插入到breadcrumbList数组中。

移除尾随项

无论何时导航回任何之前访问的页面,该之前页面的面包屑都应成为面包屑列表中的当前项和最后一项。每次都重建整个面包屑列表并不是一个最佳解决方案,因为它需要保存导航历史记录,包括所有路由路径和参数。从breadcrumbList数组中移除尾随项可以在面包屑的on-click事件方法中轻松完成。但这不能在不点击面包屑的情况下,使用其他操作导航到之前访问的路由时移除任何尾随项。因此,移除尾随项的代码最好写在refreshBreadcrumbs()方法中。然后,可以根据具有key值的breadcrumbItem的索引位置,移除重新激活路由当前位置之后的尾随项。

if (this.breadcrumbList.length > 0) {
    let bcKey: string = child.snapshot.data[this.routeDataName].key;

    //Remove the breadcrumb trailing items.
    let bcIndex: number = this.getBreadcrumbPositionByKey(bcKey);
    if (bcIndex >= 0) {
        this.breadcrumbList.splice(bcIndex);
    }
}

调用方法getBreadcrumbPositionByKey()以获取面包屑索引位置

getBreadcrumbPositionByKey(key: string): number {
    let rtnIndex: number = -1;
    for (let idx: number = this.breadcrumbList.length - 1; idx >= 0; idx--) {
        if (this.breadcrumbList[idx].key == key) {
            rtnIndex = idx;
            break;
        }
    }
    return rtnIndex;
}

重新添加还是保留当前面包屑?

您可能注意到,在上述代码逻辑中,包括当前项本身在内的尾部项被移除,并且当前面包屑项再次被重新添加。另一种选择是只移除后续项,只保留当前项,然后退出例程。替代的代码逻辑可能如下所示

if (bcIndex >= 0) {
    this.breadcrumbList.splice(bcIndex + 1);
    //Or:
    //this.breadcrumbList.length = bcIndex + 1; 

    //Exit routing.
    return;
}

代码整洁,无需重新添加当前项目。然而,对于带有URL段参数的路由层次结构,例如“/crisis-center/2”(参数值作为路由url值),由于此类路由配置的父子依赖性,crisis-center路由将自动再次被处理。结果是,标记为“危机列表”的重复面包屑将被添加到面包屑中,如下所示

Home > Crisis List > Crisis List > Crisis Detail

删除并重新添加当前面包屑项的方法解决了这个问题,并且适用于所有情况。它还可以确保所有新添加的面包屑都是最新的,以防某些数据参数在项最初添加到面包屑列表后发生更改。

处理URL参数

Angular应用程序可以有三种类型的参数,它们通过URL传递。

  1. URL段值,例如“crisis-center/2
  2. 矩阵参数,例如“hero-list;id=12;foo=foo
  3. 查询参数,例如“admin?sessionId=123456789

将面包屑项添加到面包屑列表时,激活路由的URL参数也应保存在breadcrumbItem对象实例中,以便稍后执行导航命令。URL段值可以直接保存到路由路径中,这样面包屑无需额外代码即可工作。但是,矩阵和查询参数(以及URL片段)需要从激活路由获取值,并相应地设置breadcrumbItem属性的值。

示例应用程序使用另一个中间对象来存储路径和参数数据。

export class PathParams {
    path: string = '';
    pathParamList?: Array<any> = []; //Matrix params
    queryParams?: any = undefined;
    fragment?: string = undefined;
}

getPathAndParams()方法从refreshBreadcrumbs()例程中调用,结果分配给breadcrumbItem,然后插入到breadcrumbList数组中。请阅读代码行上的注释以理解处理逻辑。

refreshBreadcrumbs(route: ActivatedRoute, 
                   pathParams: PathParams = { path: '', pathParamList: []}) {
    - - -    
    //Add URL parts for this route with breadcrumb item.
    pathParams = this.getPathAndParams(child, pathParams);
    - - -
    //Set breadcrumb item object.
    let breadcrumbItem: BreadcrumbItem = {
        - - -
        path: pathParams.path,
        pathParamList: pathParams.pathParamList,
        queryParams: pathParams.queryParams,
        fragment: pathParams.fragment                
    };
    //Add item to breadcrumb list.
    this.breadcrumbList.push(breadcrumbItem);
    - - - 
}

getPathAndParams(route: ActivatedRoute, pathParams: PathParams): PathParams {        
    let thisPath: string = '';        
        
    //Url param '/:id' is a segment.path. 
    thisPath = route.snapshot.url.map(segment => segment.path).join('/'); 
    if (thisPath != '') {
        //Process matrix params.
        //Format of pathParamList: ['path', {param data}, 'path', {param data}].
        let matParams: any = route.snapshot.url.map(segment => segment.parameters);
        if (matParams.length > 0 && Object.getOwnPropertyNames(matParams[0]).length > 0) {  
            pathParams.pathParamList.push(thisPath);
            let params: any = {};                
            for (let item of matParams) {
                for (let prop of Object.keys(item)) {                        
                    params[prop] = item[prop];                                                
                }                    
            }
            pathParams.pathParamList.push(params);                                
        } 

        //Get query params if any - always for the last segment.            
        if (route.snapshot.queryParamMap.keys.length > 0) {
            pathParams.queryParams = {};
            for (let key of route.snapshot.queryParamMap.keys) {
                pathParams.queryParams[key] = route.snapshot.queryParamMap.get(key);
            }
        }
        //Get fragment if any - always for the last segment.
        if (route.snapshot.fragment) {
            route.fragment.subscribe(value => {
                pathParams.fragment = value;
            });
        }           

        pathParams.path += `/${thisPath}`;
    }
    return pathParams;
}

面包屑导航命令

由于面包屑已准备好路径和参数值,我们可以通过调用router.navigate()方法执行导航命令。如前所述,点击页面上的任何面包屑都会调用openPageWithBreadcrumb()方法并重定向到激活路由的目标页面。

openPageWithBreadcrumb(index: number) {
    //Check and get queryParams and fragment.
    let navigationExtras: NavigationExtras;
    if (this.breadcrumbList[index].queryParams) {
        navigationExtras = {
            queryParams: this.breadcrumbList[index].queryParams
        };
    }   
    if (this.breadcrumbList[index].fragment) {
        if (!navigationExtras) {
            navigationExtras = {};
        }
        navigationExtras.fragment = this.breadcrumbList[index].fragment;
    }        

    //check and get matrix params.
    if (this.breadcrumbList[index].pathParamList && 
        this.breadcrumbList[index].pathParamList.length > 0) {            
        if (navigationExtras) {
            this.router.navigate(this.breadcrumbList[index].pathParamList, navigationExtras);
        }
        else {
            this.router.navigate(this.breadcrumbList[index].pathParamList);
        }                    
    }
    //Do general path.
    else {
        if (navigationExtras) {
            this.router.navigate([this.breadcrumbList[index].path], navigationExtras);
        }
        else {
            this.router.navigate([this.breadcrumbList[index].path]);
        }
    }                
}

上述方法中的逻辑可以通过以下注释行来解释

  • 如果存在任何查询参数或/和片段,则将它们包含在navigationExtra对象实例中,该实例将作为调用router.navigate()的第二个参数。

  • 如果从原始调用者传递了具有专门格式化矩阵参数元素的pathParamList数组值,则将该数组设置为调用router.navigate()的第一个参数。

  • 否则,使用常规的path值调用router.navigate()

浏览器固有的导航

如果相应的按钮可用,面包屑应该对以下这些浏览器固有的导航类型正确操作

  • 通过点击浏览器“刷新”按钮刷新屏幕。
  • 通过直接点击浏览器“后退”按钮进行简单历史后退。
  • 通过直接点击浏览器“前进”按钮进行简单历史前进。
  • 通过右键点击浏览器“后退”或“前进”按钮并从下拉列表中选择任何项,进行选择性历史后退或前进。

刷新浏览器屏幕

刷新当前浏览器屏幕会精确地重复现有的激活路由和URL,但这会导致丢失组件级别数据的问题。BreadcrumbComponent中的breadcrumbList数组和值在浏览器刷新后不会持久化。需要一个原生的JavaScript sessionStorage对象实例来在每次更新数组后缓存现有的breadcrumbList数组。然后,NavigationEnd事件例程需要检查并从sessionStorage对象实例获取数组的值,以恢复现有的breadcrumbList数组用于正常面包屑显示。

ngOnInit() {
    let pThis: any = this;
    this.router.events.pipe
     (filter(value => value instanceof NavigationEnd)).subscribe((value: any) => {            
        - - -
        //Check and get cached breadcrumbList.             
        if (pThis.breadcrumbList.length < 1 &&
            //Get cached breadcrumbList when browser refresh.
            window.sessionStorage.getItem('breadcrumbList') != null &&
            window.sessionStorage.getItem('breadcrumbList') != '') {
            pThis.breadcrumbList = JSON.parse(window.sessionStorage.getItem('breadcrumbList')); 
        }
            
        //Refresh breadcrumb items.            
        pThis.refreshBreadcrumbs(rootRoute);

        //Save breadcrumbList to session object after every breadcrumb update 
        //for browser refreshing action.
        window.sessionStorage.setItem
               ('breadcrumbList', JSON.stringify(pThis.breadcrumbList));                        
    });  
     - - -
}

浏览后退、前进和历史记录

对于使用后退、前进或任何历史选择的导航,不可能为目标历史页面重建面包屑,因为某些前导导航信息可能并非总是在当前页面上可用。引入了breadcrumbHistoryList对象数组来缓存浏览器导航id、完整URLbreadcrumbList,以便在返回或前进到任何历史页面时可以恢复整个面包屑。这之所以可行且实用,得益于Angular路由器NavigationStart事件提供了idurlnavigationTriggerrestoredState属性,通过这些属性可以检测到非命令式浏览器后退/前进导航类型。

功能实现的细节描述如下

  1. 在组件级别定义两个变量

    navState: any = {};
    breadcrumbHistoryList: Array<any> = [];
  2. ngOnInit()例程中,使用从NavigationStart事件获取的数据填充navState对象实例。

    //For handle browser back/forward/history scenarios.
    this.router.events.pipe
        (filter(value => value instanceof NavigationStart)).subscribe((value: any) => {
        pThis.navState = {
            id: value.id,
            url: value.urlAfterRedirects,
            navigationTrigger: value.navigationTrigger,
            restoredState: value.restoredState
        }; 
    });
  3. NavigationEnd事件例程中每次导航结束时,将当前导航id和构建的breadcrumbList数组添加到breadcrumbHistoryList中。请注意,缓存的breadcrumbList需要新的深层克隆副本。任何后退或前进操作本身也是一次新的导航,并保存了idbreadcrumbList数据。

    //Save history item for browser back/forward.
    let bcHistoryItem = {
        id: pThis.navState.id,
        breadcrumbList: glob.deepClone(pThis.breadcrumbList)
    }
    pThis.breadcrumbHistoryList.push(bcHistoryItem);
  4. NativationEnd事件例程的开头,检查navigationId并将匹配的数据数组分配给当前操作的breadcrumbList

    //Browser back/forward/history.
    if (pThis.navState.navigationTrigger == 'popstate' && 
              pThis.navState.restoredState != null) {
        for (let idx: number = pThis.breadcrumbHistoryList.length - 1; idx >= 0; idx--) {
            if (pThis.breadcrumbHistoryList[idx].id == 
                      pThis.navState.restoredState.navigationId && 
                pThis.breadcrumbHistoryList[idx].url == value.urlAfterRedirects) {
                pThis.breadcrumbList = pThis.breadcrumbHistoryList[idx].breadcrumbList;
                break;
            }
        }                
    }
  5. 以上所有代码可能还不够。如果屏幕刷新,breadcrumbHistoryList的值将丢失,而会话的导航历史记录仍然存在。sessionStorage对象也需要用于在刷新操作之前和期间分别缓存和恢复breadcrumbHistoryList

    breadcrumbHistoryList每次更新后都会保存到sessionStorage对象实例中

    //Save to sessionStorage for browser refresh.
    window.sessionStorage.setItem
        ('breadcrumbHistoryList', JSON.stringify(pThis.breadcrumbHistoryList));

    当屏幕刷新时,breadcrumbHistoryListsessionStorage对象实例中恢复

    //Restore history list after browser refresh.
    if (pThis.breadcrumbHistoryList.length < 1 && 
        window.sessionStorage.getItem('breadcrumbHistoryList') != null) {
        pThis.breadcrumbHistoryList = 
              JSON.parse(window.sessionStorage.getItem('breadcrumbHistoryList'));
    }

上述编码实现解决了任何浏览器固有导航中的面包屑问题。我们现在可以随时随地、从任何步骤向后或向前导航,选择历史列表中的任何项,或刷新屏幕。

动态更改面包屑标签

英雄个人资料页面的面包屑标签最初设置为“个人资料”。我们希望在数据可用后,将其动态更改为“英雄:<英雄姓名>”,如第一张截图所示。这需要消息服务,并且需要更新BreadcrumbComponent和加载页面的组件(例如,示例应用程序中的HeroProfileComponent)的代码。

  • BreadcrumbComponent中,订阅消息服务“bcLabelOverwrite”,从breadcrumbList数组中搜索传入的面包屑key和请求的labelName值,然后将现有的labelName值更改为请求的值。

    this.subscription_label = 
        this.messageService.subscribe('bcLabelOverwrite', (eventData) => {
        //Update breadcrumb label with data sent from message service.
        //eventData format: {key: 'string', labelName: string"}
        for (let idx: number = pThis.breadcrumbList.length - 1; idx >= 0; idx--) {                
            if (pThis.breadcrumbList[idx].key == eventData.key) {
                pThis.breadcrumbList[idx].labelName = eventData.labelName;
                break;
            }
        }            
    });
  • HeroProfileComponent中,在获取到hero.name数据后,通过消息服务“bcLabelOverwrite”向BreadcrumbComponent发送面包屑key和请求的labelName

    //Overwrite breadcrumb label with hero name.
    if (pThis.hero.name) {
        pThis.messageService.broadcast('bcLabelOverwrite', 
        { key: 'HeroProfile', labelName: 'Hero: ' + pThis.hero.name });
    }

不可执行的仅限终端的面包屑项

当打开管理页面时,由于路由守卫操作,会自动进行登录认证过程。然而,在任何管理页面上,将“登录”显示为面包屑列表中的可执行项是没有意义的,因为在建立认证会话后,登录请求不是强制性的。登录页面的面包屑可以作为只读项显示在终端位置。

登录后加载的Admin Dashboard页面在列表中不显示“Login”面包屑。

这些功能通过以下代码更改实现

  • 在路由配置中将terminalOnly属性添加到breadcrumbItem对象中

    const authRoutes: Routes = [
        {
    	    path: 'login',
    	    component: LoginComponent,
    	    data: { breadcrumbItem: 
            { key: 'Login', labelName: 'Login', terminalOnly: true, afterBaseOnly: true } },
        }
    ];
  • BreadcrumbComponent.refreshBreadcrumbs()方法中,在添加后续面包屑之前,检查并删除breadcrumbList中具有terminalOnly属性的最后一项

    if (this.breadcrumbList[this.breadcrumbList.length - 1].terminalOnly) {
        this.breadcrumbList.length = this.breadcrumbList.length - 1;
    }

另一个不可执行的仅限终端的面包屑项的例子是“未找到页面”页面。breadcrumbItem对象的terminalOnly属性需要在“未找到页面”路由的配置中指定。BreadcrumbComponent将自动将“未找到页面”视为终端面包屑项。

跨分支导航

跨分支导航可以通过遵循面包屑结构和规则部分列表#6中描述的规则来完成。为了禁用目标分支中的任何上游导航和可编辑操作,矩阵参数crossBranch,其值为"y",从原始分支传递到目标分支的页面及其所有下游页面。

下面描述的是从管理危机页面(Admin分支)重定向到危机详情页面(Crisis Center分支)的示例。

  • 当点击“管理危机”页面上的“查看危机详情”链接时,会调用带有URL矩阵参数的router.navigate()方法。

    this.router.navigate(['crisis-center', { crossBranch: 'y' }, 
                           id.toString(), { crossBranch: 'y', segmentParam: 'y' }]);
  • 我们只需要将“Crisis Detail”面包屑附加到当前的breadcrumbList中。然而,如果我们不编写额外的代码片段,父级“Crisis List”将自动添加到“Crisis Detail”项之前(参见之前的讨论)。因此,在BreadcrumbComponent.refreshBreadcrumbs()方法中添加了额外的代码行,以绕过带有URL片段参数的子路由的任何父路由。

    //Bypass adding breadcrumb for the current route if it contains 
    //a child with crossBranch 'y' and segmentParam 'y'.
    //Scenario: cross-branch navigation to a segment param route. 
    //In this case, the parent route should be excluded from the breadcrumb list.
    //Comment out below block to reproduce the issue.
    let cbpRoute: ActivatedRoute = this.findCrossBranchSegmentParamRoute(child);
    if (cbpRoute) {
        //Recursive call.
        this.refreshBreadcrumbs(child, pathParams);
        return;
    }
    
    findCrossBranchSegmentParamRoute(route: ActivatedRoute): ActivatedRoute {
        //Recursively find the child route with matrix params crossBranch and segmentParam.
        let child: ActivatedRoute;
        if (route.firstChild) {
            child = route.firstChild;
        }
        else {
           return null;
        }
    
        if (child.snapshot.params['crossBranch'] == 'y' && 
            child.snapshot.params['segmentParam'] == 'y') {
           return child;
        }
        else {
           this.findCrossBranchSegmentParamRoute(child);
        } 
    }
  • 目标CrisisDetailComponent接收crossBranch参数,并为crossBranch标志设置类级别变量。

    this.route.paramMap.pipe(switchMap((params: ParamMap) => {
        this.crossBranch = params.get('crossBranch');
        return new Observable();
    })).subscribe(item => {});
  • 模板crisis-detail.component.html禁用了editName字段,并且不渲染“保存”和“取消”按钮。因此,跨分支页面变为只读,并且不会从该页面继续进行上游操作。

    <div>
        <label>Name:&#160; </label>
        <input [disabled]="crossBranch == 'y'" [(ngModel)]="editName" placeholder="name" />
    </div>
    <!--Read-only if cross branch call-->
    <p *ngIf="crossBranch != 'y'" style="padding-top: 0.5rem;">
        <button (click)="save()">Save</button>&#160;&#160;
        <button (click)="cancel()">Cancel</button>
    </p>
  • 显示的面包屑显示了从管理分支页面到危机中心分支的危机详情页面的导航轨迹。

回归测试场景

对采用如此复杂导航方法的Web应用程序进行回归测试是非常必要的。此示例应用程序中的任何与导航相关的操作通常不会导致错误,因为存在“未找到页面”。任何测试运行都会产生三种结果之一

  1. 正常且正确的目标页面
  2. 不正确的目标页面
  3. 未找到页面”页面

导航树的测试运行可以是垂直的(在路由分支内)、水平的(在路由分支之间)或两者的混合。操作也可以随机或有意地选择任何特定页面上的可用导航选项。

下面列出了几个针对所有列出项的鼠标点击回归测试用例。您可以在每次操作后观察导航结果。

  • 主页 --(左侧菜单)英雄 --(英雄列表)英雄 --(英雄详情)危机历史 --(浏览器)刷新 --(面包屑)英雄 xxx... --(浏览器)后退 --(浏览器)前进 --(英雄详情)奖励 --(面包屑)英雄列表 --(英雄列表)前往助手 --(面包屑)英雄列表 --(左侧菜单)联系 --(英雄列表)英雄 --(面包屑)主页 --(左侧菜单)危机中心 --(联系)取消 -- 继续进行任何可用操作。

  • 主页 --(主页)点击此处打开任务板 --(任务板)打开危机中心 --(左侧菜单)英雄 --(浏览器)后退 --(危机列表)危机项 --(危机)点击此处寻求帮助 --(面包屑)危机列表 --(浏览器)后退 --(面包屑)危机详情 --(危机)编辑名称并保存 --(浏览器)后退 --(浏览器)刷新 --(面包屑)危机列表 --(浏览器)后退 --(危机详情)取消 --(左侧菜单)管理员 --(浏览器)右键点击后退并选择任何历史项 -- 继续进行任何可用操作。

  • 主页 -- (左侧菜单) 管理员 -- (登录) 登录 -- (左侧菜单) 主页 -- (浏览器) 后退 -- (管理员) 管理英雄 -- (管理员) 管理危机 -- (面包屑) 管理英雄 -- (浏览器) 后退 -- (浏览器) 刷新 -- (管理员) 登出 -- (弹窗) 取消 -- (管理员) 登出 -- (弹窗) 确定 -- (左侧菜单) 危机中心 -- (浏览器) 后退 -- (登录) 登录 -- (管理员) 管理危机 -- (管理危机) 查看危机详情 -- (危机详情) 点击此处寻求帮助 -- (面包屑) 管理仪表板 -- 继续进行任何可用操作。

读者可以根据页面上的任何可用选项,随心所欲地操作。欢迎报告您测试中发现的任何错误和不正确结果。

将面包屑组件移植到您自己的应用程序

breadcrumb.component.ts文件及其模板和CSS是一个独立的单元,即使是非Microsoft技术和Visual Studio的平台,也可以轻松地添加到您自己的项目中。除了Angular库及其依赖文件之外,您还需要一些组件的依赖文件。示例应用程序使用的bootstrap和英雄特定样式文件本身并非面包屑所需。

对于您自己的应用程序,导入组件和服务文件以构建类似于示例应用程序中所示的面包屑。以下所有文件都在.../ClientApp/app/文件夹中

  • breadcrumb/breadcrumb.component.ts
  • breadcrumb/breadcrumb.component.html
  • breadcrumb/breadcrumb.component.css
  • services/message-subject.service.ts
  • services/globals.ts

然后,您可以将带有属性和值的breadcrumbItem对象添加到路由配置定义中的数据对象中,用于您的任何面包屑。如果使用了某些功能,例如动态替换面包屑标签,您可能还需要更改其他页面中的代码。

历史

  • 3/6/2019
    • 初次发布
  • 3/14/2019
    • 增加了独立的仅客户端脚本源代码,特别是为使用非微软平台系统的开发者准备
    • 更新了“构建和运行示例应用程序”部分中的描述
  • 12/13/2019
    • 使用Angular 8 CLI和Bootstrap 4.3 CSS更新了示例应用程序
    • 添加了带有ASP.NET Core 3.0网站的源代码,用于Visual Studio 2019
    • 添加了关于使用ASP.NET Core 3.0设置示例应用程序的描述
    • 如果需要,您可以下载以前使用Angular 7 CLI和Bootstrap 3.3 CSS的源代码:Breadcrumb_Ng7_Cli_All.zip
  • 3/1/2020
    • 使用Angular 9 CLI更新了示例应用程序
    • 将ASP.NET Core项目类型更新为Visual Studio 2019的3.1版本网站
    • 编辑了示例应用程序设置说明。
    • 如果需要,您可以下载以前使用Angular 8 CLI的源代码:Breadcrumb_All_Ng8_Cli.zip
© . All rights reserved.