用现代方式在 React 中使用 gRPC:从 gRPC-web 到 Connect

用现代方式在 React 中使用 gRPC:从 gRPC-web 到 Connect

最近在探索如何在 React 项目中使用 gRPC 与后端交互, 搜了网上很多文章, 但是很震惊居然没有一篇是从 0 开始介绍得比较清楚的, 所以简单探索了一下, 用 react + rust + go 实现了一个简单的人员 CRUD 服务.

完整代码: github.com/Arichy/reac.... 请注意虽然本文介绍了多套方案, 但是在代码仓库里前端使用的是 @bufbuild + @connect 全家桶, 没有使用 grpc-webprotobuf-ts.

1. 概念区分

在进入正文之前, 有几个容易混淆的概念需要区分一下:

特性 Protobuf RPC gRPC gRPC-web Connect
定义 Protocol Buffers,谷歌开发的结构化数据序列化格式 Remote Procedure Call,远程过程调用,一种通用概念 Google 开发的高性能 RPC 框架,基于 HTTP/2 和 Protocol Buffers 浏览器端 gRPC 的实现,使前端可以直接与 gRPC 服务通信 更现代的 RPC 框架,注重开发体验,兼容 gRPC
创建者 Google 通用概念,无单一创建者 Google Google Buf 公司
传输协议 不适用(仅序列化格式) 多种 HTTP/2 HTTP/1.1 或 HTTP/2(需要代理) HTTP/1.1 或 HTTP/2
序列化 二进制,高效紧凑 多种 Protocol Buffers Protocol Buffers Protocol Buffers 或 JSON
浏览器支持 通过 JS 库支持 不直接支持 不直接支持 通过特殊客户端支持 原生支持,不需要代理
代码生成 使用 protoc 之类的编译器生成多语言代码 取决于实现 复杂,需要 protoc 复杂,需要 protoc 和插件 简化,使用 buf 工具链
使用场景 高效数据交换,跨语言系统 分布式系统通信 微服务间通信,后端服务 浏览器 → 服务器通信 全栈应用,跨平台
开发体验 需要额外的编译步骤 基础 功能丰富但配置复杂 配置复杂,需要代理 简化配置,优秀 DX

(以上表格由 Github Copilot 生成)

总的来说:

  • protobuf 是一种数据格式, 常用二进制序列化.
  • rpc 是一种通信概念
  • gRPC 是一种实现了 rpc 的框架, 使用 protobuf 作为数据格式
  • gRPC-web 是 gRPC 的浏览器端实现, 但是由于浏览器不支持 http/2, 所以需要一个代理服务来将 http/1.1 转换为 http/2
  • connect 是一个新的 rpc 框架, 兼容 gRPC, 但是更现代化, 同时也有自己的 connect 通信协议

2. 技术栈 + 结构

  1. 前端: React + TypeScript + Vite + gRPC 相关库(这一点很很混乱, 下面会介绍)
  2. gRPC 后端: Rust + tonic (一个 Rust 的 gRPC 实现)
  3. connect 后端: Go + connect
  4. protobuf files
  5. envoy + Docker (Docker 可选, 因为我不想在本地安装 envoy, 所以用 Docker 来运行 envoy)

目录结构 (一些常规文件省略):

scss 复制代码
.
├── frontend/ (generated by vite, regular files omitted)
│   ├── src/
│   │   ├── gen/
│   │   └── grpc.ts
│   ├── package.json
│   ├── vite.config.ts
│   ├── buf.gen.yaml
│   └── tsconfig.json
├── rust-grpc-backend/ (a simple Rust gRPC server, regular files omitted)
│   ├── src/
│   ├── build.rs
│   └── Cargo.toml
├── go-connect-backend/ (a simple Go connect server, regular files omitted)
│   ├── gen/ (generated by `buf generate`)
│   ├── buf.yaml
│   ├── buf.gen.yaml
│   └── main.go
├── proto/
│   └── person.proto
├── envoy.yaml
└── docker-compose.yml

envoy

首先需要知道一个点, gRPC 是基于 HTTP/2 的. 虽然现代浏览器支持 HTTP/2, 但 JavaScript 的 fetch API 无法直接控制 HTTP/2 的流、头部帧等底层能力, 这使得 gRPC 无法在浏览器中原生使用. 举个例子, gRPC 对 HTTP/2 有特定的帧格式要求, 但是 fetch API 并没有提供这样的能力. 所以我们需要一个代理服务来将浏览器发出的 HTTP/1.1 请求转换为 HTTP/2 请求. 目前比较流行的代理服务就是 envoy (放心只需要最基本的使用, 看不懂这个也没关系). 在根目录创建一个 envoy.yaml 文件, 内容如下:

yaml 复制代码
admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }

static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address: { address: 0.0.0.0, port_value: 8080 } # 前端请求发送的地址
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                codec_type: auto
                stat_prefix: ingress_http
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: local_service
                      domains: ['*']
                      routes:
                        - match: { prefix: '/' }
                          route:
                            cluster: person_service
                            timeout: 0s
                            max_stream_duration:
                              grpc_timeout_header_max: 0s
                      cors:
                        allow_origin_string_match:
                          - prefix: '*'
                        allow_methods: GET, PUT, DELETE, POST, OPTIONS
                        allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
                        max_age: '1728000'
                        expose_headers: grpc-status,grpc-message
                http_filters:
                  - name: envoy.filters.http.grpc_web
                    typed_config:
                      '@type': type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
                  - name: envoy.filters.http.cors
                    typed_config:
                      '@type': type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
                  - name: envoy.filters.http.router
                    typed_config:
                      '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
    - name: person_service
      connect_timeout: 0.25s
      type: logical_dns
      http2_protocol_options: {}
      lb_policy: round_robin
      load_assignment:
        cluster_name: person_service
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      # address: backend # 如果用 docker-compose 同时运行 backend + envoy, 用这个
                      address: host.docker.internal # 如果只用 Docker 运行 envoy, 用这个
                      port_value: 50051 # backend 的 grpc server port

看不懂没关系, 我也看不懂. 这不是重点, 只需要关心上面注释出来的两个端口和一个 address 就可以了. 创建好就可以通过 docker-compose 来运行了. 下面是一个简单的 docker-compose.yml 文件:

yaml 复制代码
services:
  # Envoy proxy for gRPC-Web support
  envoy:
    image: envoyproxy/envoy:v1.33-latest
    ports:
      - '8080:8080'
    volumes:
      - ./envoy.yaml:/etc/envoy/envoy.yaml # 将 envoy.yaml 挂载到容器里

现在可以通过 docker-compose up 来运行 envoy 了. 这个命令会运行 envoy, 并且将 8080 端口映射到宿主机的 8080 端口, 等待将来自前端的 gRPC-web 的请求转发给 50051 端口的 Rust gRPC server.

3. 处理 proto 文件 + gRPC 通信

这里我们用 proto/person.proto 作为例子, 定义最基本的 CRUD 操作 (这个文件是 copilot 帮我写的, 在 ListPeople 的时候会返回一个分页的结果, 但是没有实现分页的逻辑, 只是为了演示 proto 文件的定义):

proto 复制代码
syntax = "proto3";

package person;

// The Person message represents an individual person in our system
message Person {
  string id = 1;
  string name = 2;
  string email = 3;
  int32 age = 4;
}

// Request message for creating a new person
message CreatePersonRequest {
  Person person = 1;
}

// Response message for creating a new person
message CreatePersonResponse {
  Person person = 1;
}

// Request message for retrieving a person by ID
message GetPersonRequest {
  string id = 1;
}

// Response message for getting a person
message GetPersonResponse {
  Person person = 1;
}

// Request message for updating a person
message UpdatePersonRequest {
  Person person = 1;
}

// Response message for updating a person
message UpdatePersonResponse {
  Person person = 1;
}

// Request message for deleting a person
message DeletePersonRequest {
  string id = 1;
}

// Response message for deleting a person
message DeletePersonResponse {
  bool success = 1;
}

// Request message for listing people
message ListPeopleRequest {
  // Optional pagination fields
  int32 page_size = 1;
  int32 page_token = 2;
}

// Response message for listing people
message ListPeopleResponse {
  repeated Person people = 1;
  int32 next_page_token = 2;
}

// The PersonService provides CRUD operations for managing people
service PersonService {
  // Create a new person
  rpc CreatePerson (CreatePersonRequest) returns (CreatePersonResponse);

  // Get a person by ID
  rpc GetPerson (GetPersonRequest) returns (GetPersonResponse);

  // Update an existing person
  rpc UpdatePerson (UpdatePersonRequest) returns (UpdatePersonResponse);

  // Delete a person by ID
  rpc DeletePerson (DeletePersonRequest) returns (DeletePersonResponse);

  // List all people with optional pagination
  rpc ListPeople (ListPeopleRequest) returns (ListPeopleResponse);
}

3.1 Rust 端

在 Rust 端, 我们需要使用 tonic 来生成 gRPC 相关的代码. 这块不是重点也很简单, 所以就不展开了, 可以自己查看文档. 其实就是写一个 build.rs 文件, tonic-build 就会自动将 proto 文件编译成 Rust 代码, 然后我们就可以在 Rust 代码中使用了.

3.2 前端

这部分是本文的重点, 也是最麻烦的. 我在查找文档的时候, 被一大堆库弄晕了, 花了很长时间才理清楚每个库是做什么的. 主要是因为 gRPC 的 js 不仅可以在浏览器端使用, 还可以在 Node.js 端使用, 所以有很多库是针对 Node.js 的, 但是我们需要的是浏览器端的. 所以需要装很多库, 有些库单纯是 js 语言本身的, 和平台无关. 有的库会提供浏览器的支持.

官方实现

TL;DR: 不推荐使用官方实现, 各种库很混乱, 构建的命令巨恶心, 并且只支持 commonjs, 不支持 ES module.

官方的 gRPC-web 实现是 grpc-web. 你需要先安装一个编译器:

复制代码
brew install protobuf

这行语句安装的是 protobuf, 在命令行里的使用 protoc 调用. 它是一个编译器, 我们需要用它来将 proto 文件编译成其他语言的代码. 但是由于 protobuf 官方只支持了 C++, C#, Dart, Go, Java, Kotlin, Python, 没有支持 js, 所以我们需要安装一个插件来生成 js 代码. 这个插件就是 protoc-gen-js, 可以通过 npm 安装. 官方文档教程写的全局安装, 但是我个人喜欢局部安装到项目里.

使用官方实现的一个项目的 package.json 应该有以下库:

json 复制代码
{
  "dependencies": {
    "grpc-web": "提供浏览器端与 gRPC 服务器通信的能力, 重点是提供通信能力",
    "google-protobuf": "提供 protobuf 的 JavaScript 实现, 重点是提供 protobuf 的序列化和反序列化能力"
  },
  "devDependencies": {
    "protoc-gen-js": "上文提到的插件, 会被 protoc 调用, 用于将 proto 文件编译成 js 代码, 重点是编译能力",
    "protoc-gen-grpc-web": "另一个插件, 会被 protoc 调用, 生成 grpc-web 相关的客户端代码"
  }
}

然后运行一段脚本来将 proto 文件编译成 js 代码:

json 复制代码
{
  "scripts": {
    "gen-proto": "rm -rf src/generated && mkdir -p src/generated && protoc -I=../proto --js_out=import_style=commonjs,binary:src/generated --grpc-web_out=import_style=commonjs,mode=grpcwebtext:src/generated ../proto/*.proto"
  }
}

说实话光是看到这个编译命令就两眼一黑, 我尽量解释一下是在干什么. 先抛开前两个删除已有目录+创建目录, 我们来看 protoc 命令:

  • -I=../proto 表明去哪个目录下找 proto 文件. 因为这个 package.json 文件是在 frontend 下, 所以这里是上一层的 ../proto 目录.
  • --js_out=import_style=commonjs,binary:src/generated 这个 js_out 会传给 protoc-gen-js 插件, 下面会详细介绍.
  • ../proto/*.proto 是要编译的 proto 文件, 也是传给 protoc 的直接参数.

这个 --js_out 的样子看着过于恶心, 其格式为 --js_out=[OPTIONS:]output_dir, 其中 OPTIONS是由逗号分隔的选项, output_dir 是输出目录. 这里的 OPTIONS 有两个选项:

  • import_style=commonjs 是指定生成的 js 代码使用 commonjs 模块化, 这个选项是可选的, 如果不指定, 默认是 commonjs 模块化.
  • binary 说明生成的 js 代码应该支持对消息进行二进制的序列化和反序列化

src/generated 是生成的 js 代码的路径

然后是 --grpc-web_out 参数, 这个参数的格式和 --js_out 一样, 就不过多赘述.

运行这个命令, 会在 src/generated 下生成两个文件:

  • person_pb.js: 这个文件是 protoc-gen-js 生成的 js 代码, 里面包含了 proto 文件定义的消息类型和序列化/反序列化的逻辑
  • person_grpc_web_pb.js: 这个文件是 protoc-gen-grpc-web 生成的 js 代码, 里面包含了 gRPC 的客户端代码, 也就是我们需要调用发送请求的代码.

很不幸, protoc-gen-js 生成的 js 代码只支持 commonjs, 不支持 esm, 并且里面使用了 exports 变量

js 复制代码
goog.object.extend(exports, proto.person);

我没有找到方法让 Vite 成功打包生成的代码, 所以不推荐用官方的实现.

protobuf-ts

protobuf-ts 是一个第三方的实现. 这个实现方案只需要装两个库即可:

json 复制代码
{
  "dependencies": {
    "@protobuf-ts/grpcweb-transport": "gRPC 的运行时库"
  },
  "devDependencies": {
    "@protobuf-ts/plugin": "protoc 的编译器插件, 用于将 proto 文件编译成 ts 代码"
  }
}

然后修改编译命令:

json 复制代码
{
  "scripts": {
    "gen-proto": "rm -rf src/generated && mkdir -p src/generated && protoc -I=../proto --ts_out=src/generated ../proto/*.proto"
  }
}

可以看出, 这个方案依然依赖 protoc 命令, 但是不需要安装 protoc-gen-jsprotoc-gen-grpc-web 插件, 只需要安装 @protobuf-ts/plugin 插件, 并且命令也简洁了许多. 这个插件会被 protoc 调用, 用于将 proto 文件编译成 ts 代码. 这个方案的好处是, 生成的 ts 代码是 esm 模块化的, 可以直接在 vite 中使用.

然后更新 frontend/src/grpc.ts

ts 复制代码
import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport';
import { PersonServiceClient } from './generated/person.client';

const apiUrl = 'http://localhost:8080';

const transport = new GrpcWebFetchTransport({
  baseUrl: apiUrl,
});
export const personClient = new PersonServiceClient(transport);

现在在 React 里就可以愉快地使用了 (这里配合了 react-query):

ts 复制代码
import { personClient } from './grpc';

// in component
const peopleQueryKey = ['people'];

// READ
const { data: people } = useQuery({
  queryKey: peopleQueryKey,
  queryFn: async () => {
    const response = await personClient.listPeople({ pageSize: 1, pageToken: 1 });
    return response.response.people;
  },
});

// CREATE
const handleAddPerson = async (person: Person) => {
  try {
    await personClient.createPerson(CreatePersonRequest.create({ person })); // option 1: using create method
    queryClient.invalidateQueries({ queryKey: peopleQueryKey });
    setSelectedPerson(null);
  } catch (err) {
    console.error('Error adding person:', err);
  }
};

// UPDATE
const handleUpdatePerson = async (person: Person) => {
  try {
    await personClient.updatePerson({ person }); // option 2: directly pass params
    queryClient.invalidateQueries({ queryKey: peopleQueryKey });

    setSelectedPerson(null);
  } catch (err) {
    console.error('Error updating person:', err);
  }
};

接下来一个个运行就可以了:

  1. docker-compose up 运行 envoy
  2. 进入 rust-grpc-backend, cargo run 运行 rust grpc server
  3. 进入 frontend, yarn dev 运行前端
  4. 打开浏览器, 访问 http://localhost:5173 (vite 默认端口)

bufbuild + connect 方案

这是另一个更为系统性 + 现代化的第三方方案, 需要安装以下几个库:

json 复制代码
{
  "dependencies": {
    "@bufbuild/protobuf": "protobuf 的核心库, 提供了 protobuf 的运行时支持",
    "@connectrpc/connect": "connect 的核心库, 提供了平台无关的 connect 的运行时支持",
    "@connectrpc/connect-web": "connect 的 grpc-web 插件, 用于将在前端提供 grpc-web 的通信能力",
    "@connectrpc/connect-query": "可选, 提供 react-query 的支持, 虽然不用装这个也可以使用 react-query"
  },
  "devDependencies": {
    "@bufbuild/buf": "proto 文件的编译器",
    "@bufbuild/protoc-gen-es": "编译器插件, 生成 es 代码"
  }
}

之所以有 bufbuildconnect 两个命名空间, 是因为它们有不同的作用. @bufbuild 的库是处理 proto 文件和 protobuf 格式的, 而 @connect 的库是处理 connect/gRPC 协议的.

使用这个方案就可以摆脱复杂的 protoc 命令了, 使用 buf 命令来取代. 这个命令需要在 frontend 目录下创建一个 buf.gen.yaml 文件, 内容如下:

yaml 复制代码
# Learn more: https://buf.build/docs/configuration/v2/buf-gen-yaml
version: v2 # migration from v1 guide: https://github.com/connectrpc/connect-es/blob/main/MIGRATING.md
inputs:
  - directory: ../proto

clean: true
plugins:
  - local: protoc-gen-es # 这个插件会被 buf 调用, 用于将 proto 文件编译成 ts 代码
    opt: target=ts # 表明我们要生成 ts 代码
    out: src/gen # 生成的代码存放目录
    include_imports: true

然后在 package.json 中添加一个命令:

json 复制代码
{
  "scripts": {
    "gen-proto": "buf generate"
  }
}

运行这个命令, 会在 src/gen 下生成一个文件: src/gen/person_pb.ts. 然后更新 frontend/src/grpc.ts 文件:

ts 复制代码
import { createClient, Transport } from '@connectrpc/connect';
import { createGrpcWebTransport } from '@connectrpc/connect-web';

import { PersonService } from './gen/person_pb';

const apiUrl = 'http://localhost:8080';

export const transport: Transport = createGrpcWebTransport({
  baseUrl: apiUrl,
});

export const personClient = createClient(PersonService, transport);

@connectrpc/connect-web 库还有一个方法 createConnectTransport, 这个方法创建的 transport 会使用 connect 协议和后端通信, 而不是 gRPC-web 协议. 但是这个方法需要在后端使用 connect 的实现, 而不是 gRPC 的实现. 目前 connect 官方支持了 go, node.js, swift, kotlin, dart, 还未支持 rust. 所以我们这里只能使用 gRPC-web 协议. 但是 connect 的实现会比 gRPC 的实现更现代化, 所以如果后端有支持 connect 的实现, 可以考虑使用 createConnectTransport 方法.

4. Go connect server

文章开头说了, connect 是一个新的 rpc 框架, 兼容 gRPC, 但是更现代化, 同时也有自己的 connect 通信协议. 我们探索一下如何用 go 来实现一个 connect server. 这里我们使用 connect-go, 按照教程安装好必要的工具后, 在项目根目录下新建一个 go 项目目录, 命名为 go-connect-server, 然后在里面执行:

Step 1 . go mod init example.com/go-connect-server 初始化 go 项目, 包名为 example.com/go-connect-server

Step 2 . buf config init, 会生成一个 buf.yaml 文件, 内容如下, 不用管它

Step 3 . 创建一个 buf.gen.yaml 文件, 内容如下:

yaml 复制代码
version: v2
inputs:
  - directory: ../proto

clean: true
plugins:
  - local: protoc-gen-go # 将 proto 文件编译为 go 代码
    out: gen
    opt: paths=source_relative
  - local: protoc-gen-connect-go # 生成 connect 通信协议相关代码
    out: gen
    opt: paths=source_relative

Step 4 . 非常重要的一点, 需要修改 person.proto 文件, 在其中加入一行

proto 复制代码
option go_package = "example.com/go-connect-backend/gen;person";

go_package 定义的是这个 proto 文件生成的 go 代码文件所在的包名. 其中 example.com/go-connect-backend 是 go 项目的包名, gen 是生成的 go 代码文件所在的目录, person 是 package name.

Step 5 . 执行 buf generate, 会在 go-connect-backend/gen 目录下生成一个 person.pb.go 文件, 这个文件是将 proto 文件编译为 go 代码, 里面包含了 proto 文件定义的消息类型和序列化/反序列化的逻辑. 同时会在 go-connect-backend/gen/personconnect 目录下生成一个 person.connect.go 文件, 这个文件负责处理 connect 协议通信.

然后按照教程写一个基本的服务就可以了, 要注意可能需要处理跨域. 服务启动之后, 在前端就可以通过 createConnectTransport 方法来使用 connect 协议与后端通信了. Connect 使用 HTTP/1.1 (当然, 也支持 HTTP/2) + JSON(或 protobuf)作为传输协议, 在浏览器中无需额外代理服务就能工作, 因此前端可直接请求后端.

5. 总结

本文介绍了如何在 React 项目中使用 gRPC 与后端 gRPC server 交互, 并且使用 envoy 作为代理转发, 还简单探索了一下 go 的 connect server. 以下是一些关键信息:

  1. 分清楚 protobuf 和 gRPC. 前者是数据格式, 后者是通信协议, 所以才会出现一堆包要装, 因为不同的包处理的是不同的部分.
  2. gRPC 是基于 HTTP/2 的, 但是浏览器 js 缺少操控 HTTP/2 的底层能力, 所以需要一个代理服务来将 HTTP/1.1 转换为 HTTP/2. envoy 是一个比较流行的选择.
  3. 官方的 grpc-web 实现比较老旧, 推荐使用流行的第三方方案, 比如 protobuf-ts 或者 connect.
  4. connect 是一个新的 rpc 框架, 兼容 gRPC, 但是更现代化, 同时也有自己的 connect 通信协议. 如果后端有支持 connect 的实现, 可以考虑使用 createConnectTransport 方法, 这样就不需要使用 envoy 作为代理服务, 直接使用请求服务即可.

6. 一些小细节

  1. 我个人有很严重的硬盘洁癖, 非常不喜欢在文件系统的各个地方留下文件, 久而久之就吃满硬盘. 所以我会尽可能减少全局安装命令, 这也是使用 Docker 来运行 envoy, 而不是全局安装 envoy 然后运行的原因.
  2. 在前端我们安装了 @bufbuild/buf@bufbuild/protoc-gen-es 这两个库, 安装到了 node_modules 里, 然后通过 npm scripts 运行. 有的教程可能会让你全局安装, 可以根据个人喜好来决定.
  3. 由于 go 没有 npm scripts 的机制, 所以 go 的几个 tools 只能通过 go install 全局安装. 这里我们安装了 grpcurl, buf, protoc-gen-go, protoc-gen-connect-go 这几个工具, 其中 grpcurl 如果你已经通过 brew 安装, 就可以不用再次用 go 安装了. 这里全局安装了一个 buf, 其实在前端装的 @bufbuild/buf 是这个 buf 的一个 wrapper. @bufbuild/buf 会拉取 buf 的 binary 到 node_modules 里, 然后自己调用.
相关推荐
PBitW3 分钟前
工作中突然发现零宽字符串的作用了!
前端·javascript·vue.js
VeryCool4 分钟前
React Native新架构升级实战【从 0.62 到 0.72】
前端·javascript·架构
小小小小宇5 分钟前
JS匹配两数组中全相等对象
前端
xixixin_8 分钟前
【uniapp】uni.setClipboardData 方法失效 bug 解决方案
java·前端·uni-app
狂炫一碗大米饭9 分钟前
大厂一面,刨析题型,把握趋势🔭💯
前端·javascript·面试
星空寻流年15 分钟前
css3新特性第五章(web字体)
前端·css·css3
加油乐21 分钟前
JS计算两个地理坐标点之间的距离(支持米与公里/千米)
前端·javascript
小小小小宇21 分钟前
前端在 WebView 和 H5 环境下的缓存问题
前端
懒羊羊我小弟24 分钟前
React JSX 语法深度解析与最佳实践
前端·react.js·前端框架
冷冷清清中的风风火火28 分钟前
关于敏感文件或备份 安全配置错误 禁止通过 URL 访问 Vue 项目打包后的 .gz 压缩文件
前端·vue.js·安全