手把手搭建 Vue+TS+Express 全栈 SSR 博客系统——前端起步篇

前端项目搭建

前后端全栈博客项目 By huangyan321

在线体验:https://docsv3.hgyn23.cn

目录结构( project/client )

.
|-- build //构建相关
|-- cache //全局缓存
|-- public //公共静态文件
`-- src
    |-- @types //类型定义
    |-- App.vue //主页面
    |-- api //接口文件
    |-- assets //资产文件夹
    |   |-- fonts //字体文件
    |   |-- img //图片文件
    |   `-- svg //svg文件
    |-- common //通用组件
    |-- components //插件注册
    |-- config //配置
    |-- entry.client.ts //客户端入口
    |-- entry.server.ts //服务端入口
    |-- enums //枚举
    |-- hooks //封装的hooks
    |-- layout //布局
    |-- main.ts //主入口
    |-- router //路由
    |-- service //网络请求
    |-- store //全局存储
    |-- styles //样式
    |-- utils //工具
    `-- views //页面

下面跟我一起来站在一个开发的角度从零开始构建一个完整的Vue-SSR前端项目吧~

基本运行

我们当前要做的是实现基础的Vue-SSR工程,这个阶段我们需要保证前端代码在能够在服务端产生页面并发送至浏览器激活(hydrate),我们需要 webpackVue ,以及Node框架 Express 的支持,本篇主要依赖版本如下:

dependencies

  • vue: v3.2.41
  • vue-router: v4.1.5
  • pinia: v2.0.23
  • express: v4.16.1

dev-dependencies

  • webpack: v5.74.0
  • webpack-cli: v4.10.0
  • webpack-dev-middleware: v5.3.3
  • vue-loader: v16.8.1
  • vue-style-loader: v4.1.3
  • vue-template-compiler: v2.7.13

之前使用 webpack2 搭建过 SSR 项目,这次为了学习就用了 webpack5 一把梭了。项目搭建之前我们首先需要理清一下头绪,参考官方文档中的服务端渲染教程,要实现 SSR ,我们需要注意以下几点原则。

  • 每次对服务端的页面请求都需要创建新的应用实例(防止跨请求状态污染)
  • 限制访问平台特有的 API (如浏览器 API )
  • 保证服务端和客户端页面状态匹配

很大一部分情况下,我们需要为同一个应用执行两次构建过程:一次用于客户端,用来生成在客户端运行的js,css,html文件包;一次用于服务端,用来产出暴露 html 内容字符串生成接口的js包。当浏览器请求服务端页面时,服务端调用函数生成 html 内容字符串并与客户端的 index.html 结合发送至浏览器,浏览器激活页面。到此,一次完整的ssr处理就完成了。

所以,实现 SSR 就需要有两个入口文件,相应的,webpack 就需要传入clientserver 配置项去分别编译我们的 entry-client.jsentry-server.js ,其中 entry-client.js 是给 Browser 载入的,而 entry-server.js 则是让我们后端收到请求时载入的。由于是打包阶段用于产生输出给 webpack 的配置的文件,就不用ts编写了。本项目中主要抽取了5webpack配置文件用于组合,分别为

  • webpack.base.js:
    client, server side 共用的一些 loaderplugin;
  • webpack.dev.js
    开发阶段涉及的配置,如 devtooloptimization
  • webpack.prod.js
    打包阶段涉及的配置,如代码压缩,文件分割等;
  • webpack.client.js
    一些只有在 client side 涉及到的配置;
  • webpack.server.js
    一些只有在 server side 涉及到的配置;

参考代码如下,需要了解详细配置的小伙伴请阅读官方文档

  1. webpack.base.js
const { DefinePlugin, ProvidePlugin } = require('webpack');
const { VueLoaderPlugin } = require('vue-loader');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const { merge } = require('webpack-merge');
const resolvePath = require('./resolve-path');
const devConfig = require('./webpack.dev');
const prodConfig = require('./webpack.prod');
const clientConfig = require('./webpack.client');
const serverConfig = require('./webpack.server');
const chalk = require('chalk');
module.exports = function (env) {
  const isProduction = !!env.production;
  const isClient = env.platform == 'client';
  process.env.isProduction = isProduction;
  const CSSLoaderChains = [
    isProduction && isClient ? MiniCssExtractPlugin.loader : 'vue-style-loader',
    {
      loader: 'css-loader',
      options: {
        importLoaders: 1
      }
    }
    // 'postcss-loader',
  ];
  const baseConfig = (isProduction) => {
    return {
      output: {
        filename: 'js/[name].bundle.js',
        //输出文件路径,必须是绝对路径
        path: resolvePath('/client/dist/'),
        //异步导入块名
        asyncChunks: true,
        //相对路径,解析相对与dist的文件
        publicPath: '/dist/'
      },
      module: {
        rules: [
          // 解析css
          {
            test: /\.css$/,
            //转换规则: 从下往上
            use: CSSLoaderChains
          },
          //解析less
          {
            test: /\.less$/,
            use: [...CSSLoaderChains, 'less-loader']
          },
          //解析scss
          {
            test: /\.scss$/,
            use: [...CSSLoaderChains, 'sass-loader']
          },
          //解析stylus
          {
            test: /\.styl(us)?$/,
            use: [...CSSLoaderChains, 'stylus-loader']
          },
          //解析js(x)
          {
            test: /\.(j|t)sx?$/,
            use: ['babel-loader'],
            exclude: (file) => /core-js/.test(file) && /node_modules/.test(file)
          },
          //解析图片资源
          {
            test: /\.(png|jpe?g|gif|svg)$/,
            type: 'asset/resource',
            generator: {
              filename: 'img/[hash][ext][query]'
            },
            parser: {
              dataUrlCondition: {
                maxSize: 1024 // 1kb
              }
            }
          },
          // 解析字体文件
          {
            test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
            type: 'asset/resource',
            generator: {
              filename: 'fonts/[hash][ext][query]'
            },
            parser: {
              dataUrlCondition: {
                maxSize: 10 * 1024 // 10kb
              }
            }
          },
          //解析vue文件,并提供HMR支持
          {
            test: /\.vue$/,
            //vue-loader的使用必须依赖VueLoaderPlugin
            use: ['vue-loader']
          }
        ]
      },
      plugins: [
        //! 定义全局常量
        new DefinePlugin({
          // 生产模式下取dist文件 否则取public
          BASE_URL: isProduction ? '"/dist/static/"' : '"/public/"',
          __VUE_OPTIONS_API__: false,
          __VUE_PROD_DEVTOOLS__: false
        }),
        new ProgressBarPlugin({
          format:
            '  build [:bar] ' +
            chalk.green.bold(':percent') +
            ' (:elapsed seconds)',
          clear: false
        })
      ],
      resolve: {
        alias: {
          '@': resolvePath('/client/src'),
          config: '@/config',
          img: '@/assets/img',
          font: '@/assets/font',
          components: '@/components',
          router: '@/router',
          public: '@/public',
          service: '@/service',
          store: '@/store',
          styles: '@/styles',
          api: '@/api',
          utils: '@/utils',
          layout: '@/layout'
        },
        extensions: [
          '.js',
          '.vue',
          '.json',
          '.ts',
          '.jsx',
          '.less',
          '.styl',
          '.scss'
        ],
        //解析目录时用到的文件名
        mainFiles: ['index']
      }
    };
  };
  const config = baseConfig(isProduction);
  const mergeEnvConfig = isProduction
    ? merge(config, prodConfig(isClient))
    : merge(config, devConfig);
  const finalConfig = isClient
    ? merge(mergeEnvConfig, clientConfig(isProduction))
    : merge(mergeEnvConfig, serverConfig(isProduction));
  return finalConfig;
};
  1. webpack.dev.js
const path = require('path');
const resolvePath = require('./resolve-path');

module.exports = {
  mode: 'development',
  devtool: 'cheap-source-map',

  optimization: {
    minimize: false,
    //单独打包运行时代码
    runtimeChunk: false
  }
};
  1. webpack.prod.js
const CopyWebpackPlugin = require('copy-webpack-plugin');
const CSSMinimizerWebpackPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const glob = require('glob');
const resolvePath = require('./resolve-path');
const compressionWebpackPlugin = require('compression-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const SendAMessageWebpackPlugin = require('send-a-message-after-emit-plugin');

module.exports = function (isClient) {
  return {
    mode: 'production',

    plugins: [
      //! 用于复制资源
      new CopyWebpackPlugin({
        patterns: [
          {
            from: 'client/public',
            to: 'static',
            globOptions: {
              //! 选择要忽略的文件
              ignore: ['**/index.html', '**/.DS_store']
            }
          }
        ]
      }),
      new CSSMinimizerWebpackPlugin(),
    ],
    optimization: {
      //默认开启,标记未使用的函数,terser识别后可将其删除
      usedExports: true,
      mangleExports: true,
      // minimize: true,
      splitChunks: {
        //同步异步导入都进行处理
        chunks: 'all',
        //拆分块最小值
        // minSize: 20000,
        //拆分块最大值
        maxSize: 200000,
        //表示引入的包,至少被导入几次的才会进行分包,这里是1次
        // minChunks: 1,
        // 包名id算法
        // chunkIds: 'named',
        cacheGroups: {
          vendors: {
            name: 'chunk-vendors',
            //所有来自node_modules的包都会打包到vendors里面,可能会过大,所以可以自定义选择打包
            test: /[\/]node_modules[\/](vue|element-plus|normalize\.css)[\/]/,
            filename: 'js/vendors.js',
            chunks: 'all',
            //处理优先级
            priority: 20,
            enforce: true
          },
          monacoEditor: {
            chunks: 'async',
            name: 'chunk-monaco-editor',
            priority: 22,
            test: /[\/]node_modules[\/]monaco-editor[\/]/,
            enforce: true,
            reuseExistingChunk: true
          }
        }
      },
      //单独打包运行时代码
      runtimeChunk: false,
      minimizer: [
        new TerserPlugin({
          //剥离注释
          extractComments: true,
          // 并发构建
          parallel: true,
        })
      ]
    }
  };
};
  1. webpack.client.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const resolvePath = require('./resolve-path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
const InlineChunkHtmlPlugin = require('./plugins/InlineChunkHtmlPlugin');
module.exports = (isProduction) => {
  const config = {
    entry: {
      'entry-client': [resolvePath('/client/src/entry.client.ts')]
    },
    plugins: [
      //! 根据模板生成入口html
      new HtmlWebpackPlugin({
        title: 'lan bi tou',
        filename: 'index.html',
        template: resolvePath('/client/public/index.html'),
        inject: true,
        // // 注入到html文件的什么位置
        // inject: true,
        // // 当文件没有任何改变时使用缓存
        // cache: true,
        minify: isProduction
          ? {
              // 是否移除注释
              removeComments: true,
              // 是否移除多余的属性
              removeRedundantAttributes: true,
              // 是否移除一些空属性
              removeEmptyAttributes: true,
              // 折叠空格
              collapseWhitespace: true,
              // 移除linkType
              removeStyleLinkTypeAttributes: true,
              minifyCSS: true,
              minifyJS: {
                mangle: {
                  toplevel: true
                }
              }
            }
          : false
      })
    ]
  };
  if (isProduction) {
    config.plugins.push(
      new MiniCssExtractPlugin({
        filename: 'css/[name].css',
        chunkFilename: 'css/[name].[contenthash:6].chunk.min.css'
      }),
      new CleanWebpackPlugin(),
      new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/\.(css)$/])
    );
  }
  return config;
};
  1. webpack.server.js
const webpack = require('webpack');
const resolvePath = require('./resolve-path');
const nodeExternals = require('webpack-node-externals');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
module.exports = (isProduction) => {
  const config = {
    target: 'node', // in node env
    entry: {
      'entry-server': resolvePath('/client/src/entry.server.ts')
    },
    output: {
      filename: 'js/entry-server.js',
      library: {
      // 打包成库文件
        type: 'commonjs2'
      }
    },
    node: {
      // tell webpack not to handle following
      __dirname: false,
      __filename: false
    },
    module: {},
    plugins: [
      new webpack.optimize.LimitChunkCountPlugin({
        maxChunks: 1
      })
    ],
  };
  isProduction
    ? config.plugins.push(
        new WebpackManifestPlugin(),
      )
    : '';
  return config;
};

我们可以通过在根目录下的 package.json 中定义几个打包脚本,如

"scripts": {
    "build:client": "webpack --config ./client/build/webpack.base.js --env production --env platform=client  --progress",
  },

可使用 npm run build:client 运行打包脚本,webpack.base.js文件默认导出了一个返回配置项的函数, webpack 可通过我们指定的 --env production 选项给这个函数传递部分参数,由这些参数决定我们最终返回的 webpack 配置。

配置项告一段落。下面开始正式编写代码~

SSR 世界中,为了防止跨请求状态污染,我们要把一些实例化程序的操作放在一个函数中,以确保我们每次获取到的应用实例都是全新的。首先来看看我们的主入口文件main.ts的实现

main.ts

import { createSSRApp } from 'vue';
import App from './App.vue';
import createRouter from '@/router';
import createStore from '@/store';
import registerApp from './components';
import type { SSREntryOptions } from './@types/types';
import { ID_INJECTION_KEY } from 'element-plus';
import 'normalize.css';
export default function createApp({ isServer }: SSREntryOptions) {
  const router = createRouter(isServer);
  // 初始化 pinia
  const pinia = createStore();

  const app = createSSRApp(App);
  app.provide(ID_INJECTION_KEY, {
    prefix: Math.floor(Math.random() * 10000),
    current: 0
  });
  registerApp(app);
  app.use(router);
  // 挂载 pinia
  app.use(pinia);
  // app.use(VueMeta)
  return { app, router, pinia };
}

我们在 main.ts 中定义了一个函数,该函数用于实例化所有常见套件,如 routerpinia (后续会聊到如何使用 pinia 在客户端维持服务端的数据状态),并将其实例返回。

接着完成编写其余插件的实例化逻辑

vue-router


import {
  createRouter as _createRouter,
  createWebHistory,
  createMemoryHistory
} from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
import List from '@/views/post/list.vue';
const routes = [
  {
    path: '/',
    redirect: '/main'
  },
  {
    path: '/list',
    name: 'List',
    component: List,
  }
];
export default function createRouter(isServer: Boolean) {
  // 该函数接收一个 isServer 参数,用于创建不同环境的router实例
  return _createRouter({
    history: isServer ? createMemoryHistory() : createWebHistory(),
    routes,
    scrollBehavior(to, from, savedPosition) {
      if (to.fullPath === from.fullPath) return false;
      if (savedPosition) {
        return savedPosition;
      } else {
        return { top: 0 };
      }
    }
  });
}

这里为了实现基本运行,路由只定义了简单的Main组件。vue-router导出了createMemoryHistorycreateWebHistory方法,用于在客户端和服务端运行,具体查看官方文档

pinia

import { createPinia as _createStore } from 'pinia';
export default () => {
  const _pinia = _createStore();

  return _pinia;
};

entry-server.ts

import createApp from './main';
import type { SSRServerContext } from './@types/types';
import { renderToString } from 'vue/server-renderer';
export default async function serverEntry(
  context: SSRServerContext,
  isProduction: boolean,
  cb: (time: number) => {}
) {
  console.log('pass server');

  const { app, router, pinia } = createApp({ isServer: true });

  // 将状态给到服务端传入的context上下文,在服务端用于替换模板里的__INITIAL_STATE__,之后用于水合客户端的pinia
  await router.push(context.url);
  await router.isReady();

  const s = Date.now();
  const ctx = {};
  const html = await renderToString(app, ctx);
  if (!isProduction) {
    cb(Date.now() - s);
  }

  const matchComponents = router.currentRoute.value.matched;

  // 序列化 pinia 初始全局状态
  const state = JSON.stringify(pinia.state.value);
  context.state = state;

  if (!matchComponents.length) {
    context.next();
  }
  return { app, html, ctx };
}

server 入口函数接收3个参数,第1个是服务端上下文对象,用于在服务端接收 url 等参数,第2个参数用于判断是否是生产模式,第3个参数为回调函数,2、3参数都用于开发阶段的性能监测。根据 context.urlrouter 中匹配页面组件。renderToString服务端渲染 API
,如果匹配到页面组件,该API将用于返回应用渲染的 html 。如果 router 没有匹配到路由,意味着 context.url 并不是请求页面组件,程序将会跳过响应页面转而响应接口服务。

entry-client.ts

import createApp from './main';

(async () => {
  console.log('pass client');
  const { app, router, pinia } = createApp({ isServer: false });
  // 等待router准备好组件
  await router.isReady();

  // 挂载
  app.mount('#app');
})();

client 入口函数为立即执行函数,将在浏览器中直接运行。函数内部会实例化出与服务端完全相同的 Vue 实例,并等待vue-router准备好页面组件。

App.vue





list.vue






Server for rendering( project/server )

打包配置和入口文件都已处理完毕,接下来开始处理服务器。我们需要在 server 中完成几件事情:

  • 创建 server
  • 启动 server
  • server 静态托管 dist 文件夹
  • 接受请求,匹配 url ,编译渲染 Vue 实例并组合 html 字符串
  • 响应请求

app.ts

import express from 'express';
import path from 'path';
import cookieParser from 'cookie-parser';
import logger from 'morgan';
import fs from 'fs';
import cache from 'lru-cache';

import allowedOrigin from './config/white-list';

import userRoute from './routes/user';
import adminRoute from './routes/admin';
import errorHandler from './utils/error-handler';
import compileSSR from './compile';
// 区分开发生产环境
const isProd = process.env.NODE_ENV === 'production';

const resolve = (file: string) => path.resolve(__dirname, file);

const app = express();
Object.defineProperty(global, 'globalKey', {
  value: '123456'
});
function isOriginAllowed(origin: string | undefined, allowedOrigin: string[]) {
  for (let i = 0; i < allowedOrigin.length; i++) {
    if (origin === allowedOrigin[i]) {
      return true;
    }
  }
  return false;
}
// 跨域配置
app.all('*', function (req, res, next) {
  // 设置允许跨域的域名,*代表允许任意域名跨域
  let reqOrigin = req.headers.origin;
  if (isOriginAllowed(reqOrigin, allowedOrigin)) {
    res.header('Access-Control-Allow-Origin', reqOrigin);
  } else {
    res.header('Access-Control-Allow-Origin', 'http://docs.hgyn23.cn');
  }
  // 允许的header类型
  res.header(
    'Access-Control-Allow-Headers',
    'Content-Type,Access-Token,Appid,Secret,Authorization'
  );
  // 跨域允许的请求方式
  res.header('Access-Control-Allow-Methods', 'DELETE,PUT,POST,GET,OPTIONS');
  if (req.method.toLowerCase() == 'options') res.sendStatus(200);
  // 让options尝试请求快速结束
  else next();
});
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use('/static', express.static(__dirname + '/static'));
//微缓存服务
const serve = (path: string, cache: boolean) =>
  express.static(resolve(path), {
    maxAge: cache ? 1000 * 60 * 60 * 24 * 30 : 0
  });
if (isProd) {
  app.use('/dist/', serve('../client/dist', false));
}
app.use('/public/', serve('../client/public', true));
// app.use(express.static(path.join(__dirname, 'public')));
app.use(express.static(__dirname + '../client'));
userRoute(app);
adminRoute(app);
compileSSR(app, isProd);
export default app;

app.ts 中主要做的是对 express 的初始化和添加一些必要的中间件,用以对接口请求做准备,并且导出了 express 实例。其中在服务端构建页面的关键函数就是 compileSSR ,我们可以看到它接收 express 实例和开发环境两个参数,下面我们来看看 compileSSR 函数的实现。

compile.ts

// compile.ts
import type { Express } from 'express';
import fs from 'fs';
import path from 'path';
import type { SSRServerContext } from '@/@types/types';
import utils from './utils/index';
import escapeJSON from './utils/escape-json';
import { encode } from 'js-base64';
const resolvePath = require('../client/build/resolve-path.js');

const setupDevServer = require('../client/build/setup-dev-server.js');

let app: Express;
let wrapper: string;
let isProduction: boolean;
let serverEntry: any;
function pf(time: number) {
  utils.log.error(`实例渲染耗时:${time}ms`);
}
async function handleBundleWithEnv() {
  if (isProduction) {
    serverEntry = require(path.join(
      '../client/dist/js',
      'entry-server.js'
    )).default;
    wrapper = fs.readFileSync(
      path.join(__dirname, '../client/dist/index.html'),
      'utf-8'
    );
  } else {
    await setupDevServer(
      app,
      resolvePath('/client/dist/index.html'),
      (clientHtml: string, serverBundle: any) => {
        utils.log.success('setupDevServer invoked');
        wrapper = clientHtml;
        serverEntry = serverBundle;
        utils.log.success('等待触发');
      }
    );
  }
}
function pack(html: string, context: SSRServerContext, ctx: any) {
  // 合并html外壳
  return wrapper
    .replace('{{ APP }}', `
${html}
`) } export default async function compileSSR(server: Express, isProd: boolean) { try { app = server; isProduction = isProd; await handleBundleWithEnv(); server.get('*', async (req, res, next) => { const context: SSRServerContext = { title: '前端学习的点滴', url: req.originalUrl, next }; // 获取服务端 Vue实例 const { app, html, ctx } = await serverEntry(context, isProduction, pf); const packagedHtml = pack(html, context, ctx); res.status(200).set({ 'Content-Type': 'text/html' }).end(packagedHtml); }); } catch (err) { console.log(err); } }

compile.ts 默认导出了一个函数compileSSR,他要做的是根据当前传入服务端的 url 解析出相应的页面, serverEntry 就是服务端入口,也就是未打包时的 entry.server.ts 默认导出的函数,context 对象内有title ,解析出的 urlnext 函数。传入 next 函数用于如果 context.url 未匹配到任何页面组件可以通过调用 next 函数,让 express 执行到下一个中间件。

开发模式打包

SSR (服务端渲染)的开发模式与以往 CSR (客户端渲染)的开发模式不同, CSR 开发模式只需配置 webpack 中的 devServer 选项即可快速进行调试。 但 SSR 还存在server端的页面生成步骤,所以我们需要把开发阶段插件 webpack-dev-middlewarewebpack-hot-middleware 移植到服务端,由服务端完成文件监测和热更新,具体实现如下:

project/client/build/setup-dev-server.js

const fs = require('fs');
const memfs = require('memfs');
const path = require('path');
const resolvePath = require('./resolve-path');
const { patchRequire } = require('fs-monkey');
const webpack = require('webpack');
const chokidar = require('chokidar');
const log = require('./log');
const clientConfig = require('./webpack.base')({
  production: false,
  platform: 'client'
});
const serverConfig = require('./webpack.base')({
  production: false,
  platform: 'server'
});
const readfile = (fs, file) => {
  try {
    log.info('readfile');
    return fs.readFileSync(file, 'utf8');
  } catch (e) {}
};

/**
 * 安装模块热替换
 * @param {*} server express实例
 * @param {*} templatePath index.html 模板路径
 * @param {*} cb 回调函数
 */
module.exports = function setupDevServer(server, templatePath, cb) {
  log.info('进入开发编译节点');
  let template, readyPromise, ready, clientHtml, serverBundle;
  readyPromise = new Promise((resolve) => (ready = resolve));
  const update = () => {
    log.info('尝试更新');
    if (!clientHtml || !serverBundle)
      return log.warn(
        `${(!clientHtml && '套壳文件') || 'serverBundle'}当前未编译完成,等待中`
      );
    ready();
    log.info('发起回调');
    cb(clientHtml, serverBundle);
  };
  // 读取index.html文件
  template = readfile(fs, templatePath);
  // 监听index.html变化
  chokidar.watch(templatePath).on('change', () => {
    template = fs.readFileSync(templatePath, 'utf-8');
    log.success('index.html template updated.');
    clientHtml = template;
    update();
  });

  clientConfig.entry['entry-client'].unshift('webpack-hot-middleware/client');

  clientConfig.plugins.push(
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin()
  );

  // dev middleware 开发中间件
  const clientCompiler = webpack(clientConfig);

  const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
    publicPath: clientConfig.output.publicPath
    // noInfo: true,
  });
  // 安装 webpack开发模式中间件
  server.use(devMiddleware);

  //serverComplier是webpack返回的实例,plugin方法可以捕获事件,done表示打包完成
  clientCompiler.hooks.done.tap('devServer', (stats) => {
    //核心内容,middleware.fileSystem.readFileSync是webpack-dev-middleware提供的读取内存中文件的方法;
    //不过拿到的是二进制,可以用JSON.parse格式化;
    clientHtml = readfile(
      clientCompiler.outputFileSystem,
      path.join(clientConfig.output.path, 'index.html')
    );
    update();
  });

  //hot middleware
  server.use(
    require('webpack-hot-middleware')(clientCompiler, {
      heartbeat: 5000
    })
  );

  // 监听和更新服务端文件
  const serverCompiler = webpack(serverConfig);

  // 流式输出至内存中
  serverCompiler.outputFileSystem = memfs.fs;

  serverCompiler.watch({}, (err, stats) => {
    if (err) throw err;

    stats = stats.toJson();
    if (stats.errors.length) return console.log(stats.errors[0]);
    log.success('watch done');
    patchRequire(memfs.fs, true);
    serverBundle = require(path.join(
      serverConfig.output.path,
      'js/entry-server.js'
    )).default;
    update();
  });
  return readyPromise;
};

setup-dev-server.js 导出一个 setupDevServer 函数,该函数最终会返回一个 promise ,当 promise resolved 时,会回调 cb ,将客户端打包后的 index.html 和打包后的 entry-server 的文件所导出的函数通过 cb 入参数导出。当浏览器向服务端请求一个页面时, handleBundleWithEnv 函数会尝试调用 setupDevServer 并传入回调来获取页面,通过 serverEntry 返回的 html 字符串和 index.html 客户端套壳文件的合并,最终生成我们需要的带有 contenthtml 字符串响应数据。

以上为项目中前端部分的一些关键逻辑,源码实现在这里

你可能感兴趣的:(vue.jsssr)