让我们实现一个像 Angular Material Site 一样的主题切换🎨欢迎来到 Dev.to nanba!

2025-06-04

让我们实现一个像 Angular Material Site 一样的主题切换🎨

欢迎来到Dev.to难波!

免责声明:撰写本文时,我会尽量详细地阐述我的实现方法。所以,如果您是 Angular 新手或刚刚开始学习,希望您能够顺利阅读。如果您对本文仍有任何理解上的问题,请随时在文章下方留言。我非常乐意为您更详细地讲解。🙂

话虽如此,本文主要面向初学者。为了以防万一您是更高级的 Angular 开发者,我在下面添加了一个TL;DR; 👇🏻。您可以随意浏览您可能更感兴趣的标题。

TL;DR;

为什么要切换主题?🤷🏻‍♂️

好吧!黑暗模式其实不算什么新鲜炫酷的功能。大多数网站都把它作为自定义主题添加到自己的应用中。

他们为什么不这么做呢?黑暗模式太棒了!它看起来更舒服,更省电,还能提供极佳的用户体验(尤其是在低光照条件下)。

是的!你没听错。昏暗的氛围最适合使用黑暗模式。我的意思是,即使在光线充足的环境中,也没人会阻止你切换到黑暗模式。不过,黑暗模式在黑暗中看起来更舒服,也更直观。(明白我的意思了吗?😉)

另外,我之前提到过黑暗模式可以增强用户体验,对吧?其实,它有很多方面。在不影响设计语言的前提下,为用户提供一种自定义应用外观和体验的方式总是更好的。大多数 Web 应用(或者说,一般的应用)都是通过主题来实现这一点的。

另一个方面对某些人来说可能或多或少看起来像个噱头。但你可以通过根据用户所处的环境光照条件智能切换主题,将 Web 应用的用户体验提升到一个新的水平。我稍后会再谈到这一点

我最喜欢的实现主题的网站之一是Angular Material Site。你可能已经看到过这个开关,它可以让你在网站上更改主题。

在material.angular.io上切换主题

我们将在 Angular 应用中复制几乎相同的效果。事不宜迟,让我们开始吧。

设置

我已经在 StackBlitz 上设置了 Angular Material,您可以将其用作入门模板:

从现在开始,让我们添加一些 Angular Material 组件,用于在 UI 上显示一些内容。我将添加一个工具栏、一个图标、一个主题选项菜单和一个按钮。

由于所有这些 Angular Material 组件都将在我的中使用AppModule,因此创建一个单独的AppMaterialModule组件并从中重新导出所有与 Material 相关的模块是有意义的。

app-material.module.ts

...
import { MatButtonModule } from "@angular/material/button";
import { MatIconModule } from "@angular/material/icon";
import { MatMenuModule } from "@angular/material/menu";
import { MatToolbarModule } from "@angular/material/toolbar";
...

@NgModule({
  exports: [
    MatButtonModule,
    MatIconModule,
    MatMenuModule,
    MatToolbarModule,
  ]
})
export class AppMaterialModule {}
Enter fullscreen mode Exit fullscreen mode

现在我可以将添加AppMaterialModuleimports我的数组中AppModule

app.module.ts

...
import { AppMaterialModule } from "./app-material.module";
...

@NgModule({
  imports: [
    ...
    AppMaterialModule,
    ...
  ],
  ...
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

注意:我在这里这样做是因为我将在我的项目中使用这些 Angular Material 模块暴露的所有 Angular Material 组件AppModule。这在实际应用中没有多大意义,因为我们通常不会在所有模块中使用所有 Angular Material 组件。因此,创建一个单独的组件AppMaterialModule,然后在每个 Angular 模块中导入它可能会导致性能下降。所以在这种情况下,你可能需要避免这样做。

接下来,我现在应该能够在我的应用中使用这些 Angular Material 组件了。我想要的效果非常简单。就是这样👇🏻

示例应用程序 UI

从上图来看,我们需要一个HeaderComponent,一个MenuComponent在点击🎨图标时打开的,其余部分已经由我们的示例 StackBlitz 容纳。

实施HeaderComponent

我计划将其做成一个智能组件

附言:你可以从Stephen Fluin这个视频📺中了解有关智能和愚蠢组件模式的更多信息

好的,现在继续我们的HeaderComponent,它需要将菜单的一些选项传递给MenuComponent。每个选项都会包含 、 、 和 等内容,backgroundColor用于buttonColor显示headingColor每个菜单项上的图标;以及label、 和 ,分别value对应于每个标签。

现在我们知道Angular Material 有 4 个预建主题,分别是:

  • deeppurple-amber.css
  • indigo-pink.css
  • pink-bluegrey.css
  • purple-green.css

因此,我们需要 4 个选项。为了避免在组件本身中对这些选项进行硬编码,我只需将这些数据公开为 JSON 文件,并将其存储在assets名为 的文件夹中options.json。这样做可以让我通过路径获取它。/assets/options.json

该文件看起来是这样的:

options.json

[
  {
    "backgroundColor": "#fff",
    "buttonColor": "#ffc107",
    "headingColor": "#673ab7",
    "label": "Deep Purple & Amber",
    "value": "deeppurple-amber"
  },
  {
    "backgroundColor": "#fff",
    "buttonColor": "#ff4081",
    "headingColor": "#3f51b5",
    "label": "Indigo & Pink",
    "value": "indigo-pink"
  },
  {
    "backgroundColor": "#303030",
    "buttonColor": "#607d8b",
    "headingColor": "#e91e63",
    "label": "Pink & Blue Grey",
    "value": "pink-bluegrey"
  },
  {
    "backgroundColor": "#303030",
    "buttonColor": "#4caf50",
    "headingColor": "#9c27b0",
    "label": "Purple & Green",
    "value": "purple-green"
  }
]
Enter fullscreen mode Exit fullscreen mode

注意:考虑到这些数据是以 REST API 的形式公开的,我可以在自己的应用中获取它HttpClient。这就是我在本文中要做的事情。

或者,您也可以将其作为静态资产使用,而不是通过 REST API 公开此数据。如果这样做,您可以直接在HeaderComponentusing中导入它import options from 'path-to-options.json'

你只需要/中的中设置resolveJsonModuleesModuleInterop即可。如果你感兴趣的话,我在这个示例 StackBlitz中有一个这个版本的实现。🙂truecompilerOptionstsconfig.app.jsontsconfig.json

好的。我们继续。现在,既然我已经有了option对象的结构,我可以创建一个interface用于静态类型的代码。我们把它存储在一个名为的文件中option.model.ts

option.model.ts

export interface Option {
  backgroundColor: string;
  buttonColor: string;
  headingColor: string;
  label: string;
  value: string;
}
Enter fullscreen mode Exit fullscreen mode

完美!现在的职责HeaderComponent是:

  • 渲染标题(显然!🤷🏻‍♂️)
  • 获取选项并将其提供给MenuComponent

但我们确实需要在某个时候更改主题。所以最好将与主题相关的整个业务逻辑抽象到一个服务中,我称之为ThemeService。那么,我们先来实现它:

theme.service.ts

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs/Observable";

import { Option } from "./option.model";

@Injectable()
export class ThemeService {
  constructor(
    private http: HttpClient,
  ) {}

  getThemeOptions(): Observable<Array<Option>> {
    return this.http.get<Array<Option>>("assets/options.json");
  }

  setTheme(themeToSet) {
    // TODO(@SiddAjmera): Implement this later
  }
}
Enter fullscreen mode Exit fullscreen mode

太棒了!我们现在可以将此服务作为依赖项注入,HeaderComponent如下所示:

header.component.ts

import { Component, OnInit } from "@angular/core";
import { Observable } from "rxjs/Observable";

import { Option } from "../option.model";
import { ThemeService } from "../theme.service";

@Component({
  selector: "app-header",
  templateUrl: "./header.component.html",
  styleUrls: ["./header.component.css"]
})
export class HeaderComponent implements OnInit  {
  options$: Observable<Array<Option>> = this.themeService.getThemeOptions();

  constructor(private readonly themeService: ThemeService) {}

  ngOnInit() {
    this.themeService.setTheme("deeppurple-amber");
  }

  themeChangeHandler(themeToSet) {
    this.themeService.setTheme(themeToSet);
  }
}
Enter fullscreen mode Exit fullscreen mode

如您所见,HeaderComponent现在还负责更改主题。

模板看起来是这样的:

header.component.html

<mat-toolbar color="primary">
  <mat-toolbar-row>
    <span>Dora</span>
    <span class="spacer"></span>
    <app-menu 
      [options]="options$ | async"
      (themeChange)="themeChangeHandler($event)">
    </app-menu>
  </mat-toolbar-row>
</mat-toolbar>
Enter fullscreen mode Exit fullscreen mode

注意,我们没有直接在组件类中subscribe传入,而是使用了管道来解包。这是一种让 Angular 具有响应式特性的模式,你应该尽可能地遵循这种模式。获取选项后,我们就可以将其作为输入传递给 的属性options$ ObservableasyncMenuComponentoptions @Input

另外,由于 也承担了更改主题的责任HeaderComponent,我们可以将 实现MenuComponent为一个哑/展示组件。现在就开始吧。

实施MenuComponent

现在我们可以知道它MenuComponent会接受options一个@Input,然后遍历它们来渲染这些选项。我们还可以清楚地看到它有一个themeChange @Output属性,它会使用新选择的主题来调用处理程序。因此,我们可以MenuComponent像这样实现这个类:

menu.component.ts

import { Component, EventEmitter, Input, Output } from "@angular/core";

import { Option } from "../option.model";
import { ThemeService } from "../theme.service";

@Component({
  selector: "app-menu",
  templateUrl: "./menu.component.html",
  styleUrls: ["./menu.component.css"]
})
export class MenuComponent {
  @Input() options: Array<Option>;
  @Output() themeChange: EventEmitter<string> = new EventEmitter<string>();

  constructor(private themeService: ThemeService) {}

  changeTheme(themeToSet) {
    this.themeChange.emit(themeToSet);
  }
}
Enter fullscreen mode Exit fullscreen mode

模板看起来是这样的:

menu.component.html

<mat-icon
  class="icon" 
  [matMenuTriggerFor]="menu">
  palette
</mat-icon>
<mat-menu #menu="matMenu">
  <button
    *ngFor="let option of options"
    mat-menu-item
    (click)="changeTheme(option.value)">
    <mat-icon
      role="img" 
      svgicon="theme-example"
      aria-hidden="true">
      <svg
        xmlns="http://www.w3.org/2000/svg" 
        xmlns:xlink="http://www.w3.org/1999/xlink" 
        width="100%" 
        height="100%"
        viewBox="0 0 80 80" 
        fit="" 
        preserveAspectRatio="xMidYMid meet" 
        focusable="false">
        <defs>
          <path
            d="M77.87 0C79.05 0 80 .95 80 2.13v75.74c0 1.17-.95 2.13-2.13 2.13H2.13C.96 80 0 79.04 0 77.87V2.13C0 .95.96 0 2.13 0h75.74z"
            id="a">
          </path>
          <path
            d="M54 40c3.32 0 6 2.69 6 6 0 1.2 0-1.2 0 0 0 3.31-2.68 6-6 6H26c-3.31 0-6-2.69-6-6 0-1.2 0 1.2 0 0 0-3.31 2.69-6 6-6h28z"
            id="b">
          </path>
          <path d="M0 0h80v17.24H0V0z" id="c"></path>
        </defs>
        <use xlink:href="#a" [attr.fill]="option.backgroundColor"></use>
        <use xlink:href="#b" [attr.fill]="option.buttonColor"></use>
        <use xlink:href="#c" [attr.fill]="option.headingColor"></use>
      </svg>
    </mat-icon>
    <span>{{ option.label }}</span>
  </button>
</mat-menu>
Enter fullscreen mode Exit fullscreen mode

好了!现在一切都准备就绪。我们只需要一个切换主题的方法。该怎么做呢?

实现主题切换🎨

这是拼图的最后一块。我们可以通过几种不同的方式来实现。Angular Material 官网已经实现了这个功能,对吧?而且好消息是,它是开源的。所以我们可以访问源代码。

因此,我不会尝试重新发明轮子,而是稍微作弊一下,看看 Angular Material Docs App 是如何做到的。

Angular Material 网站是如何做到的?🤔

如果你检查实际的实现,你会发现他们实现了一种叫做 的东西ThemePicker。这就是我们在右上角的标题中看到的内容。

material.angular.io 上的主题选择器

顾名思义,该组件负责切换网站的主题。该组件调用一个名为 的服务StyleManager

你可能会问,这项服务是做什么的?好吧,当你从它更改主题时ThemePicker

  • 检查 HTML 文档中是否存在带有class属性的链接标签,其值为style-manager-theme::
    • 如果不存在这样的link标签,它会将此link标签添加到文档的头部,然后href在其上设置所选主题路径的属性。
    • 如果存在这样的link标签,那么它只需将此标签href上的属性设置link为所选的主题路径。

太好了,现在我们明白了它的作用StyleManager,我可以直接把这个StyleManager服务复制到我的项目中。复制完成后,我就可以把它注入到我的项目中ThemeService,然后用相应的值调用setStyle它的方法,理想情况下它应该可以正常工作。

那么让我们尝试一下吧。

我们的实施

我将首先把 style-manager.ts 复制到一个名为 style-manager.service.ts 的文件中:

style-manager.service.ts

/**
 * Copied from https://github.com/angular/material.angular.io/blob/master/src/app/shared/style-manager/style-manager.ts
 * TODO(@SiddAjmera): Give proper attribution here
 */

import { Injectable } from "@angular/core";

@Injectable()
export class StyleManagerService {
  constructor() {}

  /**
   * Set the stylesheet with the specified key.
   */
  setStyle(key: string, href: string) {
    getLinkElementForKey(key).setAttribute("href", href);
  }

  /**
   * Remove the stylesheet with the specified key.
   */
  removeStyle(key: string) {
    const existingLinkElement = getExistingLinkElementByKey(key);
    if (existingLinkElement) {
      document.head.removeChild(existingLinkElement);
    }
  }
}

function getLinkElementForKey(key: string) {
  return getExistingLinkElementByKey(key) || createLinkElementWithKey(key);
}

function getExistingLinkElementByKey(key: string) {
  return document.head.querySelector(
    `link[rel="stylesheet"].${getClassNameForKey(key)}`
  );
}

function createLinkElementWithKey(key: string) {
  const linkEl = document.createElement("link");
  linkEl.setAttribute("rel", "stylesheet");
  linkEl.classList.add(getClassNameForKey(key));
  document.head.appendChild(linkEl);
  return linkEl;
}

function getClassNameForKey(key: string) {
  return `app-${key}`;
}
Enter fullscreen mode Exit fullscreen mode

太好了。既然我已经按照计划准备好了这项服务,我会将它作为依赖项注入到我的项目中ThemeService,并实现以下setTheme方法:

主题.服务.ts

...
import { StyleManagerService } from "./style-manager.service";

@Injectable()
export class ThemeService {
  constructor(
    ...
    private styleManager: StyleManagerService
  ) {}

  ...

  setTheme(themeToSet) {
    this.styleManager.setStyle(
      "theme",
      `node_modules/@angular/material/prebuilt-themes/${themeToSet}.css`
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

我在这里所做的就是使用样式键的名称(在本例中为主题)以及它必须设置的属性的值来调用该setStyle方法。StyleManagerServicehref

setStyle方法再次创建一个新link标签,然后href在其上设置属性;或者更新href预先存在的link标签上的属性。

差不多就是这样了。这就是我们最终的代码。

太棒了!我们现在有了主题切换功能,就像 Angular Material 网站上的一样。而且它运行正常 😍

下一步👣

这一切都很棒。但如果我们的应用能够根据环境光自动切换主题,那岂不是很棒?🤔 嗯,这正是我们下一篇文章要做的事情。

等不及了?现在就可以读。链接如下:

结束语🎉

哇!你还在吗?谢谢你的关注。希望你喜欢。

我非常感谢Martina KrausRajat Badjatya花时间校对本文并提供所有建设性反馈,使本文更加完善。

希望本文能让你学到一些与 Angular 相关的新知识。如果觉得有用,请点击🧡/🦄图标,并将其添加到你的阅读列表(🔖)。也欢迎分享这篇文章给你刚接触 Angular 并想实现类似目标的朋友。

我正在上传本文的视频版本,请继续关注。📺

图标来源:AngularIO Press Kit | CSS by Monkik,来自 Noun Project

那么下次再见。👋🏻

文章来源:https://dev.to/angular/angular-apps-with-a-theme-switch-like-the-ngular-material-site-darkmode-3mno
PREV
Angular 文件下载进度
NEXT
针对 Angular 的自以为是的编码风格指南,使用正确的工具补充正确的风格指南。