前端模块化与 import/export入门:从「乱成一团」到「清晰可维护」

本文面向:刚会写 JS、想理清模块化概念的同学。不讲复杂原理,只讲:日常该怎么组织代码、为什么这么做、容易踩的坑在哪。


一、先体验一下:没有模块化有多难受

1.1 一个真实的场景

假设你要做一个「用户列表」页面,需要:

  • 格式化日期
  • 调用用户列表接口
  • 显示加载中状态

如果全写在一个文件里,大概是这样的:

html 复制代码
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>用户列表</title>
</head>
<body>
  <div id="app"></div>
  <script src="./app.js"></script>
</body>
</html>
javascript 复制代码
// app.js - 所有逻辑全塞在一起,有 100 多行
const API_BASE = '/api';
const list = [];
let loading = false;

function formatDate(date) {
  return new Date(date).toLocaleDateString('zh-CN');
}

function getUserList() {
  loading = true;
  fetch(API_BASE + '/user/list')
    .then(res => res.json())
    .then(data => {
      list = data;  // 注意:这里 list 是 const,会报错!变量多了容易搞混
      loading = false;
    });
}

// 假设还有 10 个其他函数...
// 三个月后你回来改 bug,根本找不到 formatDate 在哪...

问题很快就出来了:

  1. 变量、函数全混在一起,改一处容易影响别处
  2. formatDate 如果别的页面也要用,只能复制粘贴
  3. 文件越来越长,找个函数要翻很久

模块化要解决的,就是这三个问题。

1.2 模块化能做到的三件事

  • 隔离:每个文件有自己的作用域,不互相污染
  • 依赖清晰 :用 import 明确「谁用了谁」
  • 可维护:按职责拆文件,找代码、改代码都更轻松

后面我们就用 ES Modulesimport / export)来做这件事。


二、import / export 基础

2.1 先搞懂 export:导出

两种常见用法:

方式一:命名导出(一个文件可以导出多个)

javascript 复制代码
// utils/format.js
export function formatDate(date) {
  return new Date(date).toLocaleDateString('zh-CN');
}

export function formatMoney(num) {
  return `¥${num.toFixed(2)}`;
}

方式二:默认导出(一个文件只能有一个 default)

javascript 复制代码
// config.js
export default {
  apiBase: '/api',
  timeout: 5000,
};
  • 命名导出:可以导出很多个,import 时要用同名
  • 默认导出:只有一个,import 时可以随便起名字

2.2 再搞懂 import:引入

javascript 复制代码
// 引入命名导出:名字必须和 export 的一致
import { formatDate, formatMoney } from './utils/format.js';

// 引入默认导出:名字可以随便起
import config from './config.js';
console.log(config.apiBase);  // '/api'

// 把整个模块当作对象引入
import * as formatUtils from './utils/format.js';
formatUtils.formatDate(new Date());

记住一个区别:

写法 含义
import { foo } 引入命名导出{} 是语法,不是解构
import foo 引入默认导出

三、完整的可运行示例

下面是一个最小可运行项目,涵盖:utils、api、constants、composables,可以直接照抄跑起来。

3.1 项目结构

复制代码
my-project/
├── index.html
├── package.json
├── vite.config.js
└── src/
    ├── main.js              # 入口
    ├── constants/
    │   └── index.js         # 常量
    ├── utils/
    │   ├── format.js        # 格式化
    │   └── index.js         # 统一导出
    ├── api/
    │   ├── request.js       # 请求封装
    │   ├── user.js          # 用户接口
    │   └── index.js         # 统一导出
    ├── composables/         # Vue 组合式函数(可复用逻辑)
    │   └── useUserList.js
    └── App.vue              # 根组件

3.2 完整代码(可直接复制)

1. package.json

json 复制代码
{
  "name": "module-demo",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite"
  },
  "dependencies": {
    "vue": "^3.4.0",
    "axios": "^1.6.0"
  },
  "devDependencies": {
    "vite": "^5.0.0",
    "@vitejs/plugin-vue": "^5.0.0"
  }
}

2. index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>用户列表 - 模块化示例</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

3. src/constants/index.js

javascript 复制代码
// 常量:放那些「写死不变」的值,以后改起来只改这一处
export const API_BASE = '/api';
export const TIMEOUT = 10000;

// 订单状态:用常量代替魔法数字 0、1、2、3
export const ORDER_STATUS = {
  PENDING: 0,
  PAID: 1,
  SHIPPED: 2,
  COMPLETED: 3,
};

export const ORDER_STATUS_TEXT = {
  0: '待支付',
  1: '已支付',
  2: '已发货',
  3: '已完成',
};

4. src/utils/format.js

javascript 复制代码
// 纯函数:同样的输入,一定得到同样的输出,不依赖外部状态
export function formatDate(date, options = {}) {
  if (!date) return '-';
  return new Date(date).toLocaleDateString('zh-CN', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    ...options,
  });
}

export function formatMoney(num) {
  if (num == null || isNaN(num)) return '¥0.00';
  return `¥${Number(num).toFixed(2)}`;
}

export function formatPhone(phone) {
  if (!phone || phone.length !== 11) return phone;
  return `${phone.slice(0, 3)}****${phone.slice(7)}`;
}

5. src/utils/index.js

javascript 复制代码
// 统一导出:别人只需要 import from '@/utils' 就能拿到所有工具
export { formatDate, formatMoney, formatPhone } from './format.js';

6. src/api/request.js

javascript 复制代码
import axios from 'axios';
import { API_BASE, TIMEOUT } from '../constants/index.js';

// 创建一个配置好的 axios 实例,所有请求都走它
const request = axios.create({
  baseURL: API_BASE,
  timeout: TIMEOUT,
});

// 响应拦截器:统一处理错误和数据结构
request.interceptors.response.use(
  (res) => res.data,  // 直接返回 data,调用方少写一层
  (err) => {
    if (err.response?.status === 401) {
      console.warn('未登录,请先登录');
      // 实际项目:跳转登录页
    }
    return Promise.reject(err);
  }
);

export default request;

7. src/api/user.js

javascript 复制代码
import request from './request.js';

// 每个函数对应一个接口,参数和返回值一目了然
export function getUserList(params = {}) {
  return request.get('/user/list', { params });
}

export function getUserDetail(id) {
  return request.get(`/user/${id}`);
}

8. src/api/index.js

javascript 复制代码
export * from './user.js';
// 以后有 order.js,再加一行:export * from './order.js';

9. src/composables/useUserList.js

javascript 复制代码
import { ref, watch } from 'vue';
import { getUserList } from '../api/index.js';

// 把「拉列表 + loading + error」抽成组合式函数,任何组件都能复用
export function useUserList(params = {}) {
  const list = ref([]);
  const loading = ref(false);
  const error = ref(null);

  async function fetchList() {
    loading.value = true;
    error.value = null;
    try {
      const res = await getUserList(params);
      list.value = res.data || res;
    } catch (err) {
      error.value = err.message;
    } finally {
      loading.value = false;
    }
  }

  // 当 params 变化时重新请求
  watch(
    () => [params.page, params.pageSize],
    () => fetchList(),
    { immediate: true }
  );

  return { list, loading, error, fetchList };
}

10. src/App.vue

javascript 复制代码
<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">出错了:{{ error }}</div>
  <div v-else style="padding: 20px">
    <h1>用户列表</h1>
    <ul>
      <li v-for="user in list" :key="user.id">
        {{ user.name }} - 注册时间:{{ formatDate(user.createdAt) }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { useUserList } from './composables/useUserList.js';
import { formatDate } from './utils/index.js';

const { list, loading, error } = useUserList({ page: 1, pageSize: 10 });
</script>

11. src/main.js

javascript 复制代码
import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');

12. vite.config.js

javascript 复制代码
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
});

3.3 怎么运行

bash 复制代码
cd my-project
npm install
npm run dev

浏览器打开 http://localhost:5173 即可(接口需自行 mock 或接真实后端)。


四、这一套结构在解决什么问题

4.1 utils:通用纯函数

  • 职责:格式化、校验、防抖节流等和业务无关的函数
  • 原则:尽量纯函数,不依赖接口、路由、业务状态
  • 示例:formatDateformatMoneyisValidPhone

4.2 api:请求层

  • 职责:封装 axios/fetch,按业务拆接口文件
  • 原则:只负责发请求、返回数据,不写业务判断
  • 示例:getUserListgetUserDetail

4.3 composables:可复用逻辑

  • 职责:把「请求 + loading + error」等可复用逻辑抽成组合式函数
  • 原则:只在多处复用时抽,不要过度抽象
  • 示例:useUserListuseDebounce

4.4 constants:常量与配置

  • 职责:状态码、业务码、环境配置等
  • 原则:只放常量,不放逻辑
  • 示例:ORDER_STATUSAPI_BASE

五、常见坑

建议
utils 里写业务逻辑 工具函数只做通用处理,不依赖具体业务
api 层写业务判断 api 只管请求和错误,业务逻辑在组件/composable 里
魔法数字 状态码、业务码用常量,如 ORDER_STATUS.PENDING
watch 依赖写错 watch 的监听源要写对,否则会多请求或漏更新
ref 忘记 .value 在 script 里访问 ref 要加 .value,模板中自动解包
循环依赖 保持单向依赖:constants → api → composables → 组件

六、小结

模块化的目的就三点:职责清晰、依赖明确、好维护

先按 utils、api、composables、constants 这几类把代码分好,再按项目规模慢慢细化,不必一上来就设计得很复杂。


以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

相关推荐
Neweee5 分钟前
JavaScript进阶内容详解
前端
大鸡爪6 分钟前
Vue3 组件库实战(五):Icon 图标组件的设计与实现
前端·vue.js
bluceli6 分钟前
前端测试实战指南:构建高质量代码的完整体系
前端·测试
行走的陀螺仪6 分钟前
前端公共库开发保姆级路线:从0到1复刻VueUse官方级架构(pnpm+Turbo+VitePress)
前端·架构
顽固_倔强7 分钟前
深入理解 Vue3 数据绑定实现原理
前端·面试
前端付豪7 分钟前
组件拆分重构 App.vue
前端·架构·代码规范
Wect8 分钟前
React 更新触发原理详解
前端·react.js·面试
cxxcode8 分钟前
Web 帧渲染与 DOM 准备
前端
光影少年8 分钟前
React Hooks的理解?常用的有哪些?
前端·react.js·掘金·金石计划
大鸡爪9 分钟前
Vue3 组件库实战(七):从本地到 NPM:版本管理与自动化发布指南(下)
前端·vue.js