6

webpack从零搭建

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

服务器渲染系列

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

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

优势

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

局限

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

基础示例

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

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

  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/');

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

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

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

客户端渲染

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

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

基本页面结构

configalias.js

自定义的模块简化路径

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

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

<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 清除.

下面的输出用于测试

<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

图片描述
图片描述


Afterward
621 声望62 粉丝

努力去做,对的坚持,静待结果