React 驱动的 Docker 化开发环境:利用 Docker Compose 实现前端渲染层与后端服务层的物理隔离开发

各位好,坐!都坐好。别以为我站在讲台上你们就听不懂,今天咱们不讲那种满屏 importexport 的枯燥教程,咱们来聊聊怎么让你的开发环境从“一团乱麻”变成“瑞士军刀”。

我知道你们在想什么:“老张,React 项目不是只要 npm install 然后 npm start 就完事了吗?还要 Docker?是不是为了炫耀我的配置文件有多长?”

肤浅!太肤浅了!

各位,试过那种场景吗?你刚克隆了 GitHub 上的一个 React 项目,满怀期待地敲下 npm install,结果你的电脑风扇开始像直升机一样起飞,内存占用飙升到 98%,进度条卡在 unpkg 下载依赖那一行,半小时过去了,进度条还在 10%。

然后你问:“为什么我同事运行得飞快,我运行得像个蜗牛?”

答案是:环境不一致。

同事的 Mac 上安装了 Python 3.10,你的 Windows 上只有 Python 3.8;同事的 Node 版本是 18,你的还是 16;最要命的是,你的项目依赖了一个仅限 Linux 环境运行的库,你在 Windows 上死活跑不起来。

这时候,Docker 就登场了。它不是什么魔法,它就是一个“移动的客厅”。无论你把客厅搬到 Mac 上,还是搬到 Windows 上,甚至是那个装了 CentOS 的阿里云服务器上,客厅里的沙发、冰箱、电视(也就是你的代码、Node.js、NPM 依赖)都是一模一样的。

今天,咱们就来构建一个“完全 Docker 化的 React 开发天堂”。在这个天堂里,前端是 React,后端是 Node/Express,数据库是 PostgreSQL,它们被关在各自的盒子里,通过 Docker Compose 这位“管家”有序地生活在一起。

准备好了吗?让我们开始搭建这栋“物理隔离”的大楼。


第一部分:起地基——后端服务的容器化

首先,我们要解决谁的问题?后端。后端通常是 API 提供者。假设我们有个简单的 Express 服务,端口号是 5000。为了让它在 Docker 里运行,我们需要两样东西:一个 Dockerfile(房子的蓝图)和一个 package.json(房子的装修清单)。

1.1 后端的 package.json

别整那些花里胡哨的,咱们来个最纯粹的 Express 示例。

{
  "name": "backend-service",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "cors": "^2.8.5"
  }
}

1.2 后端的 Dockerfile

这是关键。很多人在这里犯傻。不要在 Dockerfile 里写 npm install 这种命令,你要告诉 Docker “直接用现成的镜像,别自己装”。这叫“Copy-on-Write”思想的体现,也即我们利用 Docker Hub 上的官方镜像。

注意,这里我指定了 node:18-alpinealpine 版本非常小,只有 5MB 左右,但它是个极客的最爱,跑 Node 项目绰绰有余。

# backend/Dockerfile
# 第一层:基础装修——搬进来一个 Node.js 的空壳
FROM node:18-alpine

# 设置工作目录,这就像是你把客厅家具都堆到了客厅中间
WORKDIR /app

# 复制 package.json 和 package-lock.json(或者 yarn.lock)到容器里
# 这一步很重要,因为 npm install 会利用缓存,只有文件变了才重新下载
COPY package*.json ./

# 安装依赖,就像在屋里买家具
RUN npm install --production

# 剩下的源代码,我们在 docker-compose 里挂载,这样改了代码不需要重建镜像
COPY . .

# 暴露端口,告诉外面的世界“我有服务,在 5000 号房间”
EXPOSE 5000

# 启动命令
CMD ["node", "index.js"]

1.3 后端的 index.js

简单得不能再简单,打印个 Hello World 就行。

// backend/index.js
const express = require('express');
const cors = require('cors');
const app = express();
const PORT = 5000;

app.use(cors());
app.use(express.json());

app.get('/', (req, res) => {
  res.json({ message: 'Backend is living in a Docker box!', status: 'ok' });
});

app.listen(PORT, () => {
  console.log(`Backend running on http://localhost:${PORT}`);
});

好了,后端的基础设施搭好了。现在咱们需要把这个 Dockerfile 变成真正的 Docker 镜像。在终端里跑一下:

cd backend
docker build -t my-backend .

第二部分:盖楼层——React 前端的容器化

接下来是重头戏,React。我们有两个选择:一是你在宿主机上装了 React CLI,直接跑;二是我们把它也塞进 Docker 里。

我强烈建议后者。为什么要后者?因为 React 的构建工具(Webpack 或 Vite)极其挑剔。有些插件在 Mac 上能用,在 Linux 上报错;有些配置在宿主机上是对的,在 Docker 容器里就是错的。

2.1 前端的 Dockerfile

React 的 Dockerfile 有两种流派。
流派 A:开发模式。在容器里运行 npm start,利用 Node 的热重载。
流派 B:生产模式。在容器里运行 npm run build,然后 Nginx 托管静态文件。

今天咱们要开发,所以选流派 A。

# frontend/Dockerfile
FROM node:18-alpine

# 指定工作目录
WORKDIR /app

# 同样先复制依赖文件,为了利用缓存
COPY package*.json ./

# 安装依赖
RUN npm install

# 复制源代码
COPY . .

# 暴露前端开发服务器通常用的 3000 端口
EXPOSE 3000

# 设置环境变量,让 React 知道我们是在容器里,可能不需要图片压缩之类的(视情况而定)
ENV CHOKIDAR_USEPOLLING=true 

# 启动 React 开发服务器
CMD ["npm", "start"]

2.2 前端的 package.json

假设你有个标准的 Create React App 结构。

{
  "name": "frontend-service",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build"
  }
}

2.3 前端配置文件:.dockerignore

这点非常重要!如果你不写这个文件,你的 node_modules 文件夹也会被复制进容器。那可是几万个文件!会让构建时间从几秒变成几分钟。

node_modules
build
dist
.git
.env

现在构建镜像:

cd frontend
docker build -t my-frontend .

第三部分:搞装修——Docker Compose 的魔法

好了,现在我们有了一个 React 容器(住 3000 号房间)和一个 Node 容器(住 5000 号房间)。但是它们现在还是孤岛,React 容器想访问后端,得翻墙出去。

我们需要一个总指挥,这就是 Docker Compose。它负责把这三个房间(前端、后端、数据库)连成一个社区。

3.1 编写 docker-compose.yml

这是整个架构的灵魂。咱们把后端、前端、还有数据库(Postgres)都列进去。

version: '3.8' # 版本号,别乱改,除非你懂历史

services:
  # 服务 1:后端 API
  backend:
    build: ./backend
    container_name: my_backend_container
    ports:
      - "5000:5000" # 把容器里的 5000 映射到宿主机的 5000
    environment:
      - PORT=5000
      - DB_HOST=postgres # 注意,这里不是 localhost,而是服务名!
      - DB_USER=postgres
      - DB_PASSWORD=secret
      - DB_NAME=mydb
    depends_on:
      - postgres # 告诉 Docker,后端启动前,先等数据库起来
    networks:
      - app_network

  # 服务 2:前端 React
  frontend:
    build: ./frontend
    container_name: my_frontend_container
    ports:
      - "3000:3000" # React 开发服务器在 3000
    environment:
      - REACT_APP_API_URL=http://backend:5000 # 关键!前端要访问后端,得用后端的服务名
    volumes:
      # 这行代码是命根子!它把宿主机的代码实时同步到容器里
      - ./frontend/src:/app/src 
    depends_on:
      - backend
    networks:
      - app_network

  # 服务 3:数据库 PostgreSQL
  postgres:
    image: postgres:13-alpine
    container_name: my_postgres_container
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=mydb
    volumes:
      - postgres_data:/var/lib/postgresql/data # 数据持久化,重启不丢数据
    networks:
      - app_network

# 定义网络,让这些服务能互相打电话
networks:
  app_network:
    driver: bridge

# 定义卷,专门给数据库用的
volumes:
  postgres_data:

3.2 理解这段配置(专家视角)

这里有几个非常值得玩味的点,大家拿笔记记一下:

  1. 服务名 vs Hostname

    • 当你在宿主机访问 localhost:5000 时,你是在找你的电脑。
    • 当你在 React 容器里访问 http://backend:5000 时,你是在找名为 backend 的那个 Docker 容器。
    • 记住:在 Docker Compose 网络里,容器之间通信,永远用服务名(backend),而不是 localhost。这叫服务发现
  2. Volumes(卷挂载)

    • frontend 下的 volumes: - ./frontend/src:/app/src
    • 这是什么意思?这就像是在两间房子之间打通了一扇窗户。你在宿主机的 VS Code 里改一行代码,容器里的文件立刻就变了,浏览器里的 React 页面会自动刷新。
    • 注意:后端通常不需要挂载,因为后端逻辑复杂,改动后重启容器即可,挂载可能会引起文件锁定问题。
  3. depends_on

    • 它只是让 Docker 知道启动顺序。如果你在代码里 await 数据库连接,你必须手动处理重试逻辑,因为 depends_on 不会等数据库真的连上,它只是等容器启动了。
  4. environment 变量

    • 我们通过环境变量把配置传给 Node 和 React。这是现代应用的标准做法。别在代码里写死数据库密码!

第四部分:连通世界——网络与通信

现在,咱们启动这个大家庭。
在项目根目录运行:

docker-compose up --build

等待那漫长的构建过程结束,你会在终端看到三个绿色的服务名字在飞奔。

场景:React 访问后端

在你的 React 组件里,你可能会写这样的代码(使用 axiosfetch):

// frontend/src/api.js
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000';

export const fetchData = async () => {
  try {
    const response = await fetch(`${API_URL}/`);
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('哎呀,好像撞墙了', error);
  }
};

由于我们在 docker-compose.yml 里设置了 REACT_APP_API_URL=http://backend:5000,所以 React 容器发出的请求是:
http://backend:5000/

Docker 的内置 DNS 系统会收到这个请求,查到 backend 容器的 IP,然后转发过去。React 在浏览器里看到的是 localhost:3000,用户完全感知不到这背后还有个复杂的网络拓扑。

场景:后端连接数据库

在后端的 Node 代码里:

const { Pool } = require('pg');
const pool = new Pool({
  host: process.env.DB_HOST, // 从环境变量读取,这里是 'postgres'
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
});

pool.connect().then(client => {
  console.log('Successfully connected to the DB');
  client.release();
});

后端容器同样通过环境变量获取 DB_HOST=postgres,直接连接。这一切都在 Docker 的隔离网络里静悄悄地完成,完全不需要端口映射到宿主机上,安全又高效。


第五部分:排雷与坑——那些你绝对会遇到的问题

光说不练假把式。当你跑起来之后,你会发现生活不是只有诗和远方,还有报错。

5.1 热更新不生效(The “Hot Reload” Fails)

你改了 React 代码,保存了,但是浏览器没反应。
原因:Docker 容器是个闭关锁国的盒子。虽然我们挂载了源码目录,但是 React 的进程还在容器里运行。它不知道外面的文件变了。
解决
确保你的 Dockerfile 里有这一行:

ENV CHOKIDAR_USEPOLLING=true 

或者在 docker-compose.yml 里加挂载参数。
但这还不够,有时候容器里的文件权限不对。试试在宿主机执行 sudo chown -R $USER:$USER ./frontend/src,把文件权限交还给宿主机用户。

5.2 “Cannot GET /” 或者 404 Not Found

你 React 页面能打开,但是点击按钮去调后端 API,报错了。
原因:跨域(CORS)。
解决:后端必须显式配置允许跨域。

// backend/index.js
const corsOptions = {
  origin: '*', // 生产环境别这么写,用具体的域名
  optionsSuccessStatus: 200
};
app.use(cors(corsOptions));

记住,Docker 容器不认你的浏览器地址栏,它只认它自己的网络环境。只要同在一个 app_network 下,默认是不需要 CORS 的(因为同源),但如果你的前端是在 localhost:3000 被浏览器访问,而后端在 localhost:5000 被浏览器访问,那浏览器就会判定为跨域。所以必须在后端配置 CORS。

5.3 内存溢出(OOM Killer)

如果你的电脑只有 8GB 内存,跑完 docker-compose up,电脑可能会卡死。
原因:Node 默认占用内存很大。两个 Node 容器加上一个 Postgres 容器,再加上宿主机的系统,内存不够了。
解决
限制容器内存:
docker-compose.yml 里加:

services:
  backend:
    # ... 其他配置 ...
    deploy:
      resources:
        limits:
          memory: 512M

给 Node 降级?不,用 node:18-alpine,那是必须的。把数据库的内存限制一下也行。

5.4 npm install 重复下载

如果你不小心在容器里运行了 npm install,然后又重新构建了镜像,Docker 会检查 node_modules 是否存在。如果存在,它直接用缓存,不下载。这很好。但如果文件变了,它会下载。这是 Docker 的一大优点:分层构建


第六部分:进阶玩法——多阶段构建与优化

刚才那个方案,对于开发环境来说已经完美了。但如果我们要部署呢?我们要把 Docker 镜像上传到 Docker Hub,让别的同事也能用,或者部署到 Kubernetes。

那就要用到多阶段构建了。刚才那个 Dockerfile 虽然能跑,但它把开发工具(Webpack 编译器)也打包进去了。那镜像有几 GB 大!太臃肿了。

6.1 生产级前端 Dockerfile

我们要分两步走:
第一步:在“构建容器”里,安装依赖并打包出静态文件。
第二步:在一个“极简容器”里,只运行一个 Nginx,把第一步生成的文件扔进去。

# 第一阶段:构建
FROM node:18-alpine as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build # 生成 dist 目录

# 第二阶段:运行
FROM nginx:alpine
# 把构建好的 dist 目录复制到 nginx 的 html 目录
COPY --from=build /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

看,现在你的前端容器只有 20MB 左右,纯静态 HTML/JS/CSS。这叫“分而治之”

6.2 开发环境的卷挂载优化

在开发时,我们不需要多阶段构建。但我们可以利用卷挂载来优化体验。
/app/node_modules 也挂载进去,这样宿主机的依赖变了,容器不用重新安装。

volumes:
  - ./frontend/src:/app/src
  - ./frontend/node_modules:/app/node_modules
  - ./frontend/public:/app/public

注意:如果你这样做,你的宿主机的 node_modules 就会变成容器里的样子,有时候会有冲突。建议只在开发急需时用,或者用 .dockerignore 严格管理。


第七部分:总结——拥抱隔离

各位,咱们讲了这么多代码,到底为了什么?

为了一致性

想象一下,你终于把项目上线了。上线时,老板让你在测试环境验证一下。
以前,你得在这个服务器上装 Node,在那个服务器上装 Python,把数据库建好,配置文件改得面目全非。
现在,你只需要执行一行命令:
docker-compose up -d

所有环境都是一模一样的。没有“在我的机器上能跑”,只有“在 Docker 里能跑”。

为了隔离
你的 React 组件报错了,是因为 lodash 的版本问题,还是浏览器兼容性问题?分清楚!因为它们现在被关在不同的盒子里了。

为了速度
虽然启动容器比直接运行 Node 要慢几秒钟,但是一旦跑起来,你的开发效率会指数级提升。你不需要再为了修一个小 Bug 而重启你的宿主机。

最后,给大家留一个小作业。
现在的架构是:前端 -> 后端 -> 数据库。
请思考一下:能不能让 React 的构建过程也跑在 Docker 里?
也就是在 docker-compose.yml 里加一个服务,让它每当你保存代码时,自动触发 npm run build,然后自动刷新另一个容器里的浏览器?
答案是。这就是 CI/CD 的雏形。你可以利用 docker-compose exec frontend npm run build 配合 watch 命令,实现一个极简的“监工系统”。

好了,今天的讲座就到这里。记住,Docker 不是银弹,但它解决的是“环境地狱”这个世纪难题。下次当你的同事再问“你怎么能在 Mac 上写代码,却在 Linux 上跑不通”的时候,请把这篇文档甩给他,然后微笑着说:“因为我的代码住在盒子里。”

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注