写点什么

项目越写越大,我是这样做拆分的

作者:小鑫同学
  • 2022 年 7 月 18 日
  • 本文字数:9414 字

    阅读完需:约 31 分钟

项目越写越大,我是这样做拆分的

写作背景:

在几年前的一次 Vue 项目改造中利用原生+H5 的形式按模块菜单来拆分了多个 Vue 项目,在拆分时考虑到多项目维护带来的成本较大,我们将项目公共使用到的资源提升到项目 root 目录下,将子项目抽取为模板通过定制的脚手架创建每个子项目到 modules 下,并且支持单独打包、单独发布。这样项目结构的好处同时避免了项目庞大带来的首屏加载时间长,也避免了多人开发出现冲突的造成的矛盾。这样的项目结构在现在看来很多项目都有在使用,比如 Vue、Vite 等,它们共同使用到的 PNPM 的包管理器来组织这样的项目。同时我也在 B 站发现有伙伴使用 PNPM 组建了包含 PC 前端、PC 后端、H5 前端这样的项目模板。我们一起来搞一搞~

PNPM 介绍:

PNPM 的特点:

  1. 节约磁盘空间并提升安装速度;

  2. 创建非扁平化的 node_modules 文件夹。

PNPM 与 NodeJs 版本支持:


上述表格来自:;

PNPM 与其他包管理功能对比:


上述表格来自:;

安装 PNPM:

npm install -g pnpm
复制代码

快速开始命令:

  1. 在项目 root 目录安装所有依赖:pnpm install

  2. 在项目 root 目录安装指定依赖:pnpm add <pkg>;

  3. 在项目 root 目录运行 CMD 命令:pnpm <cmd>;

  4. 在特定子集运行 CMD 命令:pnpm -F <package_selector> <command>

一起搞起来:

利用 vue@3 模板来创建 root 项目:

pnpm create vue@3
复制代码


定义工作空间目录结构

使用 **pnpm **管理的项目支持在 **root **目录下使用 pnpm-workspace.yaml 文件来定义工作空间目录


packages:  # all packages in direct subdirs of packages/  - 'packages/*'  # all packages in subdirs of components/  - 'components/**'  # 获取数据相关的包在 apis 目录下  - 'apis/**'  # 通用工具相关的包在 utils 目录下  - 'utils/**'
复制代码

使用 vite 来初始化公共模块:

使用 vite 内置的基础项目模板创建 apis、utils 两个公共模块

创建 apis 项目:

yarn create vite
复制代码


创建 utils 项目:

yarn create vite
复制代码


调整 apis、utils 的项目名称和版本号:

使用 vite 来初始化业务模块:

业务模块创建到 packages 目录下,创建命令同上一小节,我们这次改用 vite 内置的 vue-ts 模板

创建三个 module 项目,整体的目录大致结构如下:

my-workspace                       ├─ apis                            │  ├─ src                                                                                   │  ├─ package.json                 │  └─ tsconfig.json     ├─ utils                           │  ├─ src                                                                                  │  ├─ package.json                 │  └─ tsconfig.json  ├─ packages                        │  ├─ module1                                 │  ├─ module2                                │  └─ module3                                ├─ public                                         ├─ src                                                         ├─ env.d.ts                        ├─ index.html                      ├─ package.json                    ├─ pnpm-lock.yaml                  ├─ pnpm-workspace.yaml             ├─ README.md                       ├─ tsconfig.config.json            ├─ tsconfig.json                   └─ vite.config.ts                  
复制代码

调整三个模块项目的名称和版本号

统一包管理器的使用:

在创建的各模块的 package.json 中增加一条script,内容如下:


"preinstall": "npx only-allow pnpm"
复制代码

开发 utils 模块:

开发 Clipboard 工具类(支持移动端和 PC 端两种提示风格):

准备 Clipboard 工具类:
import Clipboard from 'clipboard'
export const handleClipboard = (text: string, event: MouseEvent) => { const clipboard = new Clipboard(event.target as Element, { text: () => text }) clipboard.on('success', () => { clipboard.destroy() }) clipboard.on('error', () => { clipboard.destroy() }); (clipboard as any).onClick(event)}
复制代码
配置相关依赖:
  1. 安装vueuse依赖库,监听屏幕变化;

  2. 安装clipboard依赖库,完成粘贴板基础功能;

  3. 安装element-plusPC 风格组件库;

  4. 安装vant移动端风格组件库;

  5. 安装vue依赖库,因提示 Issues with peer dependencies found,就先装上。

完善 Clipboard 工具类以支持不同风格提示:

utils\src\clipboard.ts


// 手动导入vant中的通知组件及样式文件import { Notify } from "vant";import "vant/es/notify/style";
// 手动导入element-plus中的通知组件及样式文件import { ElMessage } from "element-plus";import "element-plus/es/components/message/style/css";
// 导入剪切板基础依赖import Clipboard from "clipboard";// 导入vueuse/core 中监听浏览器端点变化的函数import { useBreakpoints, breakpointsTailwind } from "@vueuse/core";
const sm = useBreakpoints(breakpointsTailwind).smaller("sm");
/* 依据sm值的变化来改变使用不同的通知风格 */export const clipboardSuccess = () => sm.value ? Notify({ message: "Copy successfully", type: "success", duration: 1500, }) : ElMessage({ message: "Copy successfully", type: "success", duration: 1500, });
/* 依据sm值的变化来改变使用不同的通知风格 */export const clipboardError = () => sm.value ? Notify({ message: "Copy failed", type: "danger", }) : ElMessage({ message: "Copy failed", type: "error", });
export const handleClipboard = (text: string, event: MouseEvent) => { const clipboard = new Clipboard(event.target as Element, { text: () => text, }); clipboard.on("success", () => { // 在复制成功后提示成功通知内容 clipboardSuccess(); clipboard.destroy(); }); clipboard.on("error", () => { // 在复制失败后提示失败通知内容 clipboardError(); clipboard.destroy(); }); // eslint-disable-next-line @typescript-eslint/no-explicit-any (clipboard as any).onClick(event);};
复制代码
导出工具类的相关配置:
  1. 配置统一导出文件(utils\index.ts):


export * from "./src/clipboard";
复制代码


  1. 修改package.jsonmain字段:


{  "main": "index.ts"}
复制代码
将 utils 模块安装到 module1 项目:
  1. 下面的命令在 root 目录执行,通过-F来执行命令执行的位置是@it200/module1,执行的命令是add


pnpm -F @it200/module1 add @it200/utils
复制代码


注:当@it200/utils包升级后,执行执行pnpm update来更新相关依赖版本。


  1. 安装成功后的依赖信息如下:


{  "dependencies": {    "@it200/utils": "workspace:^0.0.1"  }}
复制代码
在 module1 项目中尝试使用 Clipboard 函数:
  1. 在模板中增加按钮:


<button @click="copy">复制</button>
复制代码


  1. setupscript中增加对应函数并导入handleClipboard


import { handleClipboard } from "@it200/utils";const copy = (e) => {  console.log("[ e ] >", e);  handleClipboard("haha", e);};
复制代码


PC 端复制成功后提示风格:



移动端复制成功后提示风格:


开发 apis 模块:

开发 axios 工具类函数:

准备 axios 工具类:
import axios, { AxiosRequestConfig } from "axios";
const pending = {};
const CancelToken = axios.CancelToken;const removePending = (key: string, isRequest = false) => { if (Reflect.get(pending, key) && isRequest) { Reflect.get(pending, key)("取消重复请求"); } Reflect.deleteProperty(pending, key);};const getRequestIdentify = (config: AxiosRequestConfig, isReuest = false) => { let url = config.url; const suburl = config.url?.substring(1, config.url?.length) ?? ""; if (isReuest) { url = config.baseURL + suburl; } return config.method === "get" ? encodeURIComponent(url + JSON.stringify(config.params)) : encodeURIComponent(config.url + JSON.stringify(config.data));};
// 创建一个AXIOS实例const service = axios.create({ baseURL: import.meta.env.VITE_BASE_API, // url = base url + request url // withCredentials: true, // send cookies when cross-domain requests timeout: 16000, // 请求超时});
// 请求拦截器service.interceptors.request.use( (config: AxiosRequestConfig) => { // 拦截重复请求(即当前正在进行的相同请求) const requestData = getRequestIdentify(config, true); removePending(requestData, true);
config.cancelToken = new CancelToken((c: any) => { Reflect.set(pending, requestData, c); });
// 请求发送前的预处理(如:获取token等) // if (store.getters.token) { // // let each request carry token // // ['X-AUTH-TOKEN'] is a custom headers key // // please modify it according to the actual situation // config.headers['X-AUTH-TOKEN'] = getToken() // } return config; }, (error: any) => { // do something with request error console.log(error); // for debug return Promise.reject(error); });
// response interceptorservice.interceptors.response.use( (response: { config: AxiosRequestConfig; data: any }) => { // 把已经完成的请求从 pending 中移除 const requestData = getRequestIdentify(response.config); removePending(requestData); const res = response.data; return res; }, (error: { message: string; config: { showLoading: any }; response: { status: any }; request: any; }) => { console.log(error.message); if (error) { if (error.response) { switch (error.response.status) { case 400: error.message = "错误请求"; break; case 401: error.message = "未授权,请重新登录"; break; default: error.message = `连接错误${error.response.status}`; } const errData = { code: error.response.status, message: error.message, }; console.log("统一错误处理: ", errData); } else if (error.request) { console.log("统一错误处理: ", "网络出错,请稍后重试"); } } return Promise.reject(error); });
export default service;
复制代码
配置相关依赖:
  1. 安装axios依赖库,完成数据请求的发送及处理;

  2. 安装vant依赖库,完成请求数据后的状态提示等。


说明:在apis模块中就不再做手机端和 PC 端的风格切换了;

完善 axios 工具类:

apis\src\axios.ts,部分逻辑有删减,仅保证基础功能正常


import { Dialog } from "vant";import "vant/es/dialog/style";
import { Toast } from "vant";import "vant/es/toast/style";
import axios, { AxiosRequestConfig } from "axios";
const pending = {};
const CancelToken = axios.CancelToken;const removePending = (key: string, isRequest = false) => { if (Reflect.get(pending, key) && isRequest) { Reflect.get(pending, key)("取消重复请求"); } Reflect.deleteProperty(pending, key);};const getRequestIdentify = (config: AxiosRequestConfig, isReuest = false) => { let url = config.url; const suburl = config.url?.substring(1, config.url?.length) ?? ""; if (isReuest) { url = config.baseURL + suburl; } return config.method === "get" ? encodeURIComponent(url + JSON.stringify(config.params)) : encodeURIComponent(config.url + JSON.stringify(config.data));};
// 创建一个AXIOS实例const service = axios.create({ baseURL: import.meta.env.VITE_BASE_API, // url = base url + request url // withCredentials: true, // send cookies when cross-domain requests timeout: 16000, // 请求超时});
// 请求拦截器service.interceptors.request.use( (config: AxiosRequestConfig) => { // 拦截重复请求(即当前正在进行的相同请求) const requestData = getRequestIdentify(config, true); removePending(requestData, true);
config.cancelToken = new CancelToken((c: any) => { Reflect.set(pending, requestData, c); });
// 是否开启loading if (config.showLoading) { Toast.loading({ duration: 0, mask: true, forbidClick: true, message: "加载中...", loadingType: "spinner", }); }
// 请求发送前的预处理(如:获取token等) // if (store.getters.token) { // // let each request carry token // // ['X-AUTH-TOKEN'] is a custom headers key // // please modify it according to the actual situation // config.headers['X-AUTH-TOKEN'] = getToken() // } return config; }, (error: any) => { // do something with request error console.log(error); // for debug Toast.loading({ message: "网络出错,请重试", duration: 1500, type: "fail", }); return Promise.reject(error); });
// response interceptorservice.interceptors.response.use( (response: { config: AxiosRequestConfig; data: any }) => { // 把已经完成的请求从 pending 中移除 const requestData = getRequestIdentify(response.config); removePending(requestData);
if (response.config.showLoading) { Toast.clear(); }
const res = response.data; return res; }, (error: { message: string; config: { showLoading: any }; response: { status: any }; request: any; }) => { console.log(error.message); if (error) { if (error.config && error.config.showLoading) { Toast.clear(); } if (error.response) { switch (error.response.status) { case 400: error.message = "错误请求"; break; case 401: error.message = "未授权,请重新登录"; break; default: error.message = `连接错误${error.response.status}`; } const errData = { code: error.response.status, message: error.message, }; console.log("统一错误处理: ", errData); Dialog({ title: "提示", message: errData.message || "Error" }); } else if (error.request) { Toast.loading({ message: "网络出错,请稍后重试", duration: 1500, type: "fail", }); } } return Promise.reject(error); });
export default service;
复制代码
编写 userApi 类,汇总关于 user 对象的数据读取:

apis\src\user.ts


import service from "./axios";
export const UserApi = { getUsers: () => service.get<any>("/users"),};
复制代码
导出 userApi 类的相关配置:
  1. 配置统一导出文件(apis\index.ts):


export * from "./src/user";
复制代码


  1. 修改package.jsonmain字段:


{  "main": "index.ts"}
复制代码
在 module2 项目中尝试使用 userApi 类:
  1. 定义模板:


<template>  <button @click="getUserList">获取用户列表</button>  <ul>    <li v-for="user in users" :key="user.id">      {{ user.name }}、{{ user.age }}    </li>  </ul></template>
复制代码


  1. 安装、导入、编写逻辑:


pnpm -F @it200/module2 add @it200/apis
复制代码


<script setup lang="ts">import { UserApi } from "@it200/apis";import { ref } from "vue";const users = ref();const getUserList = async () => {  const resp = await UserApi.getUsers();  users.value = resp;};</script>
复制代码


使用Mockend来 Mock 数据:

  1. 选择一个符合自己的方案:



  1. 选择要安装到得公共项目仓库,Github 组织不支持免费的(只为截个图):



  1. 在项目 root 目录新建.mockend.json文件:


{  "User": {    "name": {      "string": {}    },    "avatarUrl": {      "regexp": "https://i\\.pravatar\\.cc/150\\?u=[0-9]{5}"    },    "statusMessage": {      "string": [        "working from home",        "watching Netflix"      ]    },    "email": {      "regexp": "#[a-z]{5,10}@[a-z]{5}\\.[a-z]{2,3}"    },    "color": {      "regexp": "#[0-9A-F]{6}"    },    "age": {      "int": {        "min": 21,        "max": 100      }    },    "isPublic": {      "boolean": {}    }  }}
复制代码


  1. 通过 https://mockend.com/OSpoon/data-mock/users 就可以获取到 mock 数据了;

  2. 更多配置请参考。

开发 Components 模块:

开发 Card 组件,并应用到 module3 项目中:

使用 pnpm create vue@3 来创建项目模板,修改项目名称和版本号:
创建如下 card 组件目录结构:
components                   ├─ card                      │  ├─ src                    │  │  ├─ card.scss           │  │  └─ index.vue           │  └─ index.ts         
复制代码
组件模板及配置:

组件名称通过 defineComponent 函数导入,在注册组件时读取使用


<script lang="ts">import { defineComponent } from "vue";export default defineComponent({  name: "it-card",});</script><script setup lang="ts">const props = defineProps({  shadow: {    type: String,    default: "always",  },  bodyStyle: {    type: Object,    default: () => {      return { padding: "20px" };    },  },});console.log("[ props ] >", props);</script>
<template> <div class="it-card"> <div :class="`is-${shadow}-shadow`"></div> <div class="it-card__body" :style="bodyStyle"> <slot></slot> </div> </div></template>
<style lang="scss" scoped></style>
复制代码
组件样式文件:
.it-card {    border-radius: 4px;    border: 1px solid #ebeef5;    background-color: #fff;    overflow: hidden;    color: #303133;    transition: 0.3s;
.it-card__body { padding: 20px; }
.is-always-shadow { box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%); }
.is-hover-shadow:hover { box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%); }
.is-never-shadow { box-shadow: none; }}
复制代码
组件安装插件:
import type { App } from "vue";import Card from "./src/index.vue";
export default { install(app: App) { app.component(Card.name, Card); },};
复制代码
在 Components 项目中尝试使用 Card 组件:
  1. 导入组件相关配置并安装,components\src\main.ts


import Card from "./components/card/index";import "./components/card/src/card.scss";
app.use(Card);
复制代码


  1. App.vue组件中使用:


<template>  <it-card style="width: 235px" :body-style="{ padding: '0px' }">    <img      src="https://shadow.elemecdn.com/app/element/hamburger.9cf7b091-55e9-11e9-a976-7f4d0b07eef6.png"      class="image"    />    <div style="padding: 14px">      <span>好吃的汉堡</span>      <div class="bottom">        <time class="time">"2022-05-03T16:21:26.010Z"</time>      </div>    </div>  </it-card></template>
复制代码


准备导入组件的相关配置:
  1. 配置统一导出文件:


import Card from "./src/components/card/index";export default {  Card,};
复制代码


  1. 修改package.jsonmain字段:


{  "main": "index.ts"}
复制代码
安装、导入到 module3:
  1. 安装components组件包:


pnpm -F @it200/module3 add @it200/components
复制代码


  1. 导入components组件包:


import Comps from "@it200/components";import "@it200/components/src/components/card/src/card.scss";
app.use(Comps.Card);
复制代码


  1. 使用方式同在 Components 项目中验证一样,效果一样,就不再演示了。

扩展(Changesets 发布变更):

增加相关配置:

  1. 安装changesets到工作空间根目录:


pnpm add -Dw @changesets/cli
复制代码


  1. 执行changesets初始化命令:


pnpm changeset init
复制代码

生成新的 changesets:

pnpm changeset
复制代码


注意:第一次运行前请检查git分支名称和.changeset\config.json中的baseBranch是否一致。

生成示例:

PS xxx> pnpm changeset🦋  Which packages would you like to include? · @it200/module3🦋  Which packages should have a major bump? · No items were selected🦋  Which packages should have a minor bump? · @it200/module3🦋  Please enter a summary for this change (this will be in the changelogs).🦋    (submit empty line to open external editor)🦋  Summary · 增加components模块的配置和使用🦋🦋  === Summary of changesets ===🦋  minor:  @it200/module3🦋🦋  Note: All dependents of these packages that will be incompatible with🦋  the new version will be patch bumped when this changeset is applied.🦋🦋  Is this your desired changeset? (Y/n) · true🦋  Changeset added! - you can now commit it🦋🦋  If you want to modify or expand on the changeset summary, you can find it here🦋  info D:\daydayup\my-workspace\.changeset\purple-dodos-check.md
复制代码

发布变更:

执行命令,会依据先前生成的变更集来在对应的package中的项目中生成对应的CHANGELOG.md并提高对应项目的version,版本提升还需遵守语义化版本规范要求:


pnpm changeset version
复制代码



后续的步骤还需按项目的实际情况来考虑,这里将变更日志生成、版本号提升后就先告一段落了~

总结:

这里使用了工作空间的概念来实现了大项目的拆分工作,每一个单独的模块、项目都可以独立维护、测试、构建,同时在 pnpm 的 node_modules 管理模式下节约了磁盘空间并提升安装速度。在这里只是小试牛刀,更多的特性还没有体现出来,需要后续跟进学习。项目的拆分和搭建没有特别的约定要做的一模一样,符合实际情况的考虑就是最优。

发布于: 刚刚阅读数: 4
用户头像

小鑫同学

关注

公众号:前端小鑫同学 2018.12.10 加入

我是小鑫同学6年前端及跨平台开发经验,曾独立设计混开框架和重构方案,掘金/51CTO活跃作者~

评论

发布
暂无评论
项目越写越大,我是这样做拆分的_项目架构_小鑫同学_InfoQ写作社区