写作布景:

    在几年前的一次 Vue 项目改造中运用原生+H5 的方式按模块菜单来拆分了多个 Vue 项目,在拆分时考虑到多项目保护带来的成本较大,咱们将项目公共运用到的资源进步到项目 root 目录下,将子项目抽取为模板经过定制的脚手架创立每个子项目到 modules 下,而且支撑独自打包、独自发布。这样项目结构的好处一同避免了项目庞大带来的首屏加载时间长,也避免了多人开发出现抵触的形成的对立。

    这样的项目结构在现在看来许多项目都有在运用,比方 Vue、Vite 等,它们共同运用到的 PNPM 的包办理器来安排这样的项目。一同我也在 B 站发现有同伴运用 PNPM 组建了包含 PC 前端、PC 后端、H5 前端这样的项目模板。

咱们一同来搞一搞~

PNPM 介绍:

PNPM 的特色:

  1. 节约磁盘空间并进步装置速度;
  2. 创立非扁平化的 node_modules 文件夹。

PNPM 与 NodeJs 版别支撑:

Node.js pnpm 4 pnpm 5 pnpm 6 pnpm 7
Node.js 10 ✔️ ✔️
Node.js 12 ✔️ ✔️ ✔️
Node.js 14 ✔️ ✔️ ✔️ ✔️
Node.js 16 不知道 不知道 ✔️ ✔️
Node.js 18 不知道 不知道 ✔️ ✔️

上述表格来自:pnpm.io/zh/installa…;

PNPM 与其他包办理功用比照:

功用 pnpm Yarn npm
作业空间支撑(monorepo) ✔️ ✔️ ✔️
阻隔的 node_modules ✔️ – 默许 ✔️
进步的 node_modules ✔️ ✔️ ✔️ – 默许
主动装置 peers ✔️ – 经过 auto-install-peers=true ✔️
Plug’n’Play ✔️ ✔️ – 默许
零装置 ✔️
修补依靠项 ✔️ ✔️
办理 Node.js 版别 ✔️
有锁文件 ✔️ – pnpm-lock.yaml ✔️ – yarn.lock ✔️ – package-lock.json
支撑覆盖 ✔️ ✔️ – 经过 resolutions ✔️
内容可寻址存储 ✔️
动态包履行 ✔️ – 经过 pnpm dlx ✔️ – 经过 yarn dlx ✔️ – 经过 npx
Side-effects cache ✔️

上述表格来自:pnpm.io/zh/feature-…;

装置 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 的项目名称和版别号:

项目 name字段更新 version字段更新
apis apis -> @it200/apis 0.0.0 -> 0.0.1
utils utils -> @it200/utils 0.0.0 -> 0.0.1

运用 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                  

调整三个模块项意图名称和版别号

项目 name字段更新 version字段更新
module1 module1 -> @it200/module1 0.0.0 -> 0.0.1
module2 module2 -> @it200/module2 0.0.0 -> 0.0.1
module3 module3 -> @it200/module3 0.0.0 -> 0.0.1

共同包办理器的运用:

在创立的各模块的 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东西类以支撑不同风格提示:

utilssrcclipboard.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. 装备共同导出文件(utilsindex.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 interceptor
service.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东西类:

apissrcaxios.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 interceptor
service.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目标的数据读取:

apissrcuser.ts

import service from "./axios";
export const UserApi = {
  getUsers: () => service.get<any>("/users"),
};
导出userApi类的相关装备:
  1. 装备共同导出文件(apisindex.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>

www.awesomescreenshot.com/video/99767…

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

运用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. 经过 mockend.com/OSpoon/data… 就可以获取到mock数据了;
  2. 更多装备请参阅docs.mockend.com/。

开发 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. 导入组件相关装备并装置,componentssrcmain.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分支名称和.changesetconfig.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:daydayupmy-workspace.changesetpurple-dodos-check.md

发布改变:

履行指令,会根据先前生成的改变集来在对应的package中的项目中生成对应的CHANGELOG.md并进步对应项意图version,版别进步还需恪守语义化版别标准要求:

pnpm changeset version

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

后续的步骤还需按项意图实际情况来考虑,这儿将改变日志生成、版别号进步后就先告一段落了~

总结:

    这儿运用了作业空间的概念来完成了大项意图拆分作业,每一个独自的模块、项目都可以独立保护、测验、构建,一同在 pnpm 的 node_modules 办理模式下节约了磁盘空间并进步装置速度。在这儿只是小试牛刀,更多的特性还没有表现出来,需求后续跟进学习。项意图拆分和建立没有特别的约定要做的一模相同,符合实际情况的考虑就是最优。

我正在参加技能社区创作者签约方案招募活动,点击链接报名投稿。