Retep

Web Components定制博客


写博客的时候,遇到了一些定制化需求。比如我想在博客顶部加上一个可交互的组件;比如我想给图片加上自定义的注释;比如我想让图片并排排版。这些使用 markdown 实现都比较困难。

前言:Markdown 静态网页生成

我的博客的方案是使用markdown格式输入内容,通过 SSG(Static Site Generation)生成html文件。具体来说,在lib/posts.js中使用 remarkmarkdown字符串转化成对应的html字符串,再通过dangerouslySetInnerHTML注入到博客中。rehype也有丰富的插件生态,可以满足绝大多数的需求(比如为图片加注释就可以通过这个实现)。但仍然有一些问题,比如

  • 找到插件后很少能称心如意,还是需要调整样式。
  • 对于比较定制化的需求(比如并排图片,或者图片的收起展开),找不到对应的插件
  • 更极端一点,插入一个只用一次的高度定制化的组件,比如WASM101中顶部的可交互组件,要如何最无痛地实现。

没错,这篇博客重点就是灵活且无痛。不用找插件,不用学习 markdown parser 的逻辑,不用试图在字符串中绞尽脑汁地匹配然后注入,实现可以复用与组合,高度定制,随意插入的博文组件和排版

可能的解决方案

简单但受限的 NextJS 原生方案

虽然是 Markdown 写作转换成 html,但最终每一个页面在 Nextjs 中也是作为一个Page组件来实现的(pages/posts/[id].js)。所以一个简单的方案就是在这个组件内部加上一个Custom组件做对应的路由,匹配博文的标题然后渲染对应的组件。一开始 WASM101 就是这样实现的。

// components/Custom/index.js
import WasmViz from "./wasmViz";

export default function Custom(props) {
  switch (props.id) {
    case "2023-09-03-WASM-parser": {
      return <WasmViz />;
    }
    default:
      return null;
  }
}
// pages/posts/[id].js
export default function Post({ postData }) {
  //...
  const content = (
    <Layout>
      <Head>
        <title>{postData.title}</title>
        {scripts}
      </Head>
      <article>
        <h1 className={utilStyles.headingXl}>{postData.title}</h1>
        <div className={utilStyles.lightText}>
          <Date dateString={postData.pubDate} />
        </div>
        // insert custom component between the article title and body
        <Custom id={postData.id} />
        <div dangerouslySetInnerHTML={{ __html: postData.contentHtml }} />
      </article>
    </Layout>
  );
  //...
}

这样好处是开发体验和实现一个正常的 React Component 完全相同,但致命缺陷是只能在文章的开头或者结尾插入。

一次性组件:DOM 节点挂载 React

第二个方案是在 markdown 中插入 dom 节点挂载 React。rehype-raw可以保留 markdown 中的 html 元素。比如正常的加粗使用的是单星号*Bold*,rehype 可以编译为<em>Bold</em>。如果使用了rehype-raw,我们可以直接在 markdown 中写<em>Bold</em>,效果和使用单星号一致。利用这个特性我们可以在 markdown 中插入 dom 节点作为 React 的挂载点来挂载一次性组件。

---
title: "WASM 101 | 二进制格式"
pubDate: "2023-11-02"
author: "Retep"
tag: "Tech"
language: "CN"
noSSR: 0
---

<div id="wasm-viz"></div> %% 挂载React %%

## Meta-Intro

WASM(WebAssembly)是一个虚拟指令集,具备了跨平台可移植性、简单性、出色的性能和安全性,在高性能/分布式计算/嵌入式中都有很大的前景(会取代 Docker 吗?)。

这样我们可以写一个自定义组件,然后选择 dom 节点挂载。

// components/Custom/wasmViz.js

function WasmViz() {
  // my react component
}

import { render } from "react-dom";
export function load() {
  const domNode = document.getElementById("wasm-viz");
  if (domNode) {
    render(<WasmViz />, domNode); // 没错,哥们用的还是React17🤡
  }
}

由于博文在 Nextjs 中都是 SSG 的,服务端处理不了document.getElementById,所以我们把它包裹在一个load函数中,在客户端异步渲染。

// pages/posts/[id].js
export default function Post({ postData }) {
  useEffect(() => {
    import("../../components/Custom/wasmViz").then(({ load }) => {
      load();
      // console.log(ret);
    });
  }, []);
  //...
}

可复用组件:Web Components

DOM 节点挂载 React 简单,开发体验也不错,可以实现大部分的需求。但问题是多处挂载 React,性能较差。相比高度定制化的一次性组件,对于可复用组件通常我们并不需要用到 React 的状态管理,只需要像 remark 一样生成一个局部的样式和 dom 子树就够了。这个时候主角Web Components出场 🎸。Web Components 可以允许我们定义自己的html tag,配合上rehype-raw,就实现了利用浏览器(而不是 React)解释自定义组件的效果。

比如我们想实现一个图片加注释的自定义组件。预期的效果是在 markdown 文件中加入这个标签:

<image-with-caption src="/path/to/image.jpg" caption="New York Too Cold">
</image-with-caption>

就可以插入一个这样的图片:

首先我们需要注册image-with-caption这个 tag。 步骤是:

  • 声明一个ImageWithCaption的类来实现HTMLElement
  • 构造函数中初始化
  • 读取srccaption
  • 通过innerHTML来构造对应的html模版,注入对应属性
  • 注册image-with-caption
export function load() {
  class ImageWithCaption extends HTMLElement {
    constructor() {
      super();
      this.shadow = this.attachShadow({ mode: "open" });
      const src = this.getAttribute("src");
      const caption = this.getAttribute("caption");

      const template = document.createElement("template");
      template.innerHTML = `
      <style>
        .container {
          display: flex;
          flex-direction: column; 
          justify-content: center;
          align-items: center;      
        }
        img {
          width:15rem; 
          margin-left: auto;
          margin-right: auto; 
        }
        .caption {
          font-size: 0.8rem;
          color: #aaa;
        }
      </style>
      <div class="container">
        <img src=${src}></img>
        <div class="caption">${caption}</div>
      </div>
    `;
      this.shadow.append(template.content.cloneNode(true));
    }
  }
  customElements.define("image-with-caption", ImageWithCaption);
}

当然,我们也需要在客户端异步加载这个load函数,方式与DOM 节点挂载 React一致。Web Components 的好处有很多

  • 首先是性能上由于使用了浏览器的原生能力,比 React 快很多。
  • 其次使用了shadow dom以后可以将样式局部化,心智负担相比安装 plugin 再魔改更少。
  • 最后,由于 Web Components 的slot特性,我们可以做多层嵌套,实现自定义组件的自由组合。比如多列排版和图片注释的组件组合起来也非常方便。
<inline-wrapper>
  <image-with-caption src="/path/to/image1" caption="1"></image-with-caption>
  <image-with-caption src="/path/to/image2" caption="2"> </image-with-caption>
  <image-with-caption src="/path/to/image3" caption="3"> </image-with-caption>
</inline-wrapper>

结语

可以探索的 topic 还有很多,比如可组合性,用了 shadow dom 后样式的继承问题,用 Web Components 来写可交互组件,博客的玩出花 😈