别再死记优缺点了:聊聊 REST、GraphQL、WebSocket 的使用场景

你让 AI 帮你设计一个聊天应用的后端接口,它给你推荐了 GraphQL + WebSocket。你看着文档,心想:真的需要这么复杂吗?普通的 REST 不行吗?

技术选型时最容易陷入"别人都在用"的误区。我们习惯记忆"优缺点",却很容易忽视其背后的设计思想。这篇文章是我试图理解"每种方案到底解决了什么问题"的思考过程,试图分析我们应该"如何选择"。

从一个简单需求开始:获取用户信息

最简单的场景

需求:前端需要显示用户的基本信息。

json 复制代码
// Expected data
{
  "name": "Zhang San",
  "avatar": "https://...",
  "email": "zhangsan@example.com"
}

方案1:REST API

javascript 复制代码
// Environment: Browser
// Scenario: Basic data fetching

// Request
fetch('https://api.example.com/users/123')
  .then(res => res.json())
  .then(data => {
    console.log(data);
    // { id: 123, name: 'Zhang San', avatar: '...', email: '...' }
  });

// Backend design (pseudo code)
app.get('/users/:id', (req, res) => {
  const user = db.getUser(req.params.id);
  res.json(user);
});

这里就够了吗?

对于简单场景:完全够用 ✅

  • 清晰、直观、易于理解
  • 符合 HTTP 语义(GET 获取资源)

思考点

  • 如果需求开始变复杂呢?
  • 如果前端只需要用户名,不需要邮箱呢?
  • 如果需要实时更新用户状态呢?

REST:理解"无状态"的设计

REST 的核心思想

REST 不是一个协议,而是一种架构风格。

核心约束:

  • 客户端-服务器分离
  • 无状态(Stateless)← 最重要
  • 可缓存
  • 统一接口
  • 分层系统

为什么要"无状态"?

"无状态"意味着什么?让我先对比两种设计:

javascript 复制代码
// Environment: Backend
// Scenario: Stateful design (session-based)

// Request 1
POST /login
{ username: 'zhangsan', password: '123456' }
// Response: Set session, return session_id

// Request 2
GET /profile
// Header includes session_id
// Server reads user info from session

// Problem: Server needs to "remember" user login state
javascript 复制代码
// Environment: Backend
// Scenario: Stateless design (token-based)

// Request 1
POST /login
{ username: 'zhangsan', password: '123456' }
// Response: Return JWT token

// Request 2
GET /profile
// Header includes token (contains user info)
// Server parses token, no need to query session

// Advantage: Server doesn't need to "remember" anything

无状态的好处

用个类比:你去便利店买东西。

有状态的便利店(Session)

  • 店员记住了你昨天买了什么
  • 你今天再来,店员说"还是老样子吗?"
  • 问题:店员离职了怎么办?店员记不住太多人怎么办?

无状态的便利店(Token)

  • 每次你都要重新说要买什么
  • 看起来麻烦,但任何一个店员都能服务你
  • 优势:换店员、开分店都没问题

技术上的好处

  • ✅ 水平扩展容易:加服务器不需要同步 session
  • ✅ 容错性好:一台服务器挂了不影响其他
  • ✅ 可缓存:相同请求返回相同结果

REST 的典型场景

✅ 适合 REST 的场景

javascript 复制代码
// Environment: Backend API
// Scenario: Standard CRUD operations

GET    /users       // Get user list
GET    /users/123   // Get single user
POST   /users       // Create user
PUT    /users/123   // Update user
DELETE /users/123   // Delete user

// Scenario: Clear resource relationships
GET /users/123/posts      // Posts of a user
GET /posts/456/comments   // Comments of a post

特点分析:

  • 操作对象是"资源"(users、posts)
  • 动作用 HTTP 方法表示(GET、POST、PUT、DELETE)
  • URL 语义化,易于理解

❌ REST 开始不够用的场景

问题1:Over-fetching(获取了不需要的数据)

javascript 复制代码
// Environment: Browser
// Scenario: Frontend only needs name and avatar

fetch('/users/123')
  .then(res => res.json())
  .then(data => {
    // But returns complete user info
    console.log(data);
    // {
    //   id: 123,
    //   name: 'Zhang San',
    //   avatar: '...',
    //   email: '...',        // Don't need
    //   phone: '...',        // Don't need
    //   address: '...',      // Don't need
    //   bio: '...',          // Don't need
    //   createdAt: '...',    // Don't need
    // }
  });

问题2:Under-fetching(需要多次请求)

javascript 复制代码
// Environment: Browser
// Scenario: Display post + author + comments

// Approach 1: Multiple requests (N+1 problem)
const post = await fetch('/posts/456').then(r => r.json());
const author = await fetch(`/users/${post.authorId}`).then(r => r.json());
const comments = await fetch('/posts/456/comments').then(r => r.json());

// Problem: 3 network requests, slow!

// Approach 2: Backend provides combined endpoint
fetch('/posts/456?include=author,comments')

// Problem: Backend needs to write endpoints for every combination

问题3:接口版本管理

javascript 复制代码
// Environment: Backend API
// Scenario: API versioning

// v1: Basic info
GET /v1/users/123
// { id, name, email }

// v2: Added new fields
GET /v2/users/123
// { id, name, email, avatar, bio }

// Problems:
// - Maintain multiple versions
// - Client needs to know which version to use
// - When to deprecate old versions?

AI 对 REST 的理解

AI 友好度:⭐⭐⭐⭐⭐

  • ✅ AI 非常擅长生成 REST API
  • ✅ 模式简单、规范清晰
  • ✅ 大量训练数据

但 AI 可能忽略的:

  • ⚠️ 复杂的查询需求(筛选、排序、分页)
  • ⚠️ 接口粒度设计(太细 vs 太粗)
  • ⚠️ 缓存策略

REST 的最佳实践

javascript 复制代码
// Environment: Backend API
// Scenario: Good REST API design

// ✅ Use plural nouns
GET /users      // Not /user

// ✅ Use nesting for relationships
GET /users/123/posts

// ✅ Use query params for filtering
GET /posts?status=published&sort=createdAt&limit=10

// ✅ Use HTTP status codes
200 OK          // Success
201 Created     // Creation success
400 Bad Request // Client error
404 Not Found   // Resource not found
500 Server Error// Server error

// ✅ Return consistent error format
{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "User with id 123 not found"
  }
}

小结

  • REST 简单、直观、易于理解
  • 适合标准的 CRUD 操作
  • 当需求变复杂时(组合查询、自定义字段),REST 开始力不从心

GraphQL:解决 REST 的痛点

GraphQL 的核心思想

GraphQL 不是 REST 的替代品,而是不同的思路。

核心理念:

  • 客户端精确描述需要什么数据
  • 服务端按需返回,不多不少

解决 Over-fetching 和 Under-fetching

场景:显示文章详情页

javascript 复制代码
// Environment: Browser + REST
// Scenario: Multiple requests needed

// Problem 1: Over-fetching
const post = await fetch('/posts/456').then(r => r.json());
// Returns all fields of post, but only need title and content

// Problem 2: Under-fetching (multiple requests)
const author = await fetch(`/users/${post.authorId}`).then(r => r.json());
const comments = await fetch('/posts/456/comments').then(r => r.json());
javascript 复制代码
// Environment: Browser + GraphQL
// Scenario: Single request for exact data needed

const query = `
  query {
    post(id: 456) {
      title
      content
      author {
        name
        avatar
      }
      comments {
        content
        author {
          name
        }
      }
    }
  }
`;

fetch('https://api.example.com/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query })
})
  .then(res => res.json())
  .then(data => {
    console.log(data);
    // {
    //   post: {
    //     title: '...',
    //     content: '...',
    //     author: { name: '...', avatar: '...' },
    //     comments: [
    //       { content: '...', author: { name: '...' } }
    //     ]
    //   }
    // }
  });

GraphQL 的优势

  • ✅ 一次请求获取所有需要的数据
  • ✅ 精确控制返回的字段
  • ✅ 强类型系统(schema 定义数据结构)
  • ✅ 自动文档(从 schema 生成)

GraphQL 的代价

问题1:后端复杂度大增

javascript 复制代码
// Environment: Backend
// Scenario: Complexity comparison

// REST: Simple and clear
app.get('/posts/:id', async (req, res) => {
  const post = await db.posts.findById(req.params.id);
  res.json(post);
});

// GraphQL: Need to define schema and resolvers
const typeDefs = `
  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
    comments: [Comment!]!
  }
  
  type User {
    id: ID!
    name: String!
    avatar: String
  }
  
  type Comment {
    id: ID!
    content: String!
    author: User!
  }
  
  type Query {
    post(id: ID!): Post
  }
`;

const resolvers = {
  Query: {
    post: (parent, { id }, context) => {
      return context.db.posts.findById(id);
    }
  },
  Post: {
    author: (post, args, context) => {
      return context.db.users.findById(post.authorId);
    },
    comments: (post, args, context) => {
      return context.db.comments.findByPostId(post.id);
    }
  },
  Comment: {
    author: (comment, args, context) => {
      return context.db.users.findById(comment.authorId);
    }
  }
};

// Need to setup Apollo Server or other GraphQL server

复杂度对比:

  • REST:写一个路由就行
  • GraphQL:需要定义类型、写 resolver、处理关联

问题2:N+1 查询问题

javascript 复制代码
// Environment: Backend + GraphQL
// Scenario: N+1 query problem

const query = `
  query {
    posts {
      title
      author {
        name
      }
    }
  }
`;

// Without optimization, this causes:
// 1. Query all posts (1 database query)
// 2. For each post, query author (N database queries)

// Solution: DataLoader (batch loading + caching)
const DataLoader = require('dataloader');

const userLoader = new DataLoader(async (userIds) => {
  // Single query for all needed users
  const users = await db.users.findByIds(userIds);
  return userIds.map(id => users.find(u => u.id === id));
});

const resolvers = {
  Post: {
    author: (post, args, context) => {
      return context.userLoader.load(post.authorId);
    }
  }
};

问题3:缓存困难

javascript 复制代码
// REST: URL is cache key
GET /posts/456
// Browser, CDN can easily cache

// GraphQL: All requests are POST to same endpoint
POST /graphql
body: { query: "..." }
// HTTP cache doesn't work! Need application-level caching

问题4:学习曲线陡峭

团队需要学习:

  • GraphQL 查询语法
  • Schema 定义
  • Resolver 编写
  • DataLoader 优化
  • Apollo Client / Relay

何时真正需要 GraphQL?

✅ 适合 GraphQL 的场景

  1. 移动端应用

    • 网络条件差,减少请求次数很重要
    • 不同设备需要不同粒度的数据
  2. 复杂的前端需求

    • 大量的组合查询
    • 频繁变化的数据需求
  3. 多客户端(Web、iOS、Android)

    • 每个客户端需要不同的数据子集
    • 不想为每个客户端写专门的接口
  4. BFF(Backend for Frontend)模式

    • GraphQL 作为中间层
    • 聚合多个微服务的数据

❌ 不需要 GraphQL 的场景

  1. 简单的 CRUD 应用

    • REST 已经够用
    • GraphQL 是 over-engineering
  2. 团队经验不足

    • 学习成本高
    • 容易出现性能问题
  3. 后端资源有限

    • GraphQL 对后端开发要求更高
    • 需要更多的优化工作

AI 对 GraphQL 的理解

AI 友好度:⭐⭐⭐

AI 擅长的

  • ✅ 生成基础的 schema 定义
  • ✅ 生成简单的 resolver
  • ✅ 生成客户端查询

AI 不擅长的

  • ❌ 复杂的 N+1 优化
  • ❌ 缓存策略设计
  • ❌ 性能调优
  • ❌ 安全性(查询深度限制、复杂度限制)

REST vs GraphQL 对比

维度 REST GraphQL
学习曲线
后端复杂度
请求次数
数据精确性 Over/Under-fetching 精确控制
缓存 HTTP 缓存 应用层缓存
工具支持 成熟 较新但完善
适用场景 标准 CRUD 复杂查询

小结

  • GraphQL 解决了 REST 的某些痛点
  • 但带来了新的复杂度
  • 不是"更好",而是"不同的权衡"

WebSocket:实时通信的需求

问题场景:聊天应用

需求:实现一个聊天室,用户发消息后,其他人能立即看到。

方案1:REST 轮询(Polling)

javascript 复制代码
// Environment: Browser
// Scenario: Poll for new messages every 1 second

let lastMessageId = 0;

setInterval(() => {
  fetch('/messages?since=' + lastMessageId)
    .then(res => res.json())
    .then(messages => {
      if (messages.length > 0) {
        displayMessages(messages);
        lastMessageId = messages[messages.length - 1].id;
      }
    });
}, 1000);

// Problems:
// - Many useless requests (even when no new messages)
// - Delay up to 1 second (polling interval)
// - High server load

方案2:长轮询(Long Polling)

javascript 复制代码
// Environment: Browser + Backend
// Scenario: Long polling

// Client
function longPoll() {
  fetch('/messages/poll')
    .then(res => res.json())
    .then(messages => {
      displayMessages(messages);
      longPoll(); // Immediately start next request
    });
}

// Server (pseudo code)
app.get('/messages/poll', async (req, res) => {
  // Hold connection, wait for new messages
  const messages = await waitForNewMessages(30000); // Wait max 30s
  res.json(messages);
});

// Improvements:
// ✅ Reduced useless requests
// ✅ Lower latency
// ❌ Still "pull" mode, not truly real-time

方案3:WebSocket

javascript 复制代码
// Environment: Browser + WebSocket server
// Scenario: True bidirectional real-time communication

// Client
const ws = new WebSocket('wss://chat.example.com');

// Connection established
ws.onopen = () => {
  console.log('Connected');
};

// Receive messages
ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  displayMessage(message);
};

// Send message
function sendMessage(text) {
  ws.send(JSON.stringify({
    type: 'message',
    content: text
  }));
}

// Connection closed
ws.onclose = () => {
  console.log('Disconnected');
  // Reconnect logic
  setTimeout(() => {
    reconnect();
  }, 1000);
};

// Server (Node.js + ws)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

const clients = new Set();

wss.on('connection', (ws) => {
  clients.add(ws);
  
  ws.on('message', (data) => {
    const message = JSON.parse(data);
    
    // Broadcast to all clients
    clients.forEach(client => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message));
      }
    });
  });
  
  ws.on('close', () => {
    clients.delete(ws);
  });
});

WebSocket 的优势

  • ✅ 真正的双向通信(服务器可主动推送)
  • ✅ 低延迟(毫秒级)
  • ✅ 低开销(保持连接,不需要重复 HTTP 握手)
  • ✅ 高效(二进制传输可选)

WebSocket 的代价

问题1:连接管理复杂

javascript 复制代码
// Environment: Browser
// Scenario: Robust WebSocket connection management

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectDelay = 1000;
    this.maxReconnectDelay = 30000;
    this.heartbeatInterval = null;
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      console.log('Connected');
      this.reconnectDelay = 1000;
      this.startHeartbeat();
    };
    
    this.ws.onclose = () => {
      console.log('Disconnected');
      this.stopHeartbeat();
      this.reconnect();
    };
    
    this.ws.onerror = (error) => {
      console.error('Error:', error);
    };
  }
  
  reconnect() {
    setTimeout(() => {
      console.log('Reconnecting...');
      this.connect();
      // Exponential backoff
      this.reconnectDelay = Math.min(
        this.reconnectDelay * 2,
        this.maxReconnectDelay
      );
    }, this.reconnectDelay);
  }
  
  startHeartbeat() {
    this.heartbeatInterval = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping' }));
      }
    }, 30000); // Send heartbeat every 30s
  }
  
  stopHeartbeat() {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
    }
  }
}

问题2:服务器资源消耗

diff 复制代码
REST:
- Request → Response → Connection closed
- Stateless, easy to scale horizontally

WebSocket:
- Each client maintains a long connection
- 10000 users = 10000 connections
- High memory, file descriptor consumption
- Need load balancing (sticky session)

问题3:兼容性和回退

javascript 复制代码
// Environment: Backend
// Scenario: Fallback mechanism

// Need to consider:
// - Old browsers don't support WebSocket
// - Some networks don't allow WebSocket
// - Need fallback (long polling)

// Use Socket.IO for automatic handling
const io = require('socket.io')(server);

io.on('connection', (socket) => {
  // Socket.IO automatically chooses:
  // 1. WebSocket (preferred)
  // 2. Long Polling (fallback)
});

SSE:WebSocket 的轻量替代

Server-Sent Events(SSE):服务器单向推送

javascript 复制代码
// Environment: Browser + SSE
// Scenario: Server pushes real-time data (e.g., stock prices)

// Client
const eventSource = new EventSource('https://api.example.com/stock-prices');

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  updateStockPrice(data);
};

eventSource.onerror = () => {
  console.error('Connection error');
  eventSource.close();
};

// Server (Node.js)
app.get('/stock-prices', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  
  // Push every second
  const interval = setInterval(() => {
    const price = getLatestPrice();
    res.write(`data: ${JSON.stringify(price)}\n\n`);
  }, 1000);
  
  req.on('close', () => {
    clearInterval(interval);
  });
});

SSE vs WebSocket

特性 SSE WebSocket
方向 单向(服务器 → 客户端) 双向
协议 HTTP WebSocket 协议
自动重连 浏览器自动 需要手动实现
浏览器支持 IE 不支持 现代浏览器都支持
复杂度
适用场景 服务器推送 双向通信

何时选择什么?

REST(最常见)

  • ✅ 标准的 CRUD 操作
  • ✅ 不需要实时性

GraphQL

  • ✅ 复杂的数据查询
  • ✅ 多客户端,需求各异

SSE

  • ✅ 服务器单向推送(股票、通知)
  • ✅ 自动重连很重要

WebSocket

  • ✅ 双向实时通信(聊天、协作编辑)
  • ✅ 高频数据交换(游戏、实时绘图)

AI 对实时通信的理解

AI 友好度

  • WebSocket:⭐⭐⭐
  • SSE:⭐⭐⭐⭐

AI 擅长的

  • ✅ 生成基础的 WebSocket 客户端代码
  • ✅ 生成简单的服务器端代码
  • ✅ SSE 的实现(更简单)

AI 不擅长的

  • ❌ 断线重连逻辑
  • ❌ 心跳保活
  • ❌ 负载均衡配置
  • ❌ 大规模部署的优化

综合对比与决策

核心权衡维度

维度1:请求模式

  1. Pull(拉):客户端主动请求

    • REST、GraphQL
    • 优势:简单、可缓存
    • 劣势:无法主动通知
  2. Push(推):服务器主动推送

    • WebSocket、SSE
    • 优势:实时性好
    • 劣势:连接管理复杂

维度2:数据粒度

  1. 粗粒度(固定结构):

    • REST
    • 优势:简单、可预测
    • 劣势:可能 over-fetching
  2. 细粒度(自定义):

    • GraphQL
    • 优势:精确控制
    • 劣势:复杂度高

维度3:连接成本

  1. 短连接(HTTP):

    • REST、GraphQL
    • 每次请求建立连接
    • 适合低频交互
  2. 长连接:

    • WebSocket
    • 保持连接
    • 适合高频交互

决策树

graph TD A[选择数据传输方式] --> B{需要实时性?} B --> |需要| C{双向通信?} C --> |是| D[WebSocket] C --> |否| E[SSE] B --> |不需要| F{数据查询复杂?} F --> |复杂| G{多客户端?} G --> |是| H[GraphQL] G --> |否| I{团队经验?} I --> |GraphQL 经验| H I --> |REST 经验| J[REST + 定制接口] F --> |简单 CRUD| J[REST] style J fill:#d4edff style H fill:#fff4cc style D fill:#ffe0e0 style E fill:#e1f5dd

实际项目的组合使用

案例1:电商网站

  • REST:商品列表、购物车、订单
  • WebSocket:在线客服聊天
  • SSE:订单状态更新推送

案例2:协作文档(类 Google Docs)

  • GraphQL:文档结构查询
  • WebSocket:实时协作编辑
  • REST:文件上传/下载

案例3:社交应用

  • GraphQL:复杂的 feed 流查询
  • WebSocket:私信聊天
  • SSE:通知推送

关键原则

  • 没有一种方案能解决所有问题
  • 根据具体场景,组合使用不同方案

延伸与发散:AI 时代的数据传输

AI 对不同方案的生成质量

方案 AI 友好度 AI 擅长 AI 不擅长
REST ⭐⭐⭐⭐⭐ 标准 CRUD、路由设计 复杂查询优化
GraphQL ⭐⭐⭐ Schema、基础 resolver N+1 优化、缓存
WebSocket ⭐⭐⭐ 基础连接代码 重连、心跳、扩展
SSE ⭐⭐⭐⭐ 完整实现 大规模部署

AI 应用中的新场景

流式输出(Streaming)

javascript 复制代码
// Environment: Browser
// Scenario: AI generates text, returns word by word
// Like ChatGPT typing effect

// Approach 1: SSE (recommended)
const eventSource = new EventSource('/api/ai/generate');

eventSource.onmessage = (event) => {
  const chunk = event.data;
  appendToOutput(chunk);
};

// Approach 2: Fetch Stream
fetch('/api/ai/generate', {
  method: 'POST',
  body: JSON.stringify({ prompt: '...' })
})
  .then(response => {
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    
    function read() {
      reader.read().then(({ done, value }) => {
        if (done) return;
        const chunk = decoder.decode(value);
        appendToOutput(chunk);
        read();
      });
    }
    
    read();
  });

思考

  • AI 流式输出最适合用什么方案?
  • SSE vs Fetch Stream 的选择?

未来的趋势

问题:协议会继续演进吗?

可能的方向:

  1. HTTP/3 + QUIC:更快的连接建立
  2. gRPC:高性能的 RPC 框架
  3. WebTransport:下一代实时通信

待探索的问题:

  • 边缘计算如何影响数据传输选择?
  • Serverless 架构下,WebSocket 如何实现?
  • AI Agent 之间的通信,需要什么协议?

小结

这篇文章梳理了常见的数据传输方案,但没有给出"最佳答案"------因为并不存在唯一最优解。

核心收获

  • REST:简单、成熟,适合大多数场景
  • GraphQL:解决特定问题(复杂查询),但有代价
  • WebSocket:实时双向通信,连接管理复杂
  • SSE:单向推送,够用且简单

选择的逻辑

  1. 先问"我的需求是什么"
  2. 再问"哪个方案的优势匹配我的需求"
  3. 最后问"我能承担这个方案的代价吗"

开放性问题

  • 你的项目用了什么方案?为什么?
  • 有没有遇到过"选错方案"的情况?
  • 如果重新设计,你会怎么选?

参考资料

相关推荐
掘金安东尼1 天前
⏰前端周刊第 456 期(v2026.3.15)
前端·javascript·面试
还是大剑师兰特1 天前
Vue3 通用可复用动态插槽组件(终极版)
前端·javascript·vue.js
nibabaoo1 天前
前端开发攻略---在 Vue 3 项目中使用 vue-i18n 实现国际化多语言
前端·javascript·国际化·i18n·vue3
qq_437100661 天前
ElasticSearch相关记录
大数据·前端·javascript·elasticsearch·全文检索
CHU7290351 天前
剧本杀组车约玩小程序前端功能版块设计及玩法介绍
前端·小程序
清空mega1 天前
《Vue3 模板进阶:class/style 绑定、事件对象、修饰符、表单处理与高频易错点》
前端·javascript·vue.js
还是大剑师兰特1 天前
Vue3 插槽完整实战(具名插槽 + 动态插槽)
前端·javascript·vue.js
fei_sun1 天前
Vue+SpingBoot+MyBaits框架
前端·javascript·vue.js
爱吃鱼的锅包肉1 天前
利用css+js实现一个图片随鼠标滑动裁剪的功能
前端·javascript·css·计算机外设
儒雅的烤地瓜1 天前
小程序 | Vue小程序开发框架:MPvue与UniApp深度解析
前端·vue.js·uni-app·nodejs·cli·mpvue