(一)Vue3 + SSR + Vite

6,597

不知不觉都已经2021年7月了,靓仔依然没用崛起,还在继续躺平。然而大佬们关于vue3的文章已经层出不穷了,而我却没有再项目中使用过。

e7c067a2-5e8d-41b1-9dd9-e7f5fffef241.jpg

闲来无事,突然想弄一个vue3+ssr+vite的demo,来做个简单的项目实战。服务端打算用egg(未开始,后续章节会更新)。

首先拿当然是看文档,但是没有找到vue3+vite+ssr的文档,找到的都是vue2的,nuxt好像也不支持vue3+vite+ssr的方式。好在天无绝人之路,我就在vite的github仓库中找到了vue3+vite+ssr的demo

打开demo,大体逻辑基本上都写的差不多了,但是没有看到重要的router+vuex,那我们就挨个补上呗。 首先大家先来看一下经过我改造后的样子 项目目录

src
|——pages
|————About.vue
|——Home.vue
|——utils
|————index.js
|——App.vue
|——entry-client.js
|——entry-server.js
|——main.js
|——router.js
|——store.js
index.html
pageage.json
server.js
vite.config.js

server.js

const express = require('express')
const fs = require('fs')
const path = require('path')
const isTest = process.env.NODE_ENV === 'test' || !!process.env.VITE_TEST_BUILD
const serialize = require('serialize-javascript');
async function createServer(
  root = process.cwd(),
  isProd = process.env.NODE_ENV === 'production'
) {
  const resolve = (p) => path.resolve(__dirname, p)

  const indexProd = isProd
    ? fs.readFileSync(resolve('dist/client/index.html'), 'utf-8')
    : ''

  const manifest = isProd
    ? // @ts-ignore
    require('./dist/client/ssr-manifest.json')
    : {}

  const app = express()

  /**
   * @type {import('vite').ViteDevServer}
   */
  let vite
  if (!isProd) {
    vite = await require('vite').createServer({
      root,
      logLevel: isTest ? 'error' : 'info',
      server: {
        middlewareMode: 'ssr',
        watch: {
          // During tests we edit the files too fast and sometimes chokidar
          // misses change events, so enforce polling for consistency
          usePolling: true,
          interval: 100
        }
      }
    })
    // use vite's connect instance as middleware
    app.use(vite.middlewares)
  } else {
    app.use(require('compression')())
    app.use(
      require('serve-static')(resolve('dist/client'), {
        index: false
      })
    )
  }

  app.use('*', async (req, res) => {
    try {
      const url = req.originalUrl
      // 读取index.html模板文件
      let template, render
      if (!isProd) {
        // always read fresh template in dev
        template = fs.readFileSync(resolve('index.html'), 'utf-8')
        template = await vite.transformIndexHtml(url, template)
        render = (await vite.ssrLoadModule('/src/entry-server.js')).render
      } else {
        template = indexProd
        render = require('./dist/server/entry-server.js').render
      }
      // 调用服务端渲染方法,将vue组件渲染成dom结构,顺带分析出需要预加载的js,css等文件。
      const [appHtml, preloadLinks, store] = await render(url, manifest)
      // 新加 + 将服务端预取数据的store,插入html模板文件
      const state = ("<script>window.__INIT_STATE__" + "=" + serialize(store, { isJSON: true }) + "</script>");
      // 把html中的展位符替换成相对应的资源文件
      const html = template
        .replace(`<!--preload-links-->`, preloadLinks)
        .replace(`<!--app-html-->`, appHtml)
        .replace(`<!--app-store-->`, state)

      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      vite && vite.ssrFixStacktrace(e)
      console.log(e.stack)
      res.status(500).end(e.stack)
    }
  })

  return { app, vite }
}
// 创建node服务器用作ssr
if (!isTest) {
  createServer().then(({ app }) =>
    app.listen(3000, () => {
      console.log('http://localhost:3000')
    })
  )
}

// for test use
exports.createServer = createServer

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <!--preload-links-->
    <div id="app"><!--app-html--></div>
    <script type="module" src="/src/entry-client.js"></script>
    <!--app-store--><!--这个是用来预加载store的-->
  </body>
</html>

src/main.ts

因为每次请求都会到达服务器,为了数据不会被相互污染,每次请求我们都需要使用工厂函数创建新的实例,每次都返回全新的vue, router, store等

import { createSSRApp } from 'vue'
import { createStore } from './store'
import App from './App.vue'
import { createRouter } from './router'

export function createApp() {
  const app = createSSRApp(App)
  const router = createRouter()
  const store = createStore()
  app.use(router)
  app.use(store)
  return { app, router, store }
}

router

import {
  createMemoryHistory,
  createRouter as _createRouter,
  createWebHistory
} from 'vue-router'

// Auto generates routes from vue files under ./pages
// https://vitejs.dev/guide/features.html#glob-import
const pages = import.meta.glob('./pages/*.vue')

const routes = Object.keys(pages).map((path) => {
  const name = path.match(/\.\/pages(.*)\.vue$/)[1].toLowerCase()
  return {
    path: name === '/home' ? '/' : name,
    component: pages[path] // () => import('./pages/*.vue')
  }
})

export function createRouter() {
  return _createRouter({
    // use appropriate history implementation for server/client
    // import.meta.env.SSR is injected by Vite.
    history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
    routes
  })
}

数据预取

服务器端渲染的是应⽤程序的"快照",如果应⽤依赖于⼀些异步数据, 那么在开始渲染之前,需要先预取和解析好这些数据

异步获取数据

store

import { createStore as _createStore } from 'vuex'

export function createStore() {
  return _createStore({
    state() {
      return {
        count: 0
      }
    },
    mutations: {
      increment(state) {
        state.count++
      },
      init(state, count) {
        state.count = count
      }
    },
    actions: {
      getCount({ commit }) {
        return new Promise(resolve => {
          setTimeout(() => {
            console.log('run here');
            commit('init', Math.random() * 100)
            resolve()
          }, 1000)
        })
      }
    }
  })
}

src/entry-server.js 服务端渲染入口函数。

import { createApp } from './main'
import { renderToString } from '@vue/server-renderer'

import { getAsyncData } from './utils/';  // 异步处理数据的时候使用
export async function render(url, manifest) {
  const { app, router, store } = createApp()

  // set the router to the desired URL before rendering
  router.push(url)
  // store.$setSsrPath(url);
  await router.isReady()
  await getAsyncData(router, store, true);

  // passing SSR context object which will be available via useSSRContext()
  // @vitejs/plugin-vue injects code into a component's setup() that registers
  // itself on ctx.modules. After the render, ctx.modules would contain all the
  // components that have been instantiated during this render call.
  const ctx = {}
  const html = await renderToString(app, ctx)
  ctx.state = store.state
  // the SSR manifest generated by Vite contains module -> chunk/asset mapping
  // which we can then use to determine what files need to be preloaded for this
  // request.
  const preloadLinks = ctx.modules
  ? renderPreloadLinks(ctx.modules, manifest)
  : [];
  return [html, preloadLinks, store]
}

function renderPreloadLinks(modules, manifest) {
  let links = ''
  const seen = new Set()
  modules.forEach((id) => {
    const files = manifest[id]
    if (files) {
      files.forEach((file) => {
        if (!seen.has(file)) {
          seen.add(file)
          links += renderPreloadLink(file)
        }
      })
    }
  })
  return links
}

function renderPreloadLink(file) {
  if (file.endsWith('.js')) {
    return `<link rel="modulepreload" crossorigin href="${file}">`
  } else if (file.endsWith('.css')) {
    return `<link rel="stylesheet" href="${file}">`
  } else {
    // TODO
    return ''
  }
}

src/utils/index.js

// 执行注册store钩子
export const registerModules = (
  components,
  router,
  store
) => {
  return components
    .filter((i) => typeof i.registerModule === "function")
    .forEach((component) => {
      component.registerModule({ router: router.currentRoute, store });
    });
};

// 调用当前匹配到的组件里asyncData钩子,预取数据
export const prefetchData = (
  components,
  router,
  store
) => {
  const asyncDatas = components.filter(
    (i) => typeof i.asyncData === "function"
  );
  return Promise.all(
    asyncDatas.map((i) => {
      return i.asyncData({ router: router.currentRoute.value, store });
    })
  );
};

// ssr自定义钩子
export const getAsyncData = (
  router,
  store,
  isServer
) => {
  return new Promise(async (resolve) => {
    const { matched } = router.currentRoute.value;

    // 当前路由匹配到的组件
    const components = matched.map((i) => {
      return i.components.default;
    });
    // 动态注册store
    registerModules(components, router, store);

    if (isServer) {
      // 预取数据
      await prefetchData(components, router, store);
    }

    resolve();
  });
};

组件中的数据预取逻辑, /src/page/Home.vue 服务端数据预取,

export default {
  asyncData({store}) {
    return store.dispatch('getCount')
  }
})

src/client.js

客户端入口函数, 客户端在挂载到应⽤程序之前, store 就应该获取到状态

import { createApp } from './main'

const { app, router, store } = createApp()
if(window.__INIT_STATE__) {
  // 当使⽤ template 时, context.state 将作为 window.__INIT_STATE__ 状态⾃动嵌⼊到最终的 HTML
  // 在客户端挂载到应⽤程序之前, store 就应该获取到状态:

  store.replaceState(window.__INIT_STATE__._state.data)
}
router.isReady().then(() => {
  app.mount('#app')
})

最后附上package.json

  "scripts": {
    "dev": "node server",
    "build": "npm run build:client && npm run build:server",
    "build:client": "vite build --ssrManifest --outDir dist/client",
    "build:server": "vite build --ssr src/entry-server.js --outDir dist/server",
    "generate": "vite build --ssrManifest --outDir dist/static && yarn build:server && node prerender",
    "serve": "cross-env NODE_ENV=production node server",
    "debug": "node --inspect-brk server"
  },
  "dependencies": {
    "vue": "^3.1.2",
    "vue-router": "^4.0.10",
    "vuex": "^4.0.2"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^1.2.3",
    "@vitejs/plugin-vue-jsx": "^1.1.6",
    "@vue/compiler-sfc": "^3.0.5",
    "@vue/server-renderer": "^3.1.2",
    "express": "^4.17.1",
    "sass": "^1.35.1",
    "sass-loader": "^12.1.0",
    "serialize-javascript": "^6.0.0",
    "vite": "^2.3.8"
  }

当你做完上述步骤后,运行

npm run dev

打开http://localhhost:3000 应该可以看到如下画面

Image 2.png

踩过的一些坑

  1. pageage.json和@vue/server-renderer的版本要一致
  2. @vitejs/plugin-vue-jsx 这个记得要安装,不然会报错
  3. vue-router@4.x.x 中没有 router.getMatchedComponents()这个方法,但是可以使用router.currentRoute.value 来代替
  4. 暂时没有发现在.vue文件下用<script setup>的写法来写asyncData,还是只能用
export default defineComponent({
  setup() {},
  asyncData({store}) {
    return store.dispatch('getCount')
  }

参考资料

玩起来,使用vite,做vue3.0的服务端渲染(ssr)