服务器渲染 --- Vue+Koa从零搭建成功输出页面

webpack从零搭建

webpack4从零开始构建(一)
webpack4+React16项目构建(二)
webpack4功能配置划分细化(三)
webpack4引入Ant Design和Typescript(四)
webpack4代码去重,简化信息和构建优化(五)
webpack4配置Vue版脚手架(六)javascript

服务器渲染系列

服务器渲染 --- Vue+Koa从零搭建成功输出页面
服务器渲染 --- 数据预取和状态
本文最终代码仓库在Vue-ssr-demo/demo1css

为何使用服务器端渲染 (SSR)?

优点

  • 利于搜索引擎优化(Search EngineOptimization)即SEO, 针对搜索引擎爬虫抓取工具优化,传统的SPA通常在Loading期间经过AJAX请求获取内容,因此抓取工具没法抓取到页面内容
  • 首屏渲染, 无需等待全部资源下载执行可直接看到完整渲染页面,提升用户体验
  • 无需解析,直接拿到标准的html页面
  • 服务器可合理利用缓存,减小查询数据

局限

  • 浏览器和服务器环境不同,某些特定代码只能在某些特定生命周期函数运行,部分扩展库须要特殊处理才能在服务器渲染中使用
  • 服务器渲染应用程序须要出于Nodejs server运行环境
  • 在Nodejs渲染会占用大量CPU资源,须要准备相应负载和蔼用缓存
  • 不利于维护开发,学习成本高

基础示例

yarn add --dev vue-server-renderer koa Vue

这些是实现服务器渲染的关键库,先安装,而后建立一个server.js建立Vue实例并输出步骤:html

  1. 新建Koa实例,接收请求返回数据
  2. 接收请求后生成新的Vue实例
  3. 利用插件vue-server-renderer建立一个 Renderer 实例, 将 Vue 实例渲染为字符串插入Html
  4. 服务器将Html返回
const Koa = require('koa')
const Vue = require('Vue')
const renderer = require('vue-server-renderer').createRenderer()

// 建立Koa实例
const app = new Koa()
app.use(async ctx => {
  // 建立Vue实例
  const app = new Vue({
    template: `<div>SSR_DEMO</div>`
  })

  // 将 Vue 实例渲染为字符串, 回调函数第一个参数是可能抛出的错误,第二个参数是渲染完毕的字符串.
  renderer.renderToString(app, (err, html) => {
    // 发生错误输出500
    if (err) {
      ctx.throw(500, 'Internal Server Error')
      return
    }
    // 响应返回html格式
    ctx.body = (`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>demo</title></head>
        <body>${html}</body>
      </html>
    `)
  })
}).listen(3000);

console.log('已创建链接,效果请看http://127.0.0.1:3000/');

保存以后,打开终端运行文件vue

node server.js
// 已创建链接,效果请看http://127.0.0.1:3000/

打开浏览器访问地址,输出SSR_DEMO文字咱们就算完成第一步了java

客户端渲染

简单搭建一个Vue+Webpack4的demo,大体目录以下
图片描述node

里面东西不少,咱们不用一下都看完,先慢慢补起来,webpack4的基本配置就不说了,只说关键位置webpack

基本页面结构

configalias.js

自定义的模块简化路径git

const path = require("path");

// 建立 import 或 require 的别名,来确保模块引入变得更简单
module.exports = {
  "@": path.resolve(__dirname, "../src/"),
  IMG: path.resolve(__dirname, "../src/img"),
  ROUTER: path.resolve(__dirname, "../src/router"),
  VUEX: path.resolve(__dirname, "../src/vuex"),
  PAGE: path.resolve(__dirname, "../src/page"),
  CMT: path.resolve(__dirname, "../src/component"),
};

src/App.vue

渲染基本界面导航切换验证github

<template>
  <div id="app">
    <h2>欢迎来到SSR渲染页面</h2>
    <router-link to="/view1">view1</router-link>
    <router-link to="/view2">view2</router-link>
    <router-view></router-view>
  </div>
</template>
<script>
export default {};
</script>

page/view1.vue

因为没有动态更新,全部的生命周期钩子函数中,只有 beforeCreatecreated 会在服务器端渲染 (SSR) 过程当中被调用.这就是说任何其余生命周期钩子函数中的代码,只会在客户端执行.你应该避免在 beforeCreatecreated 生命周期时产生全局反作用的代码,例如定时器,由于没法在beforeDestroydestroyed 清除.web

下面的输出用于测试

<template>
  <div>
    <p>Page1</p>
  </div>
</template>

<script>
export default {
  created() {
    console.log('created')
  },
  mounted() {
    console.log('mounted')
  },
};
</script>

page/view2.vue

避免使用特定平台的 API,例如windowdocument,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此, 官方推荐方案:

  • 使用多端兼容的库
  • 第三方库模拟变量进行hack
  • 仅支持浏览器的API进行惰性访问
<template>
  <div>
    <p>Page2</p>
  </div>
</template>

<script>
export default {
  created() {
    try {
      console.log(window);
    } catch (err) {
      console.log(err);
    }
  },
};
</script>

router/index.js

使用history模式方便服务器渲染.

路由作惰性加载,有助于减小浏览器在初始渲染中下载的资源体积

由于客户端和服务端要共用同一份路由配置,因此不要直接导出实例,而是导出一个建立函数

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export default function createRouter () {
  return new Router({
    // 要记得增长mode属性,由于#后面的内容不会发送至服务器,服务器不知道请求的是哪个路由
    mode: 'history',
    routes: [
      {
        // 首页
        alias: '/',
        path: '/view1',
        component: () => import('../page/view1.vue')
      },
      {
        path: '/view2',
        component: () => import('../page/view2.vue')
      },
      { path: '*', redirect: '/view1' }
    ]
  })
}

src/app.js

服务端针对每一个请求都应该建立一个全新独立的Vue实例,由于它们须要在服务器里预先请求对应的数据,这样能够避免状态污染

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

export default function createApp () {
  // 建立 router 实例
  const router = createRouter()

  const app = new Vue({
    // 注入 router 到根 Vue 实例
    router,
    render: (h) => h(App)
  })

  // 返回 app 和 router
  return { app, router }
}

渲染模板

由于新版的HtmlWebpackPlugin不支持html变量编译,须要转成ejs模板,而后直接输出html格式

ejs/client.ejs

做为浏览器渲染的模板,很是常规的一种写法,无需复述

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
  <div id="app"></div>
</body>
</html>

ejs/server.ejs

服务器渲染页面模板,注意 <!--vue-ssr-outlet--> 注释, 这里将是应用程序 HTML 标记注入的地方,很重要!!!

里面引入的变量htmlWebpackPlugin.options.files.js后面再详解

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title><%= htmlWebpackPlugin.options.title %></title>
</head>

<body>
    <!--vue-ssr-outlet-->
    <script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
</body>

</html>

构建步骤

图片描述

官方图例

  1. 业务代码写成通用模式,导出单例app.js
  2. 使用webpack分开服务端和客户端配置入口进行打包构建
  3. 分别打包出Server BundleClient Bundle
  4. 服务端渲染生成静态页面发送到客户端混合静态标记

客户端激活

上面说的混合静态标记,由于服务器已经预先渲染好静态HTMl给到客户端,即Vue在浏览器接管由服务端发送的静态HTML,使其变成由Vue管理的动态DOM过程.

客户端会直接挂载到根元素

// 这里假定 App.vue template 根元素的 `id="app"`
app.$mount('#app')

而从服务端获取到的HTML里能够看到该根元素多了特殊属性

<div id="app" data-server-rendered="true">

这属性是让客户端知道这部分HTML是有服务器渲染无需再执行,而是应该以激活模式进行挂载.

在没有该属性的状况下也还能够向 $mount 函数的 hydrating参数位置传入 true,来强制使用激活模式(hydration):

// 强制使用应用程序的激活模式
app.$mount('#app', true)

在开发模式下,Vue 将推断客户端生成的虚拟 DOM 树 (virtual DOM tree),是否与从服务器渲染的 DOM 结构 (DOM structure) 匹配。若是没法匹配,它将退出混合模式,丢弃现有的 DOM 并从头开始渲染。在生产模式下,此检测会被跳过,以免性能损耗。

切记: 浏览器可能会更改的一些特殊的 HTML 结构

入口

entryentry-client.js

客户端的入口文件只需建立应用程序,而且将其挂载到 DOM 中, 在路由完成初始导航时调用,这意味着它能够解析全部的异步进入钩子和路由初始化相关联的异步组件,这能够有效确保服务端渲染时服务端和客户端输出的一致.

import createApp from '../src/app'

const { app, router } = createApp()
// 路由完成初始导航时调用
router.onReady(() => {
  // 挂载App.vue模板中根元素
  app.$mount('#app')
})

entryentry-server.js

服务器的入口文件作了如下几个步骤:

  1. 动态的导航到接收来自客户端的请求路由
  2. 路由完成初始导航后经过getMatchedComponents返回当前路由匹配的组件数组
  3. 无匹配组件时作失败处理
  4. 不然返回成功状态Promise
import createApp from '../src/app'

export default (context) => {
  // 由于有可能会是异步路由钩子函数或组件,因此咱们将返回一个 Promise,
  // 以便服务器可以等待全部的内容在渲染前,
  // 就已经准备就绪.
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()

    // 设置服务器端 router 的位置
    router.push(context.url)

    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()

      // 匹配不到的路由,执行 reject 函数,并返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }

      // Promise 应该 resolve 应用程序实例,以便它能够渲染
      resolve(app)
    }, reject)
  })
}

Webpack关键配置

经过使用 webpack 的自定义插件,server bundle将生成为可传递到 bundle renderer 的特殊 JSON 文件,它相比直接打包成js有如下优点:

  • 内置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map')
  • 在开发环境甚至部署过程当中热重载(经过读取更新后的 bundle,而后从新建立 renderer 实例)
  • 关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程当中用到的组件所需的CSS.
  • 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk.

configenv.js

主要是设置clientserver的全部关键配置了

const path = require('path')
const isDev = process.env.NODE_ENV === 'DEV'
const isProd = true || process.env.NODE_ENV === 'PROD'
const isServer = process.env.NODE_ENV === 'SERVER'
const client = {
  entry: {
    client: path.resolve(__dirname, '../entry/entry-client.js')
  },
  output: {
    // 打包文件名
    filename: 'bundle.client.js',
    // 输出路径
    path: path.resolve(__dirname, '../dist/client'),
    // 资源请求路径
    publicPath: '/'
  },
  htmlPluginOpt: {
    title: "浏览器渲染",
    // 本地模板文件的位置
    template: path.resolve(__dirname, '../ejs/client.ejs'),
    // 输出文件的文件名称
    filename: 'client.html'
  }
}

const server = {
  entry: {
    server: path.resolve(__dirname, '../entry/entry-server.js')
  },
  output: {
    // 打包文件名
    filename: 'bundle.server.js',
    // 输出路径
    path: path.resolve(__dirname, '../dist/server'),
    // 资源请求路径
    publicPath: '/',
    // 导出的是 module.exports.default
    libraryTarget: 'commonjs2'
  },
  htmlPluginOpt: {
    title: "服务端渲染",
    // 本地模板文件的位置
    template: path.resolve(__dirname, '../ejs/server.ejs'),
    // 输出文件的文件名称
    filename: 'server.html',
    // webpack的stats对象的assetsByChunkName属性表明的值
    files: {
      js: 'bundle.client.js'
    },
    // 不容许注入
    excludeChunks: ['server']
  }
}

const title = 'test'

module.exports = {
  isDev,
  isProd,
  isServer,
  client,
  server,
  title
}

须要注意的是server.htmlPluginOpt的配置,它控制模板禁止注入自己的chunk,而后手动注入客户端的bundle,

configwebpack-client.js

客户端执行入口,忽略一些webpack的配置,最终生成客户端构建清单vue-ssr-client-manifest.json文件

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const merge = require('webpack-merge')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const common = require('./webpack.common.js')
const dev_conf = require('./webpack.dev.js')
const { client } = require('./env')

module.exports = merge(common, dev_conf, {
  // 入口
  entry: client.entry,
  // 输出
  output: client.output,
  plugins: [
    // 生成客户端构建清单 (client build manifest)
    // 默认文件名为 `vue-ssr-client-manifest.json`
    new VueSSRClientPlugin(),
    new HtmlWebpackPlugin(client.htmlPluginOpt)
  ]
})

entryentry-server.js

服务端执行入口,跟客户端相比有几个不一样

  • 指定构建环境为node
  • 指定输出模块适用于用于 CommonJS 环境
  • 引入webpack-node-externals',将须要打包的模块加入白名单
  • 输出成vue-ssr-server-bundle.json文件
  • 避免使用CommonsChunkPlugin
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const common = require('./webpack.common.js')
const dev_conf = require('./webpack.dev.js')
const { server } = require('./env')

module.exports = merge(common, dev_conf, {
  // 入口
  entry: server.entry,
  // 输出
  output: server.output,
  // 对 bundle renderer 提供 source map 支持
  devtool: 'source-map',
  // 这容许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
  // 而且还会在编译 Vue 组件时,
  // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
  target: 'node',
  externals: nodeExternals({
    // 不要外置化 webpack 须要处理的依赖模块。
    // 你能够在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
    // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
    whitelist: /\.css$/
  }),
  // 这是将服务器的整个输出
  // 构建为单个 JSON 文件的插件。
  // 默认文件名为 `vue-ssr-server-bundle.json`
  plugins: [
    new VueSSRServerPlugin(),
    new HtmlWebpackPlugin(server.htmlPluginOpt)
  ]
})

构建文件

package.json里咱们配置几个简单的命令

"scripts": {
  "client": "cross-env NODE_ENV=PROD webpack --config ./config/webpack-client.js",
  "server": "cross-env NODE_ENV=PROD webpack --config ./config/webpack-server.js",
  "build": "yarn client && yarn server",
  "start": "node server",
  "rnm": "rimraf node_modules"
},

运行命令,生成dist/client/vue-ssr-client-manifest.jsondist/server/vue-ssr-server-bundle.json

yarn build

图片描述

服务器构建

server/router.js

服务器官方教程选择Express,可是我以为过重了,换成同个团队开发的Koa

  • 使用createBundleRenderer建立一个 BundleRenderer 实例
  • 使用客户端清单 (client manifest) 和服务器 bundle(server bundle),renderer 如今具备了服务器和客户端的构建信息,所以它能够自动推断和注入资源预加载 / 数据预取指令(preload / prefetch directive),以及 css 连接 / script 标签到所渲染的 HTML
  • 传输URL返回匹配路由组件发送给客户端
const path = require('path')
const Router = require('koa-router')
const router = new Router()
const { createBundleRenderer } = require('vue-server-renderer')
const { client, server } = require('../config/env')

// 服务器 bundle
const serverBundle = require(`${server.output.path}/vue-ssr-server-bundle.json`);
// 客户端清单, 自动推断和注入资源预加载 / 数据预取指令(preload / prefetch directive),以及 css 连接 / script 标签到所渲染的 HTML
const clientManifest = require(`${client.output.path}/vue-ssr-client-manifest.json`);
const template = require('fs').readFileSync(path.resolve(__dirname, '../dist/server/ssr.html'), 'utf-8');
const renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false, // 推荐, 默认状况下,对于每次渲染,bundle renderer 将建立一个新的 V8 上下文并从新执行整个 bundle
    template,
    clientManifest, // (可选)客户端构建 manifest
});

class Server {
  static async createHtml (ctx, next) {
    // 将 Vue 实例渲染为字符串, 回调函数第一个参数是可能抛出的错误,第二个参数是渲染完毕的字符串。
    try {
      const html = await renderer.renderToStream({
        url: ctx.request.url
      })
      ctx.status = 200
      ctx.type = 'html'
      ctx.body = html
    } catch (err) {
      console.log('err: ', err)
      ctx.throw(500, 'Internal Server Error')
    }
  }
}

router.get('*', Server.createHtml)
module.exports = router

server/index.js

  • 加入日志中间件输出
  • 设定访问资源路径
  • 加入路由
const path = require('path')
const Koa = require('koa')
const logger = require('koa-logger')
const serve = require('koa-static')
const router = require('./router')

// 建立Koa实例
const app = new Koa()
app
  .use(logger())
  .use(serve(path.resolve(__dirname, '../dist/client')))
  .use(router.routes())
  .use(router.allowedMethods())
  .listen(3005)

console.log('已创建链接,效果请看http://127.0.0.1:3005/')

运行文件启动服务器便可查看效果

yarn start

图片描述
图片描述

相关文章
相关标签/搜索