关于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,你必须要掌握以下几点知识:
- vuejs、vue-router、vuex等
- nodejs编程
- webpack构建前端工程
- 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;"> 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="输入博客内容"--> <!--></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=""> <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=""> <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"--> <!-->--> <!--<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 > ©2017-{{(new Date()).getFullYear()}} <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 dev
或npm run start
便可。
资源链接
- vue-ssr
- nuxtjs
- vuetify
- iview
- 一个完美的nodejs进程管理工具pm2