blog/nextjs-blog

使用 Next.js 重写博客框架

如果你看过我之前的博客的代码,你应该会知道我的博客虽然使用了 Vue.js 这样的前端框架,但我仅仅用了它很基础的功能。我并没有采用 vue-cli 生成的模板,而是仅仅把 Vue 用 Webpack 加载到 JS 里,然后在 HTML 中把 bundle 后的 JS 用 <script> 标签加载出来,再把 HTML 文件作为模板,使用 Python 的 Jinja2 模板引擎将各数据填入模板来生成各个页面。

而随着前端的发展,SPA(单页应用)出现了。单页应用在切换页面时不需要像传统页面那样整个重新加载一个完整的网页;而是使用路由(routing)技术,切换页面时只需要用 XMLHttpRequest 加载一点点数据,然后操作 DOM 将页面显示的内容改为数据就好了。许许多多的前端框架如 React、Vue、Angular 就是为此设计的。

既然有这样的好处,为什么我不把博客框架写成 SPA,而是用老掉牙的模板引擎来生成呢?原因在于 我太菜了不会写 我希望我的博客能被各种各样的用户访问。直接使用前端框架写出来的 SPA 的 HTML 入口往往不包含页面内容,需要在浏览器运行 JavaScript 操作 DOM 后才给页面加上了内容,如果关闭 JavaScript,页面就只会显示一个 You need to enable JavaScript to run this app.。虽然很少有人会关闭 JavaScript,但总有人出于各种各样的考虑这么做:有人认为网页应该是一个文档,应该是用来浏览的而不是用来交互的;有人为了安全、担心 Cookies 泄漏而安装了 NoScript 插件;有人不喜欢过于复杂的现代浏览器,使用一些简单的、只能识别 HTML 的浏览器(如 w3m)。

我一直认为,可定制性是 “自由” 的一个基本内涵。所以我希望我的网页就算关闭 JavaScript 也能维持一些基本的功能。

NextCloud 关闭 JS 后的提示

另外,多数搜索引擎只会去抓取你的 HTML 内容,如果你页面的数据都在 JS 里搜索引擎很有可能是抓不到的。使用这些前端框架对 SEO(搜索引擎优化)不友好。这也是绝大多数人使用 SSR 的原因。

后来我才知道服务端渲染(Server Side Rendering,SSR)这种技术。同样使用这些前端框架,SSR 指先把这些 JS 在服务器上跑一遍,将生成的 HTML 一起部署。这样浏览器一开始就得到了一个完成了的 HTML,不管你有没有禁用 JavaScript 都可以浏览。同时在切换页面时又可以使用 CSR (浏览器端渲染)提升加载速度。

(我发现最近 GitHub 也开始使用这类技术了,很多时候你在切换页面时看到的进度条不再是浏览器的进度条,而是页面内顶端的蓝色进度条,你有没有感觉 GitHub 使用起来稍微快了一些呢?)

GitHub in-page progress bar

比较热门的基于 React 的 SSR 框架有两个:Next.js 和 Gatsby,我使用的是 Next.js。

Next.js 的优点

首先 Next.js 的文档写得很好!学习时就像在通关一样,很有成就感。

全自动的路由(routing)

Next.js 中的路由可以作为一个 React 元件直接使用。

import Link from 'next/link'

const Index = () => (
  <Link href="/about">
    <a>About</a>
  </Link>
)

export default Index

这样就可以显示一个 “About” 链接,点击就可以不刷新地跳转至 /about 页面。如果浏览器关闭了 JavaScript,这个行为就会变成刷新地跳转。

getInitialProps 钩子

Next.js 在 React 的 componentWillMount componentDidMount 等生命周期钩子之外添加了 getInitialProps 钩子。

单页应用常常会从 API 服务器获取一些数据,用这些数据来渲染出页面。而 Next.js 为了优化 SSR 添加了这个钩子。在这个方法中用 fetch() 函数获取数据然后 return,这些数据将被传入 this.props 中,这样就可以供 render() 使用。

另起炉灶的 build 系统

现在大多数前端工程都会选择使用 webpack 作为组建build工具。使用过 webpack 的你应该知道 webpack 的配置文件编写是有多么的变态。一个简单的功能常常需要联合使用好几个 loader,比如我要使用 webpack 将 Sass 转化成 CSS,我就需要在 module.rules 中加入以下内容:

{
  test: /\.scss$/,
  use: [
    {
      loader: 'file-loader',
      options: {
        name: '[name].bundle.css',
      },
    },
    { loader: 'extract-loader' },
    { loader: 'css-loader' },
    {
      loader: 'postcss-loader',
      options: {
        plugins: () => [autoprefixer()],
      },
    },
    {
      loader: 'sass-loader',
      options: {
        includePaths: ['./node_modules'],
        implementation: require("sass")
      },
    }
  ],
},

而 Next.js 则使用一套很自动化的组建系统,不需要任何配置就可以启动:

  • next dev 启动本地调试服务器
  • next build 组建
  • next start 组建后就可以启动 production 服务器

要添加配置的话,配置文件 next.config.js 的格式也很简单,比如说我想在 JS 内加载 CSS:

const withCSS = require('@zeit/next-css')

module.export = withCSS()

要使用多个 loader 的话就嵌套起来:

const withSass = require('@zeit/next-sass')
const withCSS = require('@zeit/next-css')

module.export = withSass(withCSS())

当然有些 loader 你必须要配置一下,比如说指定 Sass loader 要指定包含的库所在的路径。这些配置就写在嵌套的最中间的 Object 中:

const withSass = require('@zeit/next-sass')
const withCSS = require('@zeit/next-css')

module.export = withSass(withCSS({
  sassLoaderOptions: {
    includePaths: ['./node_modules'],
    implementation: require('sass')
  },
}))

而这种格式也不会影响可定制性,你可以在 next.config.js 中嵌入 webpack 配置。比如说我要添加一个 DefinePlugin

const withSass = require('@zeit/next-sass')
const withCSS = require('@zeit/next-css')
const webpack = require('webpack')

module.export = withSass(withCSS({
  sassLoaderOptions: {
    includePaths: ['./node_modules'],
    implementation: require('sass')
  },

  webpack: (config, options) => {
    config.plugins.push(new webpack.DefinePlugin({
      PRODUCTION: JSON.stringify(process.env.NODE_ENV === 'production'),
    }))
    return config
  },
}))

Next.js 的缺点

严重依赖 API 服务器

使用 Next.js 设计 SPA 的一个基本思路就是使用 fetch() 从 API 服务器获取数据,然后渲染。这种方法导致了使用 Next.js 的网站非常依赖于 API 服务器。虽然很多情况下这是可以接受、非常健康的设计方式,因为这可以促成前后端分离。但是对于一个博客这样不算是一种服务的网站来说,我们更希望他是静态的,这样一来我们可以把它部署在任何静态网站部署服务上,如 GitHub Pages、Firebase 等;二来这样可以用诸如 web.archive.org 这类的服务归档,供几百年后我们的子孙后代访问(你在想屁吃)。虽然 Next.js 提供了输出渲染后的静态网站的功能,但是输出的静态文件在切换页面时仍然需要从 API 服务器加载数据。万一哪天我的 API 服务器挂了,你可能就会在切换页面时看到一个这样的错误:

500 | Internal Server Error

那将 API 的数据和网页放在一起静态部署不就可以了吗?还是不行,isormorphic-unfetch 在服务端渲染时不允许使用相对路径,我们无法顺利导出渲染好的网页。这其实很好理解,毕竟浏览器上的一个网页是有它自身的 URL 的,而服务器渲染时并不知道自己渲染的网页的 URL,自然无法使用相对路径。

为了使得我的博客可以部署在 GitHub Pages 等平台(作为备份),我写了一个简单的脚本 export.sh。我分开设定了两个环境变量 SSR_APICSR_API,服务端渲染时从 SSR_API 获取数据,浏览器端渲染时从 CSR_API 获取数据。然后把 SSR_API 设定为 http://localhost:5000,服务器端渲染时从本地的 API 服务器加载数据,输出渲染好的网页;把 CSR_API 设定为 /api,用户在切换页面时就从相对路径加载数据。要这么做当然得在 JavaScript 中判断一下当前运行环境是浏览器还是服务器:

const APIURL = process.browser ? CSR_API : SSR_API

哦,忘记说了,CSR_APISSR_API 两个变量是通过上面提到的 DefinePlugin 传入的:

const SSR_API = process.env.SSR_API || process.env.APIURL
const CSR_API = process.env.CSR_API || process.env.APIURL

config.plugins.push(new webpack.DefinePlugin({
  PRODUCTION: JSON.stringify(process.env.NODE_ENV === 'production'),
  SSR_API: JSON.stringnify(SSR_API),
  CSR_API: JSON.stringnify(CSR_API)
}))

按返回键时仍会重新从 API 服务器加载数据

Next.js 不会自动对之前的结果进行缓存,neither in 内存 nor in 硬盘。这就导致用户按返回键时会重新从 API 服务器加载数据。这其实很好理解,比如说如果你要做的网站是一个实时的服务网站(如购物网站),你肯定得重新获取数据,你不会希望用户添加商品后按返回键后他的购物车还是之前的状态。所以咱们也不能指望 Next.js 为我们自动缓存数据。

但对于一个博客网站来说(尤其是我们这种渣渣服务器上的),重新获取数据会造成很大的延迟,甚至高于不是 SPA 的网站(对于一个不是 SPA 的网站,切换页面时浏览器会对之前的网页进行缓存,所以返回时不会有很大的延迟)。严重影响了用户体验。

一般的解决方案是用 Redux 这类的库储存状态。但我这里用了一个简单的 workaround:

let cachedProps

class Index extends Component {
  render() {
    if (process.browser)
      cachedProps = this.props
    return (/* bla bla bla*/)
  }

  static async getInitialProps(context) {
    if (!cachedProps) {
      const fetchedCardsInfo = await fetch(`${APIURL}/cards.json`)
      const cardsInfo = await fetchedCardsInfo.json()
      return { cardsInfo }
    }
    return cachedProps
  }
}

在元件的外部定义了一个对象用来储存之前 fetch 到的数据。

最后的成果

你现在看到的博客就是重写后的结果啦!UX 基本上没有改动 毕竟懒。还是挺令人满意的。

New MeltyLand on Firefox

使用 w3m 访问:

New MeltyLand on w3m

SEO 支持:

SEO support

About Me