案例——《poem-nuxt》解析

之前有写过用 puppeteer 作为 ssr 解决方案的文章。那个对于旧项目适配很合适,新项目还是一开始就采用 ssr 框架比较好。所以,我把“品词轩”用 nuxt 重新实现了一下。

项目介绍

用 nuxt 实现的诗词网站。功能简单,主要就是为了了解一下 nuxt。(vapper 好像比 nuxt 好用些。)

项目地址:https://github.com/tonyzhou1890/poem-v4-nuxt

网站地址:https://poem-nuxt.tony93.top

实现解析

技术栈

nuxt

Nuxt 简介

nuxt 是基于 vue 的 ssr 框架。方便了 ssr 开发,但也有诸多约束。nuxt 的路由可以是约定式,也可以自定义。约定式路由模式下,pages 下面就是各个页面,layouts 下面是布局。其在 vue 原有的生命周期上添加了 asyncData 和 fetch。asyncData 在页面创建前调用,是阻塞的。所以可以在 asyncData 里请求 api。fetch 是在 created 之后调用,主要用来填充 store 数据,非阻塞。

篇幅有限,就不完整介绍 nuxt 了。反正 nuxt 文档挺长的。

项目结构

/.
│  .editorconfig
│  .eslintrc.js
│  .gitignore
│  .prettierrc
│  ecosystem.config.js // pm2 配置文件
│  jsconfig.json
│  nuxt.config.js // nuxt 配置文件
│  package.json
│  README.md
│  tsconfig.json
│  yarn.lock
│  
├─assets // 需要打包引用的资源文件
│  │  global.ts // 这个我随便放的
│  │  README.md
│  │  
│  ├─icons
│  │  └─svg
│  │          xxx.svg
│  │          
│  ├─images
│  │      background.png
│  │      
│  └─style
│          global.less
│          variables.less
│          
├─components
│  │  Header.vue
│  │  README.md
│  │  
│  ├─Pagination
│  │      index.vue
│  │      
│  ├─PoemItemCom
│  │      index.vue
│  │      
│  ├─PoemListCom
│  │      index.vue
│  │      
│  ├─SvgIcon
│  │      index.vue
│  │      
│  └─Tabs
│          index.vue
│          
├─layouts
│      default.vue
│      README.md
│      
├─middleware
│      README.md
│      
├─pages
│      article.vue
│      author.vue
│      authorList.vue
│      collection.vue
│      index.vue
│      poemList.vue
│      README.md
│      search.vue
│      subject.vue
│      subjectPoem.vue
│      
├─plugins
│      axios.js
│      icon.js
│      README.md
│      svgo.yml
│      
├─services
│      api.ts
│      
├─static
│      favicon.ico
│      README.md
│      robots.txt // 练习项目,不需要搜索引擎抓取
│      
└─store
        index.js
        README.md

Axios 的使用

在有 this 的生命周期里,使用 this.$axios,在 asyncData 里使用解构参数里的 $axios。

// pages/index.vue
export default Vue.extend({
  name: 'Home',
  asyncData({ $axios }) {
    return Promise.all([
      $axios.get(urls.home, {
        params: {
          home: true,
        },
      }),
      $axios.get(urls.poemListRandom),
    ]).then((res) => {
      return {
        authors: res[0].data.authors,
        poems: res[1].data,
      }
    })
  },
  data() {
    return {
      poems: [],
      authors: [],
    }
  },
}

但在使用之前需要配置一下。axios 是作为插件使用的,所以需要在 plugins 文件夹下编写 axios.js 文件。

// plugins/axios.js
export default function ({ app: { $axios } }) {
  // 数据访问前缀
  $axios.defaults.baseURL = 'https://xxx/xxx'

  $axios.interceptors.request.use(
    (config) => config,
    (error) => {
      Promise.reject(error)
    }
  )

  $axios.interceptors.response.use(
    (response) => {
      if (response.data.code !== 0) {
        return Promise.reject(response.data)
      } else {
        return response.data
      }
    },
    (error) => {
      return Promise.reject(error)
    }
  )
}
// nuxt.config.js
export default {
  // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
  plugins: ['@/plugins/axios'],

  // Modules: https://go.nuxtjs.dev/config-modules
  modules: [
    // https://go.nuxtjs.dev/axios
    '@nuxtjs/axios',
  ],

  // Axios module configuration: https://go.nuxtjs.dev/config-axios
  axios: {},
}

Svg-Icon 的封装

为了方便地使用 svg,需要封装一个 svg-icon 组件。

// components/SvgIcon/index.vue
<template>
  <svg :class="svgClass" aria-hidden="true">
    <use :xlink:href="iconName" />
  </svg>
</template>

<script>
export default {
  name: 'SvgIcon',
  props: {
    iconClass: {
      type: String,
      required: true,
    },
    className: {
      type: String,
      default: '',
    },
  },
  computed: {
    iconName() {
      return `#icon-${this.iconClass}`
    },
    svgClass() {
      if (this.className) {
        return 'svg-icon ' + this.className
      } else {
        return 'svg-icon'
      }
    },
  },
}
</script>

<style scoped>
.svg-icon {
  width: 1em;
  height: 1em;
  vertical-align: -0.15em;
  fill: currentColor;
  overflow: hidden;
}
</style>
// plugins/icon.js
import Vue from 'vue'
import SvgIcon from '@/components/SvgIcon' // svg组件

// register globally
Vue.component('SvgIcon', SvgIcon)

const req = require.context('@/assets/icons/svg', false, /\.svg$/)
const requireAll = (requireContext) => requireContext.keys().map(requireContext)
requireAll(req)
// nuxt.config.js
import path from 'path'

export default {
  // Global CSS: https://go.nuxtjs.dev/config-css
  css: ['@/assets/style/variables.less', '@/assets/style/global.less'],

  // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
  plugins: ['@/plugins/icon', '@/plugins/axios'],



  // Build Configuration: https://go.nuxtjs.dev/config-build
  build: {
    extend(config, _) {
      const svgRule = config.module.rules.find((rule) => rule.test.test('.svg'))
      svgRule.exclude = [path.resolve(__dirname, 'assets/icons/svg')]

      // Includes /assets/icons/svg for svg-sprite-loader
      config.module.rules.push({
        test: /\.svg$/,
        include: [path.resolve(__dirname, 'assets/icons/svg')],
        loader: 'svg-sprite-loader',
        options: {
          symbolId: 'icon-[name]',
        },
      })
    },
  },
}

less 的使用和全局样式

安装完 less 和 less-loader 就可以使用 less 了。至于全局样式,创建好文件后 nuxt.config.js 里配置一下就可以了。

// nuxt.config.js
export default {
  // Global CSS: https://go.nuxtjs.dev/config-css
  css: ['@/assets/style/variables.less', '@/assets/style/global.less'],
}

keep-alive 的使用

因为首页的诗词是随机的,所以首页需要使用 keep-alive 属性,避免每次回到首页都是不一样的内容。

// layouts/default.vue
<Nuxt keep-alive :keep-alive-props="{ include: ['Home'] }" />

include 数组里的组件名称就是组件的 name 属性。如果没写 name,就是 nuxt 自动生成的组件名称。

head 的配置

既然是针对 seo 的改版,metaInfo 自然是需要的。在 nuxt 里,只需要配置页面的 head 属性就可以。

// pages/author.vue
export default Vue.extend({
  name: 'Author',
  head() {
    return {
      title: `作者--${this.$route.query.author}`,
      meta: [
        {
          hid: 'description',
          name: 'description',
          content: `${this.$route.query.author}的诗词`,
        },
      ],
    }
  },
})

除了各个页面单独配置,最好也设置一个全局的。

// nuxt.config.js
export default {
  // Global page headers: https://go.nuxtjs.dev/config-head
  head: {
    title: '品词轩',
    htmlAttrs: {
      lang: 'zh',
    },
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: '' },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
  },
}

路由参数变化

nuxt 并没有默认根据参数变化重新渲染页面。路由相同的页面,不管参数怎么变,nuxt 都视为同一个页面。这在实际应用中肯定是不合适的。所以,需要打开参数监听——watchQuery。

// nuxt.config.js
export default {
  // https://www.nuxtjs.cn/api/pages-watchquery
  watchQuery: true,
}

然后,在页面里设置需要监听的参数。

// pages/search.vue
export default Vue.extend({

  watchQuery: ['ap', 'tp', 'cp', 'type', 'keyword'],
})

部署

本地开发完后提交 git,然后服务器上拉取项目,安装依赖。然后 yarn build 打包。

我是 nginx + node 的形式。nginx 根据域名代理到 nuxt 服务端口。

按照 nuxt 文档的指引,建立的 ecosystem.config.js 文件,但 pm2 start 启动后怎么也无法访问。用 netstat -tunlp 命令也看不到 3000 端口。

// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'NuxtAppName',
      exec_mode: 'cluster',
      instances: 'max', // Or a number of instances
      // script: './node_modules/nuxt/bin/nuxt.js',

      script: 'yarn',
      args: 'start'
    }
  ]
}

后来我把 script 部分换成 yarn,package.json 里的命令加上端口就好了。

// package.json
"scripts": {
  "start": "nuxt-ts start --port 8100",
},

本来以为部署会很顺利,没想到折腾了好久~~~~~~

性能

相比于 puppeteer 方案,nuxt 性能要好很多。在不使用缓存的情况下,浏览器端测试 qps 可以达到 30。配置为 1C2G1M 的 ecs。

总结

相比于传统服务端渲染,nuxt 和 puppeteer 都是首次加载服务端渲染,后续操作客户端渲染。所以虽然性能稍差,但可以满足需求。特别是 nuxt 这一类的方案,其性能优于 puppeteer,结合 redis 一类的内存缓存技术应该可以达到较高的 qps。但 puppeteer 泛用性强,不限定技术栈,并且还可以用于爬虫、海报渲染等场景。所以具体用什么方案还是得看场景。