什么是前后端一体化?
讲概念前,我们直接看一下 Modern.js 官网的 例子 更直观的感受一下:
// api/hello.ts
export const get = async () => "Hello Modern.js";
复制代码
// src/App.tsx
import { useState, useEffect } from "react";
import { get as hello } from "@api/hello";
export default () => {
const [text, setText] = useState("");
useEffect(() => {
hello().then(setText);
}, []);
return <div>{text}</div>;
};
复制代码
当我们打开 network 时,神奇的发现它竟然有 http://localhost:8080/api/hello 的请求发出去了,并且拿到了我们函数返回的数据。
其实前后端一体化并没有官方的定义,从其表现上来看可以简单下一个定义为:
前端代码和 nodejs 作为后端的代码放在同一个项目下
使用同一个 package.json 管理依赖
双方通过函数调用直接交互,而非传统的 ajax 请求
为什么要前后端一体化?
从我自身的感受上,至少有以下两点好处:
类型统一
如果你用 Node + ts 写过代码,一定有一个痛点,就是类型定义需要在 Node 端定义一遍,然后拷贝到前端 request 那里,要不然就需要写工具,去将 Node 端定义同步到前端,这是很不友好的开发体验。
而前后端一体化很完美的解决了这个问题。
开发简单
这种前后端一体化的方式,没有了 ajax、没有了路由、没有了 GET、POST,就像调用普通函数一样调用后端接口,这个开发体验真的是爽爆了。
评论区疑问解答
问题 1:这玩意和传统的前后端不分离有啥区别,天道轮回?
前后端一体化本质上还是前后端分离的,表现在构建出来还是两份代码(前端和后端),调用最终还是走的 ajax,和传统的前后端不分离的字符串替换模板是完全不同的;
传统的前后端不分离只适用于原生开发,因为说白了还是字符串替换,所以只能是 .html,.tsx 或者 .vue 浏览器是不认识的(当然也能实时渲染),但前后端一体化则不限制前端技术栈,本身类似于一层 ajax 的封装而已,将真正的 ajax 调用隐藏起来了。
问题 2:适用场景?
从两个项目的说明来看,他们更多的是用来 serverless 场景,因为一个函数即一个接口,这不就是传说中的 fass 概念吗,但两个项目也都能独立部署的。
原理和实现
原理
其表现看似神奇——引入一个函数即可发送 ajax 请求,但原理其实蛮简单的,关键点就是 apis 目录实际被读到两次:
一次是在构建时将函数转为 request 代码
一次是在读取函数作为后端路由处理函数
实现
为了更简单的讲解,我们使用 vite 作为构建工具(相对于 webpack 插件更容易理解),一步一步的实现一个丐版的前后端一体化功能。
1、初始化项目
参照 vite 文档:
yarn create @vitejs/app my-vue-app --template vue-ts
复制代码
cd my-vue-app
yarn
yarn dev
复制代码
看到上述界面说明已经启动成功。
2、修改、新增文件
新增 src/apis/user.ts 文件
export interface User {
name: string;
age: number;
}
interface Response<T = any> {
code: number;
msg: string;
data?: T;
}
export async function getUser(): Promise<Response<User>> {
// 假设从数据库读取
const user: User = {
name: "jack",
age: 18,
};
return {
code: 0,
msg: "ok",
data: user,
};
}
export async function updateUser(user: User): Promise<Response<User>> {
return {
code: 0,
msg: "ok",
data: user,
};
}
复制代码
修改 src/App.vue
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { getUser, User, updateUser } from "./apis/user";
const user = ref<User>();
onMounted(() => {
getUser().then((res) => {
user.value = res.data;
});
});
const handleUpdate = () => {
updateUser({ name: "li", age: 10 }).then((res) => {
alert(JSON.stringify(res.data));
});
};
</script>
<template>
<div v-if="user">
<div>username: {{ user.name }}</div>
<div>age: {{ user.age }}</div>
<button @click="handleUpdate">更新 user</button>
</div>
</template>
复制代码
我们打开 network,发现并没有发送 ajax 请求就拿到了数据,这是不对的,我们需要进一步改造。
3.将函数转为接口请求
将函数转为接口请求就需要我们在构建时修改文件内容,为此我们需要用到 vite 插件 的能力。
// 项目/myPlugin.ts
import { Plugin } from "vite";
export default function VitePlugin(): Plugin {
return {
name: "my-plugin",
transform(src, id) {
// src 是文件内容,id 是文件路径
},
};
}
复制代码
// 项目/vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import myPlugin from "./myPlugin";
export default defineConfig({
plugins: [vue(), myPlugin()],
});
复制代码
这样插件的架子就搭好了。
具体实现逻辑可以分为两步:
判断是否为 src/apis 下的文件内容
如果是将下图函数改写为 request 代码
// 目标转换结果
function getUser() {
// 1. 使用 fetch 请求
// 2. url 为 /api/ + 文件名 + 函数名,避免路由重复
return fetch("/api/user/getUser", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
}).then((res) => res.json());
}
function updateUser(data) {
return fetch("/api/user/updateUser", {
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
}).then((res) => res.json());
}
复制代码
// 具体代码实现
import * as path from "path";
import { Plugin } from "vite";
const apisPath = path.join(__dirname, "./src/apis");
// 发起请求的模板
const requestTemp = (
fileName: string,
fn: string
) => `export function ${fn}(data) {
const isGet = !data
return fetch("/api/${fileName}/${fn}", {
method: isGet ? "GET" : "POST",
body: isGet ? undefined : JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
},
}).then((res) => res.json());
}`;
export default function VitePlugin(): Plugin {
return {
name: "my-plugin",
transform(src, id) {
// 1.判断是否为 apis 目录下的文件
if (id.startsWith(apisPath)) {
// 获取文件名
const fileName = path.basename(id, ".ts");
// 正则获取函数名
const fnNames = [...src.matchAll(/async function (\w+)/g)].map(
(item) => item[1]
);
// 2.转换文件内容为 request
const code = fnNames.map((fn) => requestTemp(fileName, fn)).join("\n");
return {
code,
map: null,
};
}
},
};
}
复制代码
从上图看到文件内容已经被改写,并且已经可以发出请求,接下来我们就要拦截并处理请求了。
4.拦截请求
vite 为拦截请求专门出了一个钩子函数 configureserver,使用方式也很简单:
export default function VitePlugin(): Plugin {
return {
name: "my-plugin",
configureServer(server) {
// 我们使用 server.middlewares 进行统一处理
// https://vitejs.cn/guide/api-javascript.html#vitedevserver
server.middlewares.use((req, res, next) => {
// 判断是否为 /api 开头
if (req.url.startsWith("/api")) {
// 写一个假数据
res.end(JSON.stringify({ data: { name: "jack", age: 19 } }));
return;
}
next();
});
},
};
}
复制代码
我们看到 network 已经拦截到数据,并返回我们写的假数据了。
5.启动 express 服务处理请求
考虑到接口服务应该和 vite 内部的 server 分开,所以我们需要单独启动一个服务去处理这些请求,这里我们选择 express 去接收并处理这些请求,具体代码为:
yarn add express body-parser # express
yarn add ts-node # 用于在 node 环境下读取 ts 文件
yarn add axios # 用于转发请求
复制代码
import * as fs from "fs";
// 想要在 node 环境下直接读取文件,需要使用 ts-node 的 register
require("ts-node/register");
// 获取 apis 下的文件和函数
function getApis() {
const files = fs
.readdirSync(apisPath)
.map((filePath) => path.join(apisPath, filePath));
const apis = files
.filter((filePath) => {
const stat = fs.statSync(filePath);
return stat.isFile();
})
.map((filePath) => {
// 直接 require ts 文件
const fns = require(filePath);
const fileName = path.basename(filePath, ".ts");
return Object.keys(fns).map((fnName) => ({
fileName,
fn: fns[fnName],
}));
});
return apis.flat();
}
复制代码
// tsconfig.json
// 如果想使用 ts-node/register 读取 ts 文件,必须将 module 改为 "commonjs",这是一个坑点
{
"module": "commonjs",
}
复制代码
import { Express } from "./node_modules/@types/express-serve-static-core/index";
// 注册路由处理函数
function registerApis(server: Express) {
const apis = getApis();
// 遍历 apis,注册路由及其处理函数
apis.forEach(({ fileName, fn }) => {
// 和前端一样的路由规则
server.all(`/api/${fileName}/${fn.name}`, async (req, res) => {
// 执行函数,并将结果返回
const data = await fn(req.body);
res.send(JSON.stringify(data));
});
});
}
复制代码
// 启动 app
import express from "express";
const bodyParser = require("body-parser");
function appStart(): Promise<string> {
const app = express();
app.use(bodyParser.json());
// 注册 apis
registerApis(app);
const server = http.createServer(app);
return new Promise((resolve) => {
// listen 的第一个参数如果为 0,则表示随机获取一个未被占用的端口
server.listen(0, () => {
const address = server.address();
// 返回请求地址
if (typeof address === "string") {
resolve(`http://${address}`);
} else {
resolve(`http://127.0.0.1:${address.port}`);
}
});
});
}
复制代码
// 请求转发
function sendRequest(address: string, url: string, body: any, params: any) {
return axios.post(`${address}${url}`, body, {
params,
headers: {
"Content-Type": "application/json",
},
});
}
复制代码
// 设置 middleware 拦截请求
async function middleware() {
// 启动 app
const address = await appStart();
return async (req, res, next) => {
if (req.url.startsWith("/api")) {
// 转发请求到 app
const response = await sendRequest(address, req.url, req.body, req.query);
// 返回结果
res.end(JSON.stringify(response.data));
return;
}
next();
};
}
复制代码
export default function VitePlugin(): Plugin {
return {
// ...
async configureServer(server) {
// vite 内部的 server 也要注册 bodyParser
// 用于在转发时获取 body
server.middlewares.use(bodyParser.json());
// 注册中间件
server.middlewares.use(await middleware());
},
};
}
复制代码
从上图可以看到,我们已经可以正确发送 GET 和 POST 请求了。
6.最终插件代码
import * as path from "path";
import * as fs from "fs";
import * as http from "http";
import express from "express";
import axios from "axios";
import { Plugin } from "vite";
import { Express } from "./node_modules/@types/express-serve-static-core/index";
// 想要在 node 环境下直接读取文件,需要使用 ts-node 的 register
require("ts-node/register");
const bodyParser = require("body-parser");
const apisPath = path.join(__dirname, "./src/apis");
// 发起请求的模板
const requestTemp = (
fileName: string,
fn: string
) => `export function ${fn}(data) {
const isGet = !data
return fetch("/api/${fileName}/${fn}", {
method: isGet ? "GET" : "POST",
body: isGet ? undefined : JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
},
}).then((res) => res.json());
}`;
// 获取 apis 下的文件和函数
function getApis() {
const files = fs
.readdirSync(apisPath)
.map((filePath) => path.join(apisPath, filePath));
const apis = files
.filter((filePath) => {
const stat = fs.statSync(filePath);
return stat.isFile();
})
.map((filePath) => {
const fns = require(filePath);
const fileName = path.basename(filePath, ".ts");
return Object.keys(fns).map((fnName) => ({
fileName,
fn: fns[fnName],
}));
});
return apis.flat();
}
// 注册路由处理函数
function registerApis(server: Express) {
const apis = getApis();
// 遍历 apis,注册路由及其处理函数
apis.forEach(({ fileName, fn }) => {
server.all(`/api/${fileName}/${fn.name}`, async (req, res) => {
const data = await fn(req.body);
res.send(JSON.stringify(data));
});
});
}
// 启动 app
function appStart(): Promise<string> {
const app = express();
app.use(bodyParser.json());
registerApis(app);
const server = http.createServer(app);
return new Promise((resolve) => {
// listen 的第一个参数如果为 0,则表示随机获取一个未被占用的端口
server.listen(0, () => {
const address = server.address();
if (typeof address === "string") {
resolve(`http://${address}`);
} else {
resolve(`http://127.0.0.1:${address.port}`);
}
});
});
}
// 请求转发
function sendRequest(address: string, url: string, body: any, params: any) {
return axios.post(`${address}${url}`, body, {
params,
headers: {
"Content-Type": "application/json",
},
});
}
// 设置 middleware 拦截请求
async function middleware() {
// 启动 app
const address = await appStart();
return async (req, res, next) => {
if (req.url.startsWith("/api")) {
// 转发请求到 app
const response = await sendRequest(address, req.url, req.body, req.query);
// 返回结果
res.end(JSON.stringify(response.data));
return;
}
next();
};
}
// 将函数转为请求
function transformRequest(src: string, id: string) {
if (id.startsWith(apisPath)) {
const fileName = path.basename(id, ".ts");
const fnNames = [...src.matchAll(/async function (\w+)/g)].map(
(item) => item[1]
);
return {
code: fnNames.map((fn) => requestTemp(fileName, fn)).join("\n"),
map: null,
};
}
}
export default function VitePlugin(): Plugin {
return {
name: "my-plugin",
transform: transformRequest,
async configureServer(server) {
// vite 内部的 server 也要注册 bodyParser
// 用于在转发时获取 body
server.middlewares.use(bodyParser.json());
server.middlewares.use(await middleware());
},
};
}
复制代码