技术选型

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

我的博客本来是用 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 可以解决问题,还没试。

本文收录于以下合集: