基于Webpack5的Vue2 SSR工程实践综述
前言
技术文章,尤其是前端技术文章具有时效性。
如文中提到的部分内容出现break change或出现内容错误(文字错误/错误的理论描述),为尽可能避免对后面的读者造成困扰,如果可以的话,希望在文章的评论区或代码仓库issues中予以指正,十分感谢。
相关仓库地址:
代码仓库
文章所在仓库
阅读本文需要掌握一些前置知识,请通读:
Vue.js 服务器端渲染指南 | Vue SSR 指南 (vuejs.org)
另外,如果你找到这篇文章时的目的是调研Vue的SSR方案选型,我的观点是直接上Nuxt3。
摘要
目前常见的技术论坛中关于Vue2的SSR文章,主要是以vue/cli@3和vue/cli@4为基础创建的工程。
示例仓库主要是在HackerNews Demo的基础上,脱离vue/cli,将其webpack3的依赖升级到webpack5,消除一些旧版本依赖包存在的问题(比如node-sass),给出SPA和SSR的webpack配置。
本文的主要内容是介绍示例仓库中的webpack配置,侧重介绍SSR开发模式的构建配置思路。
正文
main requirements
| name | version | note |
|---|---|---|
| nvm | 1.1.9 | node版本控制器,用来切换node版本,不多说 |
| node.js | 16.20.0 | webpack5最低支持node10.13,pnpm最低支持node16.14,这里选择16.20.0,不多说 |
| pnpm | 8.6.1 | 包管理工具,用过都说好,不多说 |
| vue | 2.6 | vue的版本直接影响vue-loader vue-template-compiler vue-server-renderer2.7以后支持组合式 API这里保守起见,采用2.6下最后一个patch版本(当前是2.6.14) |
| webpack | 5.85.0 | 相比wp3和wp4,编译性能十分强劲,心智负担大幅度降低,文档易读性更高 |
| express | 4 | 传世经典,花个把小时看下文档就够用了 |
| chalk | 4 | chalk@5不支持require导入,这里选@4 |
快速创建一个webpack项目
pnpm init
pnpm i webpack@5 webpack-cli@5 -D
npx webpack init
空白目录下,终端依次输入上述指令,我们将会得到:
- 一个初始化过的工程
- 一个极简的webpack配置
- 自动安装的
sass sass-loader style-loader postcss postcss-loader,省去安装这部分依赖的时间
history模式
先说结论:在SSR的场景里,vue-router只能采用history模式。
为什么不能使用hash?
因为在hash模式下,页面URL的hash内容并不会随着请求一起发送到服务器中,而history模式没有这个问题。
也就是说:
当你试图访问http://localhost:9500/#/user/1时,可以在F12的network中观察到,浏览器实际上访问的是http://localhost:9500/。
而访问http://localhost:9500/user/1时,浏览器实际上访问的是http://localhost:9500/user/1,这是符合SSR期望的。
history模式有哪些需要注意的?
如果你仅仅是配置了mode: 'history',没有进行其他配置,那么你将会遭遇:
- spa应用开发场景下通过命令式导航跳转的方式能进入
http://localhost:9800/xxx - spa应用开发场景下直接输入链接访问
http://localhost:9800/xxx,页面返回Cannot GET /xxx - 在
nginx部署生产完成后直接访问http://xxx.com/xxx会返回404
上述问题本质上是同一个问题:当访问http://localhost:9800/xxx时,浏览器会尝试访问http://localhost:9800静态服务器中/xxx目录下的index.html。
而观察过构建目录dist的开发者都知道,不做特殊处理的情况下dist中不会构建出/xxx,这也是为什么返回404的原因,即:/xxx/index.html根本不存在。
本文只介绍webpack解决该问题的办法(注意这是针对SPA应用开发环境),至于nginx的解决方案建议咨询所在公司运维人员。
解决思路是:将所有请求转发到http://localhost:9800/index.html
devServer: {historyApiFallback: {rewrites: [{from: /\//,to: '/index.html',},],},// 或// historyApiFallback:true,
},
对于devServer.historyApiFallback配置,更多了解请点击DevServer | webpack 中文文档 (docschina.org)
本文聊的是SSR,为什么要花篇幅描述SPA下的history处理呢?
因为SSR的容灾降级需要用到SPA。
webpack.base.config.js
在SSR模式下,关于样式文件的loader应采用vue-style-loader。它和style-loader的不同在于:它支持vue ssr。(via vuejs/vue-style-loader: 💅 vue style loader module for webpack (github.com) )
const path = require("path")
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
const { VueLoaderPlugin } = require("vue-loader")
const CopyPlugin = require("copy-webpack-plugin")
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin")
const TerserPlugin = require("terser-webpack-plugin")const NODE_ENV = process.env.NODE_ENV
const isProd = NODE_ENV == "production"const modeMap = {production: "production",development: "development"
}const stylesHandler = isProd ? MiniCssExtractPlugin.loader : "vue-style-loader"const OptimizationMap = {development: {chunkIds: "named",moduleIds: "named",usedExports: false, // 树摇splitChunks: {chunks: "all",minSize: 1024 * 20,maxSize: 1024 * 500,minChunks: 2},runtimeChunk: {name: "runtime"}},production: {chunkIds: "deterministic",moduleIds: "deterministic",usedExports: true, // 树摇splitChunks: {chunks: "all",minSize: 1024 * 20,// maxSize: 1024 * 244, // 拆包会导致包数激增,不拆的话可能会出现单包过大的问题minChunks: 2, // 分包不能太细 不合并导致并发请求过多 浏览器有并发请求数量限制cacheGroups: {commons: {test: /[\\/]node_modules[\\/]/,name: "vendors",priority: 1},manifest: {name: "manifest"}}},// 将 optimization.runtimeChunk 设置为 true 或 'multiple',会为每个入口添加一个只含有 runtime 的额外 chunk。runtimeChunk: {name: "runtime"},minimize: true,minimizer: [new TerserPlugin({parallel: true, // 多线程extractComments: false, // 注释单独提取terserOptions: {compress: {drop_console: true // 清除console输出},format: {comments: false // 清除注释},toplevel: true, // 声明提前keep_classnames: true // 类名不变}}),new CssMinimizerPlugin({minimizerOptions: {preset: ["default",{discardComments: { removeAll: true }}]}})]}
}const config = {mode: modeMap[NODE_ENV] || "development",stats: "errors-only",infrastructureLogging: {level: "error"},output: {path: path.resolve(__dirname, "../dist"),publicPath: "/dist/",filename: isProd ? "js/[name].[contenthash:6].bundle.js" : "js/[name].bundle.js",chunkFilename: isProd ? "js/chunk_[name]_[contenthash:6].js" : "js/chunk_[name].js"},cache: !isProd,resolve: {extensions: [".js", ".vue", ".json", ".ts", ".html"],alias: {"@": path.resolve(__dirname, "../src"),vue$: "vue/dist/vue.esm.js"}},optimization: OptimizationMap[NODE_ENV],plugins: [new VueLoaderPlugin(),new CopyPlugin({patterns: [{ from: path.resolve(__dirname, "../public"), to: "public" }]})],module: {rules: [// vue-loader必须要在最外层,不能放入oneOf// https://github.com/vuejs/vue-loader/issues/1204#issuecomment-375739662// Note the rule for vue-loader must be at the top level{test: /\.vue$/i,include: [path.resolve(__dirname, "../src")],exclude: /node_modules/,use: ["vue-loader"]},{oneOf: [{test: /\.(js|jsx)$/i,exclude: /node_modules/,use: ["thread-loader", "babel-loader"]},{test: /\.s[ac]ss$/i,use: [stylesHandler,{loader: "css-loader",options: {importLoaders: 2,modules: {mode: "icss"}}},"postcss-loader","sass-loader"]},{test: /\.css$/i,include: [/element-ui/],use: [MiniCssExtractPlugin.loader,{loader: "css-loader",options: {importLoaders: 1}},"postcss-loader"]},{test: /\.css$/i,exclude: [/element-ui/],use: [stylesHandler,{loader: "css-loader",options: {importLoaders: 1}},"postcss-loader"]},{test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i,type: "asset",parser: {dataUrlCondition: {maxSize: 10000}}}]}]}
}module.exports = config
webpack.client.config.js
SSR模式不需要使用html-webpack-plugin。
不同的WebpackBar实例在初始化时要指定不同的name属性,否则在终端中构建进度条会重叠到一起。
const { merge } = require("webpack-merge")
const base = require("./webpack.base.config")
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin")
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
const Dotenv = require("dotenv-webpack")
const CompressionPlugin = require("compression-webpack-plugin")
const WebpackBar = require("webpackbar")const NODE_ENV = process.env.NODE_ENV
const isProd = NODE_ENV == "production"const clientConfig = {entry: {app: "./src/entry-client.js"},resolve: {alias: {axiosInstance: "@/utils/request-client.js"}},plugins: [new VueSSRClientPlugin(),new WebpackBar({name: "Client",color: "#7ED321"})]
}module.exports = (env, args) => {const APP_ENV = env.APP_ENV || "development"clientConfig.plugins.push(new Dotenv({path: `./envVariable/client/.env.${APP_ENV}`}))if (isProd) {clientConfig.devtool = falseclientConfig.plugins.push(new MiniCssExtractPlugin({filename: "styles/[name].[contenthash:6].css"}),new CompressionPlugin({test: /\.(css|js)$/,minRatio: 0.7}))} else {clientConfig.plugins.push(new MiniCssExtractPlugin({filename: "styles/[name].css"}))clientConfig.devtool = "cheap-module-source-map"}return merge(base, clientConfig)
}
webpack.server.config.js
const { merge } = require("webpack-merge")
const base = require("./webpack.base.config")
const nodeExternals = require("webpack-node-externals")
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
const VueSSRServerPlugin = require("vue-server-renderer/server-plugin")
const Dotenv = require("dotenv-webpack")
const CompressionPlugin = require("compression-webpack-plugin")
const WebpackBar = require("webpackbar")
const FriendlyErrorsWebpackPlugin = require("friendly-errors-webpack-plugin")
const chalk = require("chalk")
const { defaultPort } = require("./setting")
const address = require("address")// Error: Server-side bundle should have one single entry file. Avoid using CommonsChunkPlugin in the server config.
delete base.optimizationconst NODE_ENV = process.env.NODE_ENV
const isProd = NODE_ENV == "production"
const serverConfig = {target: "node",devtool: "cheap-module-source-map",entry: "./src/entry-server.js",output: {filename: "server-bundle.js",libraryTarget: "commonjs2",// clean: true // 在生成文件之前清空 output 目录},optimization: {splitChunks: false},externals: nodeExternals({allowlist: [/\.css$/]}),resolve: {alias: {axiosInstance: "@/utils/request-server.js"}},plugins: [new VueSSRServerPlugin(),new WebpackBar({name: "Server",color: "#F5A623"})]
}module.exports = (env, args) => {const APP_ENV = env.APP_ENV || "development"serverConfig.plugins.push(new Dotenv({path: `./envVariable/server/.env.${APP_ENV}`}))if (isProd) {serverConfig.plugins.push(new MiniCssExtractPlugin({filename: "styles/[name].[contenthash:6].css"}),new CompressionPlugin({test: /\.(css|js)$/,minRatio: 0.7}))} else {const port = args.port || defaultPortconst LOCAL_IP = address.ip()serverConfig.plugins.push(new MiniCssExtractPlugin({filename: "styles/[name].css"}),new FriendlyErrorsWebpackPlugin({compilationSuccessInfo: {messages: [` App running at:`, ` - Local: ` + chalk.cyan(`http://localhost:${port}`), ` - Network: ` + chalk.cyan(`http://${LOCAL_IP}:${port}`)]},clearConsole: true}))}return merge(base, serverConfig)
}
webpack.spa.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const WebpackBar = require('webpackbar');
const portfinder = require('portfinder');
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin');
const address = require('address');
const chalk = require('chalk');
const { VueLoaderPlugin } = require('vue-loader');
const Dotenv = require('dotenv-webpack');
const CopyPlugin = require('copy-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');const NODE_ENV = process.env.NODE_ENV;
const isProd = NODE_ENV == 'production';const modeMap = {production: 'production',development: 'development',
};const stylesHandler = isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader';const OptimizationMap = {development: {chunkIds: 'named',moduleIds: 'named',usedExports: false, // 树摇splitChunks: {chunks: 'all',minSize: 1024 * 20,maxSize: 1024 * 500,minChunks: 2,},runtimeChunk: {name: 'runtime',},},production: {chunkIds: 'deterministic',moduleIds: 'deterministic',usedExports: true, // 树摇splitChunks: {chunks: 'all',minSize: 1024 * 20,// maxSize: 1024 * 244, // 拆包会导致包数激增,不拆的话可能会出现单包过大的问题minChunks: 2, // 分包不能太细 不合并导致并发请求过多 浏览器有并发请求数量限制cacheGroups: {commons: {test: /[\\/]node_modules[\\/]/,name: 'vendors',priority: 1,},},},// 将 optimization.runtimeChunk 设置为 true 或 'multiple',会为每个入口添加一个只含有 runtime 的额外 chunk。runtimeChunk: {name: 'runtime',},minimize: true,minimizer: [new TerserPlugin({parallel: true, // 多线程extractComments: false, // 注释单独提取terserOptions: {compress: {drop_console: true, // 清除console输出},format: {comments: false, // 清除注释},toplevel: true, // 声明提前keep_classnames: true, // 类名不变},}),new CssMinimizerPlugin({minimizerOptions: {preset: ['default',{discardComments: { removeAll: true },},],},}),],},
};const config = {mode: modeMap[NODE_ENV] || 'development',stats: 'errors-only',entry: './src/entry-client.js',infrastructureLogging: {level: 'error',},output: {path: path.resolve(__dirname, '../dist'),publicPath: '/',filename: isProd ? 'js/[name].[contenthash:6].bundle.js' : 'js/[name].bundle.js',chunkFilename: isProd ? 'js/chunk_[name]_[contenthash:6].js' : 'js/chunk_[name].js',// clean: true, // 在生成文件之前清空 output 目录},cache: {type: 'filesystem',buildDependencies: {config: [__filename],},},devServer: {hot: true,compress: true,host: '0.0.0.0',port: '9800',open: false,client: {logging: 'error',overlay: false,},liveReload: false,historyApiFallback: {rewrites: [{from: /\//,to: '/index.html',},],},},resolve: {extensions: ['.js', '.vue', '.json', '.ts', '.html'],alias: {'@': path.resolve(__dirname, '../src'),vue$: 'vue/dist/vue.esm.js',axiosInstance: "@/utils/request-client.js"},},optimization: OptimizationMap[NODE_ENV],plugins: [new VueLoaderPlugin(),new WebpackBar({name: "Spa",color: "#9013FE"}),new HtmlWebpackPlugin({template: './public/index.spa.html',favicon: path.resolve(__dirname, '../public/favicon.ico'),}),new CopyPlugin({patterns: [{ from: path.resolve(__dirname, '../public'), to: 'public' }],}),],module: {rules: [// vue-loader必须要在最外层,不能放入oneOf{test: /\.vue$/i,include: [path.resolve(__dirname, '../src')],exclude: /node_modules/,use: ['vue-loader'],},{oneOf: [{test: /\.(js|jsx)$/i,exclude: /node_modules/,use: ['thread-loader', 'babel-loader'],},{test: /\.s[ac]ss$/i,use: [stylesHandler,{loader: 'css-loader',options: {importLoaders: 2,modules: {mode: 'icss',},},},'postcss-loader','sass-loader',],},{test: /\.css$/i,use: [stylesHandler,{loader: 'css-loader',options: {importLoaders: 1,},},'postcss-loader',],},{test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i,type: 'asset',parser: {dataUrlCondition: {maxSize: 10000,},},},],},],},
};module.exports = (env, argv) => {const APP_ENV = env.APP_ENV || 'development';config.plugins.push(new Dotenv({path: `./envVariable/client/.env.${APP_ENV}`,}));return new Promise((resolve) => {if (isProd) {config.plugins.push(new MiniCssExtractPlugin({filename: 'styles/[name].[contenthash:6].css',}),new CompressionPlugin({test: /\.(css|js)$/,minRatio: 0.7,}));resolve(config);} else {config.devtool = 'cheap-module-source-map';portfinder.basePort = config.devServer.port;portfinder.getPort((err, port) => {if (err) {reject(err);} else {const LOCAL_IP = address.ip();// publish the new Port, necessary for e2e testsprocess.env.PORT = port;// add port to devServer config,主要是这一步更新可用的端口config.devServer.port = port;// Add FriendlyErrorsPluginconfig.plugins.push(new FriendlyErrorsWebpackPlugin({compilationSuccessInfo: {messages: [` App running at:`,` - Local: ` + chalk.cyan(`http://localhost:${port}`),` - Network: ` + chalk.cyan(`http://${LOCAL_IP}:${port}`),],},clearConsole: true,}));resolve(config);}});}});
};
本地开发服务器
这块直接照抄vue-hackernews-2.0的思路。
已知,SSR的渲染关键在于renderer.renderToString,renderer是由createBundleRenderer创建而来,createBundleRenderer函数需要传入bundle clientManifest等实参。
因此,如果想在改完代码后拿到最新的内容,我们需要做以下事情:
- 声明一个
renderer - 构建server,在文件修改时重新编译,构建完成时想办法拿到最新的
bundle - 构建client,在文件修改时重新编译,构建完成时想办法拿到最新的
clientManifest - 拿到最新的
bundle和clientManifest后,通过createBundleRenderer将renderer替换 - 拿到最新的
renderer执行renderer.renderToString
以上就是setup-dev-server.js要做的工作。
setup-dev-server.js
通常情况下我们构建webpack, 通过命令指定一个wepback配置文件进行构建。
webpack --config webpack.xxx.config.js
除了上述方式,可以通过在js执行webpack(AnyWebpackConfig)来启动webpack构建。(via Node 接口 | webpack 中文文档 (docschina.org))
setupDevServer函数执行webpack(clientConfig) webpack(serverConfig) ,在compiler钩子回调中,通过outputFileSystem(via Node 接口 | webpack 中文文档 (docschina.org))获取到最新的构建结果。
renderer的更新关键在setupDevServer传入的实参cb的执行时机。
webpack-dev-middleware旧版本的outputFileSystem直接挂在devMiddleware下。新版本通过
devMiddleware.context.outputFileSystem访问
const fs = require("fs")
const path = require("path")
const MFS = require("memory-fs")
const webpack = require("webpack")
const chokidar = require("chokidar")module.exports = function setupDevServer(app, templatePath, cb, port) {const clientConfig = require("./webpack.client.config")({APP_ENV: "development"})const serverConfig = require("./webpack.server.config")({APP_ENV: "development"},{ port })const readFile = (fs, file) => {try {return fs.readFileSync(path.join(clientConfig.output.path, file), "utf-8")} catch (e) {console.log(e)}}let bundlelet templatelet clientManifestlet readyconst readyPromise = new Promise((r) => {ready = r})const update = () => {if (bundle && clientManifest) {ready()cb(bundle, {template,clientManifest})}}// read template from disk and watchtemplate = fs.readFileSync(templatePath, "utf-8")chokidar.watch(templatePath).on("change", () => {template = fs.readFileSync(templatePath, "utf-8")// console.log("index.ssr.html template updated.")update()})// modify client config to work with hot middlewareclientConfig.entry.app = ["webpack-hot-middleware/client", clientConfig.entry.app]clientConfig.output.filename = "[name].js"clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin())// dev middleware// console.log("clientCompiler start")const clientCompiler = webpack(clientConfig)const devMiddleware = require("webpack-dev-middleware")(clientCompiler, {publicPath: clientConfig.output.publicPath,serverSideRender: true// noInfo: true})app.use(devMiddleware)clientCompiler.hooks.done.tap("done", (stats) => {stats = stats.toJson()stats.errors.forEach((err) => console.error(err))stats.warnings.forEach((err) => console.warn(err))if (stats.errors.length) returnclientManifest = JSON.parse(readFile(devMiddleware.context.outputFileSystem, "vue-ssr-client-manifest.json"))update()// console.log("clientCompiler success")})// hot middlewareapp.use(require("webpack-hot-middleware")(clientCompiler, { heartbeat: 5000, log: false }))// watch and update server renderer// console.log("serverCompiler start")const serverCompiler = webpack(serverConfig)serverCompiler.hooks.done.tap("done", (stats) => {// console.log("serverCompiler success")})const mfs = new MFS()serverCompiler.outputFileSystem = mfsserverCompiler.watch({}, (err, stats) => {if (err) throw errstats = stats.toJson()if (stats.errors.length) return// read bundle generated by vue-ssr-webpack-pluginbundle = JSON.parse(readFile(mfs, "vue-ssr-server-bundle.json"))update()})return readyPromise
}
配置路径问题
在配置文件中充斥着各种路径,有些是./,有些是../,在这里略作讨论讨论。
webpackConfig.context,这个配置项默认值是Node.js 进程的当前工作目录(就是npm run执行所在目录),如果在配置路径的过程中,没有使用到path.resolve之类的api,那么./指向的目录就是webpackConfig.context所指向的目录,而不是当前文件所在目录。
这个配置项直接影响其他配置项的最终结果。比如:
entry: {home: './src/home.js',about: './src/about.js',contact: './src/contact.js',
}
还会影响到插件的配置路径,比如:
// build/webpack.spa.config.js
new HtmlWebpackPlugin({// 注意两个路径的差异template: './public/index.spa.html',favicon: path.resolve(__dirname, '../public/favicon.ico'),
})
环境变量
NODE_ENV在webpack.config.js中用于区分打包模式,可以用来配置mode配置项(只支持'none' | 'development' | 'production'),可以通过命令指定具体的值:
cross-env NODE_ENV=production
# 或
webpack --node-env production
# https://webpack.docschina.org/api/cli/#node-env
NODE_ENV并不直接影响业务代码中process.env.NODE_ENV。
业务代码中的环境变量依赖于webpack.DefinePlugin,你也可以通过dotenv-webpack加载指定的env文件注入环境变量。
webpack --node-env production --env APP_ENV=production
# https://webpack.docschina.org/api/cli/#env
// wepback plugins
new Dotenv({path: `./envVariable/client/.env.${APP_ENV}`,
})
总的来说就是NODE_ENV决定了构建方式(development|production)涉及到代码压缩等内容,APP_ENV决定了业务代码用哪套环境变量(dev|test|stag|uat|pre|prod)。
数据预取阶段的鉴权问题(Cookie)
场景:浏览器访问http://localhost:9500/user,若用户已登录则/user页面展示当前用户个人信息,如果未登录则重定向到/login
思考一个问题:在数据预取阶段(Data Pre-Fetching ),server端向后端服务器发起的异步请求是否自动携带了浏览器传过来的cookie?
答案:没有自动携带。
为了实现在server端携带client端的cookie,我们需要:
server端收到浏览器的访问请求,将req对象中的cookie保存- 将
cookie传入到store中 - 在
store.dispatch时将cookie传递给axios实例
下面给出简明代码(仅做描述思路):
// server.js
const express = require('express')
const app = express()
...
server.get('*', (req, res) => {const context = {...,// 配置context.cookiecookie: req.headers.cookie}renderer.renderToString(context, (err, html) => {...})
})
// entry-server.js
import { createApp } from './app';
// 此处形参context就是renderer.renderToString传入的context实参
export default (context) => {return new Promise((resolve,reject) => {const { app, router, store } = createApp();// 保存cookie到store里if (context.cookie) {store.commit('cookieStore/save_cookie', context.cookie);}})
}
// modules/cookieStore.js
export default {namespaced: true,state: () => ({cookie: ""}),mutations: {save_cookie(state, cookie) {state.cookie = cookie || ""}}
}
// store.js
import cookieStore from "./modules/cookie"
export function createStore() {return new Vuex.Store({state: {},getters: {cookie(state) {return state.cookieStore.cookie}},modules: {cookieStore}})
}
// vue
userInfoname:{{ userInfo.name }}age:{{ userInfo.age }}gender:{{ userInfo.gender }}
// userStore.js
import { getUserInfo } from "@/api/user.js"export default {namespaced: true,state: () => ({userInfo: {}}),getters: {},mutations: {setUserInfo(state, userInfo) {state.userInfo = userInfo}},actions: {getUserInfo({ rootGetters, commit }) {return getUserInfo({cookie: rootGetters.cookie}).then((res) => {commit("setUserInfo", res.data)})}}
}
预取阶段,asyncData执行store.dispatch("userStore/getUserInfo"),会在actions中向api函数传入cookie,通过这样处理,我们就能实现预取阶段发起的异步请求也携带了鉴权信息。
注意cookie的传参时机
在查阅和参考资料的过程中发现,很多方案是将cookie作为实参传入到asyncData中,即:
// entry-server.js
Promise.all(matchedComponents.map((Component) => {if (Component.asyncData) {return Component.asyncData({store,route: router.currentRoute,// 注意此处cookie实参cookie: context.cookie});}})
)
// vue
这里讲讲我为什么没有将cookie作为asyncData的参数层层传递
首先明确一点,在client端发起异步请求是不需要显式地设置cookie,因为它会自动携带。
只有server端数据预取阶段才需要手动设置cookie,而预取阶段发起的请求被声明在actions里,因此我们只需要在actions中的函数被调用时将cookie挂到axios实例上即可。
再一个原因就是,asyncData不仅在server端被调用,它也可以在client端的beforeRouteUpdate阶段被调用(参考示例工程的/userlist页面)。如果你显式的将cookie传给asyncData,那么你需要在任何调用到asyncData的地方,尽可能为它补充好参数。
也就是说你要在client端编写大量类似下面的代码:
// 用心体会
asyncData({store:this.$store,route:this.$route,cookie:document.cookie
})
如果你不补充参数,或许会遭遇一个经典报错:Uncaught TypeError: Cannot read properties of undefined。
综上所述,因为asyncData调用位置的不确定性,我们应该尽可能避免给asyncData额外添加参数。
优雅地处理平台差异
编写通用代码 | Vue SSR 指南 (vuejs.org)提到:
通用代码不可接受特定平台的 API,因此如果你的代码中,直接使用了像
window或document,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此。
当你试图在通用代码中访问特定平台的API,必须要通过一些hack方式编写这段代码,才能使程序平稳地运行。
下面介绍三种方式:
resolve.alias
// app.js
import titleMixin from "titleMixin"
// webpack.server.config.js
resolve: {alias: {titleMixin:"@/utils/title-server.js"}
}
// webpack.client.config.js
resolve: {alias: {titleMixin:"@/utils/title-client.js"}
}
// title-client.js
function getTitle(vm) {const { title } = vm.$optionsif (title) {return typeof title === "function" ? title.call(vm) : title}
}const clientTitleMixin = {mounted() {const title = getTitle(this)if (title) {document.title = ` ${title}`}}
}export default clientTitleMixin
// title-server.js
function getTitle(vm) {const { title } = vm.$optionsif (title) {return typeof title === "function" ? title.call(vm) : title}
}const serverTitleMixin = {created() {const title = getTitle(this)if (title) {this.$ssrContext.title = title}}
}export default serverTitleMixin
在SSR工程中,resolve.alias也可以用来兼容只能在某个平台运行的第三方包(比如一些埋点api只能在客户端运行),你可以通过定义“假”的变量导出来使代码在另一个平台运行不报错。
process.env
// app.js
import titleMixin from "@/utils/title"
Vue.mixin(titleMixin)
// utils/index.js
export const atClient = process.env.atClient == "true"
export const atServer = process.env.atServer == "true"
// utils/title.js
import { atServer } from "./index.js"
function getTitle(vm) {const { title } = vm.$optionsif (title) {return typeof title === "function" ? title.call(vm) : title}
}
const serverTitleMixin = {created() {const title = getTitle(this)if (title) {this.$ssrContext.title = title}}
}
const clientTitleMixin = {mounted() {const title = getTitle(this)if (title) {document.title = ` ${title}`}}
}
export default atServer ? serverTitleMixin : clientTitleMixin
typeof window !==‘undefined’
function getTitle(vm) {const { title } = vm.$optionsif (title) {return typeof title === "function" ? title.call(vm) : title}
}export const titleMixin = (function () {if (typeof window !== "undefined") {return {mounted() {const title = getTitle(this)if (title) {document.title = `Vue HN 2.0 | ${title}`}}}} else {return {created() {const title = getTitle(this)if (title) {this.$ssrContext.title = `Vue HN 2.0 | ${title}`}}}}
})()
不到万不得已不要在业务中直接使用if条件判断的方式进行平台区分。直接判断平台的条件代码越少,编写出来的代码可维护性越高。
总结
没啥难的,照着hackernews的思路一路平推就完了。搁这留个说明文档持续更新,防止接盘的同事一时半会儿看不懂。
参考资料
vue-hackernews-2.0
What to do when Vue hydration fails | blog.Lichter.io
概念 | webpack 中文文档 (docschina.org)
Vue.js 服务器端渲染指南 | Vue SSR 指南 (vuejs.org)
本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!
