服务端渲染(nuxt.js) 入坑到出坑

3,868 阅读7分钟

概述:

由于公司网站需要改版,想前后端分离,但又不想影响seo,后面就决定使用nuxt.js做服务端渲染。对于nuxt.js之前是一点没有了解过,经过几天文档查看。后面直接上手开始干了,在此记录下自己踩的坑,以及一些优化。现在网站已经上线FreeBuf 。   关于nuxt.js文档上描述比较清晰,没有了解过的,自行查看文档。后面我直接跳过项目搭建,以及一些文档中提及到的问题。

问题:

1. 关于路由

了解nuxt.js的都知道,路由是通过page目录下面的文件生成的。但我们做的是改版,老版的各种奇怪的路由以及各种不同路由指定同一个页面,这样的话每一个都创建一个目录文件是不现实的。以下是我的处理方法:

创建一个utils目录以及router.js文件如下:

const { resolve } = require('path')
const router = [  
    {    
        name: 'vuls',    
        path: '/vuls',    
        component: resolve('./', 'pages/articles/web/index.vue')  
    },  
    {    
        name: 'columnId',    
        path: '/column/:id(\\d+).html',    
        component: resolve('./', 'pages/articles/web/_id.vue')  
    },  
    {    
        name: 'webid',    
        path: '/web/:id',    
        component: resolve('./', 'pages/articles/web/_id.vue')  
    },
    ....
]
module.exports = router

在nuxt.config.js引入:

const zrouter = require('./utils/router')
...
module.exports = {  
mode: 'universal',
  ...
router: {    
    extendRoutes (router) {
      //我这样写的目的是把自定义的路由拼接在前面(由于一些路由的问题所以需要优先匹配自己定义的路由)
        const routerList = zrouter.concat(router)       
        return routerList    
    }  
 }
  ...
}

在这里我要提到的时我遇到一个特坑的问题:由于我在page目录下创建了

**/column/:id **路由指向到一个页面。但上线后告诉我 **/column/6666.html **这种路由指向的是文章详情和 **/column/:id **完全是两个页面。。。处理方法见上。这也是我为啥把自定义路由拼接在前面优先匹配的问题了。

2. 环境变量

在开发中环境变量肯定是需要的,我的配置方法如下:

在根目录创建env.js

const env = {  
    production: {    
        base_url: 'xxxxxxx',    
        host_name: 'xxxxxxx'  
    },  
    development: {    
        base_url: 'xxxxxx',    
        host_name: 'xxxxxx'  
    }
}
module.exports = env

nuxt.config.js

const env = require('./env')
module.exports = {  
mode: 'universal',  
    // 环境变量配置  
    env: {       
        base_url: env[process.env.NODE_ENV].base_url,    
        host_name: env[process.env.NODE_ENV].host_name,  
    },
}

package.json

{  
    ...  
    "scripts": {    
        "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server --exec babel-node",    
        "build": "nuxt build",    
        "start": "cross-env NODE_ENV=production pm2 start server/index.js --max-memory-restart 100M -i max",  
    },
    ...
}

这样就可以在页面中通过process.env.xxxx来使用了。

3. 自定义SVG全局使用

首先在assets目录创建icons目录用来存放svg

在components目录下创建svgIcon/index目录组件

<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 `#${this.iconClass}`;
	},
	svgClass() {
	    if (this.className) {
		return 'svg-icon ' + this.className;
	    } else {
		return 'svg-icon';
	    }
	}
    }
};
</script>

<style scoped lang="less">
.svg-icon {
    width: 1em;
    height: 1em;
    vertical-align: -0.15em;
    fill: currentColor;
    overflow: hidden;
}
</style>

在plugins目录下面创建svg-icon.js

import Vue from 'vue'
import SvgIcon from '@/components/svgIcon'
// 注册组件
Vue.component('svg-icon', SvgIcon)
// 预请求svg组件(通过之前的svg-sprite-loader加载)
const req = require.context('@/assets/icons', false, /\.svg$/)
const requireAll = requireContext => requireContext.keys().map(requireContext)
requireAll(req)

在nuxt.config.js中 

module.exports = {  
    mode: 'universal',
    plugins: [
        '@/plugins/svg-icon',
    ], 
    build: {
       extend (config, ctx) {                
            const svgRule = config.module.rules.find(rule => rule.test.test('.svg'))      
            svgRule.exclude = [resolve(__dirname, 'assets/icons')]      
            config.module.rules.push({        
                test: /\.svg$/,        
                include: [resolve(__dirname, 'assets/icons')],        
                loader: 'svg-sprite-loader',        
                options: {          
                    symbolId: '[name]'        
                }      
            })    
        }
    }
}

在组件中使用

<svg-icon icon-class="xxx" class-name="xxx" />

4. 文件版本号避免缓存

module.exports = {  
    ...
    build:{
        filenames: {            
            app: ({ isDev }) => isDev ? '[name].js' : process.env.npm_package_version + '.[contenthash].js',
            chunk: ({ isDev }) => isDev ? '[name].js' : process.env.npm_package_version + '.[contenthash].js',      
            css: ({ isDev }) => isDev ? '[name].css' : process.env.npm_package_version + '.[contenthash].css'    
        },
        analyze: false,//特别要注意这个要设置false不然js是无法添加版本号的    
        ...
    }  
}

5. 修改文件引入路径及cdn

module.exports = {  
    ...
    build:{
        publicPath: '/freebuf/',
        publicPath: 'https://www.xxx',// 打包后将.nuxt/dist/client目录的内容上传到您的CDN即可!
    }  
}

6. 关于用户token的问题

之前是想在服务端通过cookie里面的字段来获取token,之前是在zhong'jian但是会出现用户会串的问题,最后放弃在服务端做获取token的操作,放在客户端来做,在页面中与用户相关的内容都放在客户端来请求,这样也减轻服务器的压力

7. 路由鉴权

在网站中需要一些登录过的用户才能访问的页面,我的处理方法如下

在plugins目录下面创建router.js

import { getCookie } from '../utils/tool'
const authorityRouter = ['write']
export default ({ app, store }) => {  
    app.router.beforeEach((to, from, next) => {    
        if (authorityRouter.includes(to.name) && !getCookie('token')) {          
            return window.location.href = `${process.env.host_name}/oauth`    
        }    
        next()  
    })
}

在nuxt.config.js中

module.exports = {  
    mode: 'universal',
    ...
    plugins: [
        { src: '@/plugins/router', ssr: false },
    ],    
    ...
}

记住这只在服务端运行的。

8. 关于server error

由于用到nuxt来做服务端渲染,必须要用到asyncData,在asyncData中进行ajax请求时错误不做捕获处理的必然会出现server error。

我的处理如下:

首先在layouts中创建error页面

<template>  
    <div class="container">    
        <div class="container-top" style="background-image:url('/images/404.png')" />    
        <div class="container-bottom">      
            <p>404</p>      
            <nuxt-link to="/">        
                <a-button type="primary"> 返回首页</a-button>     
            </nuxt-link>    
        </div>  
    </div>
</template>
<script>
export default {  
    props: ['error'],  
    layout: 'blog', // 你可以为错误页面指定自定义的布局  
    head () {    
        return {      
            title: '404'    
        }  
    }
}
</script>

在asyncData中

async asyncData ({ route, error }) {    
    const [listData1, listData2, listData3, listData4] = await Promise.all([      
        userCategory().catch((e) => {        
            return error({ statusCode: 404 })      
        }),      
        categoryKeyword().catch((e) => {        
            return error({ statusCode: 404 })      
        }),      
        categorylist().catch((e) => {        
            return error({ statusCode: 404 })      
        }),      
        columnHot().catch((e) => {        
            return error({ statusCode: 404 })      
        })    
    ])    
    return {      
        userDataList: listData1.data,      
        seoData: listData2.data,      
        dataLists: listData3.data,      
        homeColumnData: listData4.data    
    }  
}
// 或者
async asyncData ({ route, error }) {    
    const [listData1, listData2, listData3, listData4] = await Promise.all([   
        try{
            userCategory()     
            categoryKeyword()      
            categorylist()      
            columnHot()  
        } catch{
            return error({ statusCode: 404 })
        }   
    ])    
    return {      
        userDataList: listData1.data,      
        seoData: listData2.data,      
        dataLists: listData3.data,      
        homeColumnData: listData4.data    
    }  
}

这样捕获错误的话就会指向自定义的error页面,也可以通过不同的statusCode值定义不同的页面内容

9. vuex

关于状态管理的话,nuxt.js里面以及集成了。

在store目录下面创建userInfo.js

export const state = () => ({
    userInfo: {},
})
export const mutations = {
    setUserInfo (state, text) {
        state.userInfo = text
    },
    deleteUserInfo (state) {
        state.userInfo = ''
    },
}

在页面中使用:

// 读
computed: {
    userInfoData () {      
        return this.$store.state.userInfo.userInfo    
    }  
}

//写
const userData = {}
this.$store.commit('userInfo/setUserInfo', userData)

//删
this.$store.commit('userInfo/deleteUserInfo')

由于nuxt.js 的集成,在服务端的一些也是可以操作vuex的

优化

1. 代码

  1. ui组件按需引入自定义主题
  2. 图片懒加载
  3. 全局css

2. 文件

const CompressionPlugin = require('compression-webpack-plugin')
const TerserPlugin = require('terser-webpack-plugin')module.exports = {  
    ...
    build:{
        plugins: [ 
        //代码压缩为gz  这个需要后端配合  Content-Encoding:gzip  
            new CompressionPlugin({
                test: /\.js$|\.html$|\.css/, // 匹配文件名        
                threshold: 10240, // 对超过10kb的数据进行压缩        
                deleteOriginalAssets: false // 是否删除原文件      
            }),
            // 屏蔽一些警告及console debugger     

            new TerserPlugin({        
                terserOptions: {          
                    compress: {            
                        warnings: true,            
                        drop_console: true,            
                        drop_debugger: true
                    }        
                }      
            })    
        ],
        // 大文件切割
        optimization: {            
            splitChunks: {
                minSize: 10000,
                maxSize: 250000
            } 
        } 
    } 
}

3. 缓存

缓存我使用的是lru-cache具体查看文档,在这里我说一下出现的坑。

// 判定是否是需要缓存的接口,是否有缓存
instance.interceptors.request.use(  (config) => {        
        if (config.cache) {      
            const { params = {}, data = {} } = config      
            key = md5(config.url + JSON.stringify(params) + JSON.stringify(data)) 
            // 记住 config.url === CACHED.get(key).config.url  这个判断必须要加上不然会出现取缓存内容错乱问题
            // 这也是我不明白的问题key已经是唯一的了,但还是会出现数据错乱问题
            if (CACHED.has(key) && config.url === CACHED.get(key).config.url) {        
                return Promise.reject(CACHED.get(key))      
            } else {        
                return config      
            }    
        } else {      
            return config    
        }
}
//接口封装页面 可以设置不同接口缓存不同时间
import instance from '@/plugins/service.js'
export const getHotList = (params) => {  
    return instance.get('/index/index', {    
        params, cache: true, time: 5000  
    }
)}
// 存缓存
instance.interceptors.response.use(  
    // 请求成功  
    (res) => {    
        if (res.status === 200) {      
        // code根据实际情况      
            if (res.data.code !== 200) {        
                return Promise.reject(res.data)      
            } else {        
                if (res.config.cache) {          
                    // 返回结果前先设置缓存          
                    CACHED.set(key, res, res.config.time)              
                }        
                return Promise.resolve(res.data)      
            }    
        } else {      
            return Promise.reject(res)    
        }  
    },  
    // 请求失败  
    (error) => {      
        if (error) {          
            if (error.status === 200) {        
                return Promise.resolve(error.data)      
            } else {        
                return Promise.reject(error)      
        }    
    }   
} 

首先我解释下上面的写法:

为啥我在axios拦截器request中使用return Promise.reject(CACHED.get(key))

原因是我在拦截器中无法直接运行 return Promise.resolve()

更不可能终结此次请求,后来直接return Promise.reject(CACHED.get(key))

把缓存数据带过来,在通过接口响应拦截器进行判断是否是从缓存中的数据,直接返回这个数据就行,对封装的接口是没有影响的。

很奇怪的一点是数据会串的问题,取值是通过key来取值的但不知道为啥返回的数据会是另外一个接口的数据,感觉很诡异。后面判断是否有缓存时多加了一个条件,就是当前的接口和缓存中的接口是否一致,勉强解决了此问题。

页面缓存:

在根目录创建pageCache.js

import instance from './plugins/service.js'
const cachePage = require('./globalCache')
const cacheUrl = require('./utils/urlCache')
export default async function (req, res, next) {  
let isUpdata = false  
const pathname = req.originalUrl  
const urlData = cacheUrl(pathname)  
if (pathname === '/') {    
    console.log(parseInt(new Date().getTime() / 1000))    
    await instance.get('xxxxxxxx', { time: parseInt(new Date().getTime() / 1000) }).then((res) => {      
        if (res.data.home) {        
        console.log('首页有更新-需要缓存-设置缓存内容', req.originalUrl)        
        isUpdata = true        
        // cachePage.set('/', null)      
    }     
     console.log(res)    
    }).catch((err) => {      
        console.log(err)    
    })  
}  
const existsHtml = cachePage.get(pathname)
    if (existsHtml && !isUpdata) {    
        console.log('取缓存内容', req.originalUrl)  
        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })  
        return res.end(existsHtml, 'utf-8')  
    } else {    
        res.original_end = res.end    
        // 重写res.end    
        if (!cacheUrl(pathname)) {      
            console.log('没有缓存-不需要缓存', req.originalUrl)      
            return next()    
        } else {      
            res.end = function (data) {        
                if (res.statusCode === 200) {          
                    // 设置缓存          
                    console.log('需要缓存-设置缓存内容', req.originalUrl)          
                    cachePage.set(pathname, data, urlData.time)        
                }        
                res.original_end(data, 'utf-8')      
            }    
        }  
    }  
    next()
}

utils中创建urlCache.js

const cacheUrl = function (url) {
    const list = new RegExp('/[articles | abc]+(/[a-z]*)?')
    const nuber = new RegExp('[0-9]')  
    if (url === '/') {    
        return { name: '/', time: 1000 * 60 }  
    } else if (nuber.test(url)) {    
        return false  
    } else if (list.test(url)) {    
        return { name: url, time: 1000 * 60 * 5 }  
    } else {    
        return false  
    }
}
module.exports = cacheUrl

这个文件的意义就是控制所需要缓存的页面已经不同缓存时间

在根目录中创建globalCache.js

const LRU = require('lru-cache')
const cachePage = new LRU({  
    max: 10 //设置最大缓存数量
})
module.exports = cachePage

最后在nuxt.config.js中引入

module.exports = {  
    ...
    serverMiddleware: [    
        './pageCache'  
    ],    
    ...
}

在这里我要提及一个问题就是检测是否有更新这个问题

上面我注释掉的**cachePage.set('/', null)**这个之前是检测到有更新,就把缓存内容设为null,在高并发的时候会引发一些问题。因为缓存内容是共用的,这边设置为null的时候,可能出现用户取缓存时,取到的结果是null。所以后面运用变量的方法来控制,避免用户取到null而引发错误 

4. 部署运行

由于运行时是通过node来运行的,在此项目部署后出现了一些问题,测试服和线上预发布环境都是ok的,但最后上线时刻首页频繁出现server error为了排查这个问题发了很长时间,最后想到是不是服务器内存和cpu的原因。

最后通过查看服务器:...cpu竟然高达100%以上。而且还只是一个线程在运行。突然明白了这个不挂才怪呢 。

后面就让运维安装了PM2,我这边改了下配置:

{  
    ...  
    "scripts": {    
        "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server --exec babel-node",    
        "build": "nuxt build",    
        "start": "cross-env NODE_ENV=production pm2 start server/index.js --max-memory-restart 100M -i max",  
    },
    ...
}
虽然node只能使用单核cpu的  ** -i max** 是服务器分配了几核就运行几个,这样的话服务器如果是8核的,运行8个的话,就会有8个线程。这样会大大减小单核的压力了。

node还有个问题就是内存溢出问题,通过 --max-memory-restart 100M来设置当内存超过多少时就重新启动。避免内存溢出问题。

下面是测试服服务器只有两核现在可以看到有两个进程了。

后面项目基本上稳定下来了。。。

5. rss 页面显示设置: