前言:之前项目需要使用ssr的vue3版本上线过一个项目,在过程中看过一部分源码,按自己理解写一下源码的解析。
官方文档 核心原理写得已经很详细了
服务端渲染流程
客户端渲染流程
启动服务
ssr start
ssr框架底层使用前端框架和服务端框架都是基于插件机制扩展
ssr start具体操作
const { parseFeRoutes, loadPlugin } = await import('ssr-server-utils')
await parseFeRoutes()
// 读取pluginjs文件配置对应的前后端框架插件
const plugin = loadPlugin()
// 启动客户端插件
await plugin.clientPlugin?.start?.(argv)
// 启动服务端插件
await plugin.serverPlugin?.start?.(argv)
plugin.js
const { midwayPlugin } = require('ssr-plugin-midway')
const { vuePlugin } = require('ssr-plugin-vue3')
module.exports = {
serverPlugin: midwayPlugin(),
clientPlugin: vuePlugin()
}
parseFeRoutes
根据路由配置生成ssr-temporary-routes.js路由配置,记录路由对应信息,ssr和csr渲染时共用
- layout
-
- layout fetch
- app component
- pages
-
- pages component
-
- pages fetch
服务端插件如何启动服务器端框架
服务器端插件只是获取对应的环境配置,进行执行服务器框架的操作 插件详情文档
// start
import { exec } from 'child_process'
import { loadConfig } from 'ssr-server-utils'
import { Argv } from 'ssr-types'
const { cliFun } = require('@midwayjs/cli/bin/cli')
const start = (argv: Argv) => {
const config = loadConfig()
exec('npx cross-env ets', async (err, stdout) => {
if (err) {
console.log(err)
return
}
console.log(stdout)
// 透传参数给 midway-bin
argv._[0] = 'dev'
argv.ts = true
argv.port = config.serverPort
await cliFun(argv)
})
}
export {
start
}
如何渲染
启动后台服务,访问路由,通过render方法渲染
import { render } from 'ssr-core-vue3'
export class Index {
@Inject()
ctx: IEggContext
@Get('/')
async handler (): Promise<void> {
try {
// 通过render函数返回流或者字符串
const stream = await render<Readable>(this.ctx, {
stream: true
})
// 进行渲染
this.ctx.body = stream
} catch (error) {
console.log(error)
this.ctx.body = error
}
}
}
render方法具体操作
// webpack server entry打包出来js bundle
const serverFile = resolve(cwd, `./build/server/${chunkName}.server.js`)
const { serverRender } = require(serverFile)
// 返回createSSRApp()
const serverRes = await serverRender(ctx, config)
// 通过@vue/server-renderer renderToNodeStream(SSRApp), renderToString(SSRApp) 返回流或者字符串
if (stream) {
const stream = mergeStream2(new StringToStream('<!DOCTYPE html>'), renderToNodeStream(serverRes))
stream.on('error', (e: any) => {
console.log(e)
})
return stream
} else {
return `<!DOCTYPE html>${await renderToString(serverRes)}`
}
Webpack ServerEntry js bundle打包的具体操作
serverRender具体操作
客户端插件plugin-vue3具体操作
const { startServerBuild } = await import('ssr-webpack/cjs/server')
const { getServerWebpack } = await import('./config/server')
const serverConfigChain = new WebpackChain()
const { startClientServer } = await import('ssr-webpack')
const { getClientWebpack } = await import('./config')
const clientConfigChain = new WebpackChain()
await Promise.all([startServerBuild(getServerWebpack(serverConfigChain)), startClientServer(getClientWebpack(clientConfigChain))])
分别进行ServerEntry js budle和 ClientEntry js budle打包
一、StartClientServer
-
base webpack
-
client webpack具体配置:
3、通过webpack-dev-server启动服务监听clientEntry js budle变动
const startClientServer = async (webpackConfig: webpack.Configuration): Promise<void> => {
const { webpackDevServerConfig, fePort, host } = config
return await new Promise((resolve) => {
const compiler = webpack(webpackConfig)
const server = new WebpackDevServer(compiler, webpackDevServerConfig)
compiler.hooks.done.tap('DonePlugin', () => {
resolve()
})
server.listen(fePort, host)
})
}
入口client-entry详解
// Routes 路由是根据
import * as Routes from '_build/ssr-temporary-routes'
const clientRender = async () => {
const store = createStore()
// 根据ssr-temporary-routes.js文件配置创建路由
const router = createRouter({
base: BASE_NAME
})
if (window.__INITIAL_DATA__) {
store.replaceState(window.__INITIAL_DATA__)
}
// 通过reactive响应数据的变化
const asyncData = reactive({
value: window.__INITIAL_DATA__ ?? {}
})
let fetchData = window.__INITIAL_DATA__ ?? {}
// 创建app实例,把asyncData、fetchData数据通过props传递
const app = createApp({
render: () => h(App, {
asyncData,
fetchData
})
})
// 挂载store和router
app.use(store)
app.use(router)
await router.isReady()
// 路由跳转前执行前通过findRoute从ssr-temporary-routesjs文件中找到下个页面的配置,再根据getAsyncCombineData方法执行配置中的“fetch”方法获取相关的数据再进行跳转
router.beforeResolve(async (to, from, next) => {
// 找到要进入的组件并提前执行 fetch 函数
const { fetch } = findRoute<IClientFeRouteItem>(FeRoutes, to.path)
const combineAysncData = await getAsyncCombineData(fetch, store, to)
to.matched?.forEach(item => {
item.props.default = Object.assign({}, item.props.default ?? {}, {
fetchData: combineAysncData
})
})
asyncData.value = Object.assign(asyncData.value, combineAysncData)
next()
})
if (!window.__USE_SSR__) {
// 如果是 csr 模式 则需要客户端获取首页需要的数据
let pathname = location.pathname
if (BASE_NAME) {
pathname = normalizePath(pathname, BASE_NAME)
}
const { fetch } = findRoute<IClientFeRouteItem>(FeRoutes, pathname)
const combineAysncData = await getAsyncCombineData(fetch, store, router.currentRoute.value)
fetchData = combineAysncData
asyncData.value = Object.assign(asyncData.value, combineAysncData)
}
window.__VUE_APP__ = app
window.__VUE_ROUTER__ = router
app.mount('#app', !!window.__USE_SSR__) // 这里需要做判断 ssr/csr 来为 true/false
}
clientRender()
二、StartServerBuild
- server webpack具体配置:
- devtool
- target
- entry(entry/server-entry.ts后面详解)
- output
-
- filename
-
- libraryTarget 'commonjs'
- externals
- watch 开发环境进行监听
- plugin
-
- webpack.optimize.LimitChunkCountPlugin ([{maxChunks: 1}])作用生成一个文件 目的
入口server-entry详解
import * as Routes from '_build/ssr-temporary-routes'
const router = createRouter()
let path = ctx.request.path
if (BASE_NAME) {
path = normalizePath(path)
}
const store = createStore()
const { cssOrder, jsOrder, dynamic, mode, customeHeadScript, customeFooterScript, chunkName, parallelFetch, disableClientRender } = config
const routeItem = findRoute<IServerFeRouteItem>(FeRoutes, path)
let dynamicCssOrder = cssOrder
if (dynamic && !viteMode) {
dynamicCssOrder = cssOrder.concat([`${routeItem.webpackChunkName}.css`])
dynamicCssOrder = await addAsyncChunk(dynamicCssOrder, routeItem.webpackChunkName)
}
const manifest = await getManifest()
const isCsr = !!(mode === 'csr' || ctx.request.query?.csr)
const { fetch } = routeItem
router.push(path)
await router.isReady()
let layoutFetchData = {}
let fetchData = {}
if (!isCsr) {
// csr 下不需要服务端获取数据
if (parallelFetch) {
[layoutFetchData, fetchData] = await Promise.all([
layoutFetch ? layoutFetch({ store, router: router.currentRoute.value }, ctx) : Promise.resolve({}),
fetch ? fetch({ store, router: router.currentRoute.value }, ctx) : Promise.resolve({})
])
} else {
if (layoutFetch) {
layoutFetchData = await layoutFetch({ store, router: router.currentRoute.value }, ctx)
}
if (fetch) {
fetchData = await fetch({ store, router: router.currentRoute.value }, ctx)
}
}
}
const combineAysncData = Object.assign({}, layoutFetchData ?? {}, fetchData ?? {})
const asyncData = {
value: combineAysncData
}
const injectCss: Vue.VNode[] = []
if (viteMode) {
injectCss.push(
h('link', {
rel: 'stylesheet',
href: `/server/static/css/${chunkName}.css`
})
)
} else {
dynamicCssOrder.forEach(css => {
if (manifest[css]) {
injectCss.push(
h('link', {
rel: 'stylesheet',
href: manifest[css]
})
)
}
})
}
const injectScript = viteMode ? h('script', {
type: 'module',
src: '/node_modules/ssr-plugin-vue3/esm/entry/client-entry.js'
}) : jsOrder.map(js =>
h('script', {
src: manifest[js]
})
)
const customeHeadScriptArr = customeHeadScript?.map((item) => h(
'script',
Object.assign({}, item.describe, {
innerHTML: item.content
})
)
) ?? []
if (disableClientRender) {
customeHeadScriptArr.push(h('script', {
innerHTML: 'window.__disableClientRender__ = true'
}))
}
const state = Object.assign({}, store.state ?? {}, asyncData.value)
const app = createSSRApp({
render: function () {
return h(
Layout,
{ ctx, config, asyncData, fetchData: layoutFetchData },
{
remInitial: () => h('script', { innerHTML: "var w = document.documentElement.clientWidth / 3.75;document.getElementsByTagName('html')[0].style['font-size'] = w + 'px'" }),
viteClient: viteMode ? () =>
h('script', {
type: 'module',
src: '/@vite/client'
}) : null,
customeHeadScript: () => customeHeadScriptArr,
customeFooterScript: () => customeFooterScript?.map((item) =>
h(
'script',
Object.assign({}, item.describe, {
innerHTML: item.content
})
)
),
children: isCsr ? () => h('div', {
id: 'app'
}) : () => h(App, { ctx, config, asyncData, fetchData: combineAysncData }),
initialData: !isCsr ? () => h('script', { innerHTML: `window.__USE_SSR__=true; window.__INITIAL_DATA__ =${serialize(state)};window.__USE_VITE__=${viteMode}` })
: () => h('script', { innerHTML: `window.__USE_VITE__=${viteMode}` }),
cssInject: () => injectCss,
jsInject: () => injectScript
}
)
}
})
app.use(router)
app.use(store)
await router.isReady()
window.__VUE_APP__ = app
return app
serverRender具体操作
-
createRouter()、createStore()
-
通过findRoute找到当前路由routeItem
- csr
-
- 无操作
- ssr
-
- 根据routeItem配置fetch方法获取数据
-
注入js和css
- css(client budle产物和用户自定义css链接)
-
${chunkName}.css
-
- userConfig.extraCssOrder 用户自定义css配置
-
${routeItem.webpackChunkName}.css
路由配置中webpackChunkName产物
-
- 读取./build/asyncChunkMap.json,匹配与splitChunks打包对应产生css文件
- js (client budle产物和用户自定义js链接)
-
runtime~${chunkName}.js
-
- vendor.js
-
${chunkName}.js
-
- userConfig.extraJsOrder 用户自定义js配置 根据asset-manifest.js关联到具体文件
-
creatSSRApp
- 通过props传递获取的数据asyncData
- 注入js和css文件
- render children内容
-
- csr模式 返回空的div内容,客户端进行渲染具体内容
<div id="app"></div>
-
- ssr 模式 把获取的数据注入widow对象中
const state = Object.assign({}, store.state ?? {}, asyncData.value) window.__INITIAL_DATA__ =${serialize(state)}
5、挂载router、store到app中
代理
开发模式下,webpack-dev-server会将前端静态资源代码到内存中(方便快速加载和热更新),只能通过前端服务端口(例如8888)去访问资源。访问服务端(端口3000)路由时,静态资源都是相对路径写入html中
- 访问路由时加载 /static路径下的文件指向地址为 http://127.0.0.1:3000/static/*
- 实际上http://127.0.0.1:3000/static/* 访问不到文件的,这时候需要把3000端口所需要的路径代理到8888端口去
具体代理的路径:
const proxyPathMap = {
'/static': remoteStaticServerOptions,
'/sockjs-node': remoteStaticServerOptions,
'/*.hot-update.js(on)?': remoteStaticServerOptions,
'/__webpack_dev_server__': remoteStaticServerOptions,
'/asset-manifest': remoteStaticServerOptions
}
生产模式下,都是真实存在资源文件,访问路径时,通过static中间件作用指向对应文件的
config.static = {
prefix: '/',
dir: [join(appInfo.appDir, './build'), join(appInfo.appDir, './public')]
}
ssr在后端框架启动的时候,加载代理的中间件进行资源的代理
class AppBootHook {
app: Application
constructor (app) {
this.app = app
}
async willReady () {
await initialSSRDevProxy(this.app)
}
}