开发专栏
使用 Nuxt.js 搭建博客前端
3 年前
3593
Nuxt.js Vue JavaScript 前端开发 快速入门 SEO

本文从搭建呼呼小笼包的博客(本博客)开始,和博主一起了解:为什么需要服务器端渲染、服务器端渲染有哪些方案、为什么选择Nuxt.js。

背景

从开始筹划开发博客起,我就深陷选择困难症……是使用熟悉的 ASP.NET MVC 技术包办前后端,还是用其它技术生成纯 HTML 静态站点,抑或开发成时下流行的 SPA ?由于工作中多用后端技术,一直很想试试玩一些不一样的东西,于是选择了 SPA 的方案。然后选择题又出现了:是使用 React,Angular 还是 Vue?UI 框架用 Element,Antd,Vuetify,Material-UI……?

本文不对比这些方案,反正我最终决定使用 Vue + Antd 来搭建博客前端。但是基本雏形出现后遇到了未曾预料的问题:作为一个博客站点,最重要的是能被人浏览,而一个名不见经传的小博客除了去论坛里宣传,更重要应该是被搜索引擎收录,也就是需要做 SEO ,比如每个页面有 title(位于 <title> 中) 和 description(位于 <meta> 中)。

<!doctype html>
<html lang="zh-cmn-Hans">

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>呼呼小笼包的博客</title>
    <link href="2.b685e0abd09869b9fb00.css" rel="stylesheet">
    <link href="0.b685e0abd09869b9fb00.css" rel="stylesheet">
</head>

<body>
    <noscript>
        <strong>We're sorry but http://bun.dev doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <script src="runtime~app.e63959ba.js"></script>
    <script src="vendor.039c2646.js"></script>
    <script src="app.48480b5f.js"></script>

    <noscript></noscript>
</body>

</html>

搜索引擎抓取到的类似上面这段 HTML 的内容,可以使用浏览器的“查看页面源”功能查看到,无论访问哪个地址得到的都是这些。即便通过 vue-meta 设置了 title 和 description 也不会体现出来,博文内容也不会加载,因为这些都是异步的。

当时我对 SPA 的理解,是一切由浏览器渲染,服务器中不存在一个物理文件与访问的内容对应,而且页面内容应该全部异步加载,这样就导致搜索引擎的爬虫在访问博客时,不会等待页面全部载入完成。为此我特地将博客临时上线并让Google、Bing和百度的爬虫抓取,发现百度的爬虫只会傻fufu的把HTML模板写死的内容抓取到,而 Google 的爬虫似乎能等待页面加载(能抓取到异步获取的博文内容),不过有的结果只有<noscript>的内容,Bing 甚至根本没收录我的博客……

看来需要有一种方法让搜索引擎拿到渲染好的内容。遇到不懂的当然先请教搜索引擎,因此了解到 SSR 的概念,但我很快否决了它——它需要服务器端的配合,需要有Web服务器的支持——博客部署在512M内存的虚拟机中,连编译个 Vue 站点都会内存不足……于是我在博客UI代码的 Github Issue 中(和自己)展开了讨论,提出了几个不同的方案,但一方面不能欺骗搜索引擎,又不想做成纯静态 HTML,然后我还是采用了 SSR 的方案😂

什么是服务器端渲染

说实话,搜索到这个方案时令我眼前一亮,既然 Vue 是 JavaScript 开发的,那跑在 nodejs 里似乎也没什么不行。不过很快我又想到,nodejs 中哪来的 window 之类的对象?服务器端渲染好了之后,浏览器里咋办?不过随着搜索引擎将我带到 Vue.js 服务器端渲染指南,我的疑虑逐一破解。

Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。 —— 《 Vue.js 服务器端渲染指南

原来,Vue 提供了服务器端渲染的良好支持,浏览器收到服务器端渲染好的内容之后,能直接呈现,并且能无缝切换到浏览器端渲染的状态。除了对 SEO 友好以外,服务器端渲染还能提高首屏加载效率(第一次访问页面的渲染、复杂计算,都由服务器端完成了)。不过缺点也正如我预料,它增加了服务器端的负担,更重要的是需要修改已经开发好的代码

前面提到,我之前否决过 SSR 的方案,在网上搜索的过程中还了解到另一个方案:预渲染。预渲染也有 SEO 的作用,但它更适用于页面较少,且不需要动态生成路由的情况。比如站点只有 //about 页面,只针对这两个页面进行渲染,得到静态 HTML 的效果,而不适合 /post/{id} 这种形式,因为这样的话,页面内容仍然需要动态加载,搜索引擎依旧无法抓取。关于预渲染,可以参阅 prerender-spa-plugin

服务器端渲染的方案

根据网上搜索的结果,大致可以找到三种 SSR 的方案

首先介绍一下 Puppeteer,它是一个没有图形界面的 Chromium 浏览器(Headless Chromium,当然现在也支持 Headless Firefox)。既然是浏览器,那它就可以完成向某个地址发起请求、解析 DOM 等操作。用它来进行 SSR 也是利用它是一个浏览器的特点,在服务器上利用 Puppeteer 访问我们动态生成的页面,等待异步加载的内容加载完毕,然后将最终渲染好的 HTML 返回给访问者(爬虫)。但从一些文章中了解到 Headless Chromium 的资源占用情况并不乐观,假设浏览器开一个 tab 需要 30M 内存,10个请求一起来就需要 300M 内存,我 512M 内存的服务器直接 GG… 并且不是光把渲染好的 HTML 丢给访问者的浏览器就结束了的,还需要保证后续的访问能正常被浏览器渲染(客户端激活),需要操心的东西太多,故没有选择 Puppeteer。

接下来说一下 Vue 官方的 SSR 方案,也是我最早尝试的方案(可能因为我是微软技术栈的开发,更喜欢用原厂提供的方案)。反复通读了一下官方的文档,发现有些地方不太好理解于是干脆直接上手一步一步做,但实在是由于文档里有些不清不楚的地方,我也懒得反复试,而且步骤十分的多,十分劝退,进行了几步就放弃了。

好在有 Nuxt.js,Vue 官方 SSR 方案放弃后只剩下这个方案了。Nuxt.js 的文档非常清晰,还提供了与 axiosvue-meta、 Webpack 等集成的方案,具体可以在 Nuxt.js 文档 中了解到,相当于 Nuxt.js 已经将 Vue 官方 SSR 方案折磨人的部分全部包办,妈妈再也不用担心我实现不了 SSR 啦!

Nuxt.js 的使用

虽然官方文档非常清晰,但我还是在博文里简单重复一下我搭建博客前端的流程。

生成项目

首先使用官方脚手架工具创建项目,非常简单:

npx create-nuxt-app

之后会有向导,一步一步的指导设置

  • Project name 设置项目名称
  • Project description 设置项目描述
  • Author name 设置作者(这三个都储存在 package.json 中,可以随时修改)
  • Choose the package manager 选择包管理工具,我选择 Npm,可以用移动光标,Enter确认
  • Choose UI framework 选择前端框架,我使用的是 Ant Design Vue,大家可以根据需要选择
  • Choose custom server framework 选择服务器框架,我选择 Koa。Koa、Express、hapi、Micro 等这些都是 Node.js 上的服务器框架,可以监听端口,处理请求和响应等,可以根据自己的喜好和需要来选择
  • Choose Nuxt.js modules 选择 Nuxtjs 的模块,我需要用 axios 来向后端 API 发请求,所以选中它,目前暂时不做 PWA,所以不选它。这里可以用键移动光标,空格键选中,或者按下 a 全选,回车键确认
  • Choose linting tools 选择你需要的 Lint 工具
  • Choose test framework 选择测试框架
  • Choose rendering mode 选择渲染模式,这里选择 Universal (SSR) 才能进行服务器端渲染
  • jsconfig.json (Recommended for VS Code) 如果你使用 VS Code,那么可以选中它。VS Code 的 jsconfig.json 文件用途参见官方文档。大意是说,如果没有 jsconfig.json ,每个 js 文件是独立的,除非一个 js 文件显示的引用了另一个 js 文件,另外还能通过配置包含/不包含哪些文件,提高 IntelliSense 的性能

等项目生成好后,可以通过 npm run dev 直接启动开发服务器,nuxtjs 就会生成客户端和服务器端 js、监听文件、启动服务器,待命令行输出 READY Server listening on http://localhost:3000 后,说明一切就绪,通过 http://localhost:3000 (默认为 3000 端口)访问就能看到生成好的站点了。

了解目录结构

Nuxt.js 项目的目录结构也非常清晰明确,官方文档 有详细的解释。它将文件夹划分成这几个部分:
Nuxt.js 目录结构

  • assets 需要处理的资源目录,例如未编译的 LESS、JavaScript 等
  • components 组件目录,即 Vue.js 的组件
  • layouts 布局目录,用于存放页面使用的布局文件
  • middleware 中间件目录,例如身份认证中间件(必须拥有 token 才能访问某些页面)的实现可以放在这里
  • pages 页面目录,用于组织应用的路由及视图,后面会说明
  • plugins 插件目录,用于在 Vue.js 初始化前运行的 JavaScript 插件
  • static 静态文件目录,比如 robots.txt(用来配置哪些内容允许搜索引擎蜘蛛抓取、哪些不行)、favicon.ico(网站图标)文件可以放在这里
  • store Vuex 配置文件,Nuxt.js 简化了 Vuex 的配置,后面会说明

路由、布局和视图

细心的童鞋们可能注意到了,上面 Nuxt.js 生成的文件中并没有路由的配置。Nuxt.js 根据 pages 文件夹下的结构,自动生成路由。以本博客为例

  • /views/about.vue 表示生成 /about 路由(基础路由)
  • /views/posts/_linkName.vue 表示生成 /posts/:linkName 路由(动态路由),_linkName 会转换为路由参数

另外我们已经知道,在 layouts 文件夹中可以配置布局,例如默认生成的 default.vue

<template>
  <div>
    <nuxt />
  </div>
</template>

这样,在 pages 中添加的页面,若没有显示指定使用哪一个布局页,就将采用 default.vue 的布局,并且页面的内容会被填充到 <nuxt /> 中。

如果我们有多套布局,例如针对管理员后端有单独的布局文件,则直接在 layouts 文件夹中新建 admin.vue

<template>
  <div>
    <div>博客后台导航菜单</div>
    <nuxt />
  </div>
</template>

并且在 pages 文件夹中新建 admin 文件夹,在 admin 文件夹中新建 index.vue(通过 /admin 访问到该页面,而不是 /admin/index

<template>
  <div>
    <h1>博客后台首页</h1>
  </div>
</template>

<script>
export default {
  layout: 'admin'
}
</script>

通过编写 JavaScript,用 layout 属性指明本页将采用 admin.vue 布局即可。

另外,Nuxt.js 为我们内置了一套 HTML 模板

<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
  <head {{ HEAD_ATTRS }}>
    {{ HEAD }}
  </head>
  <body {{ BODY_ATTRS }}>
    {{ APP }}
  </body>
</html>

页面和布局页最终会被应用到这个模板上,对我们做 SEO 至关重要的 <meta><title> 就需要生成到 {{ HEAD }} 中,我们通过在页面 JavaScript 代码中添加 head() 方法来设置它们。这里以为 /admin 页面设置 <title> 为例,打开上一步建立好的 /admin/index.vue 文件,添加 head() 方法

<template>
  <div>
    <h1>博客后台首页</h1>
  </div>
</template>

<script>
export default {
  layout: 'admin',
  head() {
    return {
      title: '博客后台首页'
    }
  }
}
</script>

当访问页面时,无论是首次访问触发服务器端渲染,还是之后的访问(浏览器渲染),都可以看到 <head></head> 中生成了 <title>博客后台首页</title>

异步数据

当在浏览器中渲染时,我们总是先触发跳转到新的页面,然后才以异步的形式加载页面所需的数据,以我的博客来说,当访问某一篇博文的时候,才会根据博文的名称发起 Ajax 请求,去 API 查找这篇博客的内容,这就导致搜索引擎的爬虫无法得到博文的内容,我们得在爬虫访问这篇博文的地址时,就能得到博文内容!

这时候就需要用到 asyncData() 方法来处理异步数据

<template>
  <div>
    <h1>{{ post.title }}</h1>
    <article>{{ post.content }}</article>
  </div>
</template>

<script>
export default {
  async asyncData ({ params }) {
    const post = await axios.get(`https://api.bun.dev/posts/${params.linkName}`)  // 从 API 获得的响应为 { title: '', content: '' }
    return { post }
  }
}
</script>

asyncData() 方法返回的内容,可以在 head() 以及其它的地方获取到,当然也可以再添加一个 data() 方法用于处理同步的内容

Vuex 状态树

当我们要使用 Vuex 时,只需要简单的往 store 文件夹中添加一个 js 文件即可,例如我们需要将当前登陆的用户信息(用户名和 token)储存在 Vuex 中,新建 currentUser.js

export const state = () => ({
  username: null,
  token: null
})

export const mutations = {
  login(state, currentUser) {
    state.username = currentUser.username
    state.token = currentUser.token
  },
  logout(state) {
    state.username = null
    state.token = null
  }
}

export const state 相当于为 new Vuex.Store() 传入了 { state: { username: null, token: null }}export const mutations 中的代码则相当于 { mutations: { login(), logout() }}(伪代码)。
当我们想要将用户信息储存下来时,通过 $storecommit() 方法,传入我们设置的文件名+ / + mutation的方法名即可触发相应方法:

this.$store.commit('currentUser/login', {
    username: 'admin',
    token: 'xxxxx'
})

而从 Vuex 中取值的方式和过去差不多,通过计算属性返回

export default {
  computed: {
    username() {
      return this.$store.state.currentUser.username
    }
  }
}

其中 currentUser 是我们的文件名,username 是在 state 中定义的属性,是不是非常容易理解,还特别的方便呢~

生成并部署

通过查看 pakcage.json 文件我们可以看到,Nuxt.js 为我们生成了 devbuildstartgenerate 几种命令:

{
  "scripts": {
    "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server",
    "build": "nuxt build",
    "start": "cross-env NODE_ENV=production node server/index.js",
    "generate": "nuxt generate"
  }
}

其中 dev 命令我们已经知道了,可以运行开发服务器;generate 命令用于生成静态站点(会把静态的路由都生成对应的 HTML 文件),而我们需要使用服务器端渲染,则使用 buildstart 命令

npm run build

项目根目录下会产生 .nuxt 文件夹,里面就是编译好的客户端及服务器端代码,通过 Nodejs 可以运行它。但如果要想发布到服务器部署,需要将

  • 编译好的 .nuxt 文件夹
  • static 文件夹
  • server 文件夹
  • package.json 文件
  • nuxt.config.js 文件

一起上传到服务器上,并且在服务器上还原 npm 包以及执行 start 命令

npm i
npm run start

我们知道,npm run start 如果窗口被关掉,运行在里面的服务器也会被释放,我们网站就挂了,所以需要一个工具当网站发生意外时让它恢复。PM2 就是 Node 的进程守护工具,通过 pm2 start 命令可以直接启动我们的程序,上面的命令可以改成

npm i
pm2 start npm -- run start

但是这里有一个坑,这个代码在我的服务器(Linux)上能正常运行,但是在 Windows 上会报错(PM2 会提示程序是 stopped 状态,通过 pm2 log 查看日志)

0|npm      | :: Created by npm, please don't edit manually.
0|npm      | ^
0|npm      |
0|npm      | SyntaxError: Unexpected token :
0|npm      |     at Module._compile (internal/modules/cjs/loader.js:720:23)
0|npm      |     at Object.Module._extensions..js (internal/modules/cjs/loader.js:787:10)
0|npm      |     at Module.load (internal/modules/cjs/loader.js:643:32)
0|npm      |     at Object.<anonymous> (C:\Users\nongz\AppData\Roaming\npm\node_modules\pm2\lib\ProcessContainerFork.js:27:21)
0|npm      |     at Module._compile (internal/modules/cjs/loader.js:776:30)
0|npm      |     at Object.Module._extensions..js (internal/modules/cjs/loader.js:787:10)
0|npm      |     at Module.load (internal/modules/cjs/loader.js:643:32)
0|npm      |     at Function.Module._load (internal/modules/cjs/loader.js:556:12)
0|npm      |     at Function.Module.runMain (internal/modules/cjs/loader.js:839:10)

经过搜索,在 Github 上有这样的 issue 和解决方案 #3657,即把 npm 换为 npm 的安装路径中的 npm-cli.js,npm 安装路径可以通过直接输入 npm 命令不带参数按回车获得,路径有空格注意加引号

# pm2 start "<npm安装路径>\bin\npm-cli.js" -- run start

pm2 start "E:\Program Files\nodejs\node_modules\npm\bin\npm-js.cmd" -- run start

这样有个缺点,会有一个控制台窗口弹出,不够完美。我们可以用 nuxt start 命令代替 npm run start

pm2 start ./node_modules/nuxt/bin/nuxt.js -- start

这样在 Windows 上就可以正常运行,也不会有多余的窗口弹出了~

至此,大家已经了解什么是服务器端渲染,为什么需要服务器端渲染,以及 Nuxt.js 的简单使用,相信通过 Nuxt.js 可以简化大家服务器端渲染的工作。

博客的第一批篇文章到这里就结束了,感谢大家的阅读。也欢迎大家通过我的微信公众号 bundev 一起沟通交流,谢谢。