我的博客本来是用 Jekyll 生成的静态网站,如今改用 Next.js + TailwindCSS + Shadcn.UI

    技术选型

    不再选择静态网站生成工具

    我的博客本来是用 Jekyll 生成的静态网站,因为网站架在 GitHub Pages 上,而 Jekyll 是 GitHub Pages 默认的构建工具。但是 Jekyll 的核心开发者年事已高,有的甚至已经去世,所以我感觉这个项目未来的活力堪忧。

    Jekyll 是用 Ruby on Rails 写成的,其他编程语言,和 Jekyll 功能类似的工具有:

    • Python 的 Pelican
    • JavaScript 的 Hexo

    但是我还想做些更复杂的事情,预计需要靠服务端来实现。所以与其花时间移植到一个和 Jekyll 功能类似的工具,不如直接一步到位,学习一个全栈框架。

    Next.js

    虽然不同的编程语言也都有自己的全栈框架,比如 Python 有 Django,但是既然浏览器主要支持 JavaScript,所以索性前后端都用 JS 比较方便,而且这样想的人很多,社区规模使得遇到问题更容易找到答案(这个优势在生成式语言模型的时代似乎没那么重要了)。

    JavaScript 语言之下,也存在至少 React 和 Vue 两大阵营。之所以选择 Next.js 这样一个基于 React.js 的框架,主要是路径依赖,很久很久以前学过赫尔辛基大学的全栈公开课,那里教的就是 React。

    对于 Next.js 本身,Youtube@Fireship 有一个很简洁的介绍:https://www.youtube.com/watch?v=Sklc_fQBmcs

    官网提供的教程在这里:https://nextjs.org/learn,和我学习的时候已经不一样了,那时候只有 page router,没有 app router。开发这一框架的 Vercel 公司也提供 Next.js 的云服务。但是鉴于其有过把用户引诱到 app router 架构然后给自家服务提价的黑历史,所以短期内不打算学和用 app router.

    TailwindCSS

    https://tailwindcss.com/

    早就学过了,但是一直没有机会用。这次本来也可以不用的,直到决定使用 Shadcn.ui 组件库,因为 TailwindCSS 是它的一个依赖项。既然已经安装了,那不用白不用。

    Shadcn.UI 而非 HeadlessUI

    https://ui.shadcn.com/

    一套组件库,也就是网页中经常出现的功能单元。这个库相对于竞品的最大优势,是允许直接复制粘贴代码而不用安装,用多少抄多少~

    TailwindCSS 自家也有一个组件库 HeadlessUI,但是主要是 <form/> 表单及其成员,侧重于向后端传数据的 HTML 元素的封装。而 Shacn 有很多炫酷的交互方面的组件,突出一个现成且好看。

    没用 Figma

    有用的功能都收钱,免费的功能不如直接 next dev 实时预览。

    踩过的坑 学到的经验

    组件化作为一种思路

    正常的网页,HTML 负责内容,CSS 负责装饰,JavaScript 负责交互。这种分工,专业上叫做解耦。

    工程实践表明,这种解耦方式非常反人类。用户看到和使用的网页,是以功能上的相似、空间上的相邻为组织的,而要对其修改时,则需要到不同源代码的不同位置去;反之,某处代码的改动,无意中可能对远处的另一部分视觉效果和功能造成破坏。

    于是较新的全栈框架,其解耦的方式都是以组件为单位的,内容、样式、交互逻辑都写在一起。

    JavaScript/JSX 语法中的 {}

    JSX 是对 JavaScript 语法的扩展,添加了类似于 HTML 标记的写法 <MyComponent></MyComponent>,用来表示 react 组件。

    在 jsx 语言的内部写 Javascript 时,需要将 JS 外面包裹一层 {}

    JavaScript 里类似 Python f-string 的结构写作${},名叫 template literal: this is var: ${var}

    ——以上规则结合起来,会产生让初学者迷惑的现象:

    • <MyComponent className=’dark’/>: 一个正常的类名,直接用引号
    • <MyComponent className={dark}/>: 类名是一个 JS 字符串变量
    • 类名的一部分根据一个变量取值:
      <MyComponent className={`bg-${dark}`}/>

    JavaScript 箭头函数中的 {}()

    JavaScript 的箭头函数类似于 python 的 lambda 纯函数,但是有不同。

    (var) => (expression(var)) 相当于 Python 中的 lambda x: expression(x)

    但是箭头函数的右侧可以是 {} 包裹的若干表达式,此时需要显式 return:

    (var) => {
        expression1(var);
        expression2(var);
        return expression3(var);
    }

    Python 的 lambda 必须是单一表达式的纯函数,不允许上面第二种写法。

    对象解包中的 {}

    当有一个包含若干键值对的对象时

    obj = {
    		k1: "value1",
    		k2: "value2",
    		k3: "value3",
    		...
    }

    可以用 const { k2 } = obj; 的方式拿到 obj[’k2’] 的值,赋给 k2

    结合上一节,

    • (k1,k2)=>(k1+k2); 是一个两个自变量的函数;
    • ({k1,k2})=>(k1+k2); 是以一个 Object 为自变量的函数,这个 Object 的名字无所谓,也不确定一共有多少个属性,但属性中至少包含 k1k2.

    TailwindCSS 的 arbitrary value、JavaScript 的模板字符串、Shadcn 中的 cn() 函数

    TailwindCSS 的很多属性都允许在方括号中使用任意值,比如背景色 bg-[#a4b4c4]

    本以为可以直接在 className 里面用 template literal <div className={bg-[${myColor}]}>,但是并不总是生效。

    这个“并不总是”是个大坑,一开始在开发模式用得好好的,结果某次刷新页面之后就挂了,简直莫名其妙。

    好在 Shadcn 提供了一个 cn() 函数,接受一个或多个 TailwindCSS 类名字符串作为输入,就可以正常使用 template literal 了,例如 <div className={cn("block","border-0",bg-[${myColor}])}></div>

    HTML + CSS 布局

    两类套路:

    1. 传统的 display, position, float 属性;
    2. Flex 和 Grid 布局,看阮一峰先生的博客里的教程就挺方便:Flex, Grid.

    理论上后者新一些,消耗的脑力也更少一些,应该是更好的选择。

    但是前者也有一些优势场景。比如现在整个博客页面的上边栏和剩下的部分就是一个上下结构的 flex 布局,这导致屏幕最右侧的滚动条其实是页面一部分的,而不是整个页面的的滚动条。在 iOS 的 Safari 浏览器下,会导致网址栏不能自动隐藏,浪费很大一片屏幕空间。之后可能会换回传统功夫。

    而像是目录,文章跳转开头和评论区的按钮等等,需要在页面滚动时相对屏幕静止的元素,就不得不用 position,而且为了锚定在正文上,还需要嵌套好几层。

    按照传统功夫——

    • display可选的取值有 4 个: block | inline | inline-block | none
      • block: 竖排,哪怕同一行内仍有空间容纳 html 中的下一个元素。
      • inline: 横排,像文字内容一样,没有盒模型
      • inline-block: 横排,但是有盒模型
      • none: 不显示,和 visibility:hidden 的区别是,后者依然占有显示时的空间。
    • position可选的取值有 5 个: static | relative | fixed | absolute | sticky
      • static: 默认值,按照 html 文件的顺序排列。
      • relative: 相对于 static 默认值进行偏移,偏移量由 top, right, bottom, left 四个性质决定。所谓 left: 50px 的意思是左侧 margin 外多出 50 像素的空间,实际是向右偏移的效果。
      • fixed: 相对于视窗的位置固定,位置由 top, right, bottom, left 四个性质决定。left: 50px 的意思是该元素的左侧 border 外沿距离窗口左边 50 像素。
      • absolute: 相对于最近一层父元素的位置固定,位置由 top, right, bottom, left 四个性质决定。left: 50px 的意思是该元素的左侧 border 外沿距离父元素左侧内沿 50 像素。设计的时候需要考虑 border 宽度。
      • sticky: 网页加载时按照 html 文件的顺序排列,直到网页滑动到某一位置,之后该元素固定在视窗,就像 fixed 一样,行为改变的位置由 top, right, bottom, left 四个性质决定。
    • float: none | left | right | inherit
      • none: 默认值,按照 html 文件的顺序排列。
      • left: 保持在父元素左侧,其他元素环绕之。
      • right: 保持在父元素右侧,其他元素环绕之。
      • inherit: 和父元素的 float 的取值一致。

    Unified.js 将 Markdown 文档转换为基于 JSX 的 HTML

    Jekyll 等静态网站生成器的核心功能,就是把 markdown 文档翻译成 html 网页文档。在 Next.js 框架下,这一工作由以 unified.js 为基础的一群第三方库来完成。

    最早看到这个框架是在 DIYGOD 的博文《如何优雅编译一个 Markdown 文档》里,但是直接抄他在 xlog 里面的代码的话,在 next.js 之下好像会报错。所以又去官网仔细读了一下文档,现在可以说是略懂。

    这个话题本身值得专门写一篇文章,所以不在这里展开了。

    Giscus 评论区切换黑夜模式

    自己写的组件的亮暗切换,是通过在 <html/> 元素添加和删除 dark 类,然后搭配 TailwindCSS 的 dark: 来实现的。

    Giscus 官方支持切换黑夜模式:https://github.com/giscus/giscus/blob/main/ADVANCED-USAGE.md#parent-to-giscus-message-events。这套方法的关键,在于服务端返回的 iframe 有一个名为 giscus-frame ****的类。

    Giscus 为 react 提供了一套组件可以直接使用,但是在这套组件里面并没有这个类。

    所以只能弃用官方的组件,自己用 useEffect 模拟官网的 <script/>

    export function MyGiscus() {
      useEffect(
        () => {
          const onPageLoad = () => {
            console.log("<MyGiscus/>: activated on page load.")
            // START real business
            const script = document.createElement('script');
            script.src = "https://giscus.app/client.js";
            script.setAttribute('data-repo',              '');
            script.setAttribute('data-repo-id',           '');
            script.setAttribute('data-category',          '');
            script.setAttribute('data-category-id',       '');
            script.setAttribute('data-mapping',           '');
            script.setAttribute('data-strict',            '');
            script.setAttribute('data-reactions-enabled', '');
            script.setAttribute('data-emit-metadata',     '');
            script.setAttribute('data-input-position',    '');
            script.setAttribute('data-theme',             '');
            script.setAttribute('data-lang',              '');
            script.crossOrigin = 'anonymous';
            script.async = true;
            document.getElementById("comments").appendChild(script);
            // END real business
          };
          // Check if the page is already loaded
          if (document.readyState==='complete') {
            onPageLoad();
          } else {
            // Add event listener for page load
            window.addEventListener('load',onPageLoad);
            // Cleanup the event listener on component unmount
            return () => { window.removeEventListener('load',onPageLoad); }
          };
        },
        []
      );
      return (<div id='comments'></div>);
    }

    Next.js 的构建参数部分

    要让 next.js 构建静态网站,需要在 next.config.js 中写:

    module.exports = {
      basePath: '/blog',
      output: 'export',
      generateBuildId: async () => "buildID",
      // i18n: {
      //   locales: ['zh-CN', 'en'],
      //   defaultLocale: 'zh-CN',
      //   localeDetection: false,
      // },
    }

    basePath 是因为博客的 GitHub 仓库 blog 不是默认的个人网站仓库;需要注意的是,next.js 自己的 Link 组件的 href 参数不需要包含这个值,但是图片等等的 src 参数需要。

    generateBuildId 函数的返回值是随便写的,不设定的话会导致输出里的 _next/ 文件夹里有很多哈希值为名的文件夹,在 git 下会被当成不同的 blob 一直留在项目里。

    i18n 参数被注释掉了,因为静态生成的 next.js 项目不支持自动 i18n.

    RSS 源和 sitemap

    RSS 由 feed 这个 npm 包来构建;

    sitemap 则是在 ChatGPT 的帮助下手写字符串。

    写好的字符串,通过在主页或者历史归档页面的 getStaticProps() 函数,写入 next.js 项目的 public/ 文件夹。

    还没解决的问题

    手机端搜索框的汉字输入问题

    页面顶端的搜索框在手机触摸屏上,用汉字输入法输入关键词之后,直接按回车键,会导致已经输入的汉字被当成拼音,传递给搜索引擎。

    暂时的办法是在输入汉字之后,按回车键之前按一下空格。

    Shadcn.Drawer 组件的上游代码报错;Google Ads

    新博客把谷歌广告撤了。赚不到多少钱不说,它还会往网页里动态添加元素,破坏原来的排版。

    话虽如此,每篇文章的右下角还是有个要饭的图标,计划用 shadcn 的 Drawer,放赞赏二维码,或者交换来的友站链接。

    但是目前 Drawer 的上游代码会报错,看起来作者已经在修复了,等更新。

    Google Analytics

    next.js 提供了 Google analytics: https://nextjs.org/docs/pages/building-your-application/optimizing/third-party-libraries

    但是加入之后没有反应,google 后台看不到,数据一落千丈。

    据说把相应的代码放到 pages/_app.js 可以解决问题,还没试。

    本文收录于以下合集: