侧边栏壁纸
博主头像
道一博主等级

云在青天水在瓶!

  • 累计撰写 15 篇文章
  • 累计创建 14 个标签
  • 累计收到 0 条评论

忘记 ajax,拥抱前后端一体化

道一
2021-11-13 / 0 评论 / 4 点赞 / 165 阅读 / 9,699 字
温馨提示:
本文最后更新于 2021-11-13,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

讲概念前,我们直接看一下 Modern.js 官网的 例子 更直观的感受一下:
// api/hello.ts

export const get = async () => "Hello Modern.js";
复制代码
// src/App.tsx

import { useState, useEffect } from "react";
import
from "@api/hello";

export default () => {
const [text, setText] = useState("");

useEffect(() => {
hello().then(setText);
}, []);
return

;
};
复制代码
当我们打开 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 插件更容易理解),一步一步的实现一个丐版的前后端一体化功能。

完整的代码已放在 GitHub:github.com/dream2023/f…

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> {
// 假设从数据库读取
const user: User = {
name: "jack",
age: 18,
};
return {
code: 0,
msg: "ok",
data: user,
};
}

export async function updateUser(user: User): Promise<Response> {
return {
code: 0,
msg: "ok",
data: user,
};
}
复制代码

修改 src/App.vue

复制代码 ​

我们打开 network,发现并没有发送 ajax 请求就拿到了数据,这是不对的,我们需要进一步改造。
3.将函数转为接口请求
将函数转为接口请求就需要我们在构建时修改文件内容,为此我们需要用到 vite 插件 的能力。
// 项目/myPlugin.ts
import
from "vite";

export default function VitePlugin(): Plugin {
return {
name: "my-plugin",
transform(src, id) {
// src 是文件内容,id 是文件路径
},
};
}
复制代码
// 项目/vite.config.ts
import
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
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
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 {
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
from "vite";
import
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 {
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());
},
};
}

0

评论区