🔥React工程化实践:构建企业级可维护应用架构

React工程化实践:构建企业级可维护应用架构

在大型前端项目中,工程化能力决定应用的成败。本文将深入探讨如何构建可维护、可扩展、高性能的React企业级应用架构

一、企业级应用的核心挑战

随着React应用规模扩大,开发者面临四大核心挑战:

  1. 代码复杂度失控:组件耦合度高,修改一处影响全局
  2. 协作效率低下:多人协作时代码风格不统一,冲突频繁
  3. 性能瓶颈:数据流混乱导致不必要的渲染
  4. 维护成本高:缺乏文档和规范,新人上手困难
graph TD A[代码复杂度] --> B[维护成本] C[协作效率] --> D[开发速度] E[性能瓶颈] --> F[用户体验] B --> G[项目失败风险] D --> G F --> G

二、模块化架构设计

2.1 分层架构模式

企业级React应用推荐采用分层架构:

text 复制代码
src/
├── application/    # 应用层
│   ├── pages/      # 页面组件
│   └── routers/    # 路由配置
├── domain/         # 领域层
│   ├── product/    # 产品领域
│   └── user/       # 用户领域
├── infrastructure/ # 基础设施层
│   ├── api/        # API服务
│   └── storage/    # 存储服务
├── ui/             # UI层
│   ├── components/ # 通用组件
│   └── layouts/    # 布局组件
└── utils/          # 工具库

2.2 领域驱动设计(DDD)实践

jsx 复制代码
// domain/user/UserContext.js
import React, { createContext, useContext } from 'react';
import { useUserService } from '../../infrastructure/api/userService';

const UserContext = createContext();

export function UserProvider({ children }) {
  const {
    user,
    loading,
    error,
    login,
    logout,
    updateProfile
  } = useUserService();
  
  return (
    <UserContext.Provider 
      value={{ 
        user, 
        loading,
        error,
        actions: { login, logout, updateProfile }
      }}
    >
      {children}
    </UserContext.Provider>
  );
}

// 自定义hook封装领域逻辑
export function useUser() {
  const context = useContext(UserContext);
  if (!context) {
    throw new Error('useUser必须在UserProvider内使用');
  }
  return context;
}

// 在组件中使用
function UserProfile() {
  const { user, loading, actions } = useUser();
  
  if (loading) return <Spinner />;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <button onClick={() => actions.updateProfile({ name: '新用户名' })}>
        更新资料
      </button>
    </div>
  );
}

2.3 组件设计原则

组件分类体系
组件类型 职责 示例 复用性
基础组件 基础UI元素 Button, Input 高
领域组件 业务功能单元 ProductCard, UserForm 中
容器组件 数据获取与状态管理 ProductListContainer 低
页面组件 路由入口 HomePage 无
组件设计模式:复合组件
jsx 复制代码
// ui/components/DataTable/DataTable.js
import React from 'react';

const DataTable = ({ children }) => (
  <table className="data-table">
    {children}
  </table>
);

// 子组件
DataTable.Header = ({ columns }) => (
  <thead>
    <tr>
      {columns.map((col, index) => (
        <th key={index}>{col.title}</th>
      ))}
    </tr>
  </thead>
);

DataTable.Body = ({ data, renderRow }) => (
  <tbody>
    {data.map((item, index) => renderRow(item, index))}
  </tbody>
);

DataTable.Footer = ({ children }) => (
  <tfoot>
    <tr>
      <td colSpan="100%">{children}</td>
    </tr>
  </tfoot>
);

// 使用示例
function ProductTable({ products }) {
  return (
    <DataTable>
      <DataTable.Header columns={[
        { title: 'ID' },
        { title: '产品名称' },
        { title: '价格' }
      ]} />
      
      <DataTable.Body 
        data={products}
        renderRow={(product) => (
          <tr key={product.id}>
            <td>{product.id}</td>
            <td>{product.name}</td>
            <td>¥{product.price}</td>
          </tr>
        )}
      />
      
      <DataTable.Footer>
        共 {products.length} 条记录
      </DataTable.Footer>
    </DataTable>
  );
}

三、状态管理进阶方案

3.1 状态管理策略选择

场景 解决方案 优点 缺点
局部状态 useState/useReducer 轻量简单 无法跨组件共享
组件间通信 Context API 内置支持 性能敏感
复杂状态 Redux/Zustand 可预测性强 学习曲线陡峭
异步数据 React Query/SWR 自动缓存管理 需要额外安装

3.2 Zustand状态管理实战

jsx 复制代码
// infrastructure/store/productStore.js
import create from 'zustand';
import { devtools } from 'zustand/middleware';

const useProductStore = create(devtools((set) => ({
  // 状态
  products: [],
  selectedProduct: null,
  loading: false,
  error: null,
  
  // Actions
  fetchProducts: async () => {
    set({ loading: true, error: null });
    try {
      const response = await fetch('/api/products');
      const data = await response.json();
      set({ products: data, loading: false });
    } catch (err) {
      set({ error: err.message, loading: false });
    }
  },
  
  selectProduct: (product) => set({ selectedProduct: product }),
  
  addProduct: (product) => 
    set((state) => ({ products: [...state.products, product] })),
  
  updateProduct: (updatedProduct) =>
    set((state) => ({
      products: state.products.map(p => 
        p.id === updatedProduct.id ? updatedProduct : p
      )
    })),
  
  deleteProduct: (id) =>
    set((state) => ({
      products: state.products.filter(p => p.id !== id)
    }))
})));

export default useProductStore;

// 在组件中使用
import useProductStore from '../infrastructure/store/productStore';

function ProductList() {
  const { 
    products, 
    loading, 
    error,
    fetchProducts,
    selectProduct
  } = useProductStore();
  
  useEffect(() => {
    fetchProducts();
  }, []);
  
  if (loading) return <Spinner />;
  if (error) return <ErrorAlert message={error} />;
  
  return (
    <div>
      {products.map(product => (
        <ProductItem 
          key={product.id} 
          product={product}
          onSelect={selectProduct}
        />
      ))}
    </div>
  );
}

3.3 状态管理最佳实践

  1. 单一数据源:避免状态分散存储
  2. 不可变更新:使用immer简化不可变操作
  3. 副作用隔离:将异步操作放在store中
  4. 选择器优化:避免不必要的渲染
jsx 复制代码
// 使用选择器优化性能
import shallow from 'zustand/shallow';

function ProductCount() {
  // 只关心products.length变化
  const count = useProductStore(
    (state) => state.products.length,
    shallow // 浅比较
  );
  
  return <div>产品总数: {count}</div>;
}

四、数据层架构设计

4.1 数据获取策略

jsx 复制代码
// infrastructure/api/apiClient.js
import axios from 'axios';
import { message } from 'antd';

const apiClient = axios.create({
  baseURL: process.env.REACT_APP_API_BASE,
  timeout: 10000,
});

// 请求拦截器
apiClient.interceptors.request.use(config => {
  const token = localStorage.getItem('authToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 响应拦截器
apiClient.interceptors.response.use(
  response => response.data,
  error => {
    if (error.response) {
      // 统一错误处理
      const { status, data } = error.response;
      if (status === 401) {
        message.error('登录已过期,请重新登录');
        redirectToLogin();
      } else {
        message.error(data.message || '请求失败');
      }
    } else {
      message.error('网络错误');
    }
    return Promise.reject(error);
  }
);

export default apiClient;

// 领域服务封装
// infrastructure/api/userService.js
import apiClient from './apiClient';

export const userService = {
  login: async (credentials) => {
    return apiClient.post('/auth/login', credentials);
  },
  
  logout: async () => {
    return apiClient.post('/auth/logout');
  },
  
  getProfile: async () => {
    return apiClient.get('/users/me');
  },
  
  updateProfile: async (data) => {
    return apiClient.patch('/users/me', data);
  }
};

// 自定义Hook封装
// infrastructure/api/useUserService.js
import { useState, useEffect } from 'react';
import { userService } from './userService';

export function useUserService() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetchUser();
  }, []);
  
  const fetchUser = async () => {
    setLoading(true);
    try {
      const data = await userService.getProfile();
      setUser(data);
      setError(null);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };
  
  const login = async (credentials) => {
    setLoading(true);
    try {
      const data = await userService.login(credentials);
      setUser(data.user);
      localStorage.setItem('authToken', data.token);
      setError(null);
      return data;
    } catch (err) {
      setError(err.message);
      throw err;
    } finally {
      setLoading(false);
    }
  };
  
  // 其他方法...
  
  return {
    user,
    loading,
    error,
    login,
    logout: userService.logout,
    updateProfile: async (data) => {
      try {
        const updatedUser = await userService.updateProfile(data);
        setUser(updatedUser);
        return updatedUser;
      } catch (err) {
        setError(err.message);
        throw err;
      }
    }
  };
}

4.2 数据缓存策略

jsx 复制代码
// infrastructure/cache/reactQueryConfig.js
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5分钟
      cacheTime: 30 * 60 * 1000, // 30分钟
      retry: 2, // 失败重试次数
      refetchOnWindowFocus: false,
    },
  },
});

// 在应用入口
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './infrastructure/cache/reactQueryConfig';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* 应用内容 */}
    </QueryClientProvider>
  );
}

// 使用示例
import { useQuery } from '@tanstack/react-query';
import { userService } from '../api/userService';

function UserProfile() {
  const { data: user, isLoading, error } = useQuery(
    ['user', 'profile'], // 查询键
    () => userService.getProfile(), // 查询函数
    {
      retry: 1,
      refetchInterval: 60 * 1000 // 每分钟刷新
    }
  );
  
  // 渲染逻辑...
}

五、测试策略与实施

5.1 测试金字塔模型

graph TD A[单元测试] --> B[集成测试] B --> C[端到端测试] A -->|70%| D[测试覆盖率] B -->|20%| D C -->|10%| D

5.2 单元测试实践

jsx 复制代码
// utils/formatPrice.test.js
import { formatPrice } from './formatPrice';

describe('formatPrice utility', () => {
  it('formats integer values correctly', () => {
    expect(formatPrice(100)).toBe('¥100.00');
  });
  
  it('formats float values with two decimals', () => {
    expect(formatPrice(99.9)).toBe('¥99.90');
  });
  
  it('handles zero correctly', () => {
    expect(formatPrice(0)).toBe('¥0.00');
  });
  
  it('formats large numbers with commas', () => {
    expect(formatPrice(1000000)).toBe('¥1,000,000.00');
  });
});

// components/Button/Button.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

describe('Button component', () => {
  it('renders with default props', () => {
    render(<Button>Click me</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveTextContent('Click me');
    expect(button).toHaveClass('btn', 'btn-primary');
  });
  
  it('renders with secondary variant', () => {
    render(<Button variant="secondary">Cancel</Button>);
    expect(screen.getByRole('button')).toHaveClass('btn-secondary');
  });
  
  it('handles click events', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click</Button>);
    fireEvent.click(screen.getByText('Click'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
  
  it('shows loading state', () => {
    render(<Button loading>Submit</Button>);
    const button = screen.getByRole('button');
    expect(button).toBeDisabled();
    expect(button).toContainElement(screen.getByTestId('spinner'));
  });
});

5.3 组件集成测试

jsx 复制代码
// components/LoginForm/LoginForm.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

describe('LoginForm', () => {
  it('submits form with valid data', async () => {
    const onSubmit = jest.fn();
    render(<LoginForm onSubmit={onSubmit} />);
    
    // 获取表单元素
    const emailInput = screen.getByLabelText('邮箱');
    const passwordInput = screen.getByLabelText('密码');
    const submitButton = screen.getByRole('button', { name: '登录' });
    
    // 模拟用户输入
    await userEvent.type(emailInput, 'test@example.com');
    await userEvent.type(passwordInput, 'password123');
    
    // 提交表单
    fireEvent.click(submitButton);
    
    // 验证提交函数被调用
    expect(onSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123'
    });
  });
  
  it('shows validation errors', async () => {
    render(<LoginForm onSubmit={jest.fn()} />);
    
    const emailInput = screen.getByLabelText('邮箱');
    const submitButton = screen.getByRole('button', { name: '登录' });
    
    // 输入无效邮箱
    await userEvent.type(emailInput, 'invalid-email');
    fireEvent.blur(emailInput);
    
    // 验证错误信息
    expect(await screen.findByText('请输入有效的邮箱地址')).toBeInTheDocument();
    
    // 尝试提交
    fireEvent.click(submitButton);
    
    // 验证未提交
    expect(screen.getByText('请输入有效的邮箱地址')).toBeInTheDocument();
  });
});

六、性能优化体系

6.1 渲染性能优化

jsx 复制代码
// 使用React.memo避免不必要的渲染
const UserCard = React.memo(({ user, onSelect }) => {
  return (
    <div className="user-card" onClick={() => onSelect(user.id)}>
      <Avatar url={user.avatar} />
      <div>
        <h3>{user.name}</h3>
        <p>{user.email}</p>
      </div>
    </div>
  );
}, (prevProps, nextProps) => {
  // 自定义比较函数
  return prevProps.user.id === nextProps.user.id && 
         prevProps.onSelect === nextProps.onSelect;
});

// 使用useMemo缓存计算结果
function UserDashboard({ users }) {
  const [filter, setFilter] = useState('');
  
  const filteredUsers = useMemo(() => {
    console.log('重新计算过滤用户');
    return users.filter(user => 
      user.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [users, filter]);
  
  return (
    <div>
      <input 
        value={filter}
        onChange={e => setFilter(e.target.value)}
        placeholder="搜索用户..."
      />
      
      <VirtualList
        height={500}
        itemCount={filteredUsers.length}
        itemSize={100}
      >
        {({ index, style }) => (
          <UserCard 
            style={style}
            user={filteredUsers[index]} 
          />
        )}
      </VirtualList>
    </div>
  );
}

6.2 资源加载优化

jsx 复制代码
// 使用React.lazy和Suspense实现代码分割
const ProductList = React.lazy(() => import('./pages/ProductList'));
const UserManagement = React.lazy(() => import('./pages/UserManagement'));

function App() {
  return (
    <Router>
      <Suspense fallback={<FullPageSpinner />}>
        <Routes>
          <Route path="/products" element={<ProductList />} />
          <Route path="/users" element={<UserManagement />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

// 使用资源预加载
const preloadResources = () => {
  // 预加载关键资源
  const resources = [
    '/images/hero-bg.jpg',
    '/fonts/roboto.woff2',
    '/api/products'
  ];
  
  resources.forEach(resource => {
    if (resource.endsWith('.jpg') || resource.endsWith('.png')) {
      const img = new Image();
      img.src = resource;
    } else if (resource.endsWith('.woff2')) {
      const link = document.createElement('link');
      link.rel = 'preload';
      link.href = resource;
      link.as = 'font';
      link.crossOrigin = 'anonymous';
      document.head.appendChild(link);
    } else if (resource.startsWith('/api')) {
      fetch(resource, { priority: 'low' });
    }
  });
};

// 在用户空闲时预加载
if ('requestIdleCallback' in window) {
  requestIdleCallback(preloadResources);
} else {
  setTimeout(preloadResources, 5000);
}

七、代码规范与团队协作

7.1 ESLint + Prettier配置

json 复制代码
// .eslintrc.json
{
  "extends": [
    "react-app",
    "plugin:react/recommended",
    "plugin:jsx-a11y/recommended",
    "prettier"
  ],
  "plugins": ["react", "jsx-a11y", "prettier"],
  "rules": {
    "prettier/prettier": "error",
    "react/prop-types": "off",
    "react-hooks/exhaustive-deps": "warn",
    "jsx-a11y/anchor-is-valid": [
      "error",
      {
        "components": ["Link"],
        "specialLink": ["hrefLeft", "hrefRight"],
        "aspects": ["invalidHref", "preferButton"]
      }
    ]
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  }
}

// .prettierrc
{
  "printWidth": 100,
  "tabWidth": 2,
  "useTabs": false,
  "semi": true,
  "singleQuote": true,
  "trailingComma": "all",
  "bracketSpacing": true,
  "jsxBracketSameLine": false,
  "arrowParens": "always"
}

7.2 Git工作流策略

graph LR A[主分支] -->|保护| B[开发分支] B --> C[功能分支] C --> D[合并请求] D --> E[代码审查] E -->|通过| F[测试] F -->|通过| G[合并到开发分支] B --> H[发布分支] H --> I[生产环境]

7.3 代码审查清单

类别 检查项
功能 是否实现需求?是否有隐藏bug?
代码质量 是否符合编码规范?是否有重复代码?
测试 是否有测试覆盖?测试用例是否充分?
性能 是否有性能问题?内存泄漏风险?
安全 是否有XSS/SQL注入风险?敏感数据处理?
文档 是否更新文档?代码注释是否清晰?

八、CI/CD流水线设计

8.1 自动化流水线

yaml 复制代码
# .github/workflows/ci-cd.yml
name: React CI/CD Pipeline

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ develop ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
    - name: Checkout code
      uses: actions/checkout@v3
      
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        
    - name: Install dependencies
      run: npm ci
      
    - name: Lint code
      run: npm run lint
      
    - name: Run tests
      run: npm test -- --coverage
      
    - name: Build production
      run: npm run build
      
    - name: Upload artifacts
      uses: actions/upload-artifact@v3
      with:
        name: production-build
        path: build/
        
  deploy-staging:
    needs: build-and-test
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    steps:
    - name: Download artifact
      uses: actions/download-artifact@v3
      with:
        name: production-build
        
    - name: Deploy to staging
      uses: aws-actions/amazon-s3-deploy@v1
      with:
        bucket: ${{ secrets.STAGING_BUCKET }}
        folder: build
        
  deploy-production:
    needs: build-and-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
    - name: Download artifact
      uses: actions/download-artifact@v3
      with:
        name: production-build
        
    - name: Deploy to production
      uses: aws-actions/amazon-s3-deploy@v1
      with:
        bucket: ${{ secrets.PRODUCTION_BUCKET }}
        folder: build

8.2 质量门禁设置

javascript 复制代码
// jest.config.js
module.exports = {
  // ...其他配置
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 85,
      lines: 90,
      statements: 90
    },
    // 关键模块更高要求
    './src/domain/': {
      branches: 90,
      functions: 95,
      lines: 95,
      statements: 95
    }
  }
};

// 在package.json中添加脚本
"scripts": {
  "test:ci": "npm test -- --coverage --watchAll=false",
  "lint:ci": "eslint . --max-warnings 0",
  "build:ci": "npm run build && npm run bundle-analyze"
}

九、监控与错误追踪

9.1 前端监控体系

jsx 复制代码
// utils/monitoring.js
export function initMonitoring() {
  // 性能监控
  if ('PerformanceObserver' in window) {
    const perfObserver = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === 'longtask') {
          logLongTask(entry);
        }
      }
    });
    perfObserver.observe({ entryTypes: ['longtask'] });
  }
  
  // 错误监控
  window.addEventListener('error', (event) => {
    logError({
      message: event.message,
      stack: event.error.stack,
      componentStack: event.componentStack
    });
  });
  
  // React渲染错误
  if (window.addEventListener) {
    window.addEventListener('unhandledrejection', (event) => {
      logError({
        type: 'unhandledrejection',
        reason: event.reason
      });
    });
  }
  
  // 用户行为跟踪
  document.addEventListener('click', (event) => {
    trackUserAction('click', {
      target: event.target.tagName,
      path: event.composedPath().map(el => el.tagName).join(' > ')
    });
  }, { capture: true });
}

// 在应用入口调用
import { initMonitoring } from './utils/monitoring';

function App() {
  useEffect(() => {
    initMonitoring();
  }, []);
  
  return /* 应用内容 */;
}

9.2 错误边界与日志上报

jsx 复制代码
// components/ErrorBoundary.jsx
import React from 'react';
import { logError } from '../utils/monitoring';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
  
  componentDidCatch(error, errorInfo) {
    // 上报错误信息
    logError({
      error,
      errorInfo,
      componentStack: errorInfo.componentStack
    });
    
    // 可选的错误恢复逻辑
    if (this.props.onRecover) {
      setTimeout(() => {
        this.setState({ hasError: false, error: null });
        this.props.onRecover();
      }, this.props.recoverTimeout || 5000);
    }
  }
  
  render() {
    if (this.state.hasError) {
      return this.props.fallback ? 
        this.props.fallback(this.state.error) : 
        <DefaultErrorPage error={this.state.error} />;
    }
    
    return this.props.children;
  }
}

// 使用示例
function App() {
  return (
    <ErrorBoundary 
      fallback={(error) => (
        <div className="error-page">
          <h2>应用发生错误</h2>
          <p>{error.message}</p>
          <button onClick={() => window.location.reload()}>重新加载</button>
        </div>
      )}
    >
      {/* 应用内容 */}
    </ErrorBoundary>
  );
}

十、总结:构建可持续演进的架构

企业级React应用架构的核心要素:

  1. 模块化设计:DDD驱动的领域划分
  2. 状态管理:Zustand+React Query的混合方案
  3. 数据层:统一API服务封装
  4. 测试体系:金字塔模型全覆盖
  5. 性能优化:从资源加载到渲染优化
  6. 工程规范:自动化代码质量控制
  7. CI/CD:高效的部署流水线
  8. 监控系统:实时错误追踪与性能分析

在下一篇文章中,我们将深入探讨《React高级特性实战:错误边界、Portals与Refs进阶》,揭示React的高级技巧和最佳实践。

架构演进建议:每6个月进行一次架构评审,根据业务变化和技术发展调整架构方向。持续收集性能指标和团队反馈,确保架构始终支撑业务需求。

相关推荐
lovingsoft27 分钟前
Chrome Lighthouse 详解
前端·chrome
Qlly37 分钟前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝41 分钟前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦42 分钟前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡1 小时前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI1 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95271 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
看浪的路人1 小时前
第9讲:大模型应用监控与可观测性——OpenTelemetry + Prometheus + Grafana 全链路追踪
java·前端·spring
内存过载1 小时前
用表格模拟甘特图:项目排期的土办法
前端·低代码
葡萄城技术团队2 小时前
用 SpreadJS 数据验证实现省市区三级联动
前端