外观
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,
});
}
/**
* H5 资源包应用:apps/ 下同时存在 h5.json 的子应用。
* 这些应用除常规 SPA 编译部署外,还需连带发布 APP 内 H5 资源包,
* 是「常规 SPA 流程」与「H5 资源包流程」的判定准则。
*/
export const APPS_WITH_H5: string[] = SUB_APPS_NAMES.filter((appName) =>
existsSync(joinPath(PATH_APPS, appName, "h5.json")),
);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
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
本地开发
假设你创建的 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,
REDIS_HOST,
REDIS_PORT,
POSTGRES_PORT,
SERVER_HOST,
SERVER_PORT,
SSH_HOST,
SSH_PORT,
SSH_USERNAME,
SSH_PASSWORD,
STANDALONE_APPS_PORT,
STANDALONE_SSG_APPS_NAMES,
STANDALONE_SSR_APPS,
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";
import { waitForPort } from "#scripts/utils/use-dev";
const allowTasks = process.env.TASKS || "";
const isGitBash = Boolean(process.env.MSYSTEM);
const sshAskpassPath = joinPath(
PATH_ROOT,
`scripts/utils/ssh-askpass.${isGitBash ? "sh" : "cmd"}`,
);
const { killAllChildProcessWhenSuitable, addTask, runTasksSequentially, printTaskStatisticsInfo } =
useTask({ allowTasks });
/**
* 异常时关闭所有子进程
*/
killAllChildProcessWhenSuitable();
/**
* 编译开始前先清空所有可能遗留的编译产物
*/
addTask("cleanAllBuildFiles", async () => {
await cleanAllBuildFiles();
});
/**
* SSH 端口转发隧道
*/
addTask("sshTunnel", async () => {
const { promise: sshExit } = runCommandInChildProcess({
appName: "sshTunnel",
command: "ssh",
args: [
"-o StrictHostKeyChecking=accept-new",
"-o ExitOnForwardFailure=yes",
// Redis 端口转发
`-L ${REDIS_PORT}:localhost:6379`,
// PostgreSQL 端口转发
`-L ${POSTGRES_PORT}:localhost:5432`,
`-p ${SSH_PORT} ${SSH_USERNAME}@${SSH_HOST} -N`,
].join(" "),
env: {
...process.env,
DISPLAY: "localhost:0",
SSH_ASKPASS: sshAskpassPath,
SSH_ASKPASS_REQUIRE: SSH_PASSWORD ? "force" : "never",
},
});
// 等待隧道端口就绪,或 SSH 进程异常退出
await Promise.race([
sshExit.then((code) => {
if (code) {
throw new Error(`SSH 隧道异常退出: code=${code}`);
}
}),
Promise.all([waitForPort(REDIS_PORT), waitForPort(POSTGRES_PORT)]),
]);
});
/**
* 启动主服务
*/
addTask("server", async (payload) => {
const { progress, taskName } = payload;
const { promise } = runCommandInChildProcess({
appName: `${progress.current}/${progress.total}-${taskName}`,
command: "node",
args: `--watch-path=./src --watch-path=./schedules --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: "cross-env",
args: `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_SSG_APPS_NAMES.includes(appName)) {
addTask(ssgTask, async (payload) => {
const { progress, taskName } = payload;
// 区分应用类型:Next.js、Nuxt 4 应用、VitePress 2 应用
const pathApp = joinPath(PATH_APPS_HOME, appName);
const pathNuxtConfig = joinPath(pathApp, "nuxt.config.ts");
const pathNextConfig = joinPath(pathApp, "next.config.ts");
const isNext = await isPathExists(pathNextConfig);
const isNuxt = await isPathExists(pathNuxtConfig);
const args = isNext
? `APP_NAME=${appName} PORT=${STANDALONE_APPS_PORT} SERVER_HOST=${SERVER_HOST} SERVER_PORT=${SERVER_PORT} next dev ./apps-home/${appName}`
: isNuxt
? `APP_NAME=${appName} PORT=${STANDALONE_APPS_PORT} nuxt dev --cwd=./apps-home/${appName}`
: `PORT=${STANDALONE_APPS_PORT} vitepress dev ./apps-home/${appName}/config`;
const { promise } = runCommandInChildProcess({
appName: `${progress.current}/${progress.total}-SSG-${taskName}`,
command: "cross-env",
args,
env: process.env,
});
const exitCode = await promise;
if (exitCode) {
throw new Error(`Task ${taskName} failed: exitCode=${exitCode}`);
}
});
}
}
}
if (allowTasks) {
const allAllowTasks = allowTasks.split(",");
const ssrTask = allAllowTasks.find((taskName) => taskName.startsWith("ssr:"));
if (ssrTask) {
const appName = ssrTask.split(":")[1] || "";
const app = STANDALONE_SSR_APPS.find((item) => item.appName === appName);
if (app) {
addTask(ssrTask, async (payload) => {
const { progress, taskName } = payload;
const args = `APP_NAME=${appName} PORT=${app.appPort} nuxt dev --host ${SERVER_HOST} --cwd=./apps-home/${appName}`;
const { promise } = runCommandInChildProcess({
appName: `${progress.current}/${progress.total}-SSR-${appName}`,
command: "cross-env",
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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
本地开发时服务端会自动对 SPA 应用本地开发服务的端口进行自动代理(src/apps.mts)
typescript
SUB_APPS.forEach(({ appName, appPort }) => {
proxyRoute(`/${appName}`, `http://${SERVER_HOST}:${appPort}/${appName}`);
});1
2
3
2
3