乐趣区

Nuxtjs服务端渲染实践搭建一个blog

关于 SSR 的简介

SSR,即服务端渲染,这其实是旧事重提的一个概念,我们常见的服务端渲染,一般见于后端语言生成的一段前端脚本,如:php 后端生成 html+jsscript 内容传递给浏览器展现,nodejs 在后端生成页面模板供浏览器呈现,java 生成 jsp 等等。

Vuejs、Reactjs、AngularJs 这些 js 框架,原本都是开发 web 单页应用 (SPA) 的,单页应用的好处就是只需要初次加载完所有静态资源便可在本地运行,此后页面渲染都只在本地发生,只有获取后端数据才需要发起新的请求到后端服务器;且因为单页应用是纯 js 编写,运行较为流畅,体验也稍好,故而和本地原生应用结合很紧密,有些对页面响应流畅度要求不是特别苛刻的页面,用 js 写便可,大大降低了 app 开发成本。

然而单页应用并不支持良好的 SEO,因为对于搜索引擎的爬虫而言,抓取的单页应用页面源码基本上没有什么变化,所以会认为这个应用只有一个页面,试想一下,一个博客网站,如果所有文章被搜索引擎认为只有一个页面,那么你辛辛苦苦写的大多数文章都不会被收录在里面的。

SSR 首先解决的就是这个问题,让人既能使用 Vuejs、Reactjs 来进行开发,又能保证有良好的 SEO,且技术路线基本都是属于前端开发栈序列,语言语法没有多大变化,而搭载在 Nodejs 服务器上的服务端渲染又可以有效提高并发性能,一举多得,何乐而不为?

ps:当然,目前某些比较先进的搜索引擎爬虫已经支持抓取单页应用页面了,比如谷歌。但并不意味着 SSR 就没用了,针对于资源安全性要求比较高的场景,搭载在服务器上的 SSR 有着天然的优势。

关于 Nuxtjs

这里是官方介绍,Nuxtjs 是诞生于社区的一套 SSR 解决方案,是一个比较完备的 Vuejs 服务端渲染框架,包含了异步数据加载、中间件支持、布局支持等功能。

关于 nuxtjs,你必须要掌握以下几点知识:

  1. vuejs、vue-router、vuex 等
  2. nodejs 编程
  3. webpack 构建前端工程
  4. babel-loader

如果想使用进程管理工具,推荐使用 pm2 管理 nodejs 进程,安装方式为:npm install -g pm2

搭建一个 blog

准备好工具

推荐下载

这里 iview 将作为一个插件在 nuxtjs 项目中使用。

注意几个配置:
nux.config.js

module.exports = {
  /*
  ** Headers of the page
  */
  head: {title: '{{ name}}',
    meta: [{ charset: 'utf-8'},
      {name: 'viewport', content: 'width=device-width, initial-scale=1'},
      {hid: 'description', name: 'description', content: '{{escape description}}' }
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico'}
    ]
  },
  plugins: [{src: '~plugins/iview', ssr: true}
  ],
  /*
  ** Customize the progress bar color
  */
  loading: {color: '#3B8070'},
  /*
  ** Build configuration
  */
  build: {
    /*
    ** Run ESLint on save
    */
    extend (config, { isDev, isClient}) {if (isDev && isClient) {
        config.module.rules.push({
          enforce: 'pre',
          test: /\.(js|vue)$/,
          loader: 'eslint-loader',
          exclude: /(node_modules)/
        })
      }
    }
  }
}

plugins 文件夹下,加入 iview.js

import Vue from 'vue';
import iView from 'iview';

Vue.use(iView);
import 'iview/dist/styles/iview.css';

如果你想要加入其它的配置,可以在 nuxt.config.js 的 plugins 配置项中加入,同时在 plugins 文件夹下加入引入逻辑。例如:
nuxt.config.js

{src: '~plugins/vuetify', ssr: true}

plugins/vuetify.js

import Vue from 'vue'
import Vuetify from 'vuetify'
Vue.use(Vuetify)

import 'vuetify/dist/vuetify.min.css'
import 'material-design-icons-iconfont/dist/material-design-icons.css'

配置很方便。

开始写页面

页面布局

<template>
  <div data-app>
    <v-app>
      <!-- header -->
      <v-toolbar dark color="primary" fixed v-show="showToolbar">
        <!--<v-toolbar-side-icon @click="drawer = !drawer"></v-toolbar-side-icon>-->

        <v-toolbar-title class="white--text"><a href="/" style="text-decoration-line:none;line-height: 40px;height: 40px;font-size:1.2em;color:white;">&nbsp;Blog</a></v-toolbar-title>

        <v-spacer></v-spacer>
        <v-spacer></v-spacer>
        <v-spacer></v-spacer>
        <!--<v-btn icon @click="showSearch">-->
          <!--<v-icon>search</v-icon>-->
        <!--</v-btn>-->
        <!--<v-text-field-->
          <!--hide-details-->
          <!--prepend-icon="search"-->
          <!--single-line-->
          <!--clearable-->
          <!--color="yellow"-->
          <!--placeholder="输入博客内容"-->
        <!--&gt;</v-text-field>-->
        <v-autocomplete
          v-model="searching"
          :items="articles"
          item-text="name"
          item-value="id"
          color="red"
          prepend-icon="search"
          placeholder="输入搜索内容"
          hide-no-data
          hide-selected
          :loading="isLoading"
          browser-autocomplete
          clearable
          :search-input.sync="changeSearch"
        >
          <template slot="selection" slot-scope="data">
            {{data.item.name}}
          </template>
          <template slot="item" slot-scope="data">
            <v-list-tile-content @click="toDetail(data.item.id)">
              <v-list-tile-title v-html="data.item.name"></v-list-tile-title>
              <v-list-tile-sub-title v-html="data.item.group"></v-list-tile-sub-title>
            </v-list-tile-content>
          </template>
        </v-autocomplete>

        <v-menu bottom transition="slide-y-transition" offset-y open-on-hover left>
          <v-btn icon
          slot="activator"
          >
            <img :src="languageChoice" alt=""width="26px">
          </v-btn>
          <v-list>
            <v-list-tile @click="languageChoice ='/imgs/cn.webp'">
              <img src="/imgs/cn.webp" alt="">&nbsp; <v-list-tile-title> 简体中文 </v-list-tile-title>
            </v-list-tile>
            <v-list-tile @click="languageChoice ='/imgs/us.webp'">
              <img src="/imgs/us.webp" alt="">&nbsp;<v-list-tile-title>English</v-list-tile-title>
            </v-list-tile>
          </v-list>
        </v-menu>
        <v-tooltip bottom >
          <v-btn icon slot="activator" href="mailto:thundervsflash@qq.com" nuxt>
            <v-icon>contact_mail</v-icon>
          </v-btn>
          <span>mailto:thundervsflash@qq.com</span>
        </v-tooltip>
        <v-menu bottom left transition="slide-y-transition" offset-y open-on-hover>
          <v-btn
            slot="activator"
            dark
            icon
          >
            <v-icon>more_vert</v-icon>
          </v-btn>

          <!--<v-list style="width:150px">-->
            <!--<v-list-tile-->
              <!--href="/about"-->
              <!--target="_blank"-->
            <!--&gt;-->
              <!--<v-avatar size="30px" color="lime">-->
                <!--<v-icon dark small>account_circle</v-icon>-->
              <!--</v-avatar>-->
              <!--<v-spacer></v-spacer>-->
              <!--<v-list-tile-title style="text-align: end"><span style="margin-right:10px;"> 关于我 </span></v-list-tile-title>-->
            <!--</v-list-tile>-->
          <!--</v-list>-->
        </v-menu>
      </v-toolbar>






      <!-- content -->
      <v-content :style="contentStyle">
          <nuxt/>
      </v-content>


        <v-btn
          fab
          color="red"
          bottom
          right
          style="bottom:20%"
          fixed
          @click="toAdd"
        >
          <v-icon color="white">add</v-icon>
        </v-btn>


      <!-- footer -->
      <v-footer style="margin-top:25px;">
        <v-layout
          justify-center
          row
          wrap
        >
          <v-flex xs12 text-xs-center indigo darken-4 white--text py-2>
            Site's built by <a href="https://vuejs.org">vuejs</a>/<a href="https://vuetifyjs.com">vuetifyjs</a>/<a
            href="https://nuxtjs.org">nuxtjs</a>/<a href="https://lumen.laravel.com">lumen</a>/<a href="https://github.com/hhxsv5/laravel-s">laravel-swoole</a>/<a
            href="https://wiki.swoole.com/">swoole</a> etc.
          </v-flex>
          <v-flex
            indigo darken-4
            py-3
            text-xs-center
            white--text
            xs12
          >
            &copy;2017-{{(new Date()).getFullYear()}}&nbsp;<strong><a href="/">Rainbow-blog</a> by Henry. All rights reserved.</strong>
          </v-flex>
        </v-layout>
      </v-footer>
      <!-- back to top -->
      <v-fab-transition>
        <v-btn
          v-show="showUp"
          color="red"
          v-model="fab"
          dark
          fab
          fixed
          bottom
          right
          @click="$vuetify.goTo(target, options)"
        >
          <v-icon>keyboard_arrow_up</v-icon>
        </v-btn>
      </v-fab-transition>
      <v-snackbar
        v-model="snackbar"
        color="info"
        :timeout="3000"
        :vertical="true"
        top
        right
      >
        {{location}}
        <v-btn
          dark
          flat
          @click="snackbar = false"
        >
          Close
        </v-btn>
      </v-snackbar>
    </v-app>
  </div>
</template>
<script>
  export default {head: {},
    data() {
        return {
          location: "",
          snackbar: false,
          languageChoice: "/imgs/cn.webp",
          contentStyle: {marginTop:"64px"},
          showToolbar: true,
          drawer: null,
          items: [{ title: 'Home', icon: 'dashboard'},
            {title: 'About', icon: 'question_answer'}
          ],
          mini: false,
          right: null,
          showUp: false,
          fab: true,
          target: 0,
          options: {
            duration: 300,
            offset: 0,
            easing: 'easeInOutCubic'
          },
          articles: [ ],
          searching: "",
          isLoading: false,
          changeSearch: ""
        }
    },
    watch: {changeSearch(newV, oldV) {if (newV == 'undefined' || !newV) {return ;}
        this.isLoading = true
        // Lazily load input items
        this.$axios.get('https://api.hhhhhhhhhh.com/blog/index?'+ '_kw='+newV)
          .then(res => {
            this.articles = res.data
            console.log(this.articles);
          })
          .catch(err => {console.log(err)
          })
          .finally(() => (this.isLoading = false))
        console.log(this.articles);
      },
      languageChoice(value) {this.$axios.post('https://api.hhhhhhhh.com/hhhhh').then(res => {
          this.snackbar = true;
          this.location = res.data.location;
        });
      }
    },
    mounted() {window.addEventListener('scroll', () => {if (window.pageYOffset > 80) {
          this.showUp = true;
          if (this.$route.fullPath == '/') {this.showToolbar = true;}
        } else {
          this.showUp = false;
          if (this.$route.fullPath == '/') {this.showToolbar = false;}
        }
      });
      if (this.$route.fullPath == '/') {
        this.contentStyle.marginTop = "0px";
        this.showToolbar = false;
      }
    },
    methods: {toAdd() {location.href = '/hhhhh'},
      getHighlight(originStr) {if (!this.searching) {return originStr;}
        let ind = originStr.indexOf(this.searching);
        let len = this.searching.length;
        return originStr.substr(0, ind) + "<code>" + this.searching + "</code>" + originStr.substr(ind + len);
      },
      toDetail(id) {location.href = "/blog/"+id;},
    }
  }
</script>
<style>
html {
  font-family: "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  font-size: 16px;
  word-spacing: 1px;
  -ms-text-size-adjust: 100%;
  -webkit-text-size-adjust: 100%;
  -moz-osx-font-smoothing: grayscale;
  -webkit-font-smoothing: antialiased;
  box-sizing: border-box;
}

*, *:before, *:after {
  box-sizing: border-box;
  margin: 0;
}

.button--green {
  display: inline-block;
  border-radius: 4px;
  border: 1px solid #3b8070;
  color: #3b8070;
  text-decoration: none;
  padding: 10px 30px;
}

.button--green:hover {
  color: #fff;
  background-color: #3b8070;
}

.button--grey {
  display: inline-block;
  border-radius: 4px;
  border: 1px solid #35495e;
  color: #35495e;
  text-decoration: none;
  padding: 10px 30px;
  margin-left: 15px;
}

.button--grey:hover {
  color: #fff;
  background-color: #35495e;
}
div.v-image__image--cover {filter: blur(5px) !important;
}
.v-btn--floating .v-icon {height: auto !important;}
  code {
    box-shadow: none !important;
    -webkit-box-shadow: none !important;
  }
</style>

所有页面都写在 page/ 文件夹之下,例如新建一个 index.vue 页面

<template>
  <div>
    <v-parallax
      src="./bg2.jpg"
      :height="bgHeight"
    >
      <v-layout
        align-center
        column
        justify-center
      >
        <h1 class="display-2 mb-3" style="color:black;">Blog</h1>
        <h4 class="subheading" style="color:black;">hhhhhhhafadsjfjasdf</h4>
        <h4 class="subheading" style="color:black;">blabla 的个人博客站,深挖网站编程艺术 </h4>
      </v-layout>
    </v-parallax>
    <!-- the blog list -->
    <v-container>

      <v-layout row wrap>
        <v-flex d-flex xs12 sm6>
          <v-card>
            <v-toolbar color="primary" dark>
              <v-toolbar-title> 博客列表 </v-toolbar-title>
              <v-spacer></v-spacer>
              <v-btn icon fab flat small @click="pageMinus">
                <v-icon>keyboard_arrow_left</v-icon>
              </v-btn>
              <v-btn fab small dark flat>{{page}}</v-btn>
              <v-btn icon fab flat small @click="pagePlus">
                <v-icon>keyboard_arrow_right</v-icon>
              </v-btn>
            </v-toolbar>

            <v-list three-line :expand="true">
              <div v-for="(item, index) in items" :key="item.id">
                <v-list-tile
                  avatar
                  ripple
                  @click="toDetail(item.id)"
                >
                  <v-list-tile-content>
                    <v-list-tile-title><strong>{{item.title}}</strong></v-list-tile-title>


                    <v-list-tile-sub-title class="text--primary">{{item.headline}}</v-list-tile-sub-title>
                    <v-list-tile-sub-title>{{item.subtitle}}</v-list-tile-sub-title>
                    <div>
                      <v-chip outline color="pink" text-color="red" small v-for="cate in item.categories" :key="cate.id">
                        {{cate}}
                      </v-chip>
                    </div>
                  </v-list-tile-content>

                  <v-list-tile-action>
                    <v-list-tile-action-text>{{item.action}}</v-list-tile-action-text>


                    <v-icon
                      color="yellow darken-2"
                    >
                      keyboard_arrow_right
                    </v-icon>
                  </v-list-tile-action>

                </v-list-tile>
                <v-divider
                  v-if="index + 1 < items.length"
                ></v-divider>
              </div>
            </v-list>
          </v-card>
        </v-flex>


        <v-flex d-flex xs12 sm5 offset-sm1>
          <v-layout row wrap>
            <v-flex d-flex>
              <v-layout row wrap>
                <h2 style="margin-top:16px;"> 最新博文:</h2>
                <v-flex
                  d-flex
                  xs12
                  v-for="post in posts" :key="post.id"
                >
                    <v-card class="my-3" hover>
                      <v-img
                        v-if="post.imgUrl"
                        class="white--text"
                        height="150px"
                        :src="post.imgUrl"
                      >
                        <v-container fill-height fluid>
                          <v-layout>
                            <v-flex xs12 align-end d-flex>
                              <span class="caption">{{post.date}}</span>
                            </v-flex>
                          </v-layout>
                        </v-container>
                      </v-img>
                      <v-card-title class="headline"><strong>{{post.title}}</strong></v-card-title>
                      <v-card-text>
                        {{post.subtitle}}
                      </v-card-text>
                      <v-card-actions>
                        <v-chip outline color="pink" text-color="red" small v-for="cate in post.categories.slice(0,3)" :key="cate">
                          {{cate}}
                        </v-chip>
                        <v-spacer></v-spacer>
                        <v-btn @click="toDetail(post.id)" flat class="blue--text"> 查看博文 </v-btn>
                      </v-card-actions>

                    </v-card>
                </v-flex>
              </v-layout>
            </v-flex>
            <v-dialog
              v-model="openLoader"
              hide-overlay
              persistent
              width="300"
            >
              <v-card
                color="primary"
                dark
              >
                <v-card-text>
                  请稍候
                  <v-progress-linear
                    indeterminate
                    color="white"
                    class="mb-0"
                  ></v-progress-linear>
                </v-card-text>
              </v-card>
            </v-dialog>
          </v-layout>
        </v-flex>
      </v-layout>
    </v-container>
  </div>
</template>
<script>
  import axios from 'axios';
  export default {
    head: {
      title: "博客 - 首页",
      meta: [
        {
          hid: 'description',
          name: 'description',
          content: 'blog description'
        },
        {name: 'keywords', content: '博客, 代码, 技术,web 开发'},
        
        {name:"baidu-site-verification", content: "nVF2mYh7tG"}
      ]
    },
    asyncData() {return axios.get('https://blabla.blabla.com/blog/index?page=1').then(res => {return axios.get('https://blabla.blabla.com/blog/index?page='+1).then(res1 => {
          return {
            items: res.data,
            posts: res1.data.splice(0, 4)
          };
        });
      });
    },
    data () {
      return {
        openLoader: true,
        bgHeight: "920",
        title: 'Your Logo',
        page: 1,
        posts: [ ],
        items: [ ],
        isMaxPage: false
      }
    },
    mounted() {this.openLoader = false;},
    methods: {toggle (index) {const i = this.selected.indexOf(index)
        if (i > -1) {this.selected.splice(i, 1)
        } else {this.selected.push(index)
        }
      },
      toDetail(id) {location.href = "/blog/"+id;},
      pageMinus() {if (this.page == 1) {return ;}
        this.page--;
      },
      pagePlus() {if (this.isMaxPage) {return ;}
        this.page++;
      }
    },
    watch: {page(val) {
        this.openLoader = true;
        this.$axios.get('https://blabla.blabla.com/blog/index?page='+val).then(res => {
          this.items = res.data;
          if (this.items.length < 7) {this.isMaxPage = true;} else {this.isMaxPage = false;}
          this.openLoader = false;
        });
      }
    }
  }
</script>
<style>
  .v-parallax__image {filter: blur(9px)
  }
  .v-list--three-line .v-list__tile {height: 175px;}
  .v-chip--small {height: 18px;}
</style>

对这一部分代码的解读:

由于博客站使用的是 vuetify 编写的,故而引用了 vuetify 作为网站的 UI 插件。

布局

写法与单页应用类似,但要注意几个不同点:

  • 单页应用一般会用 vue-router 的写法表示加载路由页面内容的位置:
<router-view></router-view>

而在 nuxt 中,要写成

<nuxt/>
  • created 和 data 中的逻辑,是在服务端加载时处理的,并不是浏览器端,浏览器端的逻辑比如 window 或 location 等对象要在 mounted 中写,否则会报错。
  • head 中定义一些元数据,这些元数据会被爬虫抓取到,可以在每一个页面中自定义。

页面

  • 单文件组件中的模板的写法与单页应用并无而已,直接写就好,只是记住不要在模板中写 js 逻辑
  • vue 实例中 head 中可以定义的变量就是指 <head></head> 中定义的参数,例如本例:
head: {
  title: "首页",
  meta: [
    {
      hid: 'description',
      name: 'description',
      content: '我就是一个小站点!'
    },
    {name: 'keywords', content: '博客, 代码, 技术, 开发'},
    {name:"google-site-verification", content:"RHlJ7VR51QWbIQFsW_s5qQrbbQPNBkTwhVLCgbFu_6g"},
    {name:"baidu-site-verification", content: "nVF2mYh7tG"}
  ]
}

将会被 node 渲染为如下 html:

<head>
    <title> 首页 </title>
    <meta hid='description' name='description' content= '我就是一个小站点' />
    <meta name='keywords' content='博客, 代码, 技术, 开发' />
    <meta name='google-site-verification' content='RHlJ7VR51QWbIQFsW_s5qQrbbQPNBkTwhVLCgbFu_6g' />
    <meta name='baidu-site-verification' content= 'nVF2mYh7tG' />
</head>

这也是 SEO 的一个关键点,请注意。

  • 在渲染页面之前,如果有一些原始数据是需要从外部拿到后才可以继续的,使用 asyncData 异步获取数据,这里异步获取数据会在数据完全获取完毕后才会去渲染页面,例如本例:
asyncData() {return axios.get('https://api.fshkehfahsfua.com/blog/list?page=1').then(res => {return axios.get('https://api.blohfhsldfhl.com/blog/listpo2?page='+1).then(res1 => {
      return {
        items: res.data,
        posts: res1.data.splice(0, 4)
      };
    });
  });
},

这里要注意一下:asyncData 中定义的数据,最好在 data 中也定义一下,因为 asyncData 的数据会覆盖 data。

data() {
    return {posts: [],
        items: [],}
}

哦对了,还有 blog 详情页_id.vue

_id.vue表示可以用形似 blog/123 来进行访问,这是 vuejs 单文件组件的常用写法,这里不赘述。

<template>
    <div>
        哈哈哈这里是详情页,敏感代码不贴了~
    </div>
</template>

<script>
import axios from 'axios';
let initId = 0;
export default {validate({params}) {
      initId = params.id;
      return /^\d+$/.test(params.id)
    },
    head() {
      return {
        title: this.title,
        meta: [{hid: 'description', name: "description", content: this.descript},
          {name: "keywords", content: this.keywords},
        ],
      }
    },
    asyncData() {},
    data() {}
}
</script>

获取那个传过来的 ID,就用 validate()中的写法,在下面用的时候,就直接使用 initId 便可

结尾

以上是一些源码的解析,本地运行命令 npm run devnpm run start便可。

资源链接

  • vue-ssr
  • nuxtjs
  • vuetify
  • iview
  • 一个完美的 nodejs 进程管理工具 pm2
退出移动版