外观
SPA 应用
技术栈
Vite + Vue 3 + Vue Router + Pinia + Element Plus
目录结构
text
apps/{应用名}/
├── index.html # 入口html
├── tsconfig.json # TypeScript 配置文件
├── vite.config.mts # Vite 配置文件
├── src/
│ ├── App.vue # 根组件
│ ├── main.ts # 入口
│ ├── router/ # 路由定义
│ ├── api/ # API 请求
│ ├── components/ # 组件
│ ├── views/ # 页面
│ ├── stores/ # 状态管理
│ ├── styles/ # 样式
│ └── assets/ # 资源1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
_base 基座
apps/_base/ 是一个特别的 SPA 应用,该应用不承载实际业务,是作为基座而存在的,用于提供跨多个 SPA 应用共享的基础代码。
路径别名
在 SPA 项目里,你可以通过如下 2 个路径别名来方便引用 SPA 项目自身文件和 apps/_base 基座里的文件。
- 基座路径别名:
@base/*=>apps/_base/src/* - 当前应用路径别名:
@/*=>apps/当前应用/src/*
自动发现
SPA 应用采用目录自动发现机制
我们通过检测目录的方式,实现了 SPA 应用的自动发现机制。
文件:src/utils/ConstantUtils.mts
typescript
/**
* 子应用(从 apps/ 目录自动发现)
*/
export const SUB_APPS_NAMES: string[] = [];
if (existsSync(PATH_APPS)) {
const dirs = readdirSync(PATH_APPS).filter((name) => {
const fullPath = joinPath(PATH_APPS, name);
return statSync(fullPath).isDirectory() && !name.startsWith("_") && !name.endsWith("_backup");
});
dirs.sort((a, b) => a.localeCompare(b));
for (const appName of dirs) {
SUB_APPS_NAMES.push(appName);
}
}
export const SUB_APPS: SubApp[] = [];
for (const appName of SUB_APPS_NAMES) {
SUB_APPS.push({
appName: appName,
appPort: APP_START_PORT + SUB_APPS.length,
});
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
本地开发
假设你创建的 SPA 应用目录为 apps/example,那么执行 npm run dev 后只需要访问 https://localhost:9000/example 就可以访问到你的 SPA 应用了,不需要配端口、也不用担心前端后请求之间的跨域问题。
SPA 应用本地开发脚本(scripts/dev.mts)
typescript
import { logInfo, joinPath, isPathExists } from "nsuite";
import open from "open";
import {
APP_FRONT_PORT,
HEALTH_CHECK_PATH,
OPEN_PATH_IN_DEV,
PATH_APPS_HOME,
PATH_PUBLIC,
PATH_ROOT,
SERVER_HOST,
SERVER_PORT,
STANDALONE_APPS_NAMES,
STANDALONE_APPS_PORT,
SUB_APPS,
} from "#utils/ConstantUtils";
import { cleanAllBuildFiles } from "#scripts/utils/clean-files";
import { runCommandInChildProcess } from "#scripts/utils/child-process";
import { buildPublic, checkServerHealth, reloadOnFilesChange } from "#scripts/utils/public-builder";
import { useTask } from "#scripts/utils/use-task";
const allowTasks = process.env.TASKS || "";
const { killAllChildProcessWhenSuitable, addTask, runTasksSequentially, printTaskStatisticsInfo } =
useTask({ allowTasks });
/**
* 异常时关闭所有子进程
*/
killAllChildProcessWhenSuitable();
/**
* 编译开始前先清空所有可能遗留的编译产物
*/
addTask("cleanAllBuildFiles", async () => {
await cleanAllBuildFiles();
});
/**
* 启动主服务
*/
addTask("server", async (payload) => {
const { progress, taskName } = payload;
const { promise } = runCommandInChildProcess({
appName: `${progress.current}/${progress.total}-${taskName}`,
command: "node",
args: `--watch --watch-preserve-output ./src/bin/www.mts`,
env: process.env,
onStdout: (data: string, resolve) => {
if (data.includes("Server is running on port")) {
resolve(0);
}
},
});
const exitCode = await promise;
if (exitCode) {
throw new Error(`Task ${taskName} failed: exitCode=${exitCode}`);
}
});
/**
* 启动消费者服务
*/
addTask("consumer-bugs", async (payload) => {
const { taskName, progress } = payload;
const { promise } = runCommandInChildProcess({
appName: `${progress.current}/${progress.total}-${taskName}`,
command: "node",
args: `--watch --watch-preserve-output ./consumers/bugs.mts`,
env: process.env,
onStdout: (data: string, resolve) => {
if (data.includes("成功启动bugs相关队列消费脚本")) {
resolve(0);
}
},
});
const exitCode = await promise;
if (exitCode) {
throw new Error(`Task ${taskName} failed: exitCode=${exitCode}`);
}
});
/**
* 顺序启动 SPA apps
*/
for (const app of SUB_APPS) {
const { appName, appPort } = app;
addTask(appName, async (payload) => {
const { progress, taskName } = payload;
const { promise } = runCommandInChildProcess({
appName: `${progress.current}/${progress.total}-SPA-${appName}`,
command: "npx",
args: `cross-env APP_NAME=${appName} PORT=${appPort} vite serve ./apps/${appName}`,
env: process.env,
onStdout: (data: string, resolve) => {
if (data.includes("to show help")) {
resolve(0);
}
},
});
const exitCode = await promise;
if (exitCode) {
throw new Error(`Task ${taskName} failed: exitCode=${exitCode}`);
}
});
}
if (allowTasks) {
const allAllowTasks = allowTasks.split(",");
const ssgTask = allAllowTasks.find((taskName) => taskName.startsWith("ssg:"));
if (ssgTask) {
const appName = ssgTask.split(":")[1] || "";
if (STANDALONE_APPS_NAMES.includes(appName)) {
addTask(ssgTask, async (payload) => {
const { progress, taskName } = payload;
// 区分应用类型:Nuxt 4 应用、VitePress 2 应用
const pathApp = joinPath(PATH_APPS_HOME, appName);
const pathNuxtConfig = joinPath(pathApp, "nuxt.config.ts");
const isNuxt = await isPathExists(pathNuxtConfig);
const args = isNuxt
? `cross-env APP_NAME=${appName} PORT=${STANDALONE_APPS_PORT} nuxt dev --cwd=./apps-home/${appName}`
: `cross-env PORT=${STANDALONE_APPS_PORT} vitepress dev ./apps-home/${appName}/config`;
const { promise } = runCommandInChildProcess({
appName: `${progress.current}/${progress.total}-${taskName}`,
command: "npx",
args,
env: process.env,
});
const exitCode = await promise;
if (exitCode) {
throw new Error(`Task ${taskName} failed: exitCode=${exitCode}`);
}
});
}
}
}
/**
* 启动 public 目录下 js/*.mts 和 css/*.scss 文件的实时编译
*/
addTask("browerSync", async () => {
await checkServerHealth({
healthCheckUrl: `http://${SERVER_HOST}:${SERVER_PORT}${HEALTH_CHECK_PATH}`,
conditionCallback: (res) => res.data.code === 200,
interval: 1000,
});
const browserSync = reloadOnFilesChange([
joinPath(PATH_PUBLIC, "js/**/*.min.js"),
joinPath(PATH_PUBLIC, "css/**/*.min.css"),
joinPath(PATH_ROOT, "views/**/*.ejs"),
]);
await new Promise((resolve) => {
browserSync.init(
{
port: APP_FRONT_PORT,
host: SERVER_HOST,
online: true,
open: false,
notify: false,
// @ts-ignore
ws: true,
proxy: `${SERVER_HOST}:${SERVER_PORT}`,
},
resolve,
);
});
await buildPublic({
enableWatch: true,
});
});
await runTasksSequentially();
printTaskStatisticsInfo();
if (OPEN_PATH_IN_DEV) {
const openUrl = `http://${SERVER_HOST}:${APP_FRONT_PORT}${OPEN_PATH_IN_DEV}`;
const localUrl = `http://localhost:${APP_FRONT_PORT}${OPEN_PATH_IN_DEV}`;
await open(openUrl);
logInfo(`\n🚀 启动成功,请访问: ${openUrl} 或 ${localUrl}`);
}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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
本地开发时服务端会自动对 SPA 应用本地开发服务的端口进行自动代理(src/apps.mts)
typescript
SUB_APPS.forEach(({ appName, appPort }) => {
proxyRoute(`/${appName}`, `http://${SERVER_HOST}:${appPort}/${appName}`);
});1
2
3
2
3