前面记录了下next 如何做服务端渲染,最近看了看vue官方得ssr说明,然后不基于next,自己来做一个vue得ssr服务端渲染.

官方文档:https://ssr.vuejs.org/

官方文档的解释:Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器和客户端上运行。

还是老样子,我们在用ssr服务端渲染之前需要先问自己是否真的需要它.

技术层面:

  • 更快的首屏渲染速度
  • 更好的 SEO

业务层面:

  • 不适合管理系统
  • 适合门户资讯类网站,例如企业官网、知乎、简书等
  • 适合移动网站

vue的ssr 主要分为两种

  1. Nuxt.js 开发框架

    1. NUXT提供了平滑的开箱即用的体验,它建立在同等的Vue技术栈之上,但抽象出很多模板,并提供了一些额外的功能,例如静态站点生成。通过 Nuxt.js 可以快速的使用 Vue SSR 构建同构应用。
  2. 基于 Vue SSR 官方文档提供的解决方案

    1. 官方方案具有更直接的控制应用程序的结构,更深入底层,更加灵活,同时在使用官方方案的过程中,也会对Vue SSR有更加深入的了解。
    2. 该方式需要你熟悉 Vue.js 本身,并且具有 Node.js 和 webpack 的相当不错的应用经验。

next的文档写的很棒了,基本跟着它的文档做就可以了,这里主要是参考vue ssr的文档,来实现一下.
首先来个最简单的,把一个vue的实例转换成模板字符串,然后从服务器返回到客户端.

npm install vue vue-server-renderer --save
const Vue = require('vue');
const server = require('express')(); //创建服务
//选择模板
const template = require('fs').readFileSync('./index.template.html', 'utf-8');

//创建渲染器
const renderer = require('vue-server-renderer').createRenderer({
  template,
});

//模板使用的数据上下文
const context = {
    title: 'vue ssr',
    metas: `
        <meta name="keyword" content="vue,ssr">
        <meta name="description" content="vue srr demo">
    `,
};
//匹配所有地址,返回html界面
server.get('*', (req, res) => {
  const app = new Vue({ //创建vue实例,每次请求都是一个新的实例,防止实例共享,数据错乱
    data: {
      url: req.url
    },
    template: `<div>访问的 URL 是: {{ url }}</div>`,
  });
   //把vue实例和上下文数据和模板结合返回字符串给客户端浏览器
  renderer
  .renderToString(app, context, (err, html) => {
    console.log(html);
    if (err) {
      res.status(500).end('Internal Server Error')
      return;
    }
    res.end(html);
  });
})
//监听端口
server.listen(8080);

模板注意点:注意 <!--vue-ssr-outlet--> 注释 -- 这里将是应用程序 HTML 标记注入的地方。

这是官网的例子:https://ssr.vuejs.org/zh/guide/

这会是一个最简单的demo,开发阶段热加载,路由定位,数据预取等都没有,下面就我们来进一步实现它。

首先我们来看一张图,来看它的构建流程:
image.png

首先来看,我们肯定是需要使用webpack来打包我们的vue程序的,因为:

  • 通常 Vue 应用程序是由 webpack 和vue-loader构建,并且许多 webpack 特定功能不能直接在Node.js 中运行(例如通过file-loader导入文件,通过css-loader导入 CSS)。
  • 尽管 Node.js 最新版本能够完全支持 ES2015 特性,我们还是需要转译客户端代码以适应老版浏览器。这也会涉及到构建步骤。

所以是这样,首先我们把所有的源代码例如(store,router,components),通过公共entry app.js和服务端的入口和客户端入口进行webpack打包,对应客户端应用程序和服务端应用程序打出来:服务器需要的bundle 也就是serve bundle,用于服务器渲染ssr, 而打出来的客户端bundle 也就是client bundle, 这个js会写入到html模板中,用于客户端激活,接管服务端发送的静态html,使其变为由vue管理的动态dom.

客户端激活的一些注意事项:

由于服务器已经渲染好了 HTML,我们显然无需将其丢弃再重新创建所有的 DOM 元素。相反,我们需要"激活"这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)。

如果你检查服务器渲染的输出结果,你会注意到应用程序的根元素上添加了一个特殊的属性:

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

data-server-rendered 特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。注意,这里并没有添加 id="app",而是添加 data-server-rendered 属性:你需要自行添加 ID 或其他能够选取到应用程序根元素的选择器,否则应用程序将无法正常激活。

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

客户端渲染注意事项:https://ssr.vuejs.org/zh/guid...

一个基本的项目应该是这样:
image.png

  • setup-dev-server: 开发模式下,用来监视打包构建,重新生成renderer渲染器
  • webpack.base.config 公共的webpack配置
  • webpack.client.config 客户端webpack配置
  • webpack.server.config 服务端webpack配置
  • pages,router,store 是vue 相关文件
  • index.template.html 模板html文件
  • app.js我们应用程序的「通用 entry」,在纯客户端应用程序中,我们将在此文件中创建根 Vue 实例,并直接挂载到 DOM。但是,对于服务器端渲染(SSR),责任转移到纯客户端 entry 文件。
  • entry-server.js 服务器 entry 使用 default export 导出函数,并在每次渲染中重复调用此函数。此时,除了创建和返回应用程序实例之外,它不会做太多事情 - 但是稍后我们将在此执行服务器端路由匹配 (server-sideroute matching) 和数据预取逻辑 (data pre-fetching logic)。
  • entry-client.js 客户端 entry 只需创建应用程序,并且将其挂载到 DOM 中
  • server.js 我们node服务的启动文件

然后下面就是一步一步来实现在这些功能.
准备工作安装依赖:
生产:

说明
vueVue.js 核心库
vue-server-rendererVue 服务端渲染工具
express基于 Node 的 Web 服务框架
cross-env通过 npm scripts 设置跨平台环境变量

开发:

说明
webpackwebpack 核心包
webpack-cliwebpack 的命令行工具
webpack-mergewebpack 配置信息合并工具
webpack-node-externals排除 webpack 中的 Node 模块
rimraf基于 Node 封装的一个跨平台rm-rf工具
friendly-errors-webpack-plugin友好的 webpack 错误提示
@babel/core , @babel/plugin-transform-runtime, @babel/preset-env, babel-loaderBabel 相关工具
vue-loader,vue-template-compiler处理 .vue 资源
file-loader处理字体资源
css-loader处理 CSS 资源
url-loader处理图片资源

然后一步一步来,首先是我们的 webpack配置,先是公共的webpack.base.config.js

/**
 * 公共配置
 */
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const path = require('path')
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')//友好的webpack错误提示
const resolve = file => path.resolve(__dirname, file)

const isProd = process.env.NODE_ENV === 'production' //判断是生产还是开发环境

module.exports = {
  mode: isProd ? 'production' : 'development',
  output: {
    path: resolve('../dist/'),
    publicPath: '/dist/',   //设置客户端请求资源时的路径,从dist打包输出目录读取
    filename: '[name].[chunkhash].js' //设置chunkhash
  },
  resolve: {
    alias: {
      // 路径别名,@ 指向 src
      '@': resolve('../src/')
    },
    // 可以省略的扩展名
    // 当省略扩展名的时候,按照从前往后的顺序依次解析
    extensions: ['.js', '.vue', '.json']
  },
  devtool: isProd ? 'source-map' : 'cheap-module-eval-source-map',
  module: {
    rules: [
      // 处理图片资源
      {
        test: /\.(png|jpg|gif)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
            },
          },
        ],
      },

      // 处理字体资源
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/,
        use: [
          'file-loader',
        ],
      },

      // 处理 .vue 资源
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },

      // 处理 CSS 资源
      // 它会应用到普通的 `.css` 文件
      // 以及 `.vue` 文件中的 `<style>` 块
      {
        test: /\.css$/,
        use: [
          'vue-style-loader',
          'css-loader'
        ]
      },
      
      // CSS 预处理器,参考:https://vue-loader.vuejs.org/zh/guide/pre-processors.html
      // 例如处理 Less 资源
      // {
      //   test: /\.less$/,
      //   use: [
      //     'vue-style-loader',
      //     'css-loader',
      //     'less-loader'
      //   ]
      // },
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
    new FriendlyErrorsWebpackPlugin() //错误提示插件
  ]
}

然后是webpack客户端打包配置:

/**
 * 客户端打包配置
 */
const { merge } = require('webpack-merge')//合并配置
const baseConfig = require('./webpack.base.config.js') //公共配置
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(baseConfig, {
  entry: {
    app: './src/entry-client.js' //客户端入口文件
  },

  module: {
    rules: [
      // ES6 转 ES5
      {
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
            cacheDirectory: true,
            plugins: ['@babel/plugin-transform-runtime']
          }
        }
      },
    ]
  },

  // 重要信息:这将 webpack 运行时分离到一个引导 chunk 中,
  // 异步代码拆分,以便可以在之后正确注入异步 chunk。
  //最后一个块包含webpack生成的在浏览器上使用各个块的加载代码,所以页面上使用的时候最后一个块必须最先加载,
  //它是最后一个块,会把 webpack的 运行代码放到manifest这里
  //https://ssr.vuejs.org/zh/guide/build-config.html
  optimization: {
    splitChunks: {
      name: "manifest",
      minChunks: Infinity
    }
  },

  plugins: [
    // 此插件在输出目录中生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ]
})

代码拆分的作用splitChunks :
通过以上设置,使用代码分割特性构建后的服务器渲染的 HTML 代码,将看起来如下(所有都是自动注入):
参考https://ssr.vuejs.org/zh/guid...

然后是webpack服务端打包配置:

/**
 * 服务端打包配置
 */
const { merge } = require('webpack-merge') //合并配置
const nodeExternals = require('webpack-node-externals') 
//与Webpack捆绑在一起作为后端时,通常不希望捆绑其node_modules依赖项。
//该库创建一个外部函数,node_modules在Webpack中捆绑时将忽略该函数
const baseConfig = require('./webpack.base.config.js')//公共配置
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(baseConfig, {
  // 将 entry 指向应用程序的 server entry 文件
  entry: './src/entry-server.js',

  // 这允许 webpack 以 Node 适用方式处理模块加载,忽略诸如path,fs等的内置模块。
  // 并且还会在编译 Vue 组件时,
  // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
  target: 'node',

  output: {
    filename: 'server-bundle.js',
    // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
    libraryTarget: 'commonjs2'
  },
  // 打包出来的文件是运行在服务端的所以
  // 不打包 node_modules 第三方包,而是保留 require 方式直接加载
  externals: [nodeExternals({
    // 白名单中的资源依然正常打包
    allowlist: [/\.css$/]
  })],

  plugins: [
    // 这是将服务器的整个输出构建为单个 JSON 文件的插件。
    // 默认文件名为 `vue-ssr-server-bundle.json`
    new VueSSRServerPlugin()
  ]
})

然后package.json添加执行命令

 "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
 "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",

这两个命令执行完之后dist中会有两个 json文件,需要我们重点关注下:
1.vue-ssr-server-bundle.json文件
2.vue-ssr-client-manifest.json文件

我们先来写下node的启动文件server,在这个2文件里会用到这俩json文件,后面我们再细讲这俩文件是干嘛的.

const express = require('express') //导入express
const fs = require('fs') // 文件系统
const { createBundleRenderer } = require('vue-server-renderer') //导入bundle渲染器
const setupDevServer = require('./build/setup-dev-server') //开发模式下,用来监视打包构建,重新生成renderer渲染器

const server = express() //创建一个 web服务

server.use('/dist', express.static('./dist')) //设置静态资源地址,可以直接访问dist文件下资源

const isProd = process.env.NODE_ENV === 'production' //是否开发环境

let renderer //渲染器
let onReady //primise对象用来 首次启动开发模式时有一些生成serverBundle和template,clientManifest操作是异步的
//所以我们需要 返回一个promise状态 等待我们的 这些文件生成,再生成渲染器后,再执行render
//
if (isProd) { //生成环境
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  const template = fs.readFileSync('./index.template.html', 'utf-8')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  renderer = createBundleRenderer(serverBundle, { //根据json配置和html模板 生成渲染器
    template,
    clientManifest
  })
} else {
  // setupDevServer 开发模式 -> 监视打包构建 -> 重新生成 Renderer 渲染器
  //返回promise 用来控制首次生成renderer
  onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
    renderer = createBundleRenderer(serverBundle, {
      template,
      clientManifest
    })
  })
}

const render = async (req, res) => {//最终的render函数
  try {
      // 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
  // 现在我们的服务器与应用程序已经解耦!
  //bundle renderer 在调用 renderToString 时,
  //它将自动执行「由 bundle 创建的应用程序实例也就是entry-server」所导出的函数(传入当前上下文作为参数,也就是
  //我们下面的{title:'',meta:''}), entry-server导出函数会返回一个新的vue实例,所以我们不需要在传入vue的实例,
  //最后会结合这个vue实例和上下文数据以及我们的配置和模板生成一个html字符串返回
    const html = await renderer.renderToString({ 
      title: '拉勾教育',
      meta: `
        <meta name="description" content="拉勾教育">
      `,
      url: req.url
    })
    //设置 响应头
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html) //返回html
  } catch (err) {
    res.status(500).end('Internal Server Error.')
  }
}

// 服务端路由设置为 *,意味着所有的路由都会进入这里
server.get('*', isProd
  ? render
  : async (req, res) => {
    // 等待有了 Renderer 渲染器以后,调用 render 进行渲染
    await onReady
    render(req, res)
  }
)

server.listen(3000, () => {//监听3000端口
  console.log('server running at port 3000.')
})

服务端:

这个文件中我们可以看到 不论是开发环境还是生产环境 都会根据json配置和html模板 renderer = 生成渲染器createBundleRenderer。
调用了renderer.renderToString 来进行html字符串的生成

renderer 通过serverBundle 拿到 entry-server构建结果

server Bundle 是 Vue SSR 构建的一个特殊的 JSON 文件,里面包含

  • entry:入口
  • files:所有构建结果资源列表
  • maps:源代码 source map 信息

server-bundle.js 就是通过 server.entry.js 构建出来的结果文件.

最终把渲染结果注入到模板中(也就是vue实例),然后结合这个vue实例和上下文数据以及我们的配置和模板生成一个html字符串返回。

接下来就是把html返回到界面上

客户端:

vue-ssr-client-manifest.json

  • publicPath:访问静态资源的根相对路径,与 webpack 配置中的 publicPath 一致
  • all:打包后的所有静态资源文件路径
  • initial:页面初始化时需要加载的文件,会在页面加载时配置到 preload 中
  • async:页面跳转时需要加载的文件,会在页面加载时配置到 prefetch 中
  • modules:项目的各个模块包含的文件的序号,对应 all 中文件的顺序;moduleIdentifier和和all数组中文件的映射关系(modules对象是我们查找文件引用的重要数据)

然后我们编写下我们的通用入口app.js和服务端入口js,客户端入口js

app.js

/**
 * 通用启动入口
 */
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router/'
import VueMeta from 'vue-meta'
import { createStore } from './store'

Vue.use(VueMeta)

Vue.mixin({
  metaInfo: {
    titleTemplate: '%s - test'
  }
})

// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
  const router = createRouter()
  const store = createStore()
  const app = new Vue({
    router, // 把路由挂载到 Vue 根实例中
    store, // 把容器挂载到 Vue 根实例中
    // 根实例简单的渲染应用程序组件。
    render: h => h(App)
  })
  return { app, router, store }
}

image.png

image.png

返回一个函数,每次调用返回一个全新vue实例,router和store都是新的防止多客户端共享一个实例

然后entry-client客户端入口

/**
 * 客户端入口
 */
import { createApp } from './app'

// 客户端特定引导逻辑……

const { app, router, store } = createApp()

if (window.__INITIAL_STATE__) { //数据预取的结果会赋值给window.__INITIAL_STATE__
  store.replaceState(window.__INITIAL_STATE__) //给store赋值
}

router.onReady(() => {//异步路由组件 等待路由加载完毕之后
  app.$mount('#app')//进行激活模式dom比对挂载
})

这样在客户端加载了这个js之后,就会重新生成vue实例和router,store并以激活模式挂载.

服务端入口

// entry-server.js
import { createApp } from './app'

export default async context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前,
    // 就已经准备就绪。
  const { app, router, store } = createApp()

  const meta = app.$meta() //获取当前app中配置的titleTemplate metaInfo信息

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

  context.meta = meta //设置上下文中的meta,把模板titleTemplate设置进去 会和组件中的metaInfo合并

  // 等到 router 将可能的异步组件和钩子函数解析完,router中由异步函数组件,要等待它加载完毕
  //用异步函数等待完成这个promsie完成  也就是路由加载完毕 
  // await new Promise(function(resolve, reject) {
  //   router.onReady(function() {
  //     resolve();
  //   })
  // })
  await new Promise(router.onReady.bind(router)) //上面函数简写 ,这里拿的是个函数为了防止this丢所以 绑定下
  //数据预取放在 serverPrefetch生命周期中,要求返回promise对象
  //这里用了vue 提供的 服务端的生命周期,也可参考官网的做法
  //https://ssr.vuejs.org/zh/guide/data.html#%E6%95%B0%E6%8D%AE%E9%A2%84%E5%8F%96%E5%AD%98%E5%82%A8%E5%AE%B9%E5%99%A8-data-store

  context.rendered = () => {
    
    //在应用渲染完成以后,router加载完之后,也就是serverPrefetch生命周期执行完毕,服务端Vuex容器中已经填充了状态数据
    // Renderer 会把 context.state 数据对象内联到页面模板中
    // 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
    //也就是说会把预取的state放到window.__INITIAL_STATE__上
    // 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
    //renderer.renderToString 会自动把context.state注入window.__INITIAL_STATE__
    context.state = store.state
  }

  return app
}

我们的服务端入口中设置了 路由的定位和数据的预取,数据的预取主要是
image.png
这里主要是把预取的数据在服务端先通过vue ssr为服务端提供的serverPrefetch生命周期函数(要求返回promise)中,取数据先放到store中,然后在entry-server中等待router加载完毕之后(serverPrefetch执行完了),store中有值了,然后放到window.__INITIAL_STATE__上(renderer.renderToString 会自动把context.state注入window.__INITIAL_STATE__)
客户端加载的时候store.replaceState(window.__INITIAL_STATE__)再更新回来.

image.png
然后配置上面的store截图可以看到.

或者可以参考vue ssr官网实现:
https://ssr.vuejs.org/zh/guid...

这会我们添加俩命令:

"build": "rimraf dist && npm run build:client && npm run build:server",
"start": "cross-env NODE_ENV=production node server.js",

然后 npm run build 后npm run start
就可以看到我们生产环境了.
到此为止生产环境配置就结束了。
接下来就是配置我们的开发环境

开发环境我们要解决:

  • 每次写完代码,都要重新打包构建
  • 重新启动 Web 服务
  • 自动刷新页面内容

主要要做的:
监视代码变动自动构建,热更新等功能
node server.js 启动应用

启动时不设置环境变量,就会走入开发环境的配置,加一个命令

"dev": "node server.js"

然后打开我们的server.js,我们来开发最后一个功能setupDevServer
,setupDevServer函数用来监视打包构建 -> 重新生成 Renderer 渲染器

setup-dev-server

const fs = require('fs') //文件系统
const path = require('path') //路径
const chokidar = require('chokidar')
const webpack = require('webpack')
const devMiddleware = require('webpack-dev-middleware')
const hotMiddleware = require('webpack-hot-middleware')

const resolve = file => path.resolve(__dirname, file)

module.exports = (server, callback) => {
  let ready
  const onReady = new Promise(r => ready = r)

  // 监视构建 -> 更新 Renderer

  let template //模板
  let serverBundle //服务端json 
  let clientManifest //客户端json

  const update = () => { //更新函数
    if (template && serverBundle && clientManifest) { //查看是否这三个文件都生成了
      ready() //改变promise状态,决议后,添加到当前tick的末尾 微任务队列中(概念上) ,等待本次tick中同步代码执行完毕之后
      //再执行server.js中的render
      callback(serverBundle, template, clientManifest) //生成渲染器,也就是说这之后,没有同步代码了,才会执行server.js中的render
    }
  }

  // 监视构建 template -> 调用 update -> 更新 Renderer 渲染器
  const templatePath = path.resolve(__dirname, '../index.template.html')
  template = fs.readFileSync(templatePath, 'utf-8')
  update()
  // fs.watch、fs.watchFile
  chokidar.watch(templatePath).on('change', () => { //通过chokidar监听 模板文件变花,变化后重新读取并更新,模板改了要刷新
    template = fs.readFileSync(templatePath, 'utf-8')
    update()
  })

  // 监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
  const serverConfig = require('./webpack.server.config')//读取服务端webpack配置
  const serverCompiler = webpack(serverConfig) //运行webpack
  const serverDevMiddleware = devMiddleware(serverCompiler, { 
    //webpack热监听编译,编译结果放到内存中,内存读写速度快,不用磁盘读写
    logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  })
  serverCompiler.hooks.done.tap('server', () => {//webpack钩子注册插件,每次编译完成后执行
    serverBundle = JSON.parse(
      serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
    ) //读取 json
    update()
  })

  // 监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
  const clientConfig = require('./webpack.client.config')//客户端webpack配置
  clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())// 实现刷新浏览器必写
  //实现刷新浏览器webpack-hot-middleware/client?noInfo=true&reload=true 是必填的 noInfo禁用控制台信息
  clientConfig.entry.app = [
    'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
    clientConfig.entry.app
  ]
  clientConfig.output.filename = '[name].js' // 热更新模式下确保一致的 hash
  const clientCompiler = webpack(clientConfig) //运客户端webpack
  const clientDevMiddleware = devMiddleware(clientCompiler, {
    //webpack热监听文件编译,编译结果放到内存中,内存读写速度快,不用磁盘读写
    publicPath: clientConfig.output.publicPath, //设置访问文件在内存路径 //绑定中间件的公共路径,与webpack配置的路径相同
    logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  })
  clientCompiler.hooks.done.tap('client', () => { //webpack钩子注册插件,每次编译完成后执行
    clientManifest = JSON.parse(
      clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
    )//读取客户端json
    update()
  })
  server.use(hotMiddleware(clientCompiler, { //注册为express的中间件
    log: false // 关闭它本身的日志输出
  }))

  // 重要!!!将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问
  //提供对 内存中打包文件的访问
  server.use(clientDevMiddleware)

  return onReady
}

上面的代码中的热更新用到了webpack-hot-middleware这个包

原理是:

  • 中间件将自身安装为 webpack 插件,并侦听编译器事件。
  • 每个连接的客户端都有一个 Server Sent Events 连接,服务器将在编译器事件上向连接的客户端发布通知。
  • 当客户端收到消息时,它将检查本地代码是否为最新。如果不是最新版本,它将触发 webpack 热模块重新加载。

好了到此为止,我们的应用就构建完成了.
现在我们的应用就非常的厉害了,当你首次访问页面的时候,它是通过服务端渲染出来的,服务端渲染拥有了更快的渲染速度以及更好的 SEO,当服务端渲染的内容来到客户端以后被客户端 Vue 结合 VueRouter 激活,摇身一变成为了一个客户端 SPA 应用,之后的页面导航也不需要重新刷新整个页面。这样我们的网站就既拥有了更好的渲染速度,也拥有了更好的用户体验。

除此之外,我们在路由中配置的异步组件(也叫路由懒加载)也是非常有意义,它们会被分割为独立的chunk(也就是单独的文件),只有在需要的时候才会进行加载。这样就能够避免在初始渲染的时候客户端加载的脚本过大导致激活速度变慢的问题。

关于它也可以来验证一下,通过 npm run build 打包构建,我们发现它们确实被分割成了独立的 chunk。然后再来看一下在运行期间这些 chunk 文件是如何加载的。你会发现除了 app 主资源外,其它的资源也被下载下来了,你是不是要想说:不是应该在需要的时候才加载吗?为什么一上来就加载了。

原因是在页面的头部中的带有 preload 和 prefetch 的 link 标签。我们期望客户端 JavaScript 脚本尽快加载尽早的接管服务端渲染的内容,让其拥有动态交互能力,但是如果你把 script 标签放到这里的话,浏览器会去下载它,然后执行里面的代码,这个过程会阻塞页面的渲染。所以看到真正的 script 标签是在页面的底部的。而这里只是告诉浏览器可以去预加载这个资源。但是不要执行里面的代码,也不要影响网页的正常渲染。直到遇到真正的 script 标签加载该资源的时候才会去执行里面的代码,这个时候可能已经预加载好了,直接使用就可以了,如果没有加载好,也不会造成重复加载,所以不用担心这个问题。

而 prefetch 资源是加载下一个页面可能用到的资源,浏览器会在空闲的时候对其进行加载,所以它并不一定会把资源加载出来,而 preload 一定会预加载。所以你可以看到当我们去访问 about 页面的时候,它的资源是通过 prefetch 预取过来的,提高了客户端页面导航的响应速度。

package.json

{
  "name": "vue-ssr",
  "private": true,
  "version": "1.0.0",
  "scripts": {
    "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
    "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
    "build": "rimraf dist && npm run build:client && npm run build:server",
    "start": "cross-env NODE_ENV=production node server.js",
    "dev": "node server.js"
  },
  "dependencies": {
    "axios": "^0.19.2",
    "chokidar": "^3.4.0",
    "cross-env": "^7.0.2",
    "express": "^4.17.1",
    "vue": "^2.6.11",
    "vue-meta": "^2.4.0",
    "vue-router": "^3.3.4",
    "vue-server-renderer": "^2.6.11",
    "vuex": "^3.5.1"
  },
  "devDependencies": {
    "@babel/core": "^7.10.4",
    "@babel/plugin-transform-runtime": "^7.10.4",
    "@babel/preset-env": "^7.10.4",
    "babel-loader": "^8.1.0",
    "css-loader": "^3.6.0",
    "file-loader": "^6.0.0",
    "friendly-errors-webpack-plugin": "^1.7.0",
    "rimraf": "^3.0.2",
    "url-loader": "^4.1.0",
    "vue-loader": "^15.9.3",
    "vue-template-compiler": "^2.6.11",
    "webpack": "^4.43.0",
    "webpack-cli": "^3.3.12",
    "webpack-dev-middleware": "^3.7.2",
    "webpack-hot-middleware": "^2.25.0",
    "webpack-merge": "^5.0.9",
    "webpack-node-externals": "^2.5.0"
  }
}

Charon
57 声望16 粉丝

世界核平