一、写在前面
在很久很久之前,「前端开发」和「切图工具人」是基本画等号的。但随着 Node.js 的发展,前端可以做全栈的工作了,极大地提升了开发效率(再也不用和后端 battle 接口字段了,真香!)。
本篇文章记录了做「vue3+express+typescript」全栈模板项目的全过程。按照这篇文章一步一步操作,你就能够搭建出一个属于自己的全栈模板项目,非常适合各位想要入门 Nodejs 全栈的前端同学食用。
本项目已经开源到 GitHub 上,各位看官给个 Star 再走呗:github.com/shadowings-…
二、技术选型
2-1、主要用到的依赖
- 编程语言:typescript
- 包管理工具:pnpm
- 前端框架:vue3(配套使用 vuex 和 vue-router)
- 打包工具:webpack
- 后端框架:express
- 部署工具:pm2
2-2、选择这些依赖的原因
编程语言选择typescript
,这主要是因为相比于javascript
,typescript
提供了诸如类型、枚举、接口、类、继承等高级特性,而这些特性对于构建维护大型项目是十分有帮助的。
前端框架选用了 vue3 全家桶,这是因为我更喜欢像 vue 这种官方提供了与之配套的基础设施(比如vuex
和vue-router
)的前端框架,而像react
这种选router
都要在好几个router
之间调研一下的框架,我就不那么喜欢了。当然,调整一下打包配置,我们完全可以把vue
换成react
。
打包工具选了webpack
,老牌且功能强大的打包工具。而没有选vite
之类的打包工具主要还是可扩展性的考虑,如果你想把vue
无缝切换成其他前端框架,尤其是一些小众一点的框架,那么使用vite
很可能会踩坑,甚至可能都找不到如何迁移。
包管理工具选了pnpm
,这主要是因为pnpm
相比于其他包管理工具,有着更快的安装速度和更小的node_modules
体积,还是很香的。
后端框架选了express
,不必多说,功能强大的主流后端框架,无脑选就 ok 了。
部署工具选了pm2
,它能很方便的守护nodejs
进程,也支持自动重启等特性,是服务器上跑express
服务必备的东西。
三、初始化项目
3-1、monorepo
在这个模板项目中,我们会以monorepo
方式管理前端项目和后台项目,也就是说,它们都会被放到同一个代码仓库中,所以我们新建一个package
目录,并在该目录下新增client
和server
目录,分别放前端和后端的代码。
3-2、初始化 npm
然后,执行npm init
命令,生成该项目的package.json
,生成的内容如下:
// 路径: package.json
{
"name": "vue-express-ts-template-project",
"description": "vue express ts template project",
"author": "shadowings-zy",
"license": "ISC"
}
3-3、初始化 git
再执行git init
命令,初始化 git 仓库。
3-4、初始化 typescript
然后,执行pnpm add typescript -D
命令,安装typescript
的依赖。依赖安装好之后,我们在项目根目录下新建一个tsconfig.json
文件,该文件用于配置typescript
,具体内容如下:
// 路径: tsconfig.json
{
"compilerOptions": {
"lib": ["ES2019", "dom"], // 编译引入的库
"target": "ES2019", // 编译目标
"module": "commonjs" // 模块类型
},
"exclude": ["./node_modules"] // 不包含的文件
}
3-5、当前目录结构
我们现在的目录结构如下:
.
├── package
│ ├── client # 前端代码
│ └── server # 后端代码
├── package.json # npm配置
└── tsconfig.json # typescript配置
做完这些初始化的操作后,我们项目的基础结构就有了,接下来,就是愉快的开发时间了。
四、后端部分
本部分会介绍如何一步一步地搭建一个express
后端服务。
4-1、开发准备
后端首先执行pnpm add express
命令安装express
,再执行pnpm add @types/express
命令安装对应的类型声明文件。
安装好依赖后,我们需要调整tsconfig.json
,我们可以在package/server
目录下建一个目录级的tsconfig.json
,用于控制server
目录下的typescript
文件,具体内容如下:
// 路径: package/server/tsconfig.json
{
"extends": "../../tsconfig.json", // 继承根目录的tsconfig
"compilerOptions": {
"outDir": "../../output", // 编译后的js文件地址
"moduleResolution": "node", // 模块解析策略
"esModuleInterop": true // 支持按照es6模块规范导入commonjs模块
},
"include": ["./**/*.ts"], // 包含的文件
"exclude": ["../../node_modules"] // 不包含的文件
}
4-2、开发后端服务
下面就进入到了本文中最重要的一部分——开发后端服务了。
我们先简单分析一下后端服务要提供的功能:
- 1、渲染 html 页面,并提供打包好的前端静态资源(js、css 等)。
- 2、提供 API 接口供前端访问。
那么针对这两个功能,我们就可以选择对应的中间件去处理了:
- 使用
express.static
进行静态资源加载,并结合compression
提供的 gzip 功能,进一步压缩产物体积。 - 使用
express.Router
将请求路由到对应的controller
并进行处理。
4-2-1、静态资源加载
首先我们在package/server
目录下新建一个app.ts
文件,这是express
项目的入口,然后使用compression
中间件和static
中间件,并监听端口,具体代码如下:
// 路径: package/server/app.ts
import express from "express";
import compression from "compression";
const staticFilePath = ""; // 静态资源路径,先空着,一会在“其他工作”中再填充
const main = async () => {
const app = express();
const port = 8081;
app.use(compression()); // 使用compression中间件gzip静态资源文件
app.use("/static", express.static(config.staticFilePath)); // 静态资源文件在服务器中的位置
// 监听端口,起服务
app.listen(port, () => {
console.log(`server started at http://localhost:${port}`);
});
};
main();
4-2-2、接口开发
我们以开发“获取用户列表”接口为例。
首先,我们在package/server
目录下新建一个db.ts
文件,先直接把数据存到一个数组里吧,这样做简单一点,实际上我们会把这里替换为读数据库的逻辑。
// 路径: package/server/db.ts
export enum IUserStatus {
INUSE,
UNUSE,
}
const user = [
{ username: "user1", email: "user1@email.com", status: IUserStatus.INUSE },
{ username: "user2", email: "user2@email.com", status: IUserStatus.INUSE },
{ username: "user3", email: "user3@email.com", status: IUserStatus.INUSE },
{ username: "user4", email: "user4@email.com", status: IUserStatus.INUSE },
{ username: "user5", email: "user5@email.com", status: IUserStatus.INUSE },
{ username: "user6", email: "user6@email.com", status: IUserStatus.UNUSE },
];
export const getUser = () => {
return user;
};
然后,我们在package/server
目录下新建一个service
目录,并在该目录下新建一个userService.ts
文件,再写一个UserService
类用来控制用户相关的逻辑,具体内容如下:
// 路径: package/server/service/userService.ts
import { getUser, IUserStatus } from "../db";
// user相关的service
export class UserService {
private userData = getUser();
// 获取用户列表信息
getUserData = () => {
const output = this.userData.filter(
(item) => item.status === IUserStatus.INUSE
);
return output ? output : [];
};
}
再然后,我们在package/server
目录下新建一个controller
目录,并在该目录下新建一个userController.ts
文件,再写一个UserController
类用来控制接口相关的逻辑,具体内容如下:
// 路径: package/server/controller/userController.ts
import { Request, Response } from "express";
import { UserService } from "../service/userService";
export class UserController {
private userService = new UserService(); // 实例化service
// 获取用户列表的接口处理逻辑
getUser = (req: Request, res: Response) => {
try {
const data = this.userService.getUserData();
return res.status(200).json({ data, message: "get user successful" });
} catch (e) {
return res.status(500).json({ data: {}, message: e.message });
}
};
}
当我们完成接口处理逻辑后,就可以配置路由文件了,我们在package/server
目录下新建一个router.ts
文件,并配置路由以及命中路由时执行的方法,具体代码如下:
// 路径: package/server/router.ts
import express from "express";
import { UserController } from "./controller/userController";
export const getRouter = () => {
const userController = new UserController(); // 实例化controller
const router = express.Router();
router.get("/user", userController.getUser); // 配置路由执行的方法,当访问/user路径时,执行getUser方法
return router;
};
最后,我们在package/server/app.ts
中注册路由,就完成获取用户列表接口的开发了,具体代码如下:
// 路径: package/server/app.ts
import express from "express";
import compression from "compression";
import { getRouter } from "./router";
const staticFilePath = ""; // 静态资源路径,先空着,一会在“其他工作”中再填充
const main = async () => {
const app = express();
const port = 8081;
app.use(compression()); // 使用compression中间件gzip静态资源文件
app.use("/static", express.static(config.staticFilePath)); // 静态资源文件在服务器中的位置
app.use("/api", getRouter()); // 挂载路由
// 监听端口,起服务
app.listen(port, () => {
console.log(`server started at http://localhost:${port}`);
});
};
main();
这样我们就完成了后端服务的开发了!
4-3、编写 npm script
后端代码编写完成后,我们还需要做一些工作才能让这个后端应用真的跑起来。
比如,开发时我们需要热更新,线上环境也需要进程守护,这些得单独写npm script
。
4-3-1、dev:server 命令
在开发时,我们需要使用ts-node
来直接执行typescript
文件,然后使用nodemon
来检测server
目录下的改动,并热更新。
那么我们就先执行pnpm add -g ts-node nodemon
来全局安装它们,然后在package.json
中写下如下命令:
// 路径: package.json
"scripts": {
// ... 其他命令
"dev:server": "NODE_ENV=dev nodemon --watch './package/server/**/*.ts' --exec 'ts-node' ./package/server/app.ts",
},
这个命令会用nodemon
检测所有命中./package/server/**/*.ts
规则的文件的改动,如果有修改,就执行ts-node ./package/server/app.ts
命令,也就是执行app.ts
中的逻辑。
这样,我们执行pnpm run dev:server
就可以在localhost:8081
上启动服务,并开始愉快的开发了。
4-3-2、build:server 命令
而当我们开发完成后,我们需要使用typescript
提供的tsc
命令,来把typescript
文件编译成javascript
文件,也就是如下命令:
// 路径: package.json
"scripts": {
// ... 其他命令
"build:server": "NODE_ENV=prod tsc --p ./package/server",
},
其中的--p ./package/server
是会选择 ./package/server
目录下的tsconfig.json
进行编译,这样就会把编译好的文件放到tsconfig.json
中指定的output
目录下
这样,我们执行pnpm run build:server
就可以编译后端代码了。
4-4、设置配置文件
另外,我们服务的开发环境和线上环境有一些不同的配置,我们还得写一段“根据环境读取配置”的逻辑。
还记得刚才空着的staticFilePath
变量吗?在我们前端的打包脚本中,打包好的文件会放到output/client
目录下,这就导致了在开发环境下和生产环境下staticFilePath
指向的目录不一致,我们就得在配置文件中单独配置他们。
在上面编写的npm script
中,我们开发环境设置了dev
的环境变量,线上环境设置了prod
的环境变量,我们可以根据这两个变量来区分不同的环境,并读取不同的配置,我们在package/server
目录下新建一个config
目录,并写入如下三个代码文件:
// 路径: package/server/config/dev.ts
import path from "path";
export const developmentConfig = {
staticFilePath: path.join(__dirname, "../../../output/client"),
};
// 路径: package/server/config/prod.ts
import path from "path";
export const productionConfig = {
staticFilePath: path.join(__dirname, "../client"),
};
// 路径: package/server/config/index.ts
import { developmentConfig } from "./dev";
import { productionConfig } from "./prod";
const env = process.env.NODE_ENV;
const getConfig = () => {
if (env === "dev") {
return developmentConfig;
}
return productionConfig;
};
export const config = getConfig();
然后,我们使用getConfig
函数拿到对应的配置,并赋值给staticFilePath
即可,就像下述代码中注释写的这样:
// 路径: package/server/app.ts
import express from "express";
import { getRouter } from "./router";
import { config } from "./config";
import compression from "compression";
const main = async () => {
const app = express();
const port = 8081;
app.use(compression());
app.use("/static", express.static(config.staticFilePath)); // 这里直接使用了配置文件中对应的配置
app.use("/api", getRouter());
app.listen(port, () => {
console.log(`server started at http://localhost:${port}`);
});
};
main();
4-5、后端结构
这样后端就大功告成了!现在整体目录结构如下:
.
├── output # 编译产物
├── package
│ ├── client # 前端代码
│ └── server # 后端代码
│ ├── app.ts
│ ├── config
│ │ ├── dev.ts
│ │ ├── index.ts
│ │ └── prod.ts
│ ├── controller
│ │ └── userController.ts
│ ├── db.ts
│ ├── router.ts
│ ├── service
│ │ └── userService.ts
│ └── tsconfig.json
├── package.json
└── tsconfig.json
五、前端部分
本部分会介绍如何一步一步地使用vue3
和webpack
开发一个可以从后端接口拉取数据并展示的前端页面。
5-1、开发准备
前端首先执行pnpm add vue@next @vue/compiler-sfc @vue/runtime-dom -D
命令安装依赖,把它们安装到devDependencies
是因为我们实际在部署的时候,用的是打包好的资源文件,而不是直接依赖它们。
安装好依赖后,我们需要调整tsconfig.json
,我们可以在package/client
目录下建一个目录级的tsconfig.json
,用于控制client
目录下的typescript
文件,具体内容如下:
// 路径: package/client/tsconfig.json
{
"extends": "../../tsconfig.json", // 继承根目录的tsconfig
"compilerOptions": {
"outDir": "../../output/client", // 编译后的js文件地址
"moduleResolution": "node", // 模块解析策略
"esModuleInterop": true, // 支持按照es6模块规范导入commonjs模块
"target": "esnext", // 编译生成esnext规范的js代码
"module": "esnext" // 编译生成的代码使用什么模块化规范
},
"include": ["./**/*.ts", "./**/*.d.ts"],
"exclude": ["../../node_modules"]
}
然后,我们在package/client
目录下新建一个index.html
文件,这是我们的html
模板文件,我们会根据这个html
文件的结构,插入打包后的js
,css
资源文件,并输出最终的html
文件,具体代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title><%=htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div id="root"></div>
</body>
</html>
同时,因为我们采用typescript
进行开发,我们需要手动声明vue
文件的类型,因此,我们需要新建一个package/client/type
目录,并在其中新建一个vue.d.ts
的声明文件,内容如下:
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
另外,还要在package/client
目录下新建一个main.ts
文件和一个app.vue
文件,前者是我们webpack
打包的入口,后者是我们要挂载到DOM
上的根组件,具体内容可以后面再填充。
5-2、配置 webpack
我们选择webpack
来打包前端项目中的资源文件,还使用webpack-dev-server
来作为开发时的热更新服务器。因此,我们也需要配置一下webpack
用于满足这些需求。
首先我们使用pnpm add webpack webpack-cli webpack-dev-server webpack-merge -D
命令来安装webpack
相关的依赖。
然后我们还要继续添加一些webpack
的loader
和plugin
,这样我们才能正常打包项目,具体添加依赖的命令是:pnpm add vue-loader@next css-loader html-webpack-plugin mini-css-extract-plugin postcss-loader ts-loader -D
安装好依赖后,在package/client
目录下新建一个build
目录,并在这个目录中新建三个名为webpack.base.js
(基础配置)、webpack.dev.js
(开发配置)、webpack.prod.js
(线上配置)的文件,具体内容如下(配置的解析可以看注释):
// 路径: package/client/webpack.base.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { VueLoaderPlugin } = require("vue-loader");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
entry: path.resolve(__dirname, "../main.ts"), // 打包文件入口
module: {
rules: [
// 解析css文件
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
"css-loader",
"postcss-loader",
],
},
// 解析ts文件
{
test: /\.ts$/,
loader: "ts-loader",
options: {
appendTsSuffixTo: [/\.vue$/],
configFile: "./tsconfig.json",
},
exclude: /node_modules/,
},
// 解析vue文件
{
test: /\.vue$/,
use: "vue-loader",
},
],
},
resolve: {
extensions: [".ts", ".js", ".vue", ".json"], // 要打包的文件后缀
alias: {
vue: "@vue/runtime-dom", // 模块名简称
},
},
plugins: [
// 将打包好的js文件插入html中
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "../index.html"),
filename: "index.html",
title: "template-project",
}),
// 打包vue模板文件需要实例化一个VueLoaderPlugin
new VueLoaderPlugin(),
],
};
// 路径: package/client/webpack.dev.js
const { merge } = require("webpack-merge");
const base = require("./webpack.base.js");
const path = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
// merge函数会将webpack基础配置和dev配置合到一起
module.exports = merge(base, {
mode: "development", // 开发模式
devtool: "inline-source-map",
output: {
filename: "js/[name].[fullhash].js", // 输出js文件名称
path: path.resolve(__dirname, "../../../output/client"), // 输出js文件路径
},
devServer: {
port: 8080, // devServer的端口
compress: true, // 是否压缩
proxy: { context: ["/api", "/api"], target: "http://localhost:8081" }, // devServer的代理,会将请求代理到localhost:8081,也就是后端服务器上
},
plugins: [
// 单独打包css文件
new MiniCssExtractPlugin({
filename: "css/[name].css",
chunkFilename: "css/[id].css",
}),
],
});
// 路径: package/client/webpack.prod.js
const { merge } = require("webpack-merge");
const base = require("./webpack.base.js");
const path = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
// merge函数会将webpack基础配置和prod配置合到一起
module.exports = merge(base, {
mode: "production", // 开发模式
output: {
filename: "js/[name].[contenthash].js", // 输出js文件名称
path: path.resolve(__dirname, "../../../output/client"), // 输出js文件路径
},
plugins: [
// 单独打包css文件
new MiniCssExtractPlugin({
filename: "css/[name].[contenthash].css",
chunkFilename: "css/[id].[contenthash].css",
}),
],
});
5-3、开发前端服务
接下来就是开发前端服务了,还记得刚才我们新建的main.ts
文件嘛?我们在这个文件中写入如下逻辑,用于将根组件挂载到DOM
中:
// 路径: package/client/main.ts
import { createApp } from "vue";
import App from "./page/app.vue";
const app = createApp(App);
app.mount("#root");
然后,我们就可以在app.vue
中编写组件逻辑了,这个组件的作用是从后台拉取用户列表并展示。
在组件中,我们使用了axios
来发起http
请求,所以要先执行pnpm add axios -D
来安装相关依赖。
// 路径: package/client/app.vue
<template>
<div class="user">
<div v-for="(item, index) in userList" :key="`user-${index}`">
用户名: {{ item.username }} 邮箱: {{ item.email }}
</div>
</div>
</template>
<script lang="ts">
import { onMounted, reactive, toRefs } from 'vue';
import axios from 'axios';
// 开发环境和线上环境的url不一样,通过环境变量进行区分
const DEV_BASE_URL = 'http://localhost:8081';
const PROD_BASE_URL = '';
const AXIOS_BASE_URL = process.env.NODE_ENV === 'dev' ? DEV_BASE_URL : PROD_BASE_URL;
export default {
setup() {
const data = reactive({
userList: []
});
onMounted(async () => {
const res = await http.get(`${AXIOS_BASE_URL}/api/user`); // 使用axios发起请求
data.userList = res.data.data; // 把数据赋值给userList
});
return {
...toRefs(data) // 将userList变成reactive的,这样在模板中也可以访问了
};
}
};
</script>
<style scoped>
.user {
margin-top: 50px;
text-align: center;
}
</style>
5-4、编写 npm script
前端代码编写完成后,我们同样需要做一些工作才能让这个前端应用真的跑起来。
5-4-1、dev:client 命令
在开发前端页面时,我们需要起一个webpack-dev-server
来热更新,所以我们用webpack serve
命令启动dev-server
,并设置配置文件的路径为./package/client/build/webpack.dev.js
。
// 路径: package.json
"scripts": {
// ... 其他命令
"dev:client": "NODE_ENV=dev webpack serve --progress --hot --config ./package/client/build/webpack.dev.js",
},
5-4-2、dev:all 命令
那么我们如果想前端后端一起开发,就直接把dev:server
和dev:client
放到一起执行就好了
// 路径: package.json
"scripts": {
// ... 其他命令
"dev:all": "npm run dev:client & npm run dev:server",
},
5-4-1、build:client 命令
在打包的时候,我们使用webpack
命令进行文件打包,并设置配置文件的路径为./package/client/build/webpack.prod.js
就可以。
// 路径: package.json
"scripts": {
// ... 其他命令
"build:client": "NODE_ENV=prod webpack --config ./package/client/build/webpack.prod.js"
},
5-4-2、build:all 命令
同样的,如果我们想前端后端一起打包,那么就直接把build:server
和build:client
放到一起执行就好了
// 路径: package.json
"scripts": {
// ... 其他命令
"build:all": "npm run clean && npm run build:client && npm run build:server",
},
5-5、前端结构
这样我们前端、后端的代码就都编写完毕了!现在整体目录结构如下:
.
├── README.md
├── output # 打包产物
├── package
│ ├── client # 前端代码
│ │ ├── build # 打包配置
│ │ │ ├── webpack.base.js
│ │ │ ├── webpack.dev.js
│ │ │ └── webpack.prod.js
│ │ ├── index.html # html模板
│ │ ├── main.ts # 入口文件
│ │ ├── app.vue # 根组件
│ │ ├── tsconfig.json
│ │ └── type
│ │ ├── asset.d.ts
│ │ └── vue.d.ts
│ └── server # 后端代码
│ ├── app.ts
│ ├── config
│ │ ├── dev.ts
│ │ ├── index.ts
│ │ └── prod.ts
│ ├── controller
│ │ └── userController.ts
│ ├── db.ts
│ ├── router.ts
│ ├── service
│ │ └── userService.ts
│ └── tsconfig.json
├── package.json
└── tsconfig.json
5-6、其它
另外,在实际的模板项目(github.com/shadowings-…)中,还有vuex
、vue-router
、加载svg
图片的逻辑,可以直接看代码。
六、部署部分
当我们想要在服务器上使用nodejs
运行编译好的javascrpit
文件,我们需要使用pm2
来守护这个node
进程,pm2
提供了很多功能,比如是在后台运行这个进程,在进程挂掉的时候及时重启等等。
那么我们就先执行pnpm add -g pm2
来全局安装它,然后在 package.json 中写下如下命令:
// 路径: package.json
"scripts": {
// ... 其他命令
"start": "NODE_ENV=prod pm2 start output/app.js"
},
这样,我们先执行pnpm run build:all
把前后端编译产物输出到output
目录下,再执行pnpm run start
就可以在服务器上启动我们编写的node
服务了。
最后,浏览器输入localhost:8081/static/index.html
,就能访问我们的服务和页面了!