Eisen's Blog

© 2024. All rights reserved.

gatsby 一些插件的运用

2021 August-14

上一篇文章 介绍了 gatsby 迁移 blog 的主要内容,针对 markdown 渲染、路由生成 gatsby 都做了足够多的工作可以快速构建这么一个博客系统。这一部分介绍通过额外的插件提升 blog 体验。

目录 & 标题锚点

通过 remark 获取的 markdown 内容本身一个名为 tableOfContents 的部分,它已经从 markdown 中提取了标题并生成了目录。额外的工作就只剩下对目录的样式做一些修改,并且添加锚点链接。

2021 08 14 15 17 25

锚点部分同样有另外一个插件 gatsby-remark-autolink-headers 完成了相应的工作。它本身就是 gatsby-transformer-remark 的一个插件。通过做如下配置即可:

{ 
    resolve: `gatsby-remark-autolink-headers`,
    options: {
        offsetY: `100`,
        icon: `<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>`,
        className: `custom-class`,
        maintainCase: false,
        removeAccents: true,
        isIconAfterHeader: true,
    }
},

注意要把 maintainCase 设置为 false 否则会因为锚点的大小写问题导致链接失效,完整的配置在 gatsby-config.js 可以看到。

插件生效后目录以及 markdown 的标题(# 开始的内容)就会增加链接并支持从目录跳转了。

2021 08 14 15 25 00

SEO 优化

这部分在国内似乎略微鸡肋,毕竟整个体系是 Google 提供的,不过我相信这部分的工作还是有必要的,因为众所周知,靠谱的程序员还是会倾向于使用英语环境并使用 Google 搜索资料。还有一个让我很震惊的地方,就是 Google Analytics 其实并没有被墙,域名是可以访问的,只是查看的 dashboard 无法直接访问。

这部分基本就是照抄 Add SEO Component 了,用到的插件就是 gatsby-plugin-react-helmet。组件的代码就直接贴在这里了:

import React from "react";
import PropTypes from "prop-types";
import { Helmet } from "react-helmet";
import { useLocation } from "@reach/router";
import { useStaticQuery, graphql } from "gatsby";

const SEO = ({ title, description, image, article }) => {
  const { pathname } = useLocation();
  const { site } = useStaticQuery(query);
  const {
    defaultTitle,
    titleTemplate,
    defaultDescription,
    siteUrl,
    defaultImage,
    twitterUsername,
  } = site.siteMetadata;
  const seo = {
    title: title || defaultTitle,
    description: description || defaultDescription,
    image: `${siteUrl}${image || defaultImage}`,
    url: `${siteUrl}${pathname}`,
  };
  return (
    <Helmet title={seo.title} titleTemplate={titleTemplate}>
      <meta name="description" content={seo.description} />
      <meta name="image" content={seo.image} />
      {seo.url && <meta property="og:url" content={seo.url} />}
      {(article ? true : null) && <meta property="og:type" content="article" />}
      {seo.title && <meta property="og:title" content={seo.title} />}
      {seo.description && (
        <meta property="og:description" content={seo.description} />
      )}
      {seo.image && <meta property="og:image" content={seo.image} />}
      <meta name="twitter:card" content="summary_large_image" />
      {twitterUsername && (
        <meta name="twitter:creator" content={twitterUsername} />
      )}
      {seo.title && <meta name="twitter:title" content={seo.title} />}
      {seo.description && (
        <meta name="twitter:description" content={seo.description} />
      )}
      {seo.image && <meta name="twitter:image" content={seo.image} />}
    </Helmet>
  );
};

export default SEO;

SEO.propTypes = {
  title: PropTypes.string,
  description: PropTypes.string,
  image: PropTypes.string,
  article: PropTypes.bool,
};

SEO.defaultProps = {
  title: null,
  description: null,
  image: null,
  article: false,
};

const query = graphql`
  query SEO {
    site {
      siteMetadata {
        defaultTitle: title
        titleTemplate
        defaultDescription: description
        siteUrl: url
        defaultImage: image
        twitterUsername
      }
    }
  }
`

然后在 blog 的模板这里做一些修改:

import React from "react"
import { graphql } from "gatsby"
import Blog from "../components/Blog"
import Base from "../layouts/base"
import Seo from "../components/seo"
export default function BlogTemplate({ data }) {
  return (
    <Base>
      <Seo title={data.blog.frontmatter.title} article={true} description={data.blog.excerpt} />      <Blog data={data.blog}/>
    </Base>
  )
}

这里使用了 excerpt 字段,当然在 graphql 里面也要增加相应的内容:

export const pageQuery = graphql`
  query BlogPostQuery($id: String) {
    blog: markdownRemark(id: { eq: $id }) {
      id
      html
      tableOfContents
      frontmatter {
        title
        date(formatString: "MMMM DD, YYYY")
      }
      excerpt(format: PLAIN, truncate: true, pruneLength: 50)    }
  }
`

初识 tailwindcss

2021 July-27

上一篇文章 中提到了已经在自己的新博客中集成了 tailwindcss 。这篇对这个思路不太一样的 css 框架做一些介绍。

核心思想

用过 bootstrap 的人都晓得,bootstrap 提供了如下内容:

  1. 一套默认的样式,比如 h1 长什么样 button.btn 长什么样
  2. 一套布局系统,默认 12 列,实现各种宽度组合
  3. 一套通用组件库,比如面包屑,比如下拉菜单

和 bootstrap 体系几乎一样的东西还很多,比如当年雅虎的 purecss

相比于 bootstrap 这种被大家所广泛认知的 css 框架,tailwindcss 有如下差别:

  1. 没有默认样式,单有一套 reset 样式,将所有的东西设置为一样的大小,比如 h1 h2 p 的字号、颜色都一样

  2. 所有的 css 样式完全通过一系列工具类(utility classes)组合实现,比如一个 button 可以这么实现:

    <div class="rounded-md shadow">
      <a href="#" class="w-full flex items-center justify-center
                          px-8 py-3 border border-transparent
                          text-base font-medium rounded-md 
                          text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg
                          md:px-10">
        Get started
      </a>
    </div>

    结果如下:

    2021 07 27 20 05 13

  3. 默认没有提供通用组建库,不过这部分我认为更多还是一个商业上的考虑

其中第二条算是 tailwindcss 最大的特色了,虽然 bootstrap 也包含很多工具类,但 bootstrap 的工具类并不是可以解决所有问题的,仅仅是一个辅助措施。而在 tailwindcss, 工具类就是实现 html 完整样式的最小单元。

全部通过类组合类拼凑样式看起来有点丑陋,以及它违反了 html 语义化的原则。不过 tailwindcss 的作者撰写了 一篇文章 对这部分困惑做了很详尽的说明。我非常推荐直接阅读原文并阅读文中引用的另外一篇文章:About HTML semantics and front-end architecture。这里我把我理解的一些重要观点放在这里,这也是我认可 tailwindcss 并开始使用的原因。

  1. 语义实际上是让 css 依赖 html。在 html 结构需要扩展的时候,css 必须随之变化,而 html 确实经常变化,所以并没有省什么事

  2. 原本的语义化思路有点问题,一个名为 authorclass 真的有意义么?从实际角度出发, class 是给 css 用的而 css 关心的是样式,所以 class 改成 media-card 更合理,也就是说语义化面向的应该是 css 而不是 html 里的内容

  3. 语义化是让 css 依赖 html 那么可不可以反过来,让 html 依赖 css?从文章 About HTML semantics and front-end architecture 获得灵感,确定反过来反而更有效:

    When you choose to author HTML and CSS in a way that seeks to reduce the amount of time you spend writing and editing CSS, it involves accepting that you must instead spend more time changing HTML classes on elements if you want to change their styles. This turns out to be fairly practical, both for front-end and back-end developers – anyone can rearrange pre-built “lego blocks”; it turns out that no one can perform CSS-alchemy.

    这里简单翻译一下:

    如果你试图找到一种方法来减少花费在 css 上的时间,那么你就必然会花费更多时间在 html 上。不过这种结果是更易于实施的:不论是前端工程师或是后端工程师,都更易于修改这些预定义的 "乐高积木",但没人是 CSS 炼金术师(轻松驾驭编辑 CSS 代码)。

  4. 相对于 bootstrap 更细粒度的工具类给了用户更大的自由度,可以完全脱离手写 css 而构建复杂的样式,不必再为扩展基本样式而担忧了

  5. 之所以反对手写 css (inline style 或者类似于 styled component)是因为它会带来大量的碎片化样式,通过 tailwindcss 可以避免样式的泛滥(比如有太多种字号大小)

入门资料

tailwindcss 的 youtube 频道里视频教程做的太好了。请把 Tailwind CSS: From Zero to Production 这个系列一口气全部看完,你就可以掌握 tailwindcss 的基本用法了。

看了之后你大概可以收获这些内容:

  1. tailwindcss 类的命名规律,你就不会太害怕自己记不住了
  2. 配合使用的一些列工具是什么,比如 vscode 插件
  3. tailwindcss 怎么做响应式设计
  4. 怎么扩展 tailwindcss
  5. 怎么使用 purgecss 对 css 文件进行压缩

目前的博客是如何使用 tailwindcss 的

博客的情况其实反而有点不太适合 tailwindcss 的场景,因为博客里面的 markdown 是需要从外部注入样式的,而不是像 tailwindcss 推荐的那样去对每个元素添加工具类。官方也有一篇视频 Styling Markdown and CMS Content with Tailwind CSS 介绍如何处理,这里我使用了官方的方式并且按照 Use TailwindCSS Typography with Dark Mode Styles 增加了 dark mode 的支持。

然后就是在 global.css 多了些修修补补的支持:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
    body {
        @apply bg-white dark:bg-gray-700 transition duration-500;
    }
    .main .permalink {
        @apply fill-current text-gray-800 dark:text-gray-200;
    }
    .main {
        @apply text-gray-800 dark:text-gray-200;
    }
    .main h1 {
        @apply text-3xl md:text-4xl font-extrabold tracking-tight my-4;
    }
    .main h2 {
        @apply mb-4 text-gray-600 dark:text-gray-300;
    }
    .main {
        @apply break-words;
    }
}

可以看到,响应式的支持和 dark mode 的支持都在里面了。

小结

我认为它在两个方面的工作是非常有意义的:

  1. 将 html 和 css 的依赖反转是正确的。事实上由于业务的变化,展示信息的变化,html 的变化速度是很快的,每次让 css 被动变更确实会导致额外的工作量。中立的细粒度的 css 一定程度上会有所帮助
  2. 直接在 class 上增增减减就能实现排版从认知上确实让人觉得容易了,而且有了 STATE VARIANTS (就是 focus: hover: md: 这样的前缀工具类)确实让 css 的工作变得更集中更清楚了,比一个独立的文件以及松散的排布要好

tailwindcss 似乎在尝试将 css 乐高化以进一步降低前端工程师的门槛。但我觉得这个乐高依然是有门槛的,如果你甚至不晓得 flex display position float 这些基本的概念,那么你依然没办法去使用这些工具类。


把博客从 jekyll 迁移到 gatsby

2021 July-17

考虑到自己的前端技能有一阵子没有更新了,同时看到刘老师用 docusaurus 把 openbayes docs 切换之后访问速度和使用体验都提升了不少,于是也想自己尝试一下 gatsby(与 docusaurus 技术栈类似但适用范围又大大增加)。这里先拿自己的小博客开刀,把原来的 jekyll 技术栈切换过来。

本来打算一小步一小步迁移过来并做比较细致的记录,结果一不小心步子迈的有点大,甚至把 tailwindcss 的部分也一口气引入到了技术栈之中。不过考虑到篇幅,这里还是会分多篇文章做介绍。首先是介绍把 jekyll 迁移到 gatsby 的部分。

另,以下部分是相对比较容易通过搜索获取到的内容,对应的官方网站都有非常详尽的介绍,这里就不再赘述。

  1. gatsby 的基本安装
  2. graphql 的知识

Gatsby 的思路

虽然 gatsby 官方把 static 划掉了加上了 dynamic,但以我目前的了解,它依然是一个 static site generator,只是在生成的路由的方面有了 动态 的感觉。

2021 07 17 20 10 00

翻看 gatsby 官方的信息后,这里为 gatsby 总结出如下几个特点:

  1. gatsby 使用的模板引擎为 react(jekyll 用的就是 ruby 的 erb),相比于其他的后端模板引擎,它本身就是前端框架,使用 react 相当于具备了创建完整前端技术栈的能力。
  2. gatsby 也有自己的一套静态路由,以及最近也有了 动态 路由。这部分似乎没有特别多的新意,毕竟路由原本就是传统后端框架的必要组成部分。但是 gatsby 的动态路由是通过带参数形式的文件名实现的(当然还有个更灵活的方式: gatsby-node.js),这个思路感觉像是在原有的静态路由的延申,这似乎是一种把路由的使用门槛降低的好办法,还有另外一个框架 next.js 也使用了类似的方式。
  3. gatsby 的 graphql data layer 可以通过一系列插件将多种形式的数据源以 graphql 的形式提供给处于 develop mode 的 gatsby server ,并很方便的填充到模板中。同时,数据源还可以使用插件做数据的扩展(添加字段,修改字段),在后面有关插件的部分我也会做一些介绍。

graphql data layer
graphql data layer

快速开始

在开始操作之前我自然是 google 了一番,发现想要把 jekyll 切到 gatsby 的人不在少数。并且官方网站里也有适配 markdown 作为数据源的内容。这里先罗列下从 jekyll 到 gatsby 迁移的几个必须步骤,并在下文一一介绍。

  1. markdown 文件的整体迁移
  2. 生成路由
  3. 处理 blog 中的代码高亮
  4. 优化 blog 中的 image
  5. 部署

markdown 文件的整体迁移

jekyll 出现的那个时代没有想现在这样如此多的外部数据源可以选择,其静态页面生成的数据源一定是文件。但 2021 年情况发生了变化,从 gatsby 的官方数据源插件来看,其中大量的数据源是 headless cms(比如 contentful 比如 wordpress),不过对我来说从 jekyll 迁移,我关心的也就是文件数据源。

这里用到了两个插件:

  1. gatsby-source-filesystem 将特定目录作为数据源添加到 gatsby 的数据层
  2. gatsby-transformer-remark 使用 remarkmarkdown 文件解析为 html 并为 markdown 提供了很多有用的字段(比如 frontmatter,比如目录)

gatsby 所有的插件都需要单独安装并在 gatsby-config.js 做配置,这里展示下上述两个插件的基本配置:

module.exports = {
  siteMetadata: {
  },
  plugins: [
    {
      resolve: `gatsby-transformer-remark`,
      options: {
        // Plugins configs 这里后续做扩展,支持语法高亮、图片优化
        plugins: [
        ],
      },
    },
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        name: `blogs`,
        path: `${__dirname}/src/blogs/`,
      },
    },
  ],
};

gatsby-source-filesystem 的配置部分可以看到,markdown 文件被放到了 src/blogs 目录下。在执行命令 yarn run start 之后,通过 http://localhost:8000/__graphql 构建如下 graphql 语句就能够获取到文件的信息了:

{
  allFile(filter: {sourceInstanceName: {eq: "blogs"}}) {
    nodes {
      relativePath
      extension
    }
  }
}

可以看到对应的结果如下所示:

2021 07 18 14 20 38

不过直接使用这个似乎数据也做不了什么,还需要 gatsby-transformer-remark 将 markdown 做解析,对应的数据通过另外一个 graphql 的接口获取:

query {
  allMarkdownRemark {
    nodes {
      id
      html
      frontmatter {
        title
      }
    }
  }
}

2021 07 18 14 26 08

顺便说一下 gatsby 提供的 graphiql 增加了 graphql-explorer 可以通过点击的方式快速的拼接 graphql 语句。

生成路由

固定路由

有了数据源,下一步就是构建博客的基本的路由结构:

/ -- 首页,展示最新的 N 篇博客
/about -- 关于,一个独立的页面
/page/{page-number} -- 做分页,每页固定数量的博客,当然提供翻页功能
/{slug} -- 每篇博客的语义 url 用来展示每篇独立的博客
/archive -- 所有博客的总览页面,罗列了所有的博客标题

上文提了,gatsby 为了简化路由,为 /src/pages 目录下每个文件都提供了对应的 url 路径。比如 src/pages/about.js 的内容就对应了 /about 路由,再比如 src/pages/projects/main.js 对对应了 /projects/main 路由。也就是说对于固定路由来说,直接给个对应文件并且按 gatsby 的规约用 graphql 获取数据做渲染就好了。这里我以 /archive 的代码举个例子:

export default function Archive({ data }) {
  const groupByYearResult = groupByYear(data.allMarkdownRemark.nodes);

  return (
    <Base>
      <div>
        <h1 className="text-4xl font-extrabold tracking-tight my-4 text-gray-800">Archive</h1>
        {groupByYearResult.map(({ key, value }) => (
          <div key={key}>
            <h2 className="text-3xl font-bold tracking-tight my-4 text-gray-800">{key}</h2>
            <YearItems blogs={value} />
          </div>
        ))}
      </div>
    </Base>
  );
}

function YearItems({ blogs }) {
  return (
    <div>省略了...</div>
  );
}

export const query = graphql`
  query QueryBlogTitles {
    allMarkdownRemark(sort: { fields: frontmatter___date, order: DESC }) {
      nodes {
        id
        frontmatter {
          title
          date(formatString: "MMMM-DD")
          year: date(formatString: "YYYY")
        }
        fields {
          slug_without_date
        }
      }
    }
  }
`;

function groupByYear(blogs) {
  // 省略了...
}

文件可以分为两个部分,一部分是 react 的模板,一部分是 graphql 数据的获取,简单明了,这里就不再赘述了。

从文件生成 slug 并使用 react 模板生成 blog 页面

相对于固定路由,动态路由不是说像 /blog-migrate-from-jekyll-to-gatsby 这样的页面是在用户请求的时候用 server 端临时拼装页面,而是指在 gatsby 部署的时候动态的生成一系列的静态页面。上文提到了,既然我可以从 graphql 里面罗列一系列的 markdown 内容了,那我自然可以通过遍历的方式去生成一个个页面并提供各自的路由。

具体在 gatsby 做的时候需要这么做:

1. 准备单个 blog 的模板页面


export default function BlogTemplate({ data }) {
  return (
    <Base>
      <Blog data={data.blog}/>
    </Base>
  )
}

export const pageQuery = graphql`
  query BlogPostQuery($id: String) {
    blog: markdownRemark(id: { eq: $id }) {
      id
      html
      tableOfContents
      frontmatter {
        title
        date(formatString: "MMMM DD, YYYY")
      }
    }
  }
`

这是 src/templates/blog.js 文件的内容,可以看到,在 pageQuery 引入了参数 $id,这部分在下文会介绍怎么填充这个字段。

2. 在 gatsby-node.js 中创建页面

gatsby 在 build 的时候会执行 gatsby-node.js 文件,这个文件中可以调用 gatsby 的内部 api 以实现动态创建页面的目的。

const { createFilePath } = require("gatsby-source-filesystem")

// 1. 为 markdown 增加额外的字段 slug 和 slug_without_date
exports.onCreateNode = ({ node, actions, getNode }) => {
  const { createNodeField } = actions
  if (node.internal.type === `MarkdownRemark`) {
    const filename = createFilePath({ node, getNode })
    // get the date and title from the file name
    const [, date, title] = filename.match(
      /^\/([\d]{4}-[\d]{2}-[\d]{2})-{1}(.+)\/$/
    );

    // create a new slug concatenating everything
    createNodeField({ node, name: `slug`, value: `/${date.replace(/\-/g, "/")}/${title}/` })
    createNodeField({ node, name: `slug_without_date`, value: `/${title}` })
  }
}

// 2. 获取所有的 markdown 数据
const path = require("path");
exports.createPages = async ({ graphql, actions, reporter }) => {
  // Destructure the createPage function from the actions object
  const { createPage } = actions
  const result = await graphql(`
    {
      allMarkdownRemark {
        nodes {
          id
          fields {
            slug
            slug_without_date
          }
        }
        pageInfo {
          totalCount
        }
      }
    }
  `)
  if (result.errors) {
    reporter.panicOnBuild('🚨  ERROR: Loading "createPages" query');
  }
  
  const posts = result.data.allMarkdownRemark.nodes;
  // 3. 调用 api createPage 创建
  posts.forEach((node, index) => {
    createPage({
      path: node.fields.slug, // 这里是路由
      component: path.resolve(`./src/templates/blog.js`), // 这里是模板的位置
      context: { id: node.id }, // 这里是传递给模板的参数
    })

    createPage({
      path: node.fields.slug_without_date, // 生成另外一个路由
      component: path.resolve(`./src/templates/blog.js`),
      context: { id: node.id },
    });
  })
}

首先,通过 onCreateNode 的 hook 通过文件名解析出来了 slug。举个例子,文件名是 2021-07-17-blog-migrate-from-jekylly-to-gatsby.md 那么就会解析出两个 slug:

  • /2021/07/17/blog-migrate-from-jekylly-to-gatsby
  • /blog-migrate-from-jekylly-to-gatsby

之后通过 createNodeField 方法把 slug 再次塞回 MarkdownRemark 类型的 fields 属性里,后面就可以通过 fields.slug 访问这些属性了。

之所以创建 /2021/07/17/xxx 的路由(而不仅仅有 /xxx 的路由)是因为这时 jekyll 的默认路由样式,算是做一个兼容保证原来的链接不会失效。

到目前为止,blog 的主要功能算是建立好了。

处理 blog 中的代码高亮

remark 这个插件自己也可以使用插件,使用 prismjs 就可以实现代码的高亮了。

简单罗列下配置:

{
      resolve: `gatsby-transformer-remark`,
      options: {
        // Plugins configs
        plugins: [
          {
            resolve: `gatsby-remark-prismjs`,
            options: {
              aliases: { // 这里仅仅是多了两个语言的 alias
                sh: "bash",
                gql: "graphql"
              },
            },
          },
        ],
      },
    },

最后记得按照文档把 css 文件添加进来。

优化 blog 中的 image

这部分工作也算是 gatsby 的一个亮点。这个插件通过对 img 的 srcset 的支持可以实现在不同宽度的页面上去加载不同宽度的图片。并且这些不同宽度的图片也都由插件自动生成,称得上是开箱即用了。更多的信息去 gatsby remark images 一看就晓得了。

在 github pages 部署

这部分 gatsby 已经给准备好了,跟着 How Gatsby Works with GitHub Pages 基本就能解决。最后通过一个 github action 实现了自动部署(而不是每次都自己 yarn run deploy):

name: build-and-deploy

on:
  push:
    branches:
      - '**'

jobs:
  build:
    runs-on: ubuntu-latest
    name: Git Repo Sync
    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-node@v2
      with:
        node-version: '14'
    - name: install deps
      run: yarn install --frozen-lockfile
    - name: build
      run: yarn run build
    - name: deploy
      uses: peaceiris/actions-gh-pages@v3
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_branch: main
        publish_dir: ./public

这里我的主分支是 master 并采用 main 作为了发布分支,直接使用 action actions-gh-pages 把 public 目录提交到 main 就可以了。

小结

gatsby 并不像 jekyll 那么开箱即用,但其功能确实强大不少,灵活性和扩展性不是一个数量级的了。不过这也要求使用者对前端的技术栈足够了解。

另一方面,gatsby 把 graphql 作为默认的数据查询语言,对于熟悉 graphql 的人来说自然是非常便利,但也一定程度上提升了使用门槛。

后续工作

  1. 增加样式,目前是 plain html,只有代码块是花花绿绿的
  2. SEO 优化
  3. 首页,这部分在 jekyll 的时候是个分页,现在想要做类似的实现
  4. tags 的展示和按照 tags 罗列文章,这也是之前 jekyll 的功能,也希望做成类似的样子

相关资源

把几个用到但是没有提及的链接放到这里: