Vue 服务端渲染(SSR)、Nuxt.js - 从入门到实践

前言

10月初有幸接到公司官网改版需求,要求采用服务端渲染模式对原网站进行seo优化。

由于团队一直使用的vue技术栈,所以我第一时间想到的就是采用vue 服务端渲染(SSR)来实现该需求,即能减少团队其他成员后期维护的成本,又能把现有其他项目封装好的内容稍微改改就能直接copy过来使用,大大节省了开发时间(除去ui中途调整,整体改造时间只花了2-3天)。

改造前后对比

1、原公司网站(改版前),采用 vue(SPA) 模式开发

网址:www2.nicomama.com/ 暂时已经关闭

2、新公司网站(改版后),采用 Vue 服务端渲染(SSR) 模式开发

网址:www.nicomama.com/

当然直接从浏览器打开只能看到两个网站只是在风格上和界面上做了升级。

接下来让我们看看两个网站区别在哪里,使用chrome浏览器分别打开两个网站,右击查看源码。

截图如下:

1、改版前

2、改版后

可以明显看出改版后网站源码增加了不止几倍之多,简而言之服务端渲染的模式就是:在请求一个网址的时候,服务端收到请求之后把html的内容先生成好然后再返回给浏览器。这样子搜索引擎就可以通过你返回的a标签抓取到网站的其他页面了,依此类推搜索引擎就可以收录网站的所有(暴露出来的)路径了,后面还会给大家看一下网站改版后的一些搜录数据变化。

在看下面内容之前建议大家先去看下《Vue SSR指南》,这是文档地址ssr.vuejs.org/zh/#%E4%BB%…

正文

接下来这一块Vue SSR的概念介绍和好处坏处对比的内容是对文档提及的概念摘要,看过文档的可以直接忽略~

Vue SSR

简而言之就是将本来要放在浏览器执行创建的组件,放到服务端先创建好,然后生成对应的html将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

Vue SSR 相比 SPA(单页应用)好处及坏处

1、好处

  1. 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。

  2. 更快的内容到达时间(time-to-content),特别是对于缓慢的网络情况或运行缓慢的设备。

2、坏处

1)开发条件所限。浏览器特定的代码,只能在某些生命周期钩子函数(lifecycle hook)中使用;一些外部扩展库(external library)可能需要特殊处理,才能在服务器渲染应用程序中运行。

2)涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序(SPA)不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。

3)更多的服务器端负载。在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用 CPU 资源(CPU-intensive - CPU 密集),因此如果你预料在高流量环境(high traffic)下使用,请准备相应的服务器负载,并明智地采用缓存策略。

了解完概念之后,让我们动手实现第一个Vue SSR实例把~

注意:以下内容需要有一定vue基础

相比vue SPA(单页应用),Vue增加了一些扩展工具,首先我们来看一下比较重要的一个工具vue-server-renderer,从名字可以看出它是在服务端渲染的时候用的。

让我们来看一下它的功能和用法

1、创建一个空项目 mkdir vuessr && cd vuessr

2、运行 npm init 进行初始化

3、安装我们需要的依赖 cnpm install vue vue-server-renderer --save

4、创建index.js代码如下:

// 第 1 步:创建一个 Vue 实例
const Vue = require('vue')
const app = new Vue({
  template: `
Hello World
`
}) // 第 2 步:创建一个 renderer const renderer = require('vue-server-renderer').createRenderer() // 第 3 步:将 Vue 实例渲染为 HTML renderer.renderToString(app, (err, html) => { if (err) throw err console.log(html) // =>
Hello World
}) 复制代码

5、运行 node index.js 可以看到在控制台输出了

    <div data-server-rendered="true">Hello Worlddiv>
复制代码

我们再将生成好的html放到指定的html模版里面再返回到浏览器不就实现服务端渲染功能了?

6、安装依赖 cnpm install express --save

7、创建app.js代码如下:

const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()

server.get('*', (req, res) => {
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `
访问的 URL 是: {{ url }}
`
}) renderer.renderToString(app, (err, html) => { if (err) { res.status(500).end('Internal Server Error') return } res.end(` Hello ${html} `) }) }) server.listen(8080) 复制代码

8、运行node app.js

9、打开浏览器输入http://localhost:8080/发现我们的内容已经显示出来了,如果有同学发现有中文乱码的问题,可以设置一下编码:

renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    //设置编码
    res.writeHead(200, {'Content-Type':'text/html;charset=utf-8'});
    res.end(`
      
      
        Hello
        ${html}
      
    `)
})
复制代码

然后重新运行一下试试。

10、当然直接采用字符串来拼接html内容是非常不优雅的,而且容易出错,我们可以改写成模版形式,创建文件index.template.html 代码如下:


<html lang="en">
  <head><title>Hellotitle>head>
  <body>
    
  body>
html>
复制代码

注意 注释--这里将是应用程序 HTML 标记注入的地方。

11、修改app.js

const renderer = require('vue-server-renderer').createRenderer({
    template: require('fs').readFileSync('./index.template.html', 'utf-8')
})
//***
renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    res.writeHead(200, {'Content-Type':'text/html;charset=utf-8'});
    res.end(html)
})
复制代码

12、重新运行一下试试,看看是否能正常运行。

13、它还支持模板插值操作,修改文件index.template.html,代码如下:

<html>
  <head>
    
    <title>{{ title }}title>

    
    {{{ meta }}}
  head>
  <body>
    
  body>
html>
复制代码

修改文件index.js ,需要调整的代码如下:

const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer({
  template: require('fs').readFileSync('./index.template.html', 'utf-8')
})
const context = {
  title: 'hello vuessr',
  meta: `
    
  `
}

server.get('*', (req, res) => {
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `
访问的 URL 是: {{ url }}
`
}) renderer.renderToString(app, context, (err, html) => { if (err) { res.status(500).end('Internal Server Error') return } res.writeHead(200, {'Content-Type':'text/html;charset=utf-8'}); res.end(html) }) }) server.listen(8080) 复制代码

14、再次重新运行一下试试,看看是否能正常运行。以下是查看浏览器源码的截图,可以看到模板定义的内容已经替换成对应的数据了。

代码地址:

github.com/taoxhsmile/…

具体详细的api介绍大家可以去看文档,以上只是简单的使用。

到此为止我们已经实现一个最基础的vue 服务端渲染的工程了。

是不是很简单?不过要从头搭建整套Vue SSR还是一个非常繁琐的过程。

后续我也会给大家介绍VueSSR开箱即用的框架Nuxt.js,其实它就是对Vue SSR的一个封装,概念还是一样的。当然再没了解Vue SSR的基本实现过程,直接去使用Nuxt.js还是会一头雾水(大神忽略~)。

在搭建项目之前我们先来看看指南中提供的这张运行原理图吧:

从图中我们可以大致看出vue ssr的运作过程:我们首先通过 webpack 打包 - 服务器需要「服务器 bundle」然后用于服务器端渲染(SSR),而「客户端 bundle」会发送给浏览器,用于混合静态标记。有了理论基础之后让我们一起实践一下吧。

使用Vue-cli为基础搭建VueSSR

选择vue-cli的webpack模版生成的代码基本上可以复用到VueSSR可以省去繁琐的webpack配置的过程。

1、安装vue-cli,参考文档cli.vuejs.org/zh/guide/cl…,这里就不做过多介绍,我采用的版本是2.9.6。

2、运行 vue init webpack vuessr-vuecli 选择配置如下

? Project name vuessr-vuecli
? Project description A Vue.js project
? Author taoxinhua 
? Vue build standalone
? Install vue-router? Yes
? Use ESLint to lint your code? No
? Set up unit tests No
? Setup e2e tests with Nightwatch? No
? Should we run `npm install` for you after the project has been created? (recommended) npm
复制代码

3、进入文件夹,运行 cnpm run dev;看下项目是否能正常运行

4、接下来让我们一起对代码进行改造吧~

新增
src/components
    --about.vue
    --home.vue 
复制代码

about.vue


复制代码

home.vue


复制代码
修改
src/router
    --index.js
复制代码

index.js

import Vue from 'vue'
import Router from 'vue-router'
import home from '@/components/home'
import about from '@/components/about'

Vue.use(Router)

export default () => {
  return new Router({
    mode:'history',
    routes: [
      {
        path: '/',
        name: 'home',
        component: home
      },
      {
        path: '/about',
        name: 'about',
        component: about
      }
    ]
  })
}
复制代码
新增
src
    --app.js
    --App.vue 
    --entry-client.js 客户端打包入口文件
    --entry-server.js 服务端打包入口文件
复制代码

app.js

import Vue from 'vue'
import createRouter from './router'
import App from './App.vue'

// 实例 每次请求都会创建新的实例

export default (context) => {

  const router = createRouter()

  const app = new Vue({
    router,
    components: { App },
    template: ''
  })
  return { router, app }

}
复制代码

App.vue






复制代码

entry-client.js

import createApp from './app'

let { app, router } = createApp()

router.onReady(() => {
  app.$mount('#app')
})
复制代码

entry-server.js

// 服务端这边,需要把访问的路径给到vue-router

import createApp from './app'

// 外面的express服务使用  {url: / /about}
export default (context) => {
  return new Promise((resolve, reject) => {

    let { app, router } = createApp(context);
    router.push(context.url);

    router.onReady(() => {
      // 访问路径,可定匹配到组件
      let matchedCompoents = router.getMatchedComponents();

      if (!matchedCompoents.length) {
        return reject({ code: 404 })
      }
      resolve(app)
    }, reject)

  })
}
复制代码
新增
build/dev-server.js
server.js
复制代码

dev-server.js

const serverConf = require('./webpack.server.conf');

const webpack = require('webpack')
const fs  =require('fs')
const path = require('path');
const Mfs = require('memory-fs')
const axios = require('axios')

module.exports = (cb) => {

  const webpackComplier = webpack(serverConf);

  var mfs = new Mfs();
  webpackComplier.outputFileSystem = mfs;

  webpackComplier.watch({}, async (error, stats) => {
    if (error) return console.log(error);

    stats = stats.toJson();
    stats.errors.forEach(err => console.log(err))
    stats.warnings.forEach(err => console.log(err))

    // server Bundle json文件
    let serverBundlePath = path.join(
      serverConf.output.path,
      'vue-ssr-server-bundle.json'
    )

    let serverBundle = JSON.parse(mfs.readFileSync(serverBundlePath, "utf-8"))

    //console.log(serverBundle)

    // client Bundle json文件
    let clientBundle = await axios.get('http://localhost:8080/vue-ssr-client-manifest.json')

    // 模板

    let template = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');

    cb(serverBundle, clientBundle, template)

  })
}
复制代码

server.js

const devServer = require('./build/dev-server');
const express = require('express');
const app = express();
const vueRenderer = require('vue-server-renderer')
const path = require('path');

app.get('*', async (req, res) => {


  res.status(200);
  res.setHeader('Content-Type', 'text/html;charset=utf-8;')

  devServer(function(serverBundle,clientBundle,template){
    let renderer = vueRenderer.createBundleRenderer(serverBundle,{
      template,
      clientManifest: clientBundle.data,
      runInNewContext: false
    })

    renderer.renderToString({ url: req.url }).then((html) => {
      res.end(html)
    }).catch(err => console.log(err))
  })

})

app.listen(5000, () => {
  console.log('启动成功')
})
复制代码
修改
build/webpack.dev.conf.js
复制代码

webpack.dev.conf.js

//...忽略
const portfinder = require('portfinder')
//新增内容-start
const vueSSRClientPlugin = require('vue-server-renderer/client-plugin')
//新增内容-end
const HOST = process.env.HOST
//...忽略
new CopyWebpackPlugin([
  {
    from: path.resolve(__dirname, '../static'),
    to: config.dev.assetsSubDirectory,
    ignore: ['.*']
  }
]),
//新增内容-start
new vueSSRClientPlugin()
//新增内容-end
//...忽略
复制代码

新增文件

build/webpack.server.conf.js
复制代码

webpack.server.conf.js

const webpack = require('webpack');
const merge = require('webpack-merge')
const base = require('./webpack.base.conf');
const vueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const webpackNodeExternals = require('webpack-node-externals')

module.exports = merge(base,{
  target: 'node',
  devtool: 'source-map',
  entry: './src/entry-server.js',
  output: {
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2'
  },
  externals: [webpackNodeExternals({
    whitelist: /\.css$/
  })],
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': '"devlopment"',
      'process.env.VUE_ENV': '"server"'
    }),
    new vueSSRServerPlugin()
  ]
})
复制代码
修改
index.html
复制代码

index.html



  
    "utf-8">
    "viewport" content="width=device-width,initial-scale=1.0">
    vuessr-vuecli
  
  
    
"app">
复制代码
修改
package.json
增加server脚本
复制代码

package.json

"scripts": {
    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
    "start": "npm run dev",
    "build": "node build/build.js",
    "server": "node server.js"
},
复制代码

到此为止我们的文件都已经调整完毕了,让我们先来运行一下看看吧。

cnpm run dev
cnpm run server
复制代码

可以看到我们的程序已经正常运行了,再来看看网页源代码是否是通过服务端渲染完毕之后再返回的。

没错到这一步我们算是大功高成了,接下来我们回过头来看看整个项目的运行原理吧~ 毕竟这才是重点。

首先回想一下前面的运行原理图,第一步是不是先通过webpack分别打包出给服务端用的bundle和客户端用的bundle。

我们先来找一下客户端用的bundle我们生成在哪里吧

我们运行 cnpm run dev 实际上就是用来生成客户端用的bundle。这一步比较简单,回想一下我们是不是调整了 webpack.dev.conf.js 增加了一个插件 vue-server-renderer/client-plugin 就是用来生成客户端用的bundle, 只不过我们并没有直接把这个bundle生成具体的文件,而是放在了缓存中,我们可以直接通过浏览器访问 http://localhost:8080/vue-ssr-client-manifest.json 查看到这份json文件。

服务端用的bundle

其实它也生成在缓存中,build/dev-server.js 首先我们通过 webpackComplier.outputFileSystem = mfs; 修改了webpack的输出形式(改成输出到缓存中),然后在从缓存中拿到该文件

let serverBundlePath = path.join(
  serverConf.output.path,
  'vue-ssr-server-bundle.json'
)

let serverBundle = JSON.parse(mfs.readFileSync(serverBundlePath, "utf-8"))
复制代码

我们再来看一下 server.js

devServer(function(serverBundle,clientBundle,template){
let renderer = vueRenderer.createBundleRenderer(serverBundle,{
  template,
  clientManifest: clientBundle.data,
  runInNewContext: false
})

renderer.renderToString({ url: req.url }).then((html) => {
  res.end(html)
}).catch(err => console.log(err))
})
复制代码

可以看到我们将拿到的serverBundle(服务端Bundle),clientBundle(客户端Bundle),template(index.html模版) 最终交由vue-server-renderer来进行最终处理。

代码地址

github.com/taoxhsmile/…

大功高成~ 原理大概就是这样,代码的细节还需要大家自行去查看和消化

讲完原理之后,让我们来看看Nuxt.js又是咋回事吧

在进入下一个环节之前,还是建议大家先去看官方文档 zh.nuxtjs.org/guide/

Nuxt.js是什么?

简而言之Nuxt.js就是Vue SSR的一个开箱即用的框架。安装好就可以直接写业务代码,而不需要做过多的配置。

既然是一个框架,那我们的就要按照它的规则来配置和写代码,前面提供的官方文档提供了非常详细的介绍。这里就不做过多介绍,我们直接进行实战吧。 首先我们先创建一个新项目。

在改造官网期间刚好Nuxt.js 2.0发布了,看了一下改动还是比较多了,为了确保项目正常上线,决定还是先采用稳定的1.0+版本进行编码(毕竟新版本方发布多少会有一点问题)。以下也是针对1.0+版本开发过程中遇到的一些问题,大家可以借鉴以下。

以下内容主要针对实战过程中遇到的一些问题来进行分享:

第一步让我们先创建一个新项目吧,安装文档地址:zh.nuxtjs.org/guide/insta… 我们采用 create-nuxt-app 命令来安装,运行

npx create-nuxt-app vuessr-nuxt 
复制代码

或者

cnpm install -g create-nuxt-app
create-nuxt-app vuessr-nuxt
复制代码

选择配置如下

? Project name vuessr-nuxt
? Project description My unreal Nuxt.js project
? Use a custom server framework express
? Use a custom UI framework none
? Choose rendering mode Universal
? Use axios module yes
? Use eslint no
? Use prettier no
? Author name taoxinhua
? Choose a package manager npm
复制代码

然后进入进入项目运行,cnpm run dev 看看项目是否能正常运行,如果不能运行,第一步先检查以下node版本,我的node版本是v8.12.0。第二步如果node升级之后还是不行,运行cnpm install 看下是否有依赖包少安装了。如果这两步还不能解决问题,大家可以在评论区提问。

打开浏览器进入 http://127.0.0.1:3000,可以看到我们的项目已经可以运行了。没错就是这么简单

1、使用axios遇到的坑。

注意

在使用1.0+版本开发过程中发现每次修改文件,服务端代码都会重新加载并执行一遍,如果直接把axios的钩子函数放到plugin中去执行,会发现每次修改完毕之后钩子函数都会重复添加一次,导致参数重复被处理,比如我发送请求之前要把传递的data转成字符串的形式。会发现下面代码的config.data会出现重复累加的情况。

axios.interceptors.request.use(function (config) {
    let data = config.data || {};
    let auth = buildHttpHeaders();

    config.url = getApiUrl(config.url);
    
    config.data = qs.stringify({
        data: JSON.stringify(data),
        auth: JSON.stringify(auth)
    })
    //在请求发出之前进行一些操作
    return config;
}, function (err) {
    //Do something with request error
    return Promise.reject(err);
});
复制代码

解决方法

创建文件

assets/js/config/config-axios.js

import Axios from 'axios'
import qs from 'qs'
import { getUUID } from '~/assets/js/tools/index'
import { getApiUrl } from '~/assets/js/config/config-urls.js'

function buildHttpHeaders() {
    return {
        "x-user-id": '',
        "x-access-token": '',
        "x-platform": 'pc',
        "x-client-token": getUUID(),
        "x-system-version": '10.1.1',
        "x-client-version": '2.0.1',
        "x-method-version": '1.0',
        "x-network-type": '3g',
    }
}

let axios = Axios.create();

// 添加一个请求拦截器
axios.interceptors.request.use(function (config) {
    let data = config.data || {};
    let auth = buildHttpHeaders();

    config.url = getApiUrl(config.url);
    
    config.data = qs.stringify({
        data: JSON.stringify(data),
        auth: JSON.stringify(auth)
    })
    //在请求发出之前进行一些操作
    return config;
}, function (err) {
    //Do something with request error
    return Promise.reject(err);
});

//添加一个响应拦截器
axios.interceptors.response.use(function (res) {
    //在这里对返回的数据进行处理
    return res.data;
}, function (err) {
    //Do something with response error
    return Promise.reject(err);
});

export default axios
复制代码

每次通过Axios.create返回一个全新的axios实例。

以上是项目里面拷贝出来的代码,无法单独运行,大家可以针对各自的需求进行相应调整。

plugins/axios.js

import Vue from 'vue';
import axios from '~/assets/js/config/config-axios'

Vue.prototype.$$axios = axios;

export default ({ app }, inject) => {
    // Set the function directly on the context.app object
    app.$$axios = axios;
}
复制代码

这样每次代码进行热更新就不会出现上面的问题了。

这里还把axios挂到了Vue的原型下面和app对象下面,页面不需要引用axios就进行调用了。

2、asyncData

在nuxt中组件文件申明的asyncData方法会被忽略,所以所有数据的加载都要放到对应page的asyncData中。

3、如何增加额外的全局js文件

我们可以通过定制模版来处理,在根目录下创建app.html模版文件,nuxt默认的模版为



  
    {{ HEAD }}
  
  
    {{ APP }}
  

复制代码

比如我们要增加搜索引擎的收录代码我们可以



  
    {{ HEAD }}
    
    
    
    
    
    
  
  
    {{ APP }}
  

复制代码

4、让项目支持ip访问 在package.json 文件中增加如下配置即可

"config": {
    "nuxt": {
      "host": "0.0.0.0",
      "port": "3000"
    }
},
复制代码

5、正式环境、测试环境区分配置

package.json

"scripts": {
    "dev": "cross-env API_ENV=local nuxt",
    "build_beta": "cross-env API_ENV=beta nuxt build",
    "build_pro": "cross-env API_ENV=pro nuxt build",
    "start": "nuxt start",
    "generate": "nuxt generate",
    "lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
    "precommit": "npm run lint"
  },
复制代码

添加build_beta、build_pro,然后通过cross-env来设置环境

nuxt.config.js

env: {
    API_ENV: process.env.API_ENV
},
复制代码

这样就可以在代码中,通过process.env.API_ENV 来获取到环境变量的值了。

6、通过pm2启动项目

pm2-config.json

{
    "apps": [
        {
            "name": "nicomama-site",
            "script": "npm",
            "args" : "start",
            "watch": [".nuxt"]
        }
    ]
}
复制代码

7、项目部署

先运行 cnpm run build_beta 或者 cnpm run build_pro进行打包,然后通过运行 pm2 start pm2-config.json 来运行项目

后续有空再给大家理一份项目的结构

再来看一下上线之后搜索引擎的收录情况吧

seo.chinaz.com/?q=www.nico…

可以看到网站的文章已经基本上都被谷歌收录到了,至于百度收录的数据比较少可能是因为网站整体改版的原因造成的,搜索引擎需要一定时间去更新,这个还有待观察。

那么就先到这里了

署名

by:Tao

转载于:https://juejin.im/post/5bd3fba951882526f11d5575

你可能感兴趣的:(Vue 服务端渲染(SSR)、Nuxt.js - 从入门到实践)