前端我的项目搭建
前后端全栈博客我的项目 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
),咱们须要 webpack
、 Vue
,以及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
就须要传入client
、server
配置项去别离编译咱们的 entry-client.js
、 entry-server.js
,其中 entry-client.js
是给 Browser
载入的,而 entry-server.js
则是让咱们后端收到申请时载入的。因为是打包阶段用于产生输入给 webpack
的配置的文件,就不必ts编写了。本我的项目中次要抽取了5个webpack
配置文件用于组合,别离为
webpack.base.js
:
client, server side 共用的一些loader
和plugin;
webpack.dev.js
开发阶段波及的配置,如devtool
和optimization
;webpack.prod.js
打包阶段波及的配置,如代码压缩,文件宰割等;webpack.client.js
一些只有在client side
波及到的配置;webpack.server.js
一些只有在server side
波及到的配置;
参考代码如下,须要理解具体配置的小伙伴请浏览官网文档
- 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;};
- 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 }};
- 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, }) ] } };};
- 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;};
- 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
中定义了一个函数,该函数用于实例化所有常见套件,如 router
、 pinia
(后续会聊到如何应用 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导出了createMemoryHistory
和createWebHistory
办法,用于在客户端和服务端运行,具体查看官网文档。
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.url
在 router
中匹配页面组件。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
<template> <div class="app-wrap"> <div class="main"> <router-view v-slot="{ Component }"> <transition name="fade" mode="out-in"> <component :is="Component"></component> </transition> </router-view> </div> </div></template><script lang="ts" setup></script>
list.vue
<template> <h1 class="list"> Hello World </h1></template><script lang="ts" setup></script><style lang="stylus" scoped></style>
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.tsimport 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 }}', `<div id="app">${html}</div>`)}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
,解析出的 url
和 next
函数。传入 next
函数用于如果 context.url
未匹配到任何页面组件能够通过调用 next
函数,让 express
执行到下一个中间件。
开发模式打包
SSR
(服务端渲染)的开发模式与以往 CSR
(客户端渲染)的开发模式不同, CSR
开发模式只需配置 webpack
中的 devServer
选项即可疾速进行调试。 但 SSR
还存在server
端的页面生成步骤,所以咱们须要把开发阶段插件 webpack-dev-middleware
和webpack-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
客户端套壳文件的合并,最终生成咱们须要的带有 content
的 html
字符串响应数据。
以上为我的项目中前端局部的一些要害逻辑,源码实现在这里