写作布景:
在几年前的一次 Vue 项目改造中运用原生+H5 的方式按模块菜单来拆分了多个 Vue 项目,在拆分时考虑到多项目保护带来的成本较大,咱们将项目公共运用到的资源进步到项目 root 目录下,将子项目抽取为模板经过定制的脚手架创立每个子项目到 modules 下,而且支撑独自打包、独自发布。这样项目结构的好处一同避免了项目庞大带来的首屏加载时间长,也避免了多人开发出现抵触的形成的对立。
这样的项目结构在现在看来许多项目都有在运用,比方 Vue、Vite 等,它们共同运用到的 PNPM 的包办理器来安排这样的项目。一同我也在 B 站发现有同伴运用 PNPM 组建了包含 PC 前端、PC 后端、H5 前端这样的项目模板。
咱们一同来搞一搞~
PNPM 介绍:
PNPM 的特色:
- 节约磁盘空间并进步装置速度;
- 创立非扁平化的 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
快速开端指令:
- 在项目root目录装置一切依靠:
pnpm install
; - 在项目root目录装置指定依靠:
pnpm add <pkg>
; - 在项目root目录运行CMD指令:
pnpm <cmd>
; - 在特定子集运行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)
}
装备相关依靠:
- 装置
vueuse
依靠库,监听屏幕改动; - 装置
clipboard
依靠库,完结粘贴板根底功用; - 装置
element-plus
PC风格组件库; - 装置
vant
移动端风格组件库; - 装置
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);
};
导出东西类的相关装备:
- 装备共同导出文件(
utilsindex.ts
):
export * from "./src/clipboard";
- 修正
package.json
的main
字段:
{
"main": "index.ts"
}
将utils模块装置到module1项目:
- 下面的指令在root目录履行,经过
-F
来履行指令履行的方位是@it200/module1
,履行的指令是add
。
pnpm -F @it200/module1 add @it200/utils
注:当@it200/utils
包升级后,履行履行pnpm update
来更新相关依靠版别。
- 装置成功后的依靠信息如下:
{
"dependencies": {
"@it200/utils": "workspace:^0.0.1"
}
}
在module1项目中测验运用Clipboard函数:
- 在模板中添加按钮:
<button @click="copy">仿制</button>
- 在
setup
的script
中添加对应函数并导入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;
装备相关依靠:
- 装置
axios
依靠库,完结数据恳求的发送及处理; - 装置
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类的相关装备:
- 装备共同导出文件(
apisindex.ts
):
export * from "./src/user";
- 修正
package.json
的main
字段:
{
"main": "index.ts"
}
在module2项目中测验运用userApi类:
- 界说模板:
<template>
<button @click="getUserList">获取用户列表</button>
<ul>
<li v-for="user in users" :key="user.id">
{{ user.name }}、{{ user.age }}
</li>
</ul>
</template>
- 装置、导入、编写逻辑:
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数据:
- 挑选一个符合自己的方案:
- 挑选要装置到得公共项目库房,Github安排不支撑免费的(只为截个图):
- 在项目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": {}
}
}
}
- 经过 mockend.com/OSpoon/data… 就可以获取到mock数据了;
- 更多装备请参阅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组件:
- 导入组件相关装备并装置,
componentssrcmain.ts
import Card from "./components/card/index";
import "./components/card/src/card.scss";
app.use(Card);
- 在
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>
预备导入组件的相关装备:
- 装备共同导出文件:
import Card from "./src/components/card/index";
export default {
Card,
};
- 修正
package.json
的main
字段:
{
"main": "index.ts"
}
装置、导入到module3:
- 装置
components
组件包:
pnpm -F @it200/module3 add @it200/components
- 导入
components
组件包:
import Comps from "@it200/components";
import "@it200/components/src/components/card/src/card.scss";
app.use(Comps.Card);
- 运用方式同在Components项目中验证相同,效果相同,就不再演示了。
扩展(Changesets发布改变):
添加相关装备:
- 装置
changesets
到作业空间根目录:
pnpm add -Dw @changesets/cli
- 履行
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 办理模式下节约了磁盘空间并进步装置速度。在这儿只是小试牛刀,更多的特性还没有表现出来,需求后续跟进学习。项意图拆分和建立没有特别的约定要做的一模相同,符合实际情况的考虑就是最优。
我正在参加技能社区创作者签约方案招募活动,点击链接报名投稿。