手把手教你做一个「vue3+express+typescript」的全栈模板项目

2,476 阅读9分钟

一、写在前面

在很久很久之前,「前端开发」和「切图工具人」是基本画等号的。但随着 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,这主要是因为相比于javascripttypescript提供了诸如类型、枚举、接口、类、继承等高级特性,而这些特性对于构建维护大型项目是十分有帮助的。

前端框架选用了 vue3 全家桶,这是因为我更喜欢像 vue 这种官方提供了与之配套的基础设施(比如vuexvue-router)的前端框架,而像react这种选router都要在好几个router之间调研一下的框架,我就不那么喜欢了。当然,调整一下打包配置,我们完全可以把vue换成react

打包工具选了webpack,老牌且功能强大的打包工具。而没有选vite之类的打包工具主要还是可扩展性的考虑,如果你想把vue无缝切换成其他前端框架,尤其是一些小众一点的框架,那么使用vite很可能会踩坑,甚至可能都找不到如何迁移。

包管理工具选了pnpm,这主要是因为pnpm相比于其他包管理工具,有着更快的安装速度和更小的node_modules体积,还是很香的。

后端框架选了express,不必多说,功能强大的主流后端框架,无脑选就 ok 了。

部署工具选了pm2,它能很方便的守护nodejs进程,也支持自动重启等特性,是服务器上跑express服务必备的东西。

三、初始化项目

3-1、monorepo

在这个模板项目中,我们会以monorepo方式管理前端项目和后台项目,也就是说,它们都会被放到同一个代码仓库中,所以我们新建一个package目录,并在该目录下新增clientserver目录,分别放前端和后端的代码。

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

五、前端部分

本部分会介绍如何一步一步地使用vue3webpack开发一个可以从后端接口拉取数据并展示的前端页面。

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文件的结构,插入打包后的jscss资源文件,并输出最终的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相关的依赖。

然后我们还要继续添加一些webpackloaderplugin,这样我们才能正常打包项目,具体添加依赖的命令是: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:serverdev: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:serverbuild: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-…)中,还有vuexvue-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,就能访问我们的服务和页面了! 请添加图片描述