让 Hugo 用上 React

using-hugo-with-react

最近把博客主题又双叒叕重写了一遍,嘛,生命不息,折腾不止。依然基于 Hugo,但这次把 React 缝合进去了,使用 Vite 构建前端,Tailwind CSS 编写样式,这篇博客记录下整个折腾过程。

新主题在这 https://github.com/sxyazi/hugo-theme-lavias

数据源

由于 Hugo 是一个静态站点生成器,而我又希望在前端使用 React,做成 SPA,所以就会有“数据来源”的问题。

为了同时满足对 SEO 的基本要求,我还是选择使用 Hugo 生成 HTML 格式,只不过这些 HTML 只包含最基本的结构:

<!-- https://github.com/sxyazi/hugo-theme-lavias/blob/main/layouts/_default/list.html -->
{{ define "content" }}
<section id="posts">
	{{- range (.Paginator (.Site.Params.listPaginate | default 100)).Pages -}}
		<article>
			<a href="{{ .RelPermalink }}" title="{{ .Title }}">{{ .Title }}</a>
			<time datetime="{{ .PublishDate.Format "2006-01-02T15:04:05Z0700" }}">{{ .PublishDate.Format "01-02" }}</time>
		</article>
	{{- end }}
	{{ partial "paginator.html" . }}
</section>
{{ end }}

之后将它们放置在一个 hidden element 里,即下面的 <main>,而面向用户的实际内容渲染到 <app> 中:

<!-- https://github.com/sxyazi/hugo-theme-lavias/blob/main/layouts/_default/baseof.html -->
<main>{{ block "content" . }}{{ end }}</main>

<app></app>
<noscript>You need to enable JavaScript to run this app.</noscript>

在使用这些数据时,我为每种数据源创建了一个单独的 Hook,这个 Hook 只是简单而单调地,从 main 中以 id 为标识,选择不同的数据,下面以文章列表 posts 为例:

// https://github.com/sxyazi/hugo-theme-lavias/blob/main/src/hooks/usePosts.ts
export type Post = {
	title: string
	link: string
	date: Date
	summary?: string
}

export const usePosts = () => {
	const [posts, setPosts] = useState<Post[]>([])
	const source = useSource()

	useEffect(() => {
		setPosts(
			Array.from(source.querySelectorAll('#posts > article')).map((article) => {
				const a = article.querySelector(':scope > a')
				const time = article.querySelector(':scope > time')

				return {
					title: a?.textContent!,
					link: a?.getAttribute('href')!,
					date: new Date(time?.getAttribute('datetime')!),
					summary: article.querySelector(':scope > details')?.textContent ?? undefined,
				}
			})
		)
	}, [source])
	return posts
}

前端路由

可以发现,上面代码中使用了 useSource() 这个 Hook,它的返回值是随着前端路由的变化,对最新 main 元素的引用:

// https://github.com/sxyazi/hugo-theme-lavias/blob/main/src/hooks/useSource.ts
export const EMPTY_DIV = document.createElement('div')

export const useSource = () => {
	const location = useLocation()
	const { source, path } = useContext(AppContext)

	if (location.pathname !== path.current?.last) {
		return EMPTY_DIV
	}
	return source
}

这里使用了 AppContext,这是一个 custom context,主要的逻辑都在这里面实现:

// https://github.com/sxyazi/hugo-theme-lavias/blob/main/src/providers/AppProvider.ts
export const AppContext = createContext<{
	source: HTMLElement
	path: RefObject<{ entry: string; last: string }>
}>({
	source: EMPTY_DIV,
	path: createRef(),
})

export const AppProvider = ({ children }: { children: ComponentChildren }) => {
	const location = useLocation()
	const path = useRef({ entry: location.pathname, last: location.pathname })
	const [source, setSource] = useState<HTMLElement>(document.querySelector('main')!)

	useEffect(() => {
		if (location.pathname === path.current.last) {
			return
		} else if (location.pathname === path.current.entry) {
			path.current.last = path.current.entry
			setSource(document.querySelector('main')!)
			return
		} else {
			path.current.last = location.pathname
		}

		setSource(EMPTY_DIV)
		fetch(location.pathname)
			.then((res) => res.text())
			.then((res) => parse(res).querySelector('main')!)
			.then(setSource)
	}, [location])

	return <AppContext.Provider value={{ source, path }}>{children}</AppContext.Provider>
}

这个 context 被注入到 App 的根部,以便能够在任何位置都能使用它。

构建

这部分是最麻烦的,由于 Vite 对项目结构的严苛要求,使得为了适配这类特殊场景,需要做的工作更多。为此,我写了一个单独的脚本做这件事:

// https://github.com/sxyazi/hugo-theme-lavias/blob/main/cmd.js
const base = dirname(fileURLToPath(import.meta.url))
switch (process.argv[2]) {
	case 'dev':
		const restore = replaceBase(true)
		child_process.execSync('rm -rf dist')
		try {
			child_process.execSync('hugo -d themes/hugo-theme-lavias/dist --config config.yaml,themes/hugo-theme-lavias/hugo.dev.yaml', {
				cwd: resolve(base, '../../'),
			})
		} finally {
			restore()
		}
		child_process.spawnSync('pnpm', ['exec', 'vite', 'dev', 'dist', '--config', resolve(base, 'vite.config.js')], {
			stdio: 'inherit',
		})
		break

	case 'build':
		child_process.execSync('rm -rf static/assets static/index.html')
		child_process.spawnSync('pnpm', ['exec', 'vite', 'build', 'src', '--config', resolve(base, 'vite.config.js')], {
			stdio: 'inherit',
		})

		replaceBase()
		child_process.execSync('rm -rf static/index.html')
		break
}

为了进入 dev 模式,在站点的根目录执行 hugo 构建,构建的资产放到 themes/hugo-theme-lavias/dist,即相对于该主题的 dist 目录下。

构建时我指定了 2 个配置文件,一个是站点本身的,一个是为了 development 而复写的一些配置:

# https://github.com/sxyazi/hugo-theme-lavias/blob/main/hugo.dev.yaml
minify:
  disableHTML: true

目前只是把 HTML 压缩关掉了,因为 Hugo 压缩后的 HTML 会删除部分 tag 的结束标记,而导致 babel 解析时出错。之后基于构建出的 dist,执行 vite dev,并指定上层的 vite.config.js 为配置文件:

// https://github.com/sxyazi/hugo-theme-lavias/blob/main/vite.config.ts
export default defineConfig({
	// ...
	resolve: {
		alias: {
			'/src': resolve(__dirname, 'src'),
		},
	},
})

这里指定 resolve 的原因,是因为 vite dev dist 会以 dist 目录为 project root 寻找 src,然而 src 并不在 dist 里,而是与 vite.config.js 在同一层目录。

而对于最终构建,还需要为 Vite 指定构建目录,即构建到位于主题的 static 目录下,这个目录的文件会自动合并到 Hugo 站点中:

// https://github.com/sxyazi/hugo-theme-lavias/blob/main/vite.config.ts
export default defineConfig({
	// ...
	build: {
		emptyOutDir: false,
		outDir: resolve(__dirname, 'static'),
	},
})

其它像上面出现的 extractreplaceBase 等函数不再一一赘述,主要用途就是在 dev 时,将 layouts/_default/baseof.html 中的 script 替换为 /src/main.tsx;并在 build 时,将该 script 替换为实际构建出来的带 hash 的文件名,如 /assets/index.d64dc623.js,具体可以参考 cmd.js

自动化

现在需要每次在本地执行 pnpm build,并把构建的文件 push 上去。为了能够构建自动化,我添加了一个 GitHub Actions 的 workflow,它会在我 push 到 main 分支时自动完成构建,省去了本地构建的步骤。

最后

由于这次把主题单独分离到了一个新的 repo,因此博客的构建脚本发生了一些变化,具体在这篇文章