本文主要讲述如何将一个现有的vue-cli项目按nuxt进行SSR改造。

nuxt官网文档有新项目使用nuxt的相关指南,如有相关需要的可以直接转移到官网了解
nuxt官网地址:nuxt官网

注意官网上相关文档基本都是英文的,如想看中文文档可以在w3cSchool上找到,地址:https://www.w3cschool/nuxtjs/nuxtjs-b4kl36fw.html

1. 背景

今年自己完成了几个项目的开发,基本都是基于自己总结的一整套前后端基础应用来进行开发的,其中前端使用VUE+elementUi(移动端使用vant);开发过程确实爽快;但其中一个项目开发完成后,遇到了大问题:由于采用的是Vue的单页面模式进行开发,网站信息搜索引擎无法做索引!

搜索引擎无法进行索引的核心原因就是,其在爬取网站数据的时候,是不会执行其中包含的JS过程的;而采用Vue的方式开发的应用,其数据都是来源于axios或者其它的ajax方法获取的数据!也就是说,想要友好的支持搜索引擎,就必须采用服务器端渲染的相关技术,比如JSP,就是一个典型的服务器端渲染技术,用户请求一个地址然后展示到浏览器中的数据都是服务器端处理好的,浏览器只管展示;又比如静态页面,所有页面都是预先编写或生成好的,浏览器将请求拿到的数据直接展现即可。

对于Java生态来说,有以下方案可以实现服务器端渲染:

  • JSP
  • 模板引擎,如Thymeleaf/Velocity等;
  • 基于Vue的SSR改造;

JSP基本已经算是步入老年,除了一些非常古老的系统,新的相信已经很少人使用。Thymeleaf在Spring官网文档中都有相关的集成案例,如果是一个全新的项目,应该算是比较好的方案;但对于已经完成前端所有功能开发的项目来说,使用模板引擎重新实现一套成本过高。对于我来说,也只能选择最后一个方案了。

​ 关于Vue服务器端渲染的介绍,可以参考官方文档:https://cn.vuejs/v2/guide/ssr.html。这其中主要有两种方式,其一是使用vue-server-renderer插件,其二是使用nuxt;在本项目做改造时,关于vue-server-renderer的介绍不如现有文档清晰,因此使用了nuxt的方案。关于vue-server-renderer的方案,后续有时间再尝试将现有项目进行改造,个人感觉官方的东西按理应该会比nuxt要好用,但由于没用过也无法做出真实的评价,建议有兴趣的可以尝试。

2. 现有项目改造

nuxt与传统的vue-cli项目,在目录结构、路由、组件生命周期上都有所不同;主要的改造步骤如下:

2.1 组件安装

  • nuxt安装 :

    cnpm install nuxt --save

  • cross-env:

    cnpm install cross-env --save-dev

    cross-env主要是在运行npm命令时指定环境变量

  • axios及proxy安装(也可以使用原生的axios)

    cnpm install @nuxtjs/axios --save-dev
    cnpm install @nuxtjs/proxy --save-dev

2.2 不同环境配置

如果有多个环境,如本地、测试、生产等,那么需要为每个环境指定API接口地址;可以在项目根目录下创建env.js文件,内容如下:

module.exports = {
    dev: {
        NODE_ENV: 'dev',
        URL_PREFIX: '/api/front',
        BASE_URL: 'http://localhost:8302',
        // BASE_URL: 'http://***',
    },
    prod: {
        NODE_ENV: 'prod',
        URL_PREFIX: '/api/front',
        BASE_URL: 'http://***',
    }
}

这个里面我定义了两个环境,dev与prod;后续在nuxt.config.js中会使用到这些配置的信息;

2.4 修改package.json指令

要启动我们的应用,需要修改package.json文件中的scripts信息,并采用nuxt来执行,scripts定义如下所示:

"scripts": {
    "nd": "cross-env NODE_ENV=dev nuxt --mode local",
    "nb": "cross-env NODE_ENV=prod nuxt build --mode prod",
    "nr": "cross-env NODE_ENV=prod nuxt start --port=3001 --mode prod",
    "serve": "cross-env NODE_ENV=prod nuxt --mode prod"
  },

上面我定义了几个指定,nd是本地启动的连接dev环境,nb是编译prod环境,nr是启动编译prod后的结果,serve是直接启动prod环境(包含有build与start过程)。

指令定义好后还不能直接启动,需要继续进行改造。

2.3 增加主配置文件nuxt.config.js

如果使用的是vue-cli,其主配置文件是vue.config.js,而nuxt的主配置文件是nuxt.config.js ,我们需要在根目录下创建这个文件,文件内容参考如下:

const env = require('./env');

module.exports = {
    env: {
        baseUrl: env[process.env.NODE_ENV].BASE_URL,
        apiPrefix: env[process.env.NODE_ENV].URL_PREFIX
    },

    css: [
        '@/assets/css/main.scss',
    ],
    plugins: [],
    modules: [
        '@nuxtjs/axios',
        '@nuxtjs/proxy',
        '@nuxtjs/style-resources'
    ],
    axios: {
        proxy: true,
        credentials: true,
    },

    proxy: {
        '/api/front': {
            target: env[process.env.NODE_ENV].BASE_URL,
        },
        '/file': {
            target: env[process.env.NODE_ENV].BASE_URL,
        },
        '/files': {
            target: env[process.env.NODE_ENV].BASE_URL,
        },
    },

    head: {
        link: [{
            rel: 'stylesheet',
            href: '//at.alicdn/t/font_2040977_ozb90g6ebjp.css'
        }, ],
        script: [{
            // src: 'http://g.tbcdn/mtb/lib-flexible/0.3.4/??flexible_css.js,flexible.js'
        }],
        meta: [{
                charset: 'utf-8'
            },
            {
                name: 'viewport',
                content: 'width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no'
            },
        ]
    },

    build: {
        extend(config, {
            isDev,
            isClient
        }) {
            if (isDev && isClient) {
                config.module.rules.push({
                    enforce: 'pre',
                    test: /\.(js|vue)$/,
                    loader: 'eslint-loader',
                    exclude: /(node_modules)/
                })
            }
        },
        postcss: {
            plugins: {},
            preset: {
                autoprefixer: true
            },
        },
    }
}

可以看到他从env.js中加载了环境配置信息,然后在env中定义了两个成员,一个是baseUrl,一个是apiPrefix;这样定义完成后,在项目的其它地方就可以通过process.env.baseUrl及process.env.apiPrefix进行引用了,当然你可以继续定义其它的成员,也同样可以通过process.env.***进行引用。

  • css成员是项目使用的公共css;
  • modules配置项目使用的nuxt相关组件;
  • axios配置nuxt/axios组件的相关信息,如果直接使用原生的axios,这边可以不用管理 ;
  • proxy配置接口及文件代理;
  • head配置公共的页面信息,如引用的css、js及其它的页面头配置;

其它配置项我基本没调,可以不用关注,如有需要可了解官方文档。

2.5 目录修改

传统vue项目页面文件基本都是在src目录下,nuxt去掉了这个目录,需要将其下层目录全部往上提;

可以看下我这个项目改造完后的目录结构:

  • assets: 存储图片及css等静态资源;
  • common: 存储js文件
  • components: 存储公共组件
  • layouts: nuxt的布局文件
  • pages: 各个模块的页面
  • plugins: nuxt扩展的js文件
  • store: vuex的配置文件

2.6 增加layouts

在layouts下增加default.vue,作为整个应用的入口(对应原项目的App.vue);定义如下:

<template>
    <Nuxt></Nuxt>
</template>

也可以在里面定义一些内容,然后使用来指定加载的子页面区域,如:

<template>
    <el-button>测试</el-button>
    <Nuxt></Nuxt>
</template>

当然也可以在这个文件里面跟写其它Vue页面一样增加script及css相关内容。

2.7 路由改造

Nuxt使用了自动加载路由的方案,他会根据你项目的目录来自动创建router对象;

一个简单的示例,pages下的目录示例及其对应的路由如下所示:

test:
  a.vue     -- 对应路径:/test/a
  index.vue -- 对应路由:/test
index:
  _id:
    a.vue      -- 对应路由:/{id}/a
    index.vue  -- 对应路由:/{id}
  index.vue    -- 对应路由:/,会被包含到上一级的index.vue的<nuxt-child>中
index.vue      -- 对应路由:/,其中如果包含有nuxt-child元素,那么会将index目录下的index.vue加载到nuxt-child元素位置  

刚上手这个地方在理解上有点麻烦,其主要包含以下几条规则:

  • index.vue:如果目录下有index.vue,那么将会生成到目录的路由,该路由指向的组件即为index.vue;

  • 嵌套路由:如果一个页面有同名的同级目录,并且页面中包含有元素,那么会生成相应的嵌套路由,如目录如下

    test: 
      index.vue
      a.vue
    test.vue
    

    对应生成router路由层级关系如下:

    {
        "path": "/test", 
        "component": test,   -- 对应test.vue 
        "children": [
        	{
        		"path": "", 
        		"component": "test-index" -- 对应test/index.vue
        	}, {
        		"path": "a", 
        		"component": "test-a" -- 对应test/a  
        	}
        ]
    }
    
  • 动态路由:带路由参数的处理, 如/news/12:

    目录是:

    news:
      _id.vue
      a.vue
    news.vue
    

    生成路由:

    {
        "path": "/news", 
        "component": news,   -- 对应news.vue 
        "children": [
        	{
        		"path": ":id?", 
        		"component": "id" -- 对应news/_id.vue
        	}, {
        		"path": "a", 
        		"component": "a" -- 对应news/a.vue
        	}
        ]
    }
    

    有些时候情况比较特殊,如我们想要实现/news/12、/news/12/comment这样的路径,那么目录需要组织如下:

    news:
      _id:
        index.vue
        comment.vue
      _id.vue
    news.vue
    

    生成的路由:

    {
        "path": "/news", 
        "component": news,   -- 对应news.vue 
        "children": [
        	{
        		"path": ":id?", 
        		"component": "id",  -- 对应news/_id.vue
        		"children": [
        		 	{
                        "path": "", 
                        "component": "index" -- 对应news/_id/index.vue
                	}, {
                		"path": "comment", 
                		"component": "comment" -- 对应news/_id/comment.vue 
                	}
        		]
        	}
        ]
    }
    

    如果动态有多层的情况,原理也是一致的,如/news/1/2 其中第一个数字是typeId,那么目录可以组织如下:

    news:
      _typeId:
        index.vue        -- 生成/news/1的路由
        _id:            
          index.vue      -- 生成/news/1/2路由
          comment.vue    -- 生成/news/1/2/comment路由
        _id.vue
      _typeId.vue 
    news.vue 
    

    注意,与目录同名的vue文件也可以不需要,如果目录下的所有页面没有共用的元素;如假设上面index.vue/comment.vue两个页面完全不一样,那么_id.vue这个文件就不需要了,目录结构变成:

    news:
      _typeId:
        index.vue        -- 生成/news/1的路由
        _id:            
          index.vue      -- 生成/news/1/2路由
          comment.vue    -- 生成/news/1/2/comment路由
      _typeId.vue 
    news.vue 
    

    如type也是一样,那么_typeId.vue也不需要,简化成以下样式:

    news:
      _typeId:
        index.vue        -- 生成/news/1的路由
        _id:            
          index.vue      -- 生成/news/1/2路由
          comment.vue    -- 生成/news/1/2/comment路由
    news.vue 
    

    具体需要根据项目页面设计样式来决定是否需要与目录同名的文件。

注意

  • 变更目录后需要变更原项目所有使用router-link进行跳转的地方,将路由修正为修改后目录结构生成的路由结构,并使用来进行跳转。

  • 如想看生成的路由结果,可以查看_nuxt目录下的router.js文件。

2.8 Store改造

首先在根目录下创建store目录,Nuxt将会去这个目录下查找配置;我们可以在这个目录下创建相关的js文件。

Nuxt支持两种模式,一种是模块化,一种是经典的方式(后续会废弃,慎用),一般使用模块化的方式,如我在这个目录下创建了一个index.js文件,内容如下:

export const state = () => ({
    login: false,
    needLogin: 1,
    hallInfo: null
})

export const mutations = {
    login: (state, result) => {
        state.login = result;
    },

    needLogin: (state) => {
        state.needLogin++;
    },

    setHallInfo: (state, hallInfo) => {
        state.hallInfo = hallInfo;
    }
}

export const getters = {
    hallInfo: (state) => {
        return state.hallInfo;
    }
}

然后在页面中就可以使用mutations方法及getters中的方法了:

this.$store.commit("setHallInfo", {...});
var hallInfo = this.$store.getters.hallInfo(); 

我这个项目本身并未过多的使用store,因此所有状态都直接存储在index.js中了,如果项目复杂,就可以拆分成不同模块了。 如增加一个todos.js的模块:

export const state = () => ({
  list: []
})

export const mutations = {
  add (state, text) {
    state.list.push({
      text,
      done: false
    })
  },
  remove (state, { todo }) {
    state.list.splice(state.list.indexOf(todo), 1)
  },
  toggle (state, todo) {
    todo.done = !todo.done
  }
}

使用

this.$store.state.todos.list
this.$store.commit('todos/add', ...)

2.9 main.js改造

在main.js中我们引用了很多的一些组件,如ElementUI,引用方式如下:

import ElementUI from "element-ui";
Vue.use(ElementUI);

可能还包含有很多的其它组件引用;

但在nuxt中不识别main.js的内容,我们可以在plugins目录下创建extend.js文件,然后将这些引用复制到这个组件中,如我的extend.js:

import Vue from 'vue'
import dateFormat from '@/common/Date'
import 'element-ui/lib/theme-chalk/index.css';
import ElementUI from "element-ui";

Vue.use(ElementUI);

// import https from "./https";

Vue.prototype.$api = process.env.apiPrefix;

第三步再通过nuxt.config.js的plugins进行引用;

plugins: [
        {
            src: '~plugins/extend',
            ssr: true
        }
    ],

其中ssr指的是否会在服务器端渲染使用,如果引用的组件不需要在服务器端渲染时,需要设置成false。

2.10 window/document未定义问题处理

经过以上处理,我们就可以使用npm run nd(在package.json中定义)来执行项目了;如果不出意外,将会抛出很多window/document未定义的问题!原因是我们加载的很多模块本身是在浏览器当中执行的,在我们进行SSR改造后,这部分模块在服务器端渲染时也会进行,但服务器端不是在浏览器中渲染的,使用window/document这类浏览器中的对象就必然会出现undefined异常。

这个时候我们需要告诉nuxt这些组件不需要在服务器端渲染时使用,因此需要在plugins目录下创建对应js,然后再在nuxt.conf.js的plugins中配置ssr为false,如我使用了visibility这个组件,改造前是在main.js进行了引用,改造后我在plugins目录下创建visibility.js,里面内容如下:

import Vue from 'vue'
import visibility from 'vue-visibility-change';

Vue.use(visibility)

然后nuxt.config.js中plugins配置如下:

plugins: [
	    {
            src: '~plugins/visibility',
            ssr: false
        },
        {
            src: '~plugins/extend',
            ssr: true
        },
]

需要将ssr指定为false,这样就解决掉visibility组件抛出的window/document未定义的问题了。

对于其它组件抛出的未定义问题,也可以按上述一样的方式进行处理。

2.11 created方法替换

如果项目有使用到created方法的,需要统一将created方法替换成mounted方法。否则会报大量的window/document未定义的异常。
这是因为created方法会在服务器端渲染的时候执行,而mounted方法只会在客户端执行。

2.12 服务器端渲染数据获取-asyncData

经过以上改造,我们的项目可能已经能够正常运行了,这个时候打开浏览器F12然后刷新页面,你会发现请求返回的页面内容中还是没包含有数据信息,如一个资讯的页面,资讯的内容在返回的页面中还是没有,仍旧是通过浏览器端的ajax请求去获取到数据然后再展示到页面中的!

这个时候就需要使用到asyncData或者fetch方法了。

我项目当中是将那些需要在服务器端渲染页面时就获取数据的部分通过asyncData来获取,实现如下:

  • 定义server-https.js文件,将服务器端获取数据的接口进行封装

    这里和客户端区分开是因为客户端的https.js文件我会有一些对Cookie、Cache进行的操作,而这一类操作在服务器端是无法执行的。

    定义内容如下:

    import axios from 'axios';
    import qs from 'qs'
    
    const myAxios = axios.create({
        baseURL: process.env.baseUrl + process.env.apiPrefix,
        timeout: 20000,
        headers: {
            post: {
                'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
            }
        }
    });
    
    myAxios.interceptors.request.use((config) => {
        if (config.method === 'post' &&
            config.headers['Content-Type'] !== 'application/json;charset=UTF-8' &&
            config.headers['Content-Type'] !== 'multipart/form-data') {
            config.data = qs.stringify(config.data);
        }
    
        return config;
    }, (error) => {
        console.log('错误的传参')
        return Promise.reject(error);
    });
    
    myAxios.interceptors.response.use((res) => {
        return Promise.resolve(res.data);
    }, (error) => {
        console.log(error);
        return Promise.reject(error);
    });
    
    export function post(url, params) {
        return new Promise((resolve, reject) => {
            myAxios.post(url, params, {
                    headers: {
                        'Content-Type': 'application/json;charset=UTF-8'
                    }
                })
                .then(response => {
                    resolve(response);
                }, err => {
                    reject(err);
                })
                .catch((error) => {
                    reject(error)
                })
        })
    }
    
    export function get(url, param) {
        return new Promise((resolve, reject) => {
            myAxios.get(url, {
                    params: param
                })
                .then(response => {
                    resolve(response)
                }, err => {
                    reject(err)
                })
                .catch((error) => {
                    reject(error)
                })
        })
    }
    
    export default {
        post,
        get,
    }
    
  • 在组件中定义syncData方法:

    <template>
        <div>
            {{baseInfo.title}}
        </div>
    </template>
    <script>
    import { get } from "@/common/server-https";
    export default {
       data() {
       		return {
       			baseInfo: {}
       		};  
       },  
       asyncData({ params }) {
           return get("/news/find-by-id", { id: params.id }).then((resp) => {
                return { baseInfo: resp };
           });
        },
        ...
    }
    </script>
    

    经过以上两步,就完成了当前页面的服务器端数据获取改造。这个时候我们打开浏览器F12,然后刷新页面,观察网络请求,发现并没有/news/find-by-id这个请求,因为他是在服务器端进行的,在浏览器上看不到;然后我们看请求页面返回的内容,其中已经包含了渲染后的title。

  • asyncData说明:

    可以看到上面asyncData我们是返回了一个对象,{baseInfo: resp},返回的整个对象都会被nuxt自动渲染到页面相应的字段中去,如果我们接口返回的是多个信息,如假设是资讯的基础信息、资讯的评论信息,返回resp结构是:

    {
        "baseInfo": {
            "title": "测试资讯"
        },
        "comments": [
            {"content": "测试评论"}
        ]
    }
    

    那么asyncData我们可以直接返回resp:

    asyncData({ params }) {
           return get("/news/find-by-id", { id: params.id }).then((resp) => {
    			return resp
           });
        },
    

    这样在页面中就可以直接使用baseInfo、 comments来引用数据了,而且也可以在其它方法中通过this.baseInfo、thisments获取使用数据。这个特性对于我们的网站首页等尤其有用,我们可以将首页要展现的内容在一个接口中返回,完成整个首页的SSR渲染。

补充说明

并不要将所有数据查询都处理成asyncData的方式,这会加重服务器端的负担;仅使用asyncData来加载那些SEO需要的数据,其它数据都在客户端通过传统的ajax方式加载。

asyncData方法可以接收参数(主要使用到的):

  • params: 路径查询参数
  • query: URL查询参数
  • store: Vuex上下文
  • route: 页面路由信息

其它使用不多,如有兴趣可访问https://zh.nuxtjs/docs/2.x/concepts/context-helpers
注意asyncData中不能使用this对象。

2.13 页面Head处理

SEO最为重要的内容就是页面Head中的keywords/description等几项,可以通过head方法提供。

  • 通用head设置:网站的head通用信息可以在nuxt.config.js中定义;也可以在default.vue中处理,如我在default中处理的示例如下:

    export default {
        data() {
            return {};  
        }, 
        head() {
                return {
                    title: "***",
                    meta: [
                        {
                            name: "keywords",
                            content:
                                "*,*,*",
                            hid: "keywords",
                        },
                        {
                            name: "description",
                            content:
                                "描述",
                            hid: "description",
                        },
                    ],
                };
            },
        methods: {
            
        }
    }
    
  • 页面个性化Head设置

    也是通过head方法,在需要处理的页面中处理,如:

    head() {
            return {
                title: this.baseInfo.name,
                meta: [
                    {
                        name: "keywords",
                        content: this.baseInfo.keywords,
                        hid: "keywords",
                    },
                    {
                        name: "description",
                        content: this.baseInfo.description,
                        hid: "description",
                    },
                ],
            };
        },
    

    一个现有项目的nuxt改造基本就是这样,当然改造过程可能还会遇到其它的一些问题,只能是遇到问题再去找方案处理了

更多推荐

Vue现有项目Nuxt改造实例解析