目标:用 vite2 + vue3 + Ts 搭建一个开箱即用的最简 ssr 通用项目,  包含必要的 vuex vue-router asyncData header管理。

 

一 通过官方脚手架搭建一个 vue-ts 的 SPA 项目

首先安装 yarn 包管理工具: 

 

创建一个简单的 vue-ts 项目: 

 

1
2
3
4
5
6
7
// 选择 vue-ts 模版
 
cd demo
 
yarn
 
yarn dev

  

 

http://localhost:3000/

浏览器打开 http://localhost:3000/ 一个最简单的 vue3 + typescript 的 SPA 单页应用就搭建好了。

 

 

 

二 对 SPA 单页应用,进行 ssr 渲染改造。

在 src 目录下添加两个入口文件 

 

项目目录下 修改 index.html文件

 

// entry-client.ts

1
2
3
4
5
6
7
8
9
import { createSSRApp } from 'vue';
 
import App from './App.vue';
 
 
 
const app = createSSRApp(App);
 
app.mount('#app', true);

  

// entry-server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { createSSRApp } from 'vue';
 
import App from './App.vue';
 
import { renderToString } from '@vue/server-renderer';
 
 
 
export async function render(url, manifest) {
 
  const app = createSSRApp(App);
 
  const context = {};
 
  const appHtml = await renderToString(app, context);
 
  return { appHtml };
 
}

  

 

新建node端web服务器入口文件(开发环境): server-env.js ,官方推荐 express,安装node包: yarn add -D express

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
const fs = require('fs');
 
const path = require('path');
 
const express = require('express');
 
const { createServer: createViteServer } = require('vite');
 
 
 
async function createServer() {
 
  const app = express();
 
 
 
  const vite = await createViteServer({
 
    server: { middlewareMode: true },
 
  });
 
 
 
  app.use(vite.middlewares);
 
 
 
  app.use('*', async (req, res) => {
 
    // serve index.html - we will tackle this next
 
    const url = req.originalUrl;
 
 
 
    try {
 
      // 1. Read index.html
 
      let template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8');
 
 
 
      // 2. Apply vite HTML transforms.
 
      template = await vite.transformIndexHtml(url, template);
 
 
 
      // 3. Load the server entry. vite.ssrLoadModule
 
      const { render } = await vite.ssrLoadModule('/src/entry-server.js');
 
 
 
      // 4. render the app HTML.
 
      const { appHtml } = await render(url);
 
 
 
      // 5. Inject the app-rendered HTML into the template.
 
      const html = template.replace(``, appHtml);
 
 
 
      // 6. Send the rendered HTML back.
 
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
 
    } catch (e) {
 
      // If an error is caught,
 
      vite.ssrFixStacktrace(e);
 
      console.error(e);
 
      res.status(500).end(e.message);
 
    }
 
  });
 
 
 
  app.listen(3000, () => {
 
    console.log('http://localhost:3000');
 
  });
 
}
 
 
 
createServer();

  

package.json文件 新增dev命令

 

// package.json

1
2
3
4
5
"scripts": {
 
    "dev": "node server-env.js"
 
  },

  

终端运行 yarn dev, 浏览器打开:http://localhost:3000/  网页右键“显示页面源码”、

 

生产环境打包,package.json新增 build 相关命令

 

//package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
"scripts": {
 
    "dev": "node server-env.js",
 
    "build:client": "vite build --outDir dist/client --ssrManifest",
 
    "build:server": "vite build --outDir dist/server --ssr src/entry-server.js ",
 
    "build": "yarn build:client && yarn build:server",
 
    "preview": "yarn build && node server.js"
 
  },

  

新建 node 端web服务器入口文件(生产环境): server.js ,个人选择 koa搭建生产环境服务器,安装 node 包:yarn add -D koa koa-static

 

// server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
const fs = require('fs');
 
const path = require('path');
 
const Koa = require('koa');
 
const staticPath = require('koa-static');
 
 
 
const app = new Koa();
 
const resolve = (p) => path.resolve(__dirname, p);
 
 
 
const template = fs.readFileSync(resolve('./dist/client/index.html'), 'utf-8');
 
const manifest = require('./dist/client/ssr-manifest.json');
 
const render = require('./dist/server/entry-server.js').render;
 
 
 
app.use(staticPath(resolve('./dist/client'), { index: false }));
 
 
 
app.use(async (ctx, next) => {
 
  const url = ctx.req.url;
 
  try {
 
    const { appHtml } = await render(url, manifest);
 
 
 
    let html = template.replace(``, appHtml);
 
 
 
    ctx.body = html;
 
  } catch (error) {
 
    console.log(error);
 
    next();
 
  }
 
});
 
 
 
app.listen(3000, () => {
 
  console.log('http://localhost:3000');
 
});

  

终端运行: yarn preview,浏览器打开:http://localhost:3000。最简 ssr 改造完成。

 

三 安装生产上必备的 vue 全家桶: scss vuex vue-router

首先安装scss支持: yarn add -D sass.  

 

安装vue-router 和 vuex :  yarn add vuex@next vue-router@next  vuex-router-sync@next

 

新建 src/store/index.ts 和 src/router/index.ts 两个文件

 

// src/router/index.ts

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router';
 
 
 
export default function () {
 
  const routerHistory = import.meta.env.SSR === false ? createWebHistory() : createMemoryHistory();
 
 
 
  return createRouter({
 
    history: routerHistory,
 
    routes: [
 
      {
 
        path: '/',
 
        name: 'home',
 
        component: () => import('../views/Home.vue'),
 
      },
 
      {
 
        path: '/about',
 
        name: 'about',
 
        component: () => import('../views/About.vue'),
 
      },
 
      {
 
        path: '/:catchAll(.*)*',
 
        name: '404',
 
        component: () => import('../views/404.vue'),
 
        meta: {
 
          title: '404 Not Found',
 
        },
 
      },
 
    ],
 
  });
 
}

  

// src/store/index.ts

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { createStore as _createStore } from 'vuex';
 
 
 
export default function createStore() {
 
  return _createStore({
 
    state: {
 
      message: 'Hello vite2 vue3 ssr',
 
    },
 
    mutations: {},
 
    actions: {
 
      fetchMessage: ({ state }) => {
 
        return new Promise((resolve) => {
 
          setTimeout(() => {
 
            state.message = 'Hello vite2 vue3 ssr typescript scss vuex vue-router';
 
            resolve(0);
 
          }, 200);
 
        });
 
      },
 
    },
 
    modules: {},
 
  });
 
}

  

新建对应的 src/views/页面 Home.vue  About.vue  404.vue, 略。

 

修改 entry-client.ts 和 entry-server.js文件,加入相应的 vuex 和 router

 

// entry-client.ts

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { createSSRApp } from 'vue';
 
import App from './App.vue';
 
import { sync } from 'vuex-router-sync';
 
 
 
import createStore from './store';
 
import createRouter from './router';
 
 
 
const router = createRouter();
 
const store = createStore();
 
sync(store, router);
 
 
 
const app = createSSRApp(App);
 
app.use(router).use(store);
 
 
 
router.beforeResolve((to, from, next) => {
 
  next();
 
});
 
 
 
router.isReady().then(() => {
 
  app.mount('#app', true);
 
});

  

// entry-server.js

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { createSSRApp } from 'vue';
 
import App from './App.vue';
 
import { renderToString } from '@vue/server-renderer';
 
 
 
import createStore from './store';
 
import createRouter from './router';
 
import { sync } from 'vuex-router-sync';
 
 
 
export async function render(url, manifest) {
 
  const router = createRouter();
 
  const store = createStore();
 
  sync(store, router);
 
 
 
  const app = createSSRApp(App);
 
  app.use(router).use(store);
 
 
 
  router.push(url);
 
 
 
  await router.isReady();
 
 
 
  const context = {};
 
  const appHtml = await renderToString(app, context);
 
  return { appHtml };
 
}

  

App.vue

Home.vue

终端运行: yarn dev 查看开发环境效果。终端运行: yarn preview 查看生产环境效果。

 

 

 

 

四 服务端预取数据 asyncData

服务端预取数据采用 vue2的 asyncData 方式。

 

新建 vue-extend.d.ts 文件

 

// vue-extend.d.ts

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { RouteRecordRaw } from 'vue-router';
 
 
 
export interface AsyncDataContextType {
 
  route: RouteRecordRaw;
 
  store: any; // 类型不决 用 any。  -.-!
 
}
 
 
 
declare module '@vue/runtime-core' {
 
  interface ComponentCustomOptions {
 
    asyncData?(context: AsyncDataContextType): Promise;
 
  }
 
}

  

在Home.vue 添加 asyncData,store里用 setTimeout 模拟异步请求。

 

// Home.vue

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export default defineComponent({
 
  setup() {
 
    const store = useStore();
 
 
 
    return { store };
 
  },
 
  asyncData({ store }) {
 
    return store.dispatch('fetchMessage');
 
  },
 
});

  

修改entry-client.ts中路由守卫, router.beforeResolve( ) 相关。

 

// entry-client.ts

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
router.beforeResolve((to, from, next) => {
 
  let diffed = false;
 
  const matched = router.resolve(to).matched;
 
  const prevMatched = router.resolve(from).matched;
 
 
 
  if (from && !from.name) {
 
    return next();
 
  }
 
 
 
  const activated = matched.filter((c, i) => {
 
    return diffed || (diffed = prevMatched[i] !== c);
 
  });
 
 
 
  if (!activated.length) {
 
    return next();
 
  }
 
 
 
  const matchedComponents: any = [];
 
  matched.map((route) => {
 
    matchedComponents.push(...Object.values(route.components));
 
  });
 
  const asyncDataFuncs = matchedComponents.map((component: any) => {
 
    const asyncData = component.asyncData || null;
 
    if (asyncData) {
 
      const config = {
 
        store,
 
        route: to,
 
      };
 
 
 
      return asyncData(config);
 
    }
 
  });
 
  try {
 
    Promise.all(asyncDataFuncs).then(() => {
 
      next();
 
    });
 
  } catch (err) {
 
    next(err);
 
  }
 
});

  

修改entry-server.js中 render 函数。

 

// entry-server.js

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
export async function render(url, manifest) {
 
  const router = createRouter();
 
  const store = createStore();
 
  sync(store, router);
 
 
 
  const app = createSSRApp(App);
 
  app.use(router).use(store);
 
 
 
  router.push(url);
 
  await router.isReady();
 
 
 
  const to = router.currentRoute;
 
  const matchedRoute = to.value.matched;
 
  if (to.value.matched.length === 0) {
 
    return '';
 
  }
 
 
 
  const matchedComponents = [];
 
  matchedRoute.map((route) => {
 
    matchedComponents.push(...Object.values(route.components));
 
  });
 
 
 
  const asyncDataFuncs = matchedComponents.map((component) => {
 
    const asyncData = component.asyncData || null;
 
    if (asyncData) {
 
      const config = {
 
        store,
 
        route: to,
 
      };
 
      return asyncData(config);
 
    }
 
  });
 
 
 
  await Promise.all(asyncDataFuncs);
 
 
 
  const context = {};
 
  const appHtml = await renderToString(app, context);
 
  return { appHtml };
 
}

  

终端运行 yarn dev查看效果, 服务端预取数据渲染正确,但devtools 有一个报错:Hydration completed but contains mismatches.  是客户端和服务端的 store 未同步

 

 

同步方式如下:

 

index.html 文件添加相应的 window.__INITIAL_STATE__  标识

 

 

修改entry-server.js 的 render函数 返回 state

 

// entry-server.js

 

1
2
3
4
5
6
7
8
9
10
11
export async function render(url, manifest) {
 
  //...
 
  const appHtml = await renderToString(app, context);
 
  const state = store.state;
 
  return { appHtml, state };
 
}

  

在 server-env.js  和 server.js 修改 html模版 注意 `' '`。

 

// server-env.js 和 server.js

 

1
2
3
4
5
6
7
8
9
const { appHtml, state } = await render ;
 
 
 
const html = template
 
      .replace(``, appHtml)
 
      .replace(`''`, JSON.stringify(state))

  

 entry-client.ts 文件末添加  store.replaceState()函数同步state。

 

// entry-client.ts if (window.__INITIAL_STATE__) {   store.replaceState(window.__INITIAL_STATE__); }

在shims-vue.d.ts 添加  typescript支持 

 

// shims-vue.d.ts

 

1
2
3
4
5
interface Window {
 
  __INITIAL_STATE__: any;
 
}

  

终端运行 yarn dev 和 yarn preview 查看效果。

 

这一阶段源码: https://github.com/damowangzhu/vite2-vue3-ssr_steps/tree/v2

 

五 Head管理,ssr for SEO

以 title 为例,description 和 keywords 雷同。

 

在 src/router/indext.ts 写入 meta 信息 

 

// src/router/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
 
  path: '/',
 
  name: 'home',
 
  component: () => import('../views/Home.vue'),
 
  meta: {
 
    title: 'Home title',
 
  },
 
},

  

在index.html文件添加 title 标记

 

<!--title-->

在server.js 和 server-env.js 修改模版

 

// server.js server-env.js

 

1
2
3
4
5
6
7
const html = template
 
      .replace(``, appHtml)
 
      .replace(`''`, JSON.stringify(state))
 
      .replace('', state.route.meta.title || 'Index');

  

在entry-client.ts 文件做个前端路由跳转兼容 

 

// entry-client.ts

 

1
2
3
4
5
6
7
8
9
if (from && !from.name) {
 
    return next();
 
  } else {
 
    window.document.title = (to.meta.title || '首页') as any;
 
  }

  

六 增加配置文件,开发环境和生产环境

项目目录下新建 .env.development 和 .env.production 文件

 

// .env.development

1
2
3
4
5
NODE_ENV=development
 
VITE_API_URL=/
 
VITE_ASSET_URL=/

  

// .env.production

1
2
3
VITE_API_URL=/
 
VITE_ASSET_URL=/

  

修改 vite.config.ts 文件, 配置文件通过 loadEnv 获取.env files 环境变量, 

 

如果静态资源要发布CDN,可设置 例如: VITE_ASSET_URL=https://cdn.domain.com/

 

程序内部通过 

 

 

// vite.config.ts

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { defineConfig, loadEnv } from 'vite';
 
import vue from '@vitejs/plugin-vue';
 
 
 
export default defineConfig(({ mode }) => {
 
  const env = loadEnv(mode, process.cwd());
 
 
 
  return {
 
    base: env.VITE_ASSET_URL,
 
    plugins: [vue()],
 
  };
 
});

  

七 代码格式化和 typescript 类型检查

官方推荐 Vscode + Volar, 通过 ide 的插件做类型检查等

 

Vscode安装 volar 和 Prettier 插件, 新建 .prettierrc.js 文件 , Vscode默认格式化选择 prettier

 

// .prettierrc.js

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module.exports = {
 
  trailingComma: "none",
 
  printWidth: 130,
 
  bracketSpacing: true,
 
  arrowParens: "always",
 
  tabWidth: 2,
 
  semi: true,
 
  singleQuote: true,
 
  jsxBracketSameLine: true,
 
};

  

修改 ts.config.json 增加两条验证规则。

 

//  ts.config.json

 

1
2
3
"noUnusedLocals": true, // 不允许未使用的变量
 
"noImplicitReturns": true, // 函数不含隐式返回值

  

终端运行 yarn dev 和 yarn preview 查看效果。

 

若生产环境编译需要Ts类型检查 可通过 vue-tsc 插件,但编译会慢很多,修改package.json 配置文件。

 

// package.json

 

1
2
3
4
5
"build:client": "vue-tsc --noEmit && vite build --ssrManifest --outDir dist/client",
 
最后 升级vue3 到最新版本: yarn add vue@next;

  

本文源码:  https://github.com/ygunoil/vite2-vue3-ssr_steps

 

 

 

参考资料: 

 

https://vitejs.dev/guide/ssr.html  

https://cn.vitejs.dev/config/#async-config

https://www.bookstack.cn/read/vitejs-2.4.4-zh/guide-ssr.md

https://github.com/vitejs/vite/tree/main/packages/playground/ssr-vue

 

https://github.com/vok123/vue3-ts-vite-ssr-starter

 

posted on   ygunoil  阅读(1041)  评论(0编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
阅读排行:
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· 通过 API 将Deepseek响应流式内容输出到前端
历史上的今天:
2019-12-22 TypeScript 和 JavaScript 的区别
< 2025年3月 >
23 24 25 26 27 28 1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31 1 2 3 4 5
点击右上角即可分享
微信分享提示