使用 Blazor 设置 html 元标记

Setting html meta tags with Blazor

这是参考 #10450

目标: 使用来自页面本身的数据为 SEO 和 Open Graph 目的设置元标记(标题、描述等)。使用 Javascript 互操作将无济于事,因为无法抓取页面。

我使用了@honkmother 的 suggestion 并将基本组件进一步向上移动以封装 <html> 标记,但由于某种原因,这影响了路由。所有链接都以 ~/ 开头,我似乎不明白为什么。

我已经创建了一个示例 repo here 如果有人有兴趣看一看。

_Hosts.cshtml

@page "/"
@namespace BlazorMetaTags.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}

<!DOCTYPE html>
<component type="typeof(AppBase)" render-mode="ServerPrerendered" />

AppBase.cs

using BlazorMetaTags.Shared;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace BlazorMetaTags
{
    public class AppBase : ComponentBase
    {
        protected override void BuildRenderTree(RenderTreeBuilder builder)
        {
            builder.OpenElement(0, "html");
            builder.AddAttribute(1, "lang", "en");

            builder.OpenElement(2, "head");
            builder.OpenComponent<Head>(3);
            builder.CloseComponent();
            builder.CloseElement();

            builder.OpenElement(3, "body");

            builder.OpenElement(4, "app");
            builder.OpenComponent<App>(5);
            builder.CloseComponent();
            builder.CloseElement();

            builder.OpenComponent<Body>(6);
            builder.CloseComponent();

            builder.AddMarkupContent(7, " <script src='_framework/blazor.server.js'></script>");
            builder.CloseElement();
            builder.CloseElement();

        }

    }

    public class MetaTags
    {
        public string Title { get; set; } = "";

        public string Description { get; set; } = "";
    }
}

Head.razor 组件设置元标签

@inject AppState _appState

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <title>@_appState.MetaTags.Title</title>
    <meta name="description" content="@_appState.MetaTags.Description">

    <base href="~/" />
    <link rel="stylesheet" href="/css/bootstrap/bootstrap.min.css" />
    <link href="/css/site.css" rel="stylesheet" />
</head>

@code {

    protected override async Task OnInitializedAsync()
    {
        _appState.OnChange += StateHasChanged;
    }

}

Body.razor

<div id="blazor-error-ui">
    <environment include="Staging,Production">
        An error has occurred. This application may no longer respond until reloaded.
    </environment>
    <environment include="Development">
        An unhandled exception has occurred. See browser dev tools for details.
    </environment>
    <a href="" class="reload">Reload</a>
    <a class="dismiss"></a>
</div>

AppState.cs

using System;

namespace BlazorMetaTags
{
    public class AppState
    {
        public MetaTags MetaTags { get; private set; } = new MetaTags();

        public event Action OnChange;

        public void SetMetaTags(MetaTags metatags)
        {
            MetaTags = metatags;
            NotifyStateChanged();
        }

        private void NotifyStateChanged() => OnChange?.Invoke();
    }
}

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();
    services.AddServerSideBlazor();
    services.AddScoped<AppState>();
    services.AddSingleton<WeatherForecastService>();
}

根据的建议,正确的解决方案是更改

<base href="~/" />

<base href="/" />

这对我有用!

我提出我对这个问题的决定。这个解决方案有几个优点:

  1. SEO 数据将始终是最新的,无需使用 Blazor Server 的“_framework/blazor.server.js”,这将使您能够获取各种机器人程序(包括 Postman)的最新 SEO 数据、curl 和其他非浏览器程序。
  2. 无需使用 DevExpress Free Blazor Utilities 和 Dev Tools 或其他类似的外部库。
  3. 不需要重载整个 head 标签。因为当你重载整个 head 标签并使用外部 CSS 样式时,会发生以下情况:在第一次渲染期间,CSS 样式不起作用,但开始在第二次渲染后工作,导致页面闪烁,因为 CSS 样式在第二次渲染后开始工作。

这是我经过测试的有效解决方案:

  1. 文件“_Host.cshtml”:
@page "/"
@namespace App.Pages
@using App.Components
@using App.Helpers
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

@{
  Layout = null;
  string path = UrlHelper.GetLastPath(this.HttpContext.Request.Path);
  var (title, keywords, description, canonical) = UrlHelper.GetSeoData(path);
}

<!DOCTYPE html>
<html lang="en">
<head>
  <base href="/" />
  @*SEO*@
  <component type="typeof(TitleTagComponent)" render-mode="Static" param-Content=@title />
  <component type="typeof(KeywordsMetaTagComponent)" render-mode="Static" param-Content=@keywords />
  <component type="typeof(DescriptionMetaTagComponent)" render-mode="Static" param-Content=@description />
  <component type="typeof(CanonicalMetaTagComponent)" render-mode="Static" param-Content=@canonical />
  @*Extra head tag info*@
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="robots" content="index, follow">
  <meta name="author" content="App.com">
  <meta name="copyright" lang="en" content="App.com">
  @*Site icons*@
  <link rel="icon" href="favicon.ico" type="image/x-icon">
  @*External CSS*@
  <link rel="stylesheet" href="css/bootstrap.min.css" />
</head>

<body>
  <component type="typeof(App)" render-mode="ServerPrerendered" />

  <div id="blazor-error-ui">
    <environment include="Staging,Production">
      Server connection error. Refresh the page.
    </environment>
    <environment include="Development">
      An unhandled exception has occurred. See browser dev tools for details.
    </environment>
    <a href="" class="reload">Reload</a>
    <a class="dismiss"></a>
  </div>

  <script src="_framework/blazor.server.js"></script>

</body>
</html>
  1. 文件“TitleTagComponent.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace App.Components {
  public class TitleTagComponent : ComponentBase {
    [Parameter]
    public string Content { get; set; }

    protected override void BuildRenderTree(RenderTreeBuilder builder) {
      base.BuildRenderTree(builder);
      builder.OpenElement(0, "title");
      builder.AddContent(1, Content ?? string.Empty);
      builder.CloseElement();
    }

  }
}
  1. 文件“KeywordsMetaTagComponent.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace App.Components {
  public class KeywordsMetaTagComponent : ComponentBase {
    [Parameter]
    public string Content { get; set; }

    protected override void BuildRenderTree(RenderTreeBuilder builder) {
      base.BuildRenderTree(builder);
      builder.OpenElement(0, "meta");
      builder.AddAttribute(1, "name", "keywords");
      builder.AddAttribute(2, "content", Content ?? string.Empty);
      builder.CloseElement();
    }

  }
}
  1. 文件“DescriptionMetaTagComponent.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace App.Components {
  public class DescriptionMetaTagComponent : ComponentBase {
    [Parameter]
    public string Content { get; set; }

    protected override void BuildRenderTree(RenderTreeBuilder builder) {
      base.BuildRenderTree(builder);
      builder.OpenElement(0, "meta");
      builder.AddAttribute(1, "name", "description");
      builder.AddAttribute(2, "content", Content ?? string.Empty);
      builder.CloseElement();
    }

  }
}
  1. 文件“CanonicalMetaTagComponent.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace App.Components {
  public class CanonicalMetaTagComponent : ComponentBase {
    [Parameter]
    public string Content { get; set; }

    protected override void BuildRenderTree(RenderTreeBuilder builder) {
      base.BuildRenderTree(builder);
      builder.OpenElement(0, "link");
      builder.AddAttribute(1, "rel", "canonical");
      builder.AddAttribute(2, "href", Content ?? string.Empty);
      builder.CloseElement();
    }

  }
}
  1. 文件“UrlHelper.cs
using System;
using System.Text.RegularExpressions;

namespace App.Helpers {
  public static class UrlHelper {
    /// <summary>
    /// Regular expression to get all paths from short URL path without "/". Moreover, the first and last "/" may or may not be present. 
    /// Example: from the string "path1/path2/path3" - path1, path2 and path3 will be selected
    /// </summary>
    private static Regex ShortUrlPathRegex = new(@"^((?:/?)([\w\s\.-]+)*)*/*", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant);

    /// <summary>
    /// Retrieving the last path from short URL path based on a regular expression.
    /// </summary>
    /// <param name="path">Short URL path</param>
    /// <returns>Last path from short URL path</returns>
    public static string GetLastPath(string path) {
      if (string.IsNullOrWhiteSpace(path))
        return string.Empty;
      try {
        var match = ShortUrlPathRegex.Match(path);
        if (!match.Success)
          return string.Empty;
        return match.Groups[2].Value;
      }
      catch (Exception) {
        return string.Empty;
      }
    }

    /// <summary>
    /// Get data for SEO (title, keywords, description, canonical) depending on the path URL
    /// </summary>
    /// <param name="path">URL path</param>
    /// <returns>
    /// Tuple: 
    /// item1 - title 
    /// item2 - keywords 
    /// item3 - description 
    /// item4 - canonical
    /// </returns>
    public static (string, string, string, string) GetSeoData(string path) {
      string title = "App";
      string keywords = "app1, app2, app3";
      string description = "Default App description.";
      string canonical = "https://app.com";

      if (string.IsNullOrWhiteSpace(path))
        return (title, keywords, description, canonical);

      switch (path.ToLower()) {
        case "page1":
          title = "page1 on App.com";
          keywords = "page1, page1, page1";
          description = "Description for page1";
          canonical = "https://app.com/page1";
          return (title, keywords, description, canonical);

        case "page2":
          title = "page2 on App.com";
          keywords = "page2, page2, page2";
          description = "Description for page2";
          canonical = "https://app.com/page2";
          return (title, keywords, description, canonical);

        case "page3":
          title = "page3 on App.com";
          keywords = "page3, page3, page3";
          description = "Description for page3";
          canonical = "https://app.com/page3";
          return (title, keywords, description, canonical);
      }

      return (title, keywords, description, canonical);
    }

  }

}

根据 Brad Bamford 的评论,我添加了一个 GetSeoDataDB 方法示例,其中我将 switch 替换为对数据库的查询,以便根据使用 Dapper 的页面从数据库中动态检索 SEO 数据:

    public static async Task<(string, string, string, string)> GetSeoDataDB(string path) {
      string title = "App";
      string keywords = "app1, app2, app3";
      string description = "Default App description.";
      string canonical = "https://app.com";

      if (string.IsNullOrWhiteSpace(path))
        return (title, keywords, description, canonical);

      var ConnectionString = "";
      var sql = $@"
SELECT *
FROM `SeoTable`
WHERE `Page` = '{path}'
;";
      using IDbConnection db = new MySqlConnection(ConnectionString);
      try {
        var result = await db.QueryFirstOrDefaultAsync<SeoModel>(sql);
        title = string.Join(", ", result.Title);
        keywords = string.Join(", ", result.Keywords);
        description = string.Join(", ", result.Description);
        canonical = string.Join(", ", result.Canonical);
      }
      catch (Exception ex) {
        return (title, keywords, description, canonical);
      }

      return (title, keywords, description, canonical);
    }