基于midway-vue3进行ssr框架源码解析

1,384 阅读4分钟

前言:之前项目需要使用ssr的vue3版本上线过一个项目,在过程中看过一部分源码,按自己理解写一下源码的解析。

官方文档 核心原理写得已经很详细了

vue ssr文档参考

服务端渲染流程

客户端渲染流程

启动服务

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具体操作

具体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

  1. base webpack

  2. client webpack具体配置:

    • devtool
    • entry(entry/client-entry.ts后面详解)
    • output
    • optimization
      • runtimeChunk
      • splitChunks
      • minimizer(terser、optimize-css)非开发环境进行压缩处理
    • plugin
      • 自定义插件WriteAsyncManifest生产asyncChunkMap.json,作用: 记录splitChunks时,加密异步模块 name,防止名称过长,进行map对应

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

  1. 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具体操作

  1. createRouter()、createStore()

  2. 通过findRoute找到当前路由routeItem

    • csr
      • 无操作
    • ssr
      • 根据routeItem配置fetch方法获取数据
  3. 注入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关联到具体文件
  4. 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中

具体代理的路径:
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)
  }
}