一个基于 Vue.js的服务端渲染应用框架 ,nuxt 解决了SPA的通病(首屏白屏,SEO)
文章目录
1. 搭建Nuxt
1.1 快速入门
为了快速入门,Nuxt.js团队创建了脚手架工具 create-nuxt-app
确保安装了npx(npx在NPM版本5.2.0默认安装了),详细查询官网教程
$ npx create-nuxt-app <项目名>
1.2 资源目录
项目完整资源目录
api:express 目录
assets:公共资源目录(.js .css .jpg)
components:公共组件目录
layouts:页面入口目录(default.vue 类似vue里的App.vue)
locales:国际化语言包文件目录
middleware:中间件目录
pages:页面级目录
plugins:公共插件目录
static:静态资源目录
store:vuex目录
.eslintrc.js:ESlint 代码检测工具配置文件
.gitignore:git上传规则配置文件
nuxt.config.js:nuxt 规则配置文件
package.json:npm 依赖包配置文件
1.3 生命周期
-
nuxt 生命周期
-
nuxt context 思维导图
2. 配置Nuxt
2.1 引入UI框架
-
UI框架使用的是ant-design-vue: 支持自定义主题颜色和组件国际化等
$ npm install --save ant-design-vue
-
创建插件目录plugins,添加antd.js:Vue全局引入ant-design-vue,开发时可直接使用框架组件
// antd.js import Vue from 'vue' import antd from 'ant-design-vue' Vue.use(antd)
-
配置nuxt.config.js:引入antd.js,可自定义ant-design-vue框架主题颜色
// nuxt.configs.js module.exports = { /** * 第三方插件 */ plugins: [ {src: '~/plugins/antd.js', ssr: true}, ], /** * 编译配置 */ build: { extend (config, ctx) { /** * 自定义 ant-design-vue 主题颜色 */ config.module.rules.push({ test: /\.less$/, use: [{ loader: 'less-loader', options: { modifyVars: { 'primary-color': '#2EA9DF', 'link-color': '#2EA9DF', 'border-radius-base': '4px' }, javascriptEnabled: true } }] }) } } }
2.2 引入中间件
中间件包含路由守卫、国际化等
-
创建中间件目录middleware,添加route.js
// route.js export default ({req}) => { // 服务端渲染时 if (process.server) { // 业务逻辑 } // 客户端渲染时 if (process.client) { // 添加路由守卫,动态改变路由的跳转 app.router.beforeEach((to, from, next) => { // 业务逻辑 }) } }
-
配置nuxt.config.js,引入route.js
// nuxt.configs.js module.exports = { /** * 中间件拦截器 */ router: { middleware: ['route'] } /** * 第三方插件 */ plugins: [ {src: '~/plugins/antd.js', ssr: true}, ], /** * 编译配置 */ build: { extend (config, ctx) { /** * 自定义 ant-design-vue 主题颜色 */ config.module.rules.push({ test: /\.less$/, use: [{ loader: 'less-loader', options: { modifyVars: { 'primary-color': '#2EA9DF', 'link-color': '#2EA9DF', 'border-radius-base': '4px' }, javascriptEnabled: true } }] }) } } }
2.3 引入国际化
界面静态文字根据国际化动态切换语言,附上官网教程
-
创建目录 locales,添加index.js和需要的国际化 *.json
// index.js export default () => ['zh_TW', 'zh_CN', 'en_US']
-
创建目录store,添加index.js:nuxt默认集成vuex,会自动检测store是否存在
// index.js import Locale from '~/locales' /** * 全局变量 * @returns {{locales, locale: *}} */ export const state = () => ({ locales: Locale(), locale: Locale()[0], }) export const mutations = { /** * @param locale 当前选中的国际化标识 * @constructor */ SET_LANG (state, locale) { if (state.locales.indexOf(locale) !== -1) { state.locale = locale } } } export const actions = { /** * @param commit 国际化修改 * @param val 国际化标识 */ updateLang ({commit}, val) { commit('SET_LANG', val) }, }
-
添加中间件 i18n.js:校验客服端传递的国际化标识,动态渲染界面文字
// i18n.js import Tool from '~/assets/utils/tool' // 服务端从request获取Cookie的工具 export default function ({ isHMR, app, store, route, params, error, redirect, req }) { let cookies = Tool.getcookiesInServer(req) let languageCookie = cookies.language ? cookies.language : null const defaultLocale = app.i18n.fallbackLocale // If middleware is called from hot module replacement, ignore it if (isHMR) return // Get locale from params const locale = params.lang || defaultLocale if (store.state.locales.indexOf(locale) === -1) { return error({ message: 'This page could not be found.', statusCode: 404 }) } // Set locale store.commit('SET_LANG', store.state.locale) app.i18n.locale = languageCookie || store.state.locale // If route is /<defaultLocale>/... -> redirect to /... if (locale === defaultLocale && route.fullPath.indexOf('/' + defaultLocale) === 0) { const toReplace = '^/' + defaultLocale + (route.fullPath.indexOf('/' + defaultLocale + '/') === 0 ? '/' : '') const re = new RegExp(toReplace) return redirect( route.fullPath.replace(re, '/') ) } }
-
添加插件 i18n.js:Vue全局引入i18n,可以在DOM中使用
{{$t(...)}}
,JS中使用this.$t(...)
获取国际化文字// i18n.js import Vue from 'vue' import VueI18n from 'vue-i18n' import Cookie from 'js-cookie' Vue.use(VueI18n) export default ({ app, store }) => { let data = {} let Locale = store.state.locales for (let i = 0; i < Locale.length; i++) { data[Locale[i]] = require(`~/locales/${Locale[i]}.json`) } // Set i18n instance on app // This way we can use it in middleware and pages asyncData/fetch app.i18n = new VueI18n({ locale: Cookie.get('language') || store.state.locale, fallbackLocale: Cookie.get('language') || store.state.locale, messages: data }) // 自定义页面跳转方法 app.i18n.path = (link) => { return `/${app.i18n.locale}/${link}` } }
-
配置Nuxt.config.js:引入插件i18n.js
// nuxt.configs.js module.exports = { /** * 中间件拦截器 */ router: { middleware: ['route','i18n'] } /** * 第三方插件 */ plugins: [ {src: '~/plugins/antd.js', ssr: true}, {src: '~/plugins/i18n.js', ssr: true} ], /** * 编译配置 */ build: { extend (config, ctx) { /** * 自定义 ant-design-vue 主题颜色 */ config.module.rules.push({ test: /\.less$/, use: [{ loader: 'less-loader', options: { modifyVars: { 'primary-color': '#2EA9DF', 'link-color': '#2EA9DF', 'border-radius-base': '4px' }, javascriptEnabled: true } }] }) } } }
2.4 引入第三方插件
-
全局引入:配置nuxt.config.js,添加资源文件的路径
// nuxt.config.js module.exports = { head: { script: [ { src: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js' } ], // 不对<script>标签中内容做转义处理 __dangerouslyDisableSanitizers: ['script'], link: [ { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Roboto' } ] } }
-
局部引入:下载第三方插件至assets中,在组件中使用
// *.vue import jquery from '~/assets/utils/jquery'
2.5 引入axios.js
-
安装axios.js
$ npm install --save axios
-
插件目录添加 axios.js
// axios.js import * as axios from 'axios' let devBaseUrl = '/api' // 开发环境 公共接口前缀 let testBaseUrl = '' // 测试环境 公共接口前缀 let proBaseUrl = '' // 上线环境 公共接口前缀 let options = { baseURL: devBaseUrl, timeout: 30000 // 请求超时时间5分钟 } // 创建axios实例 let services = axios.create(options) // 全局拦截请求 services.interceptors.request.use() // 全局拦截响应 services.interceptors.response.use() export default services
-
*.vue组件中使用axios.js
// *.vue <script> import tool from '~/assets/utils/tool' import axios from '~/plugins/axios' export default { data () { return { // .... } }, methods: { // 获取图形验证码 getCaptcha () { let data = { traceId: tool.UUID() } axios.post('/user/image/verifiycode/get', data) .then(res => { // ... }) .catch(err => { this.$message.error(this.$t(err.message)) }) } } } </script>
2.6 加载静态资源
nuxt默认将assets里面小于1KB的图片转base64,大于1KB的图片应该放在static静态资源目录进行加载:加载静态资源
-
加载assets的静态资源
// *.css 文件中 .class { background-image: url('~assets/image.png') }
// *.vue 组件中 <template> <img src="~/assets/image.png"> </template>
-
加载static的静态资源
// *.css 文件中 .class { background-image: url('../static/my-image.png') }
// *.vue 组件中 <template> <img src="/my-image.png"/> </template>
3. 开发Nuxt
开发流程与Vue类似,遵循Vue规范即可:Nuxt学习教程
4. 调试Nuxt
4.1 界面调试
Nuxt 集成了热更新,修改样式会自动同步至浏览器
-
方式一:vue-devtools 开发者工具:查看Vue Dom层级、Vuex、事件、路由等信息
-
方式二:浏览器自带调试器
4.2 JS调试
需要了解Nuxt在运行时的生命周期
-
客服端生命周期:使用浏览器断点调试
-
服务端生命周期:使用console.log() 打印控制台调试
export default { /** * 服务端请求数据,仅限页面级组件 */ asyncData ({req, error}) { if (process.server) { return axios.post(`url`, data) .then(res => { console.log(res) }) .catch(e => { error({ statusCode: 500, message: e.toString() }) }) } } }
4.3 代理跨域
开发环境时,请求接口需要跨域代理
线上发布时,如果采用了Nginx反向代理,则不需要跨域代理
-
配置nuxt.config.js:使用
@nuxtjs/axios
代理跨域module.exports = { /** * 跨域代理 */ modules: ['@nuxtjs/axios'], axios: { proxy: process.env.NODE_ENV === 'development', // 是否使用代理 // prefix: '', credentials: true }, proxy: { // 全局拦截代理, '/api' 为 axios.js的baseURL '/api': { target: (process.env.NODE_ENV === 'development') ? 'http://192.168.91.200' : '', changeOrigin: true, pathRewrite: {'': ''} } } }
5. 性能优化方案
先看一组移动端网页优化前后的对比
网页评分工具(需翻墙): https://developers.google.com/speed/pagespeed/insights/
-
移动端优化前的评分:25
-
移动端优化后的评分:98
5.1 UI框架按需引入
nuxt默认集成打包分析器,便于针对性优化:webpack-bundle-analyzer
-
执行打包命令,查询优化前框架资源大小
$ nuxt build -a
-
优化前:vendors.app.js 1.93MB
-
配置antd.js:全局引入改为按需引入
// antd.js import Vue from 'vue' import {Button} from 'ant-design-vue' import 'ant-design-vue/lib/button/style/index.less' // Vue只引入了Button组件,webpack在打包ant-design-vue时 // 只会把Button组件打进vendors.app.js资源包 Vue.use(Button)
-
配置nuxt.config.js:moment.js去掉多余语言包,默认加载了全部国际化语言
// nuxt.config.js module.exports = { /** * 编译配置 */ build: { plugins: [ // 默认英语,语言包只引入简体繁体 new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /zh-cn|zh-tw/) ] } }
-
优化后:vendors.app.js 1.5MB(内含30多个UI组件)
5.2 服务端渲染数据
在服务端生命周期请求接口数据:SEO优化,减少页面白屏
// *.vue 例子
export default {
/**
* 服务端渲染
* @param params 项目ID
* @returns {{pagination, data: Array, publicInfo: string}}
*/
asyncData ({req, params}) {
if (process.server) {
/**
* 用户购买历史
*/
let p1 = new Promise((resolve, reject) => {
let pageData = {
traceId: tool.UUID(),
data: {
pageNum: 1,
pageSize: 10,
filter: {
projectId: params.id
}
}
}
axios.post(`${tool.getAddress(req)}/pto/invest/project/list`, pageData)
.then(res => {
resolve(res)
})
.catch(err => {
reject(err)
})
})
/**
* 公示信息
*/
let p2 = new Promise((resolve, reject) => {
let params = {
traceId: tool.UUID(),
data: {
projectId: params.id
}
}
axios.post(`${tool.getAddress(req)}/pto/project/publicity/get`, params)
.then(res => {
resolve(res)
})
.catch(err => {
reject(err)
})
})
let data = []
let pagination = {}
let publicInfo = ''
return Promise.all([p1, p2]).then((array) => {
if (array[0].data.code.toString() === '0') {
data = array[0].data.data.list
pagination.total = array[0].data.data.total
}
if (array[1].data.code.toString() === '0' && array[1].data.data) {
publicInfo = array[1].data.data.publicContent
}
return {data, pagination, publicInfo}
})
}
},
5.3 组件异步加载
<script>
// 组件阻塞加载
import banner from '~/pages/computer/HomeComponents/HomeBanner'
// 组件异步加载
const HowFund = () => import('~/pages/computer/HomeComponents/HowFund')
export default {
components: {
banner,
HowFund
}
}
5.4 JS按需加载
// nuxt.config.js
module.exports = {
/**
* JS按需加载
*/
render: {
resourceHints: false
}
}
5.5 其它优化方案
-
服务端 Nginx 开启 gzip 压缩传输
-
静态资源添加CDN
-
减少资源请求数量,减少静态资源文件大小
-
避免首屏 DOM树 规模过大
-
优先加载首屏数据,懒加载首屏外图片/数据
-
减少单个资源包太大:资源分包splitChunks
5.6 完整的nuxt.config.js
const webpack = require('webpack')
module.exports = {
/*
** Headers of the page
*/
head: {
title: 'PTOHome',
meta: [
{charset: 'utf-8'},
{
name: 'viewport',
content: 'maximum-scale=1.0,minimum-scale=1.0,user-scalable=0,width=device-width,initial-scale=1.0'
}
],
// 不对<script>标签中内容做转义处理
__dangerouslyDisableSanitizers: ['script']
},
/**
* 第三方插件
*/
plugins: [
{src: '~/plugins/antd.js', ssr: true},
{src: '~/plugins/i18n.js', ssr: true}
],
vue: {
config: {
productionTip: false, // 阻止 vue 在生产启动时生成提示
devtools: true // 开启调试工具
}
},
/**
* JS按需加载
*/
render: {
resourceHints: false
},
/**
* 编译配置
*/
build: {
analyze: true,
vendor: ['axios'],
extend (config, ctx) {
/*
** Run ESLINT on save
*/
if (ctx.isDev && ctx.isClient) {
config.module.rules.push({
enforce: 'pre',
test: /\.(js|vue)$/,
loader: 'eslint-loader',
exclude: /(node_modules|.nuxt)/
})
}
/**
* 自定义 ant-design-vue 主题颜色
*/
config.module.rules.push({
test: /\.less$/,
use: [{
loader: 'less-loader',
options: {
modifyVars: {
'primary-color': '#2EA9DF',
'link-color': '#2EA9DF',
'border-radius-base': '4px'
},
javascriptEnabled: true
}
}]
})
},
postcss: {
preset: {
// 更改postcss-preset-env 设置
autoprefixer: {
grid: true
}
}
},
plugins: [
// 默认英语,语言包只引入简体繁体
new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /zh-cn|zh-tw/)
]
},
/**
* API middleware 中间件
*/
serverMiddleware: [
'~/api/index.js'
],
/**
* 中间件拦截器
*/
router: {
middleware: ['i18n', 'route']
},
/**
* 加载进度条风格
*/
loading: {
color: '#41B883'
},
/**
* APP启动端口
* 默认: 3000
*/
server: {
port: 80,
host: '0.0.0.0'
},
/**
* 跨域代理
*/
modules: ['@nuxtjs/axios'],
axios: {
baseUrl: 'http://192.168.91.200',
proxy: process.env.NODE_ENV === 'development', // 是否使用代理
// prefix: '',
credentials: true
},
proxy: {
// 全局拦截代理
'/api/v2': {
target: (process.env.NODE_ENV === 'development') ? 'http://192.168.91.200' : 'http://192.168.91.183',
changeOrigin: true,
pathRewrite: {'': ''}
}
}
}
6. 发布Nuxt
6.1 打包
-
执行打包命令,获取生产资源:
.nuxt
目录// 或 npm run build $ nuxt build
6.2 发布
服务器需要安装node环境,配置工具Pm2或Nginx
-
复制目录文件
.nuxt 、api、static、nuxt.config.js、package.json
到发布的服务器目录 -
执行安装命令,安装依赖环境
$ npm install
-
Pm2的方式:执行项目启动命令,nuxt.config.js需要配置跨域代理
$ pm2 start ./node_modules/nuxt/bin/nuxt.js --name projectName -- start
-
Nginx的方式:执行项目启动命令,nuxt.config.js不需要配置跨域代理
$ pm2 start ./node_modules/nuxt/bin/nuxt.js --name projectName -- start
// Nginx 配置参考 # # The default server # server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; # Load configuration files for the default server block. client_max_body_size 1024m; client_header_timeout 120s; client_body_timeout 120s; client_body_buffer_size 256k; # 后端转发 location /api/v2/ { rewrite ^/api/v2/(.*)$ /$1 break; proxy_read_timeout 300; # Some requests take more than 30 seconds. proxy_connect_timeout 300; # Some requests take more than 30 seconds. proxy_send_timeout 600; proxy_set_header X-Real-IP $remote_addr; # 获取客户端真实IP proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header Host $host:$server_port; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 获取代理者的真实ip proxy_buffer_size 32k; proxy_buffers 32 256k; proxy_busy_buffers_size 512k; proxy_temp_file_write_size 512k; proxy_pass http://172.19.0.24:8080; } # 页面转发 location / { proxy_read_timeout 300; # Some requests take more than 30 seconds. proxy_connect_timeout 300; # Some requests take more than 30 seconds. proxy_send_timeout 600; proxy_set_header X-Real-IP $remote_addr; # 获取客户端真实IP proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header Host $host:$server_port; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 获取代理者的真实ip proxy_buffer_size 32k; proxy_buffers 32 256k; proxy_busy_buffers_size 512k; proxy_temp_file_write_size 512k; proxy_pass http://172.19.0.7; } error_page 404 /404.html; location = /40x.html { } error_page 500 502 503 504 /50x.html; location = /50x.html { } }