目前来说,搜索引擎仍然不能很好的处理SPA页面(2019.1)。那么为了流量考虑,必须应对SEO的需求。现代的前端工具链对工作效率的提升自不必言,是不可能回到古典时代的,那很明显就只能走服务端渲染(SSR)方案了。
vue ssr名声最大的方案自然是nuxt.js,但是我本来是不想用的,因为项目结构会发生剧烈的变化,连router都没了,感受一下,这是项目根目录:
因此我还是想要基于vue cli3的项目结构来搞,不做太多的变动。
我考察了几个Vue SSR方案,感觉都不是很行:官方SSR配置麻烦案例少,vue-cli3里面流行的几个,比如akryum/vue-cli-plugin-ssr、vueneue及其升级版uvue都不怎么成熟。最后没有办法,决定试一下nuxt.js,于是……
但是nuxt.js实在有着太多的不同,例如项目结构变化很大,router.js没了,vuex store写法有变化,router钩子没了等等。老项目毕竟也有一些体量,这么折腾我可接受不了,不过经过一番调查,我发现这些问题不是不可以解决。因此虽然迁移是要迁移的,但是要尽量保持vue-cli 3项目的风味,以最小的改动完成迁移。为此我做了很多调查,本文的目的就在于此。
重建项目
没得说,直接掏出命令行开始吧,这是官网get started文档的操作:
npx create-nuxt-app my-project
我们的原则是尽量和vue cli3项目一致。选项基本上选默认,除了eslint打开。值得注意的是有个axios module,这就是个this.$axios插件,我个人目前认为没啥用,也不影响一般的axios使用方式。
然后进入目录,一开始肯定跟我一样是一脸懵逼的:
懵逼不要紧,一步步对照着往下做就可以了。
ESLint 配置
首先把.eslintrc.js对照着修改一下:
我习惯的代码风格是4空格缩进的standard,_给lodash,$留给全局工具函数,我估计大家各有各的说法,自己酌情修改:
module.exports = {
root: true,
env: {
browser: true,
node: true
},
parserOptions: {
parser: 'babel-eslint'
},
extends: [
'plugin:vue/essential',
'@vue/standard'
],
// required to lint *.vue files
plugins: [
'vue'
],
// add your custom rules here
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
// indent 4 spaces
'indent': ['error', 4, { 'SwitchCase': 1 }],
// allow paren-less arrow functions
'arrow-parens': 0,
// allow async-await
'generator-star-spacing': 0
},
'globals': {
'$': true,
'_': true
}
}
这里别忘了把包装上
npm install --save-dev @vue/eslint-config-standard
package.json
改一下项目版本和项目说明,在scripts里加上serve,这样就能 npm run serve 了
nuxt项目里有npm run lint,但行为和vue cli3不一样,只检测不修复。这里给lint加一个 --fix让它可以自动格式化。
"scripts": {
"dev": "nuxt",
"serve": "nuxt",
"build": "nuxt build",
"start": "nuxt start",
"generate": "nuxt generate",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore . --fix",
"precommit": "npm run lint"
},
项目依赖我觉得就没啥好说的了,vue cli3的项目依赖非常简洁,很容易分辨,基本上无脑拷贝过来就行了。
保持vue cli3风格目录结构
项目目录多了一大堆目录,我们依稀能够辨认出components、store、asssets等等,这时很容易恍然大悟,nuxt.js把之前在src目录里的内容弄出来了。
我还是比较习惯传统的风格,因此又把他们放了回去:
修改nuxt.config.js,加一行srcDir: 'src/',
创建一个叫src的目录,把以下目录剪切进去
是不是感觉熟悉多了:
scss配置
npm install --save-dev node-sass sass-loader
postcss: {
plugins: {
autoprefixer: {}
}
},
Webpack插件 / 注册全局模块(如lodash)
plugins: [
new webpack.ProvidePlugin({
'_': path.resolve(__dirname, './src/lodash.js')
})
],
使用代码router,而不是nuxt自动生成的导航配置
可以注意到nuxt.js一个很大不同是router.js没了,按官方的说法是使用目录自动动态生成router.js。我觉得其实还可以,但没有必要那么精巧。老项目迁移,写都写了,直接拿过来就是。
好在可以改,nuxt.js官方提供了一个插件:nuxt-community/router-module
将其加入项目
npm install --save @nuxtjs/router
然后修改配置文件:
modules: [
'@nuxtjs/router'
],
router.js写在src/下,需要略微改变写法,导出为createRouter函数:
export function createRouter () {
let router = new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{ path: '/', name: 'index', component: Index },
{ path: '/about', name: 'about', component: About }
]
})
return router
}
移除所有异步加载
有可能你像我一样碰到了这样的报错,然后一脸懵逼:
render function or template not defined in component: anonymous
我排查了半天,发现是router.js中异步加载的锅(当然前提是router.js按照上文移植过来了)。
const TopicNew = () => import('@/views/topic-new.vue')
改了就好了。
Vuex
nuxt.js的vuex写法有比较大的区别,建议直接看着文档改 Vuex Store - Nuxt.js
这里给个最简单的例子吧:
export const state = () => ({
counter: 0
})
export const mutations = {
increment (state) {
state.counter++
}
}
export const actions = {
async init ({ state, commit }) {
}
}
也有一种变动较小的方法,官方文档里讲了。但也说明了在nuxt.js 3之后就不会再支持。
说句题外话,import store from './store/index' 在ssr项目中是无法使用了(包括vue官方ssr)。
Router全局钩子
有两种办法,第一种是写nuxt plugin直接拿router对象
export default ({ app }) => {
app.router.beforeEach(async function (to, from, next) {
}
}
然后在外面nuxt.config.js里写一句
plugins: [
'@/plugins/route'
],
但这种办法如果在beforeEach里面做请求拿数据,就会引起DOM渲染不同步的警告(nuxt.js v2.3.4)。只要发出请求,不做任何其他事情,就会出错(其实这是非常不科学的)。
顺便提一句,这里通过app.store可以拿到store
第二种办法,也是nuxt.js官方推荐的办法,是写middleware:
我在src/middleware下写一个router-guards.js
export default async function ({ store, route, redirect, req }) {
console.log('hello')
// let ret = await axios.get(`http://localhost:8888/`)
}
这样就可以了。
保持用户登录身份 / 获取用户数据
注意,SSR情况下服务端会替你渲染首屏,当用户已经登录了,页面上势必会变得不同!SSR服务端去请求后端的时候,是不会管你的cookies和access_token之类东西的,因此会导致服务端和浏览器端DOM不同步。
首先可能有人想到在上面的middleware里面做这个操作,毕竟在SPA页面中beforeEach里向服务器拿当前用户的用户信息,也算是一个常规操作。
我开始的思路也是这样的,但请注意,尽管逻辑好像没有问题,这是一个错误示范:
import api from '@/netapi'
export default async function ({ store, route, redirect, req }) {
if (!store.state.misc) {
let ret = await api.misc()
store.commit('SET_MISC', ret.data)
if (process.server) {
ret = await api.userInfo2(req.headers)
} else {
ret = await api.userInfo()
}
if (ret.code === 0) {
store.commit('SET_USERDATA', ret.data)
}
}
}
middleware会在服务端和浏览器端都执行,但第一次执行只是在服务端(第一次访问服务端会渲染直接整个网页给客户端,因此不需要客户端做什么),这时拿到的req就是客户端向服务端发起的那个网页请求。
所以这里就很明显了,第一个api.userInfo2()是专供服务端的,并且把网页请求的headers拼装进去,以期望把cookies也带进去以用户身份拿用户信息。
第二个api.userInfo()只在浏览器端执行,这是SPA时候的正常操作。
但是请再次注意:这种办法是不行的!!虽然理论上看起来正确,但也会原因不明的报服务端浏览器端不同步……如果有谁清楚为什么,麻烦请告诉我。
正确做法:
store/index.js中写这样一个action:
export const actions = {
async nuxtServerInit ({ commit }, { req }) {
let ret = await api.misc()
commit('SET_MISC', ret.data)
ret = await api.userInfo2(req.headers)
if (ret.code === 0) {
commit('SET_USERDATA', ret.data)
}
}
}
与上面的错误示范原理是完全一样的,这个action只在服务端执行,这里的req是客户端向服务端发起的那个网页请求。
有人可能会问只在服务端给state赋了值,那浏览器端又没有这么做,怎么办?
但其实不用担心,服务端渲染首屏,完成后会把当前的vuex状态和组件状态都保存起来传给浏览器,然后浏览器端读档,因此就不用再取数据了。
如果不太明白参考官方案例 Auth External API (JWT) 。
Store模式(vuex低配版)的迁移
所谓store模式,在vuex文档里讲到“什么时候应该用vuex的时候”被提了一句:Vuex 是什么? | Vuex
其实简单来说就是一个被多个组件import的js文件,在里面存放数据。
基本上可以告诉大家,忘了这回事吧。SSR的时候机制与SPA不同,无法保证全局共享同一份数据。可能你看着这里设置了,但是实际上渲染模板的时候读不出来。
所以直接改成vuex吧。
修改router-link
全局替换为nuxt-link
总结
其实我还是比较希望nuxt能够以vue cli3插件的形式被使用,这样的话事情就简单多了。不过世事无常,虽然工具链一直在进步,我们也不能干等着那一天的到来,姑且先自己动动腿往那一边走两步吧。
话又说回来,迁移到SSR这种大工程又岂止是我这二三句能够完全说清楚的,有些坑我自己还呆在里面呢!只是希望抛砖引玉,后面我也会更新一些自己遇到的问题和解决方法,也希望大家不吝赐教。
以下是一个按文中方法修改后的nuxt.js空项目:
https://github.com/fy0/nuxt-example-project-like-vue-cli3github.com