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

什么是前后端一体化?
讲概念前,我们直接看一下 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());
    },
  };
}
复制代码

最后
如果你觉得此文对你有一丁点帮助,点个赞。或者可以加入我的开发交流群:1025263163相互学习,我们会有专业的技术答疑解惑

如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点star:http://github.crmeb.net/u/defu不胜感激 !

猜你喜欢

转载自blog.csdn.net/qq_39221436/article/details/121327628