阅读视图
React 入门秘籍:像搭积木一样写网页,JSX 让开发爽到飞起!
vite+ts+monorepo从0搭建vue3组件库(五):vite打包组件库
打包配置
vite 专门提供了库模式的打包方式,配置其实非常简单,首先全局安装 vite 以及@vitejs/plugin-vue
pnpm add vite @vitejs/plugin-vue -D -w
在components下新建vite.config.ts。我们需要让打包后的结构和我们开发的结构一致,如下配置我们将打包后的文件放入dlx-ui 目录下,因为后续发布组件库的名字就是 dlx-ui,当然这个命名大家可以随意.具体代码在下方
然后在 components/package.json 添加打包命令scripts
"scripts": {
"build": "vite build"
},
声明文件
到这里其实打包的组件库只能给 js 项目使用,在 ts 项目下运行会出现一些错误,而且使用的时候还会失去代码提示功能,这样的话我们就失去了用 ts 开发组件库的意义了。所以我们需要在打包的库里加入声明文件(.d.ts)。
全局安装vite-plugin-dts
pnpm add vite-plugin-dts -D -w
在vite.config.ts中引入,完整的配置文件如下:
// components/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [
vue(),
dts({
entryRoot: './src',
outDir: ['../dlx-ui/es/src', '../dlx-ui/lib/src'],
//指定使用的tsconfig.json为我们整个项目根目录下,如果不配置,你也可以在components下新建tsconfig.json
tsconfigPath: '../../tsconfig.json',
}),
],
build: {
//打包文件目录
outDir: 'es',
emptyOutDir: true,
//压缩
//minify: false,
rollupOptions: {
//忽略打包vue文件
external: ['vue'],
input: ['index.ts'],
output: [
{
//打包格式
format: 'es',
//打包后文件名
entryFileNames: '[name].mjs',
//让打包目录和我们目录对应
preserveModules: true,
exports: 'named',
//配置打包根目录
dir: '../dlx-ui/es',
},
{
//打包格式
format: 'cjs',
//打包后文件名
entryFileNames: '[name].js',
//让打包目录和我们目录对应
preserveModules: true,
exports: 'named',
//配置打包根目录
dir: '../dlx-ui/lib',
},
],
},
lib: {
entry: './index.ts',
},
},
})
执行pnpm run build打包,出现了我们需要的声明的文件
可以看到打包时打包了2种模式,一种是es模式,一种是cjs模式,当用户引入组件库时使用哪种呢?我们可以修改/components/package.json的代码:
-
main: 指向
lib/index.js,这是 CommonJS 模块的入口文件。Node.js 环境和不支持 ES 模块的工具会使用这个文件。 -
module: 指向
es/index.mjs,这是 ES 模块的入口文件。现代前端工具(如 Vite)会优先使用这个文件。
"main": "lib/index.js", // CommonJS 入口文件
"module": "es/index.mjs", // ES 模块入口文件
但是此时的所有样式文件还是会统一打包到 style.css 中,还是不能进行样式的按需加载,所以接下来我们将让 vite 不打包样式文件,样式文件后续单独进行打包。后面我们要做的则是让样式文件也支持按需引入,敬请期待。
vite+ts+monorepo从0搭建vue3组件库(四):button组件开发
组件属性
button组件接收以下属性
- type 类型
- size 尺寸
- plain 朴素按钮
- round 圆角按钮
- circle 圆形按钮
- loading 加载
- disabled禁用
- text 文字
button组件全部代码如下:
// button.vue
<template>
<button
class="dlx-button"
:class="[
buttonSize ? `dlx-button--${buttonSize}` : '',
buttonType ? `dlx-button--${buttonType}` : '',
{
'is-plain': plain,
'is-round': round,
'is-circle': circle,
'is-disabled': disabled,
'is-loading': loading,
'is-text': text,
'is-link': link,
},
]"
:disabled="disabled || loading"
@click="handleClick"
>
<span v-if="loading" class="dlx-button__loading">
<span class="dlx-button__loading-spinner"></span>
</span>
<span class="dlx-button__content">
<slot></slot>
</span>
</button>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
defineOptions({
name: 'DlxButton',
})
const props = defineProps({
// 按钮类型
type: {
type: String,
values: ['primary', 'success', 'warning', 'danger', 'info'],
default: '',
},
// 按钮尺寸
size: {
type: String,
values: ['large', 'small'],
default: '',
},
// 是否为朴素按钮
plain: {
type: Boolean,
default: false,
},
// 是否为圆角按钮
round: {
type: Boolean,
default: false,
},
// 是否为圆形按钮
circle: {
type: Boolean,
default: false,
},
// 是否为加载中状态
loading: {
type: Boolean,
default: false,
},
// 是否禁用
disabled: {
type: Boolean,
default: false,
},
// 是否为文字按钮
text: {
type: Boolean,
default: false,
},
// 是否为链接按钮
link: {
type: Boolean,
default: false,
},
})
const buttonSize = computed(() => props.size)
const buttonType = computed(() => props.type)
const handleClick = (evt: MouseEvent) => {
if (props.disabled || props.loading) return
emit('click', evt)
}
const emit = defineEmits(['click'])
</script>
<style lang="less" scoped>
.dlx-button {
display: inline-flex;
justify-content: center;
align-items: center;
line-height: 1;
height: 32px;
white-space: nowrap;
cursor: pointer;
color: #606266;
text-align: center;
box-sizing: border-box;
outline: none;
transition: 0.1s;
font-weight: 500;
padding: 8px 15px;
font-size: 14px;
border-radius: 4px;
background-color: #fff;
border: 1px solid #dcdfe6;
&:hover,
&:focus {
color: #409eff;
border-color: #c6e2ff;
background-color: #ecf5ff;
}
&:active {
color: #3a8ee6;
border-color: #3a8ee6;
outline: none;
}
// 主要按钮
&--primary {
color: #fff;
background-color: #409eff;
border-color: #409eff;
&:hover,
&:focus {
background: #66b1ff;
border-color: #66b1ff;
color: #fff;
}
&:active {
background: #3a8ee6;
border-color: #3a8ee6;
color: #fff;
}
}
// 成功按钮
&--success {
color: #fff;
background-color: #67c23a;
border-color: #67c23a;
&:hover,
&:focus {
background: #85ce61;
border-color: #85ce61;
color: #fff;
}
&:active {
background: #5daf34;
border-color: #5daf34;
color: #fff;
}
}
// 警告按钮
&--warning {
color: #fff;
background-color: #e6a23c;
border-color: #e6a23c;
&:hover,
&:focus {
background: #ebb563;
border-color: #ebb563;
color: #fff;
}
&:active {
background: #cf9236;
border-color: #cf9236;
color: #fff;
}
}
// 危险按钮
&--danger {
color: #fff;
background-color: #f56c6c;
border-color: #f56c6c;
&:hover,
&:focus {
background: #f78989;
border-color: #f78989;
color: #fff;
}
&:active {
background: #dd6161;
border-color: #dd6161;
color: #fff;
}
}
// 信息按钮
&--info {
color: #fff;
background-color: #909399;
border-color: #909399;
&:hover,
&:focus {
background: #a6a9ad;
border-color: #a6a9ad;
color: #fff;
}
&:active {
background: #82848a;
border-color: #82848a;
color: #fff;
}
}
// 大尺寸
&--large {
height: 40px;
padding: 12px 19px;
font-size: 14px;
border-radius: 4px;
}
// 小尺寸
&--small {
height: 24px;
padding: 5px 11px;
font-size: 12px;
border-radius: 3px;
}
// 朴素按钮
&.is-plain {
background: #fff;
// 不同类型按钮的默认状态
&.dlx-button--primary {
color: #409eff;
border-color: #409eff;
}
&.dlx-button--success {
color: #67c23a;
border-color: #67c23a;
}
&.dlx-button--warning {
color: #e6a23c;
border-color: #e6a23c;
}
&.dlx-button--danger {
color: #f56c6c;
border-color: #f56c6c;
}
&.dlx-button--info {
color: #909399;
border-color: #909399;
}
&:hover,
&:focus {
background: #ecf5ff;
border-color: #409eff;
color: #409eff;
}
&:active {
background: #ecf5ff;
border-color: #3a8ee6;
color: #3a8ee6;
}
// 为不同类型的朴素按钮添加对应的悬浮状态
&.dlx-button--primary {
&:hover,
&:focus {
background: #ecf5ff;
border-color: #409eff;
color: #409eff;
}
&:active {
border-color: #3a8ee6;
color: #3a8ee6;
}
}
&.dlx-button--success {
&:hover,
&:focus {
background: #f0f9eb;
border-color: #67c23a;
color: #67c23a;
}
&:active {
border-color: #5daf34;
color: #5daf34;
}
}
&.dlx-button--warning {
&:hover,
&:focus {
background: #fdf6ec;
border-color: #e6a23c;
color: #e6a23c;
}
&:active {
border-color: #cf9236;
color: #cf9236;
}
}
&.dlx-button--danger {
&:hover,
&:focus {
background: #fef0f0;
border-color: #f56c6c;
color: #f56c6c;
}
&:active {
border-color: #dd6161;
color: #dd6161;
}
}
&.dlx-button--info {
&:hover,
&:focus {
background: #f4f4f5;
border-color: #909399;
color: #909399;
}
&:active {
border-color: #82848a;
color: #82848a;
}
}
}
// 圆角按钮
&.is-round {
border-radius: 20px;
}
// 圆形按钮
&.is-circle {
border-radius: 50%;
padding: 8px;
}
// 文字按钮
&.is-text {
border-color: transparent;
background: transparent;
padding-left: 0;
padding-right: 0;
&:not(.is-disabled) {
// 默认文字按钮
color: #409eff;
&:hover,
&:focus {
color: #66b1ff;
background-color: transparent;
border-color: transparent;
}
&:active {
color: #3a8ee6;
}
// 不同类型的文字按钮颜色
&.dlx-button--primary {
color: #409eff;
&:hover,
&:focus {
color: #66b1ff;
}
&:active {
color: #3a8ee6;
}
}
&.dlx-button--success {
color: #67c23a;
&:hover,
&:focus {
color: #85ce61;
}
&:active {
color: #5daf34;
}
}
&.dlx-button--warning {
color: #e6a23c;
&:hover,
&:focus {
color: #ebb563;
}
&:active {
color: #cf9236;
}
}
&.dlx-button--danger {
color: #f56c6c;
&:hover,
&:focus {
color: #f78989;
}
&:active {
color: #dd6161;
}
}
&.dlx-button--info {
color: #909399;
&:hover,
&:focus {
color: #a6a9ad;
}
&:active {
color: #82848a;
}
}
}
// 文字按钮的禁用状态
&.is-disabled {
color: #c0c4cc;
}
}
// 链接按钮
&.is-link {
border-color: transparent;
color: #409eff;
background: transparent;
padding-left: 0;
padding-right: 0;
&:hover,
&:focus {
color: #66b1ff;
}
&:active {
color: #3a8ee6;
}
}
// 禁用状态
&.is-disabled {
&,
&:hover,
&:focus,
&:active {
cursor: not-allowed;
// 普通按钮的禁用样式
&:not(.is-text):not(.is-link) {
background-color: #fff;
border-color: #dcdfe6;
color: #c0c4cc;
// 有颜色的按钮的禁用样式
&.dlx-button--primary {
background-color: #a0cfff;
border-color: #a0cfff;
color: #fff;
}
&.dlx-button--success {
background-color: #b3e19d;
border-color: #b3e19d;
color: #fff;
}
&.dlx-button--warning {
background-color: #f3d19e;
border-color: #f3d19e;
color: #fff;
}
&.dlx-button--danger {
background-color: #fab6b6;
border-color: #fab6b6;
color: #fff;
}
&.dlx-button--info {
background-color: #c8c9cc;
border-color: #c8c9cc;
color: #fff;
}
}
}
}
// 有颜色的按钮禁用状态 - 直接选择器
&.is-disabled.dlx-button--primary {
background-color: #a0cfff;
border-color: #a0cfff;
color: #fff;
}
&.is-disabled.dlx-button--success {
background-color: #b3e19d;
border-color: #b3e19d;
color: #fff;
}
&.is-disabled.dlx-button--warning {
background-color: #f3d19e;
border-color: #f3d19e;
color: #fff;
}
&.is-disabled.dlx-button--danger {
background-color: #fab6b6;
border-color: #fab6b6;
color: #fff;
}
&.is-disabled.dlx-button--info {
background-color: #c8c9cc;
border-color: #c8c9cc;
color: #fff;
}
// 文字按钮禁用状态
&.is-disabled.is-text {
background-color: transparent;
border-color: transparent;
color: #c0c4cc;
}
// 链接按钮禁用状态
&.is-disabled.is-link {
background-color: transparent;
border-color: transparent;
color: #c0c4cc;
}
// 加载状态
&.is-loading {
position: relative;
pointer-events: none;
&:before {
pointer-events: none;
content: '';
position: absolute;
left: -1px;
top: -1px;
right: -1px;
bottom: -1px;
border-radius: inherit;
background-color: rgba(255, 255, 255, 0.35);
}
}
.dlx-button__loading {
display: inline-flex;
align-items: center;
margin-right: 4px;
}
.dlx-button__loading-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid #fff;
border-radius: 50%;
border-top-color: transparent;
animation: button-loading 1s infinite linear;
}
}
@keyframes button-loading {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
</style>
引用
在play的src下新建example,存放各个组件的代码,先在play下安装vue-router
pnpm i vue-router
目录结构如下
app.vue如下:
<template>
<div class="app-container">
<div class="sidebar">
<h2 class="sidebar-title">组件列表</h2>
<ul class="menu-list">
<li
v-for="item in menuItems"
:key="item.path"
:class="{ active: currentPath === item.path }"
@click="handleMenuClick(item.path)"
>
{{ item.name }}
</li>
</ul>
</div>
<div class="content">
<router-view></router-view>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const currentPath = ref('/button')
const menuItems = [
{ name: 'Button 按钮', path: '/button' },
// 后续添加其他组件...
]
const handleMenuClick = (path: string) => {
currentPath.value = path
router.push(path)
}
</script>
<style scoped>
.app-container {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
background-color: #f5f7fa;
border-right: 1px solid #e4e7ed;
padding: 20px 0;
}
.sidebar-title {
padding: 0 20px;
margin: 0 0 20px;
font-size: 18px;
color: #303133;
}
.menu-list {
list-style: none;
padding: 0;
margin: 0;
}
.menu-list li {
padding: 12px 20px;
cursor: pointer;
color: #303133;
font-size: 14px;
transition: all 0.3s;
}
.menu-list li:hover {
color: #409eff;
background-color: #ecf5ff;
}
.menu-list li.active {
color: #409eff;
background-color: #ecf5ff;
}
.content {
flex: 1;
padding: 20px;
}
</style>
router/index.ts如下:
import { createRouter, createWebHistory } from 'vue-router'
import ButtonExample from '../example/button.vue'
const routes = [
{
path: '/',
redirect: '/button',
},
{
path: '/button',
component: ButtonExample,
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
play下执行pnpm run dev
运行效果:
vite+ts+monorepo从0搭建vue3组件库(三):开发一个组件
1.在packages下新建components和utils文件夹,分别执行pnpm init,并将他们的包名改为@dlx-ui/components和@dlx-ui/utils,目录结构如下:
组件目录
组件编写
button.vue
<!-- button组件 -->
<template>
<button class="button" :class="typeClass" @click="handleClick">
测试按钮
<slot></slot>
</button>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
type: {
type: String,
default: 'default',
},
})
const typeClass = ref('')
const handleClick = () => {
console.log('click')
}
</script>
<style lang="less" scoped>
.button {
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
background: #fff;
border: 1px solid #dcdfe6;
}
</style>
然后在button/index.ts将其导出
import Button from './button'
export { Button }
export default Button
因为我们后面会有很多组件的,比如 Icon,Upload,Select 等,所以我们需要在components/src/index.ts集中导出所有组件
// components/src/index.ts
export * from './button'
最后在components下的index.ts中,导出所有组件,供其他页面使用
export * from './src/index'
局部引用组件
在play项目中,安装@dlx-ui/components,并且在app.vue中使用
在play目录下执行pnpm add @dlx-ui/components
然后在app.vue中引入button
<template>
<Button>按钮</Button>
</template>
<script setup lang="ts">
import { Button } from '@dlx-ui/components'
</script>
<style scoped>
</style>
全局挂载组件
有的时候我们使用组件的时候想要直直接使用 app.use()挂载整个组件库,其实使用 app.use()的时候它会调用传入参数的 install 方法,因此首先我们给每个组件添加一个 install 方法,然后再导出整个组件库,我们将 button/index.ts 改为
import _Button from './button.vue'
import type { App, Plugin } from "vue";
type SFCWithInstall<T> = T & Plugin;
const withInstall = <T>(comp: T) => {
(comp as SFCWithInstall<T>).install = (app: App) => {
const name = (comp as any).name;
//注册组件
app.component(name, comp as SFCWithInstall<T>);
};
return comp as SFCWithInstall<T>;
};
export const Button = withInstall(_Button);
export default Button;
components/index.ts修改为
import * as components from "./src/index";
export * from "./src/index";
import { App } from "vue";
export default {
install: (app: App) => {
for (let c in components) {
app.use(components[c]);
}
},
};
组件命名
此时我们需要给button.vue一个name:dlx-button好在全局挂载的时候作为组件名使用
在setup语法糖中使用defineOptions
defineOptions({
name: 'dlx-button',
})
main.ts全局挂载组件库
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import dlxui from '@dlx-ui/components'
const app = createApp(App)
app.use(dlxui)
createApp(App).mount('#app')
在app.vue中引入
<template>
<dlx-button>全局挂载的按钮</dlx-button>
</template>
<script setup lang="ts"></script>
tauri2+vue+vite实现基于webview视图渲染的桌面端开发
创建应用
- 环境依赖
- 创建项目
pnpm create tauri-app
应用程序更新
1.安装依赖
安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用
pnpm tauri add updater
2.配置
密钥生成,在package.json文件中添加,如下命令生成更新公钥和私钥
"@description:updater": "Tauri CLI 提供了 signer generate 命令 生成更新密钥",
"updater": "tauri signer generate -w ~/.tauri/myapp.key"
在windows环境变量配置私钥,输入cmd 命令行执行 win cmd
set TAURI_PRIVATE_KEY="content of the generated key"
set TAURI_KEY_PASSWORD="password"
powershell
$env:TAURI_PRIVATE_KEY="content of the generated key"
$env:TAURI_KEY_PASSWORD="password"
在 src-tauri\tauri.conf.json 文件中开启自动升级,并将公钥添加到里面,设置你的升级信息json文件获取的url路径
{
"app": {},
"bundle": {
"createUpdaterArtifacts": true,
"icon": []
},
"plugins": {
"updater": {
"active": true,
"windows": {
"installMode": "passive"
},
"pubkey": "公钥",
"endpoints": ["https://xxx/download/latest.json"]
}
}
}
更新 latest.json 内容
{
"version": "v1.0.0",
"notes": "Test version",
"pub_date": "2020-06-22T19:25:57Z",
"platforms": {
"darwin-x86_64": {
"signature": "Content of app.tar.gz.sig",
"url": "https://github.com/username/reponame/releases/download/v1.0.0/app-x86_64.app.tar.gz"
},
"darwin-aarch64": {
"signature": "Content of app.tar.gz.sig",
"url": "https://github.com/username/reponame/releases/download/v1.0.0/app-aarch64.app.tar.gz"
},
"linux-x86_64": {
"signature": "Content of app.AppImage.tar.gz.sig",
"url": "https://github.com/username/reponame/releases/download/v1.0.0/app-amd64.AppImage.tar.gz"
},
"windows-x86_64": {
"signature": "Content of app.msi.sig",
"url": "https://github.com/username/reponame/releases/download/v1.0.0/app-x64.msi.zip"
}
}
}
tauri 权限配置 src-tauri\capabilities\default.json
{
"permissions": [
"updater:default",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install"
]
}
3.封装hooks
src\hooks\updater.ts
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
export default () => {
const message = window.$message;
const dialog = window.$dialog;
const checkV = async () => {
return await check()
.then((e: any) => {
if (!e?.available) {
return;
}
return {
version: e.version,
meg: `新版本 ${e.version} ,发布时间: ${e.date} 升级信息: ${e.body}`,
};
})
.catch((e) => {
console.error("检查更新错误,请稍后再试 " + e);
});
};
const updater = async () => {
dialog.success({
title: "系统提示",
content: "您确认要更新吗 ?",
positiveText: "更新",
negativeText: "不更新",
maskClosable: false,
closable: false,
onPositiveClick: async () => {
message.success("正在下载更新,请稍等");
await check()
.then(async (e: any) => {
if (!e?.available) {
return;
}
await e.downloadAndInstall((event: any) => {
switch (event.event) {
case "Started":
message.success(
"文件大小:" + event.data.contentLength
? event.data.contentLength
: 0
);
break;
case "Progress":
message.success("正在下载" + event.data.chunkLength);
break;
case "Finished":
message.success("安装包下载成功,10s后重启并安装");
setTimeout(async () => {
await relaunch();
}, 10000);
break;
}
});
})
.catch((e) => {
console.error("检查更新错误,请稍后再试 " + e);
});
},
onNegativeClick: () => {
message.info("您已取消更新");
},
});
};
return {
checkV,
updater,
};
};
4.调用示例
<template>
<div>
{{ meg }}
<n-button type="primary" @click="updateTask">检查更新</n-button>
</div>
</template>
<script setup lang="ts">
import { message } from "@tauri-apps/plugin-dialog";
import pkg from "../../package.json";
import useUpdater from "@/hooks/updater";
import { ref } from "vue";
const meg = ref("版本检测 ");
const { checkV, updater } = useUpdater();
const state = ref(false);
const updateTask = async () => {
if (state.value) {
await updater();
} else {
let res = await checkV();
if (res) {
meg.value = "发现新版本:" + res.meg;
state.value = pkg.version !== res.version;
}
}
};
</script>
自定义系统托盘
前端方式(hooks函数)【推荐】
1.配置
添加自定义图标权限 src-tauri\Cargo.toml
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
2.封装hooks
src\hooks\tray.ts
// 获取当前窗口
import { getCurrentWindow } from "@tauri-apps/api/window";
// 导入系统托盘
import { TrayIcon, TrayIconOptions, TrayIconEvent } from "@tauri-apps/api/tray";
// 托盘菜单
import { Menu } from "@tauri-apps/api/menu";
// 进程管理
import { exit } from "@tauri-apps/plugin-process";
// 定义闪烁状态
let isBlinking: boolean = false;
let blinkInterval: NodeJS.Timeout | null = null;
let trayInstance: TrayIcon | any | null = null;
let originalIcon: string | any;
/**
* 在这里你可以添加一个托盘菜单,标题,工具提示,事件处理程序等
*/
const options: TrayIconOptions = {
// icon 项目根目录/src-tauri/
icon: "icons/32x32.png",
tooltip: "zero",
menuOnLeftClick: false,
action: (event: TrayIconEvent) => {
if (
event.type === "Click" &&
event.button === "Left" &&
event.buttonState === "Down"
) {
// 显示窗口
winShowFocus();
}
},
};
/**
* 窗口置顶显示
*/
async function winShowFocus() {
try {
// 获取窗体实例
const win = getCurrentWindow();
// 检查窗口是否见,如果不可见则显示出来
if (!(await win.isVisible())) {
await win.show();
} else {
// 检查是否处于最小化状态,如果处于最小化状态则解除最小化
if (await win.isMinimized()) {
await win.unminimize();
}
// 窗口置顶
await win.setFocus();
}
} catch (error) {
console.error("Error in winShowFocus:", error);
}
}
/**
* 创建托盘菜单
*/
async function createMenu() {
try {
return await Menu.new({
// items 的显示顺序是倒过来的
items: [
{
id: "show",
text: "显示窗口",
action: () => {
winShowFocus();
},
},
{
id: "quit",
text: "退出",
action: () => {
exit(0);
},
},
],
});
} catch (error) {
console.error("Error in createMenu:", error);
return null;
}
}
/**
* 创建系统托盘
*/
export async function createTray() {
try {
const menu = await createMenu();
if (menu) {
options.menu = menu;
const tray = await TrayIcon.new(options);
trayInstance = tray;
originalIcon = options.icon; // 保存原始图标
return tray;
}
} catch (error) {
console.error("Error in createTray:", error);
}
}
/**
* 开启图标闪烁
* @param icon1 图标1路径(可选,默认原始图标)
* @param icon2 图标2路径(可选,默认alt图标)
* @param interval 闪烁间隔(默认500ms)
*/
export async function startBlinking(
icon1?: string,
icon2?: string,
interval: number = 500
) {
if (!trayInstance) {
console.error("Tray not initialized");
return;
}
// 如果正在闪烁,先停止
stopBlinking();
// 设置图标路径
const targetIcon1 = icon1 || originalIcon;
const targetIcon2 = icon2 || "icons/32x32_alt.png"; // 备用图标路径
isBlinking = true;
let currentIcon = targetIcon1;
blinkInterval = setInterval(async () => {
try {
currentIcon = currentIcon === targetIcon1 ? targetIcon2 : targetIcon1;
await trayInstance!.setIcon(currentIcon);
} catch (error) {
console.error("Blinking error:", error);
stopBlinking();
}
}, interval);
}
/**
* 停止闪烁并恢复原始图标
*/
export function stopBlinking() {
if (blinkInterval) {
clearInterval(blinkInterval);
blinkInterval = null;
isBlinking = false;
// 恢复原始图标
if (trayInstance) {
trayInstance
.setIcon(originalIcon)
.catch((error) => console.error("恢复图标失败:", error));
}
}
}
/**
* 销毁托盘(自动停止闪烁)
*/
export async function destroyTray() {
try {
stopBlinking();
if (trayInstance) {
await trayInstance.destroy();
trayInstance = null;
}
} catch (error) {
console.error("Error destroying tray:", error);
}
}
3.调用示例
结合不同场景引入 hooks 函数,调用对应方法,其中 createTray函数 可以放到 main.ts 中在系统启动时创建
// 场景示例:即时通讯应用
class ChatApp {
async init() {
// 应用启动时初始化托盘
await createTray();
}
onNewMessage() {
// 收到新消息时启动红色提醒闪烁
startBlinking("icons/msg_new.png", "icons/msg_alert.png");
}
onMessageRead() {
// 用户查看消息后停止闪烁
stopBlinking();
}
async shutdown() {
// 退出时清理资源
await destroyTray();
}
}
// 场景示例:下载管理器
class DownloadManager {
onDownloadProgress() {
// 下载时使用蓝色图标呼吸灯效果
startBlinking("icons/download_active.png", "icons/download_idle.png", 1000);
}
onDownloadComplete() {
// 下载完成停止闪烁并显示完成图标
stopBlinking();
trayInstance?.setIcon("icons/download_done.png");
}
}
前后端结合方式(Rust函数)
1.配置
添加自定义图标权限 src-tauri\Cargo.toml
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
添加配置 src-tauri\tauri.conf.json 自定义图标
"app": {
"windows": [
],
"trayIcon": {
"iconPath": "icons/icon.ico",
"iconAsTemplate": true,
"title": "时间管理器",
"tooltip": "时间管理器"
}
},
2.Rust 封装
托盘事件定义,新建 tray.rs 文件 src-tauri\src\tray.rs
use tauri::{
menu::{Menu, MenuItem, Submenu},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, Runtime,
};
pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
let quit_i = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let show_i = MenuItem::with_id(app, "show", "显示", true, None::<&str>)?;
let hide_i = MenuItem::with_id(app, "hide", "隐藏", true, None::<&str>)?;
let edit_i = MenuItem::with_id(app, "edit_file", "编辑", true, None::<&str>)?;
let new_i = MenuItem::with_id(app, "new_file", "添加", true, None::<&str>)?;
let a = Submenu::with_id_and_items(app, "File", "文章", true, &[&new_i, &edit_i])?;
// 分割线
let menu = Menu::with_items(app, &[&quit_i, &show_i, &hide_i, &a])?;
// 创建系统托盘 let _ = TrayIconBuilder::with_id("icon")
let _ = TrayIconBuilder::with_id("tray")
// 添加菜单
.menu(&menu)
// 添加托盘图标
.icon(app.default_window_icon().unwrap().clone())
.title("zero")
.tooltip("zero")
.show_menu_on_left_click(false)
// 禁用鼠标左键点击图标显示托盘菜单
// .show_menu_on_left_click(false)
// 监听事件菜单
.on_menu_event(move |app, event| match event.id.as_ref() {
"quit" => {
app.exit(0);
}
"show" => {
let window = app.get_webview_window("main").unwrap();
let _ = window.show();
}
"hide" => {
let window = app.get_webview_window("main").unwrap();
let _ = window.hide();
}
"edit_file" => {
println!("edit_file");
}
"new_file" => {
println!("new_file");
}
// Add more events here
_ => {}
})
// 监听托盘图标发出的鼠标事件
.on_tray_icon_event(|tray, event| {
// 左键点击托盘图标显示窗口
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app);
Ok(())
}
lib.rs 使用,注册函数暴露给前端调用
#[cfg(desktop)]
mod tray;
// 自定义函数声明
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_log::Builder::new().build())
// 添加自定义托盘
.setup(|app| {
#[cfg(all(desktop))]
{
let handle: &tauri::AppHandle = app.handle();
tray::create_tray(handle)?;
}
Ok(())
})
// Run the app
// 注册 Rust 后端函数,暴露给前端调用
.invoke_handler(tauri::generate_handler![
greet
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
3.前端调用Rust暴露函数
<template>
<div>
<button class="item" @click="flashTray(true)">开启图标闪烁</button>
<button class="item" @click="flashTray(false)">关闭图标闪烁</button>
</div>
</template>
<script setup lang="ts">
import { TrayIcon } from "@tauri-apps/api/tray";
const flashTimer = ref<Boolean | any>(false);
const flashTray = async (bool: Boolean) => {
let flag = true;
if (bool) {
TrayIcon.getById("tray").then(async (res: any) => {
clearInterval(flashTimer.value);
flashTimer.value = setInterval(() => {
if (flag) {
res.setIcon(null);
} else {
// res.setIcon(defaultIcon)
// 支持把自定义图标放在默认icons文件夹,通过如下方式设置图标
// res.setIcon('icons/msg.png')
// 支持把自定义图标放在自定义文件夹tray,需要配置tauri.conf.json参数 "bundle": {"resources": ["tray"]}
res.setIcon("tray/tray.png");
}
flag = !flag;
}, 500);
});
} else {
clearInterval(flashTimer.value);
let tray: any = await TrayIcon.getById("tray");
tray.setIcon("icons/icon.png");
}
};
</script>
窗口工具栏自定义
1. 配置
配置文件开启权限 src-tauri\capabilities\default.json
"permissions": [
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-toggle-maximize",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-hide",
"core:window:allow-unminimize",
"core:window:allow-set-size",
"core:window:allow-close",
]
关闭默认窗口事件 src-tauri\tauri.conf.json
"app": {
"windows": [
{
"decorations": false,
}
],
},
2. 自定义实现
前端调用
<script setup lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
const appWindow = getCurrentWindow();
onMounted(() => {
windowCustomize();
});
const windowCustomize = () => {
let minimizeEle = document.getElementById("titlebar-minimize");
minimizeEle?.addEventListener("click", () => appWindow.minimize());
let maximizeEle = document.getElementById("titlebar-maximize");
maximizeEle?.addEventListener("click", () => appWindow.toggleMaximize());
let closeEle = document.getElementById("titlebar-close");
closeEle?.addEventListener("click", () => appWindow.close());
};
</script>
<template>
<div data-tauri-drag-region class="titlebar">
<div class="titlebar-button" id="titlebar-minimize">
<img src="@/assets/svg/titlebar/mdi_window-minimize.svg" alt="minimize" />
</div>
<div class="titlebar-button" id="titlebar-maximize">
<img src="@/assets/svg/titlebar/mdi_window-maximize.svg" alt="maximize" />
</div>
<div class="titlebar-button" id="titlebar-close">
<img src="@/assets/svg/titlebar/mdi_close.svg" alt="close" />
</div>
</div>
</template>
<style scoped>
.titlebar {
height: 30px;
background: #329ea3;
user-select: none;
display: flex;
justify-content: flex-end;
position: fixed;
top: 0;
left: 0;
right: 0;
}
.titlebar-button {
display: inline-flex;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
user-select: none;
-webkit-user-select: none;
}
.titlebar-button:hover {
background: #5bbec3;
}
</style>
webview 多窗口创建
1. 配置
配置文件开启权限 src-tauri\capabilities\default.json
"permissions": [
"core:webview:default",
"core:webview:allow-create-webview-window",
"core:webview:allow-create-webview",
"core:webview:allow-webview-close",
"core:webview:allow-set-webview-size",
]
2. hooks 函数封装
import { nextTick } from "vue";
import {
WebviewWindow,
getCurrentWebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import { emit, listen } from "@tauri-apps/api/event";
export interface WindowsProps {
label: string;
url?: string;
title: string;
minWidth: number;
minHeight: number;
width: number;
height: number;
closeWindLabel?: string;
resizable: boolean;
}
export default () => {
// 窗口事件类型
type WindowEvent = "closed" | "minimized" | "maximized" | "resized";
// 创建窗口
const createWindows = async (
args: WindowsProps = {
label: "main",
title: "主窗口",
minWidth: 800,
minHeight: 600,
width: 800,
height: 600,
resizable: true,
}
) => {
if (!(await isExist(args.label))) {
const webview = new WebviewWindow(args.label, {
title: args.title,
url: args.url,
fullscreen: false,
resizable: args.resizable,
center: true,
width: args.width,
height: args.height,
minWidth: args.minWidth,
minHeight: args.minHeight,
skipTaskbar: false,
decorations: false,
transparent: false,
titleBarStyle: "overlay",
hiddenTitle: true,
visible: false,
});
// 窗口创建成功
await webview.once("tauri://created", async () => {
webview.show();
if (args.closeWindLabel) {
const win = await WebviewWindow.getByLabel(args.closeWindLabel);
win?.close();
}
});
// 窗口创建失败
await webview.once("tauri://error", async (e) => {
console.error("Window creation error:", e);
if (args.closeWindLabel) {
await showWindow(args.closeWindLabel);
}
});
// 监听窗口事件
setupWindowListeners(webview, args.label);
return webview;
} else {
showWindow(args.label);
}
};
// 设置窗口监听器
const setupWindowListeners = (webview: WebviewWindow, label: string) => {
// 关闭请求处理
webview.listen("tauri://close-requested", async (e) => {
await emit("window-event", {
label,
event: "closed",
data: { timestamp: Date.now() },
});
console.log("label :>> ", label);
const win = await WebviewWindow.getByLabel(label);
win?.close();
// const win = label ? await WebviewWindow.getByLabel(label) : await getCurrentWebviewWindow();
// win?.close();
});
// 最小化事件
webview.listen("tauri://minimize", async (e) => {
await emit("window-event", {
label,
event: "minimized",
data: { state: true },
});
});
// 最大化事件
webview.listen("tauri://maximize", async (e) => {
await emit("window-event", {
label,
event: "maximized",
data: { state: true },
});
});
// 取消最大化
webview.listen("tauri://unmaximize", async (e) => {
await emit("window-event", {
label,
event: "maximized",
data: { state: false },
});
});
};
// 窗口间通信 - 发送消息
const sendWindowMessage = async (
targetLabel: string,
event: string,
payload: any
) => {
const targetWindow = await WebviewWindow.getByLabel(targetLabel);
if (targetWindow) {
targetWindow.emit(event, payload);
}
};
// 监听窗口消息
const onWindowMessage = (event: string, callback: (payload: any) => void) => {
return listen(event, ({ payload }) => callback(payload));
};
// 窗口控制方法
const windowControls = {
minimize: async (label?: string) => {
const win = label
? await WebviewWindow.getByLabel(label)
: await getCurrentWebviewWindow();
await win?.minimize();
},
maximize: async (label?: string) => {
const win = label
? await WebviewWindow.getByLabel(label)
: await getCurrentWebviewWindow();
await win?.maximize();
},
close: async (label?: string) => {
const win = label
? await WebviewWindow.getByLabel(label)
: await getCurrentWebviewWindow();
win?.close();
},
toggleMaximize: async (label?: string) => {
const win = label
? await WebviewWindow.getByLabel(label)
: await getCurrentWebviewWindow();
const isMaximized = await win?.isMaximized();
isMaximized ? await win?.unmaximize() : await win?.maximize();
},
};
// 获取当前窗口
const nowWindow = async () => {
const win = await getCurrentWebviewWindow();
return win;
};
// 关闭窗口
const closeWindow = async (label?: string) => {
if (label) {
const win = await WebviewWindow.getByLabel(label);
win?.close();
} else {
const win = await getCurrentWebviewWindow();
win?.close();
}
};
// 显示窗口
const showWindow = async (label: string, isCreated: boolean = false) => {
const isExistsWinds = await WebviewWindow.getByLabel(label);
if (isExistsWinds) {
nextTick().then(async () => {
// 检查是否是隐藏
const hidden = await isExistsWinds.isVisible();
if (!hidden) {
await isExistsWinds.show();
}
// 如果窗口已存在,首先检查是否最小化了
const minimized = await isExistsWinds.isMinimized();
if (minimized) {
// 如果已最小化,恢复窗口
await isExistsWinds.unminimize();
}
// 如果窗口已存在,则给它焦点,使其在最前面显示
await isExistsWinds.setFocus();
});
} else {
if (!isCreated) {
return createWindows();
}
}
};
//窗口是否存在
const isExist = async (label: string) => {
const isExistsWinds = await WebviewWindow.getByLabel(label);
if (isExistsWinds) {
return true;
} else {
return false;
}
};
return {
createWindows,
sendWindowMessage,
onWindowMessage,
...windowControls,
nowWindow,
showWindow,
isExist,
closeWindow,
};
};
3. 调用
window 父级
<template>
<div class="window-controls">
<n-button @click="minimizeWindow">最小化</n-button>
<n-button @click="toggleMaximizeWindow">{{
isMaximized ? "恢复" : "最大化"
}}</n-button>
<n-button @click="maximizeWindow">最大化</n-button>
<n-button @click="closeWindow">关闭</n-button>
<n-button @click="openChildWindow">打开子窗口</n-button>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import useWindowManager from "@/hooks/windowManager";
const {
createWindows,
minimize,
maximize,
toggleMaximize,
close,
onWindowMessage,
} = useWindowManager();
const isMaximized = ref(false);
const openChildWindow = () => {
createWindows({
label: "child",
title: "子窗口",
url: "/child",
minWidth: 400,
minHeight: 300,
width: 600,
height: 400,
resizable: true,
});
};
// 监听子窗口消息
onWindowMessage("child-message", (payload) => {
console.log("Received from child:", payload);
});
// 窗口控制方法
const minimizeWindow = async () => {
await minimize("child"); // 最小化窗口
};
const maximizeWindow = async () => {
await maximize("child"); // 最大化窗口
};
const toggleMaximizeWindow = async () => {
await toggleMaximize("child"); // 切换最大化/还原
};
const closeWindow = async () => {
await close("child"); // 关闭窗口
};
</script>
childView.vue 子组件
<template>
<div class="child">
<h1>Child Window</h1>
<n-button @click="sendToMain">Send Message to Main</n-button>
<n-button @click="close">Close</n-button>
</div>
</template>
<script setup lang="ts">
import useWindowManager from "@/hooks/windowManager";
const { sendWindowMessage, close } = useWindowManager();
// const {close} = windowControls
// 向主窗口发送消息
const sendToMain = () => {
sendWindowMessage("main", "child-message", {
timestamp: Date.now(),
content: "Hello from child!",
});
};
</script>
系统通知 notification
1.安装依赖
安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用
pnpm tauri add notification
2.配置
tauri 权限配置 src-tauri\capabilities\default.json
{
"permissions": [
"notification:default",
"notification:allow-get-active",
"notification:allow-is-permission-granted"
]
}
3.封装hooks
src\hooks\notification.ts
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from "@tauri-apps/plugin-notification";
export default () => {
const checkPermission = async () => {
const permission = await isPermissionGranted();
if (!permission) {
const permission = await requestPermission();
return permission === "granted";
} else {
return true;
}
};
const sendMessage = async (title: string, message: string) => {
const permission = await checkPermission();
if (permission) {
await sendNotification({
title,
body: message,
// 这里演示,你可以作为参数传入 win11 测试没效果
attachments: [
{
id: "image-1",
url: "F:\\tv_task\\public\\tauri.png",
},
],
});
}
};
return { sendMessage };
};
4.调用示例
<template>
<div>
<n-button @click="sendNot">notification 通知</n-button>
</div>
</template>
<script setup lang="ts">
import useNotification from "@/hooks/notification";
const { sendMessage } = useNotification();
const sendNot = async () => {
await sendMessage("提示", "您当前有代办的任务需要处理!");
};
</script>
日志
1.安装依赖
安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用
pnpm tauri add log
2.配置
tauri 权限配置 src-tauri\capabilities\default.json
{
"permissions": ["log:default"]
}
3.封装hooks
src\hooks\log.ts
import {
trace,
info,
debug,
error,
attachConsole,
} from "@tauri-apps/plugin-log";
// 启用 TargetKind::Webview 后,这个函数将把日志打印到浏览器控制台
const detach = await attachConsole();
export default () => {
// 将浏览器控制台与日志流分离
detach();
return {
debug,
trace,
info,
error,
};
};
4.调用示例
<template>
<div>
<h1>控制台效果</h1>
<div class="console">
<div
class="console-line"
v-for="(line, index) in consoleLines"
:key="index"
:class="{
'animate__animated animate__fadeIn':
index === consoleLines.length - 1,
}"
>
{{ line }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import TauriLog from "@/hooks/log";
import { ref } from "vue";
const { info } = TauriLog();
info("我来了");
const consoleLines = ref([
"Welcome to the console!",
"This is a cool console interface.",
"You can type commands here.",
"Press Enter to execute.",
]);
</script>
程序启动监听
hooks 函数封装
src\hooks\start.ts
import { invoke } from "@tauri-apps/api/core";
function sleep(seconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
async function setup() {
console.log("前端应用启动..");
await sleep(3);
console.log("前端应用启动完成");
// 调用后端应用
invoke("set_complete", { task: "frontend" });
}
export default () => {
// Effectively a JavaScript main function
window.addEventListener("DOMContentLoaded", () => {
setup();
});
};
调用日志打印
src\main.ts
import start from "@/hooks/start";
start();
Http 封装
axios 请求,会在打包后存在跨域问题,所以使用 tauri 插件,进行http封装
1.安装依赖
安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用
pnpm tauri add http
2.配置
tauri 权限配置 src-tauri\capabilities\default.json
{
"permissions": [
{
"identifier": "http:default",
"allow": [
{
"url": "http://**"
},
{
"url": "https://**"
},
{
"url": "http://*:*"
},
{
"url": "https://*:*"
}
]
}
]
}
3.封装hooks
src\utils\exception.ts
export enum ErrorType {
Network = "NETWORK_ERROR",
Authentication = "AUTH_ERROR",
Validation = "VALIDATION_ERROR",
Server = "SERVER_ERROR",
Client = "CLIENT_ERROR",
Unknown = "UNKNOWN_ERROR",
}
export interface ErrorDetails {
type: ErrorType;
code?: number;
details?: Record<string, any>;
}
export class AppException extends Error {
public readonly type: ErrorType;
public readonly code?: number;
public readonly details?: Record<string, any>;
constructor(message: string, errorDetails?: Partial<ErrorDetails>) {
super(message);
this.name = "AppException";
this.type = errorDetails?.type || ErrorType.Unknown;
this.code = errorDetails?.code;
this.details = errorDetails?.details;
// Show error message to user if window.$message is available
if (window.$message) {
window.$message.error(message);
}
}
public toJSON() {
return {
name: this.name,
message: this.message,
type: this.type,
code: this.code,
details: this.details,
};
}
}
src\utils\http.ts
import { fetch } from "@tauri-apps/plugin-http";
import { AppException, ErrorType } from "./exception";
/**
* @description 请求参数
* @property {"GET"|"POST"|"PUT"|"DELETE"} method 请求方法
* @property {Record<string, string>} [headers] 请求头
* @property {Record<string, any>} [query] 请求参数
* @property {any} [body] 请求体
* @property {boolean} [isBlob] 是否为Blob
* @property {boolean} [noRetry] 是否禁用重试
* @return HttpParams
*/
export type HttpParams = {
method: "GET" | "POST" | "PUT" | "DELETE";
headers?: Record<string, string>;
query?: Record<string, any>;
body?: any;
isBlob?: boolean;
retry?: RetryOptions; // 新增重试选项
noRetry?: boolean; // 新增禁用重试选项
};
/**
* @description 重试选项
*/
export type RetryOptions = {
retries?: number;
retryDelay?: (attempt: number) => number;
retryOn?: number[];
};
/**
* @description 自定义错误类,用于标识需要重试的 HTTP 错误
*/
class FetchRetryError extends Error {
status: number;
type: ErrorType;
constructor(message: string, status: number) {
super(message);
this.status = status;
this.name = "FetchRetryError";
this.type = status >= 500 ? ErrorType.Server : ErrorType.Network;
}
}
/**
* @description 等待指定的毫秒数
* @param {number} ms 毫秒数
* @returns {Promise<void>}
*/
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* @description 判断是否应进行下一次重试
* @returns {boolean} 是否继续重试
*/
function shouldRetry(
attempt: number,
maxRetries: number,
abort?: AbortController
): boolean {
return attempt + 1 < maxRetries && !abort?.signal.aborted;
}
/**
* @description HTTP 请求实现
* @template T
* @param {string} url 请求地址
* @param {HttpParams} options 请求参数
* @param {boolean} [fullResponse=false] 是否返回完整响应
* @param {AbortController} abort 中断器
* @returns {Promise<T | { data: T; resp: Response }>} 请求结果
*/
async function Http<T = any>(
url: string,
options: HttpParams,
fullResponse: boolean = false,
abort?: AbortController
): Promise<{ data: T; resp: Response } | T> {
// 打印请求信息
console.log(`🚀 发起请求 → ${options.method} ${url}`, {
body: options.body,
query: options.query,
});
// 默认重试配置
const defaultRetryOptions: RetryOptions = {
retries: options.noRetry ? 0 : 3, // 如果设置了noRetry,则不进行重试
retryDelay: (attempt) => Math.pow(2, attempt) * 1000, // 指数退避策略
retryOn: [500, 502, 503, 504],
};
// 合并默认重试配置与用户传入的重试配置
const retryOptions: RetryOptions = {
...defaultRetryOptions,
...options.retry,
};
const { retries = 3, retryDelay, retryOn } = retryOptions;
// 获取token和指纹
const token = localStorage.getItem("TOKEN");
//const fingerprint = await getEnhancedFingerprint()
// 构建请求头
const httpHeaders = new Headers(options.headers || {});
// 设置Content-Type
if (!httpHeaders.has("Content-Type") && !(options.body instanceof FormData)) {
httpHeaders.set("Content-Type", "application/json");
}
// 设置Authorization
if (token) {
httpHeaders.set("Authorization", `Bearer ${token}`);
}
// 设置浏览器指纹
//if (fingerprint) {
//httpHeaders.set('X-Device-Fingerprint', fingerprint)
//}
// 构建 fetch 请求选项
const fetchOptions: RequestInit = {
method: options.method,
headers: httpHeaders,
signal: abort?.signal,
};
// 获取代理设置
// const proxySettings = JSON.parse(localStorage.getItem('proxySettings') || '{}')
// 如果设置了代理,添加代理配置 (BETA)
// if (proxySettings.type && proxySettings.ip && proxySettings.port) {
// // 使用 Rust 后端的代理客户端
// fetchOptions.proxy = {
// url: `${proxySettings.type}://${proxySettings.ip}:${proxySettings.port}`
// }
// }
// 判断是否需要添加请求体
if (options.body) {
if (
!(
options.body instanceof FormData ||
options.body instanceof URLSearchParams
)
) {
fetchOptions.body = JSON.stringify(options.body);
} else {
fetchOptions.body = options.body; // 如果是 FormData 或 URLSearchParams 直接使用
}
}
// 添加查询参数
if (options.query) {
const queryString = new URLSearchParams(options.query).toString();
url += `?${queryString}`;
}
// 拼接 API 基础路径
//url = `${import.meta.env.VITE_SERVICE_URL}${url}`
// 定义重试函数
async function attemptFetch(
currentAttempt: number
): Promise<{ data: T; resp: Response } | T> {
try {
const response = await fetch(url, fetchOptions);
// 若响应不 OK 并且状态码属于需重试列表,则抛出 FetchRetryError
if (!response.ok) {
const errorType = getErrorType(response.status);
if (!retryOn || retryOn.includes(response.status)) {
throw new FetchRetryError(
`HTTP error! status: ${response.status}`,
response.status
);
}
// 如果是非重试状态码,则抛出带有适当错误类型的 AppException
throw new AppException(`HTTP error! status: ${response.status}`, {
type: errorType,
code: response.status,
details: { url, method: options.method },
});
}
// 解析响应数据
const responseData = options.isBlob
? await response.arrayBuffer()
: await response.json();
// 打印响应结果
console.log(`✅ 请求成功 → ${options.method} ${url}`, {
status: response.status,
data: responseData,
});
// 若有success === false,需要重试
if (responseData && responseData.success === false) {
const errorMessage = responseData.errMsg || "服务器返回错误";
window.$message?.error?.(errorMessage);
throw new AppException(errorMessage, {
type: ErrorType.Server,
code: response.status,
details: responseData,
});
}
// 若请求成功且没有业务错误
if (fullResponse) {
return { data: responseData, resp: response };
}
return responseData;
} catch (error) {
console.error(`尝试 ${currentAttempt + 1} 失败的 →`, error);
// 检查是否仍需重试
if (!shouldRetry(currentAttempt, retries, abort)) {
console.error(
`Max retries reached or aborted. Request failed → ${url}`
);
if (error instanceof FetchRetryError) {
window.$message?.error?.(error.message || "网络请求失败");
throw new AppException(error.message, {
type: error.type,
code: error.status,
details: { url, attempts: currentAttempt + 1 },
});
}
if (error instanceof AppException) {
window.$message?.error?.(error.message || "请求出错");
throw error;
}
const errorMessage = String(error) || "未知错误";
window.$message?.error?.(errorMessage);
throw new AppException(errorMessage, {
type: ErrorType.Unknown,
details: { url, attempts: currentAttempt + 1 },
});
}
// 若需继续重试
const delayMs = retryDelay ? retryDelay(currentAttempt) : 1000;
console.warn(
`Retrying request → ${url} (next attempt: ${currentAttempt + 2}, waiting ${delayMs}ms)`
);
await wait(delayMs);
return attemptFetch(currentAttempt + 1);
}
}
// 辅助函数:根据HTTP状态码确定错误类型
function getErrorType(status: number): ErrorType {
if (status >= 500) return ErrorType.Server;
if (status === 401 || status === 403) return ErrorType.Authentication;
if (status === 400 || status === 422) return ErrorType.Validation;
if (status >= 400) return ErrorType.Client;
return ErrorType.Network;
}
// 第一次执行,attempt=0
return attemptFetch(0);
}
export default Http;
src\utils\request.ts
import Http, { HttpParams } from "./http.ts";
import { ServiceResponse } from "@/enums/types.ts";
const { VITE_SERVICE_URL } = import.meta.env;
const prefix = VITE_SERVICE_URL;
function getToken() {
let tempToken = "";
return {
get() {
if (tempToken) return tempToken;
const token = localStorage.getItem("TOKEN");
if (token) {
tempToken = token;
}
return tempToken;
},
clear() {
tempToken = "";
},
};
}
export const computedToken = getToken();
// fetch 请求响应拦截器
const responseInterceptor = async <T>(
url: string,
method: "GET" | "POST" | "PUT" | "DELETE",
query: any,
body: any,
abort?: AbortController
): Promise<T> => {
let httpParams: HttpParams = {
method,
};
if (method === "GET") {
httpParams = {
...httpParams,
query,
};
} else {
url = `${prefix}${url}?${new URLSearchParams(query).toString()}`;
httpParams = {
...httpParams,
body,
};
}
try {
const data = await Http(url, httpParams, true, abort);
const serviceData = (await data.data) as ServiceResponse;
//检查服务端返回是否成功,并且中断请求
if (!serviceData.success) {
window.$message.error(serviceData.errMsg);
return Promise.reject(`http error: ${serviceData.errMsg}`);
}
return Promise.resolve(serviceData.result);
} catch (err) {
return Promise.reject(`http error: ${err}`);
}
};
const get = async <T>(
url: string,
query: T,
abort?: AbortController
): Promise<T> => {
return responseInterceptor(url, "GET", query, {}, abort);
};
const post = async <T>(
url: string,
params: any,
abort?: AbortController
): Promise<T> => {
return responseInterceptor(url, "POST", {}, params, abort);
};
const put = async <T>(
url: string,
params: any,
abort?: AbortController
): Promise<T> => {
return responseInterceptor(url, "PUT", {}, params, abort);
};
const del = async <T>(
url: string,
params: any,
abort?: AbortController
): Promise<T> => {
return responseInterceptor(url, "DELETE", {}, params, abort);
};
export default {
get,
post,
put,
delete: del,
};
src\api\manage.ts
import request from "@/utils/request";
export const getAction = <T>(
url: string,
params?: any,
abort?: AbortController
) => request.get<T>(url, params, abort);
export const postAction = <T>(
url: string,
params?: any,
abort?: AbortController
) => request.post<T>(url, params, abort);
export const putAction = <T>(
url: string,
params?: any,
abort?: AbortController
) => request.put<T>(url, params, abort);
export const deleteAction = <T>(
url: string,
params?: any,
abort?: AbortController
) => request.delete<T>(url, params, abort);
4.调用示例
<template>
<div>
<n-button @click="postTest">测试POST</n-button>
</div>
</template>
<script setup lang="ts">
import { postAction } from "@/api/manage";
const postTest = () => {
let url = `/sys/login`;
postAction(url, {
username: "admin",
password: "tick20140513",
}).then((res) => {
text.value = res.token;
});
};
</script>
歼20居然是个框架-基于 Signals 信号的前端框架设计
大家好,我是 anuoua,今天我们来讲讲基于 Signal 如何构建一个前端框架。
以 Vue 为响应式前端框架的代表,以 React 则是非响应式前端框架的代表,算是目前前端框架的稳定格局。
响应式的优点不言而喻,是高性能前端框架的选择。
而响应式也有不同的设计理念,区别于 Vue 的 reactivity,preact 的作者提出了 Signal 这种响应式的理念,和深度劫持的 reactivity 不同,Signal 更简单直观,其理念传播广泛,目前 Signal 作为 js 语言特性被提出成为 proposal。
响应式前端框架的现状
目前一些具有代表性的前端框架,基本都走向了响应式 API + 真实 DOM,例如:svelte、solid、vue,这几个前端框架在性能上有了大幅提升,但是仍然存在一些问题。
Vue 3
Vue 作为响应式框架的开创者,Vue3 仍然是虚拟 DOM,而 Vue 3 vapor 转向真实 DOM。Vue 3 版本中遇到最严重的问题是自动解包、**解构以及类型,**为了解决这些问题作者试验过很多语法,最终在数个的迭代后,还是上了编译手段,在SFC中使用宏用来解决开发体验以及 Typescript 类型问题。
<script setup>
const props = defineProps({
foo: String
})
</script>
除此之外,Vue 的问题就在于官方没有引导用户到理想的开发模式上去,组件写法太多,导致社区力量分散,发力不在一处。如果统一使用 SFC 开发,统一使用 composition api,那么社区就不会陷入使用 jsx 还是 SFC,使用 options 还是 composition api 的纠结,那么社区的生态会好很多。
Svelte
Svelte 借助编译手段将视图转换成真实DOM实现,在 Svelte 5 中转向了和 Vue 类似的深度劫持的响应式API。它设计了一种叫 runes 的概念,通过编译技术追踪由特殊函数名创建的变量,将其编译成响应式代码,基本解决了类似 Vue 的困扰,无需手动解包,开发体验不错。
let message = $state('hello');
我认为 Svelte 的 runes 已经很接近完美了,开发体验很不错。
但 Svelte 本身仍然有以下几点问题:
第一:它有自己的 DSL .svelte,我认为 JSX 更佳,Typescript 对 JSX 的支持非常好,DSL 支持 TS 总是需要付出更多的代价,而且需要支付更多的学习成本。
第二:它的响应式仍然是和 Vue 一样的默认深度劫持,如果是复杂嵌套对象,劫持内部对象会被包装带来会有隐晦的debug负担和理解成本。我认为 Signal 信号的浅劫持理念更加简单和直观。
第三:runes 还不够完美,若在大型应用中使用其创建的变量,会导致和普通变量混淆,编译器可以追踪变量,但是在多文件代码复杂组合的时候,很难区分是普通变量还是响应式变量,给debug带来困难。
Solid
Solidjs,它则是视图部分采取编译手段,API部分保持原生,让用户裸使用原生 Signal API,Solidjs 的 API 是符合 Signal 理念的,没有深度劫持。但是原生的 Signal API 看起来使用较为繁琐。
import { createSignal } from "solid-js";
const [count, setCount] = createSignal(0);
Solid 性能不错,JSX + TS 的开发体验基本拉满,唯一的问题是裸 Signal API 的使用不够优雅,略显繁琐。
例如它也不能直接解构 props,需要借助帮助函数才能维持响应性。
通病
它们在支持 Web Component 这点上,都没有做好无缝的开发体验,有额外的使用成本。
总结
以上三个框架都抛弃了虚拟DOM,配合响应式API,性能表现都非常好,但它们都或多或少都有令人在意的问题,很难找到理想中的前端框架。
| 框架 | 真实 DOM | Signal | JSX | Signal API 编译 |
|---|---|---|---|---|
| Vue | 支持(Vapor Mode) | 兼容(shallowRef) | 兼容 | 混合 |
| Svelte | 支持 | 不支持 | 不支持 | 支持 |
| Solid | 支持 | 支持 | 支持 | 不支持 |
理想的前端框架
如果我们需要一个新的前端框架,那么应该怎么设计?
根据上述总结,我认为 真实 DOM + JSX + Signal API 编译策略 + Web Component 一等支持 才是最接近完美的方案。
而 Solid 已经接近我们想要的了,给它加上剩下两个特性基本上就满足我们需要了。
所以怎么实现一个“完美”的框架呢?
从细粒度绑定到组件
signal 如何细粒度绑定 DOM 更新呢?又是怎么从基本的绑定演化为框架组件呢?
我们先从 Signal 的用法说起。
Signal 的基本用方法
// 声明信号
const name = signal("hello");
// 派生信号
const displayName = computed(() => name.value + " world");
// 副作用绑定
effect(() => {
// 当 name.value = "hello2";
// console => 1. "hello world" 2. "hello2 world"
console.log(displayName);
});
绑定DOM元素
// 声明信号
const name = signal("hello");
// 派生信号
const displayName = computed(() => name.value + " world");
const text = document.createTextNode("");
// 副作用绑定
effect(() => {
text.nodeValue= displayName.value;
});
演化成组件
一个只有 text 节点的组件:
const App = () => {
const name = signal("hello");
const displayName = computed(() => name.value + " world");
return (() => {
const text = document.createTextNode("");
effect(() => {
text.nodeValue= displayName.value;
});
return text;
})();
}
更复杂的组件
在 div 中添加 text 节点:
const App = () => {
const name = signal("hello");
const displayName = computed(() => name.value + " world");
return (() => {
const el1 = (() => {
const text = document.createTextNode("");
effect(() => {
text.nodeValue= displayName.value;
});
return text;
})();
const div = document.createElement("div");
div.append(el1);
return div;
})();
}
演化成 JSX
Solid 的编译策略和上述是类似的,视图的编译是有规律的,创建 - 绑定 - 挂载,只要是有规律的,那就可以通过 DSL 来描述,JSX 正好可以表达这个过程。
const App = () => {
const name = signal("hello");
const displayName = computed(() => name.value + " world");
return <div>{displayName.value}</div>;
}
可以看到复杂的视图创建流程通过 DSL 的使用配合编译手段,开发体验可以大幅提升。
同时需要指出 Solid 的编译方式未必是最好的,编译后的代码量挺大,还有各种闭包嵌套,可以稍微改进一下,编译成:
import { jsx, template } from "some/jsx-runtime"
const temp1 = template("<div>");
const App = () => {
const name = signal("hello");
const displayName = computed(() => name.value + " world");
return jsx(temp1(), {
get children() {
return displayName.value;
}
});
}
Solid 把一部分 DOM 操作过程也编译出来了,事实上创建真实 DOM 的过程很大一部分是通用的,我们把创建元素的方法抽出来 jsx,用于创建和组装元素,这样编译出来的代码也会相对直观。
同时需要注意到 template 方法,它做了一件事,内部使用 cloneNode 去创建静态节点,这样可以提升性能。
总结
这套编译策略,从演化中总结编译策略,然后完成 JSX AST 转换实现,确实是有创新思维和难度的,属于框架的创新点核心。
最先搞视图转真实 DOM 编译的是 Svelte,而 Solid 完成了更高效的实现,又最终促进了 Svelte 5 的诞生,使 Web 框架在性能得到了上大幅升级。
完整的框架要考虑的更多
只靠上面的编译策略显然是不够的,需要考虑很多细节问题。
组件的创建,事实上挺复杂的,组件是有实例的,初始化实例的过程中需要做很多工作。
比如:利用插桩来定位组件组件的边界,假设组件直接返回 <><span>1</span><span>2</span></> ,如果没有插桩框架将无法识别边界,在做列表 diff 的时候,组件内元素集合的移除、添加、移动等操作将错乱。
const App = () => {
const fragment = document.createDocumentFragment();
const instance = {
range: [
document.createTextNode(""),
document.createTextNode(""),
]
}
const span1 = document.createElement("span");
const span2 = document.createElement("span");
fragment.append(instance.range[0]);
fragment.append(span1);
fragment.append(span2);
fragment.append(instance.range[1]);
return fragment;
}
界面突变和 diff 算法
和 React 和 Vue 一样,这类编译型的前端框架仍然有 diff 过程。
界面突变的根本逻辑就是列表渲染,而列表渲染一定会涉及 diff,而 Vue 高效的 diff 算法也是可以使用的,算法和实现分离,不同的框架有不同的实现。
为什么说界面突变的根本逻辑是列表渲染?
条件渲染本质也是列表渲染,我们来看一个三目逻辑 :
// React
const List = () => {
const [toggle, setToggle] = 0;
useEffect(() => {
setToggle((toggle[0] + 1) % 2);
});
return [toggle].map(i => (<Fragment key={i}>{i}</Fragment>))
}
实际上就是列表 [0] 和 [1] 之间相互切换。
Switch Case 逻辑也类似:
// React
const List = ({ value }) => {
const [list, setList] = [1,2,3,4];
const deriveList = list.filter(i => i === value).slice(0, 1);
return [deriveList].map(i => (<Fragment key={i}>{i}</Fragment>));
}
根据 value 的值过滤列表,即可以实现 Switch Case 逻辑。
虚拟 DOM 和 真实 DOM 的 diff 实现差异
虚拟 DOM 的 diff 是从的组件节点(Vue)或者根节点(React)开始,遍历一遍,抽离出 DOM 指令以更新视图。
但是真实 DOM 的框架,列表是细粒度绑定的,当列表变化后,更新视图是在副作用内执行的,所以它需要一个特定的组件或者函数来封装这个副作用的逻辑,在 Solid 中就是 <For> 组件, Vue Vapor 和 Svelte 是在编译的时候编译成了一个特定的函数。
svelte:
$.each(node, 16, () => expression, $.index, ($$anchor, name, index, $$array) => {
$.next();
var text_2 = $.text('...');
$.append($$anchor, text_2);
});
diff 算法可以借鉴,但是虚拟 DOM 和 真实 DOM 框架在 diff 算法中进行的操作并不一样,理论上 Solid 也可以用 Vue 3 的算法。
开发体验升级
上面指出 Solid 体验已经很好的,但是仍有不足,裸 Signal API 的使用不够优雅,getter setter 满屏幕跑,Vue Svelte 为了解决体验问题都通过对应的编译策略来解决这个问题,而 Solid 没有,有点遗憾。
事实上开发体验这块,React 除了需要手动管理依赖这块过于逆天之外,它的开发体验真的不错。
React 的组件状态写法已经很简洁了,不用像 Vue,Solid 那样套 computed。
const App = () => {
const [name, setName] = useState("");
const displayName = "Info: " + name
return <div onClick={() => setName(name + "world")}>{displayName}</div>
}
也就是说,如果我们能改进 Solid,给它加上一组编译手段,改进 Signal 的使用体验,是不是会提升开发体验呢?
让我们尝试推演一下。
理想的组件形态
我们先提出一个理想中的组件形态,要求足够简洁,开发体验足够好:
const App = () => {
let name = "hello";
return (
<div onClick={() => {name = name + "world"}}>{name}</div>
)
}
我们希望改变 name 的时候,视图就会更新,但是这样是做不到的,改变一个变量没有任何作用。
但是如果是信号就不一样了:
const App = () => {
const name = signal("");
return (
<div onClick={() => name.value = name.value + "xxx"}>{name.value}</div>
)
}
我们根据上文所说的 JSX 编译手段,创建元素可以绑定副作用,name.value是可以被副作用收集到,并在name.value 更新的时候顺便更新视图。
import { jsx, template } from "some/jsx-runtime"
const temp1 = template("<div>");
const App = () => {
const name = signal("");
return jsx(temp1(), {
get onClick() {
return () => {
name.value = name.value + "xxx";
}
},
get children() {
return name.value;
}
});
}
这时候就需要编译来完成我们的代码转换,在这里我们把信号变量使用 **$** 标记。然后就代码如下:
const App = () => {
let $name = "hello";
return (
<div onClick={() => {$name = $name + "world"}}>{$name}</div>
)
}
这个代码和我们理想中的组件代码非常接近了,要是真的能这样写代码,那么开发体验就能得到大幅提升。
Signal 信号编译策略
前面提到使用 $ 标记信号,就是一种创新的编译策略,通过特殊命名标记变量,将变量编译成响应式信号代码。
编译策略说明
这里我们按照 preact/signals 库的 api 做示例。
编译策略一:let 搭配 $ 开头的变量,即为声明信号。
let $name = "hello"
// 编译成
import { signal } from "@preact/signal";
let $name = signal("hello");
编译策略二:读取 $ 开头的变量会默认解包
let $name = "hello";
console.log($name);
// 编译成
let $name = signal("hello");
console.log($name.value);
编译策略三:const 搭配 $ 开头的变量,为声明派生信号。
let $name = "hello";
const $display = $name + "world";
// 编译成
import { signal, computed } from "@preact/signal";
let $name = signal("hello");
const $display = computed(() => $name.value + "world");
编译策略四:$use 开头的为自定义 hooks 。
const $useName = () => {
let $name = "hello";
return {
name: $name
}
}
// 编译成
const $useName = () => {
let $name = signal("hello");
return computed(() => ({
name: $name.value
}))
}
编译策略五:解构 + 变量传递。
函数入参,入参的响应传递,解构变量需要设置$前缀
const App = ({ name: $name, ...$rest }) => {
console.log($rest);
return <div>{$name}</div>
}
// 编译为
const App = ($__0) => {
const $name = computed(() => $__0.value.name);
const $rest = computed(() => {
const { name, ...rest } = $__0.value;
return rest;
});
console.log($rest.value);
return <div>{$name.value}</div>
}
自定义 hook 返回,解构的时候为了不丢失响应,同样也要解构变量设置$前缀,这样就能触发编译。
const $useName = () => {
let $name = "hello";
return {
name: $name
}
}
// 解构后的变量也必须是 $ 开头,否则丢失响应,退化为普通变量(原始信号对象,可手动.value使用)
const { name: $name } = $useName();
// 自定义 hook 返回赋值的变量也必须是 $ 开头,否则丢失响应,退化为普通变量(原始信号对象,可手动.value使用)
const $nameSignal = $useName();
// 编译成
const $useName= () => {
let $name = signal("hello");
return computed(() => ({
name: $name.value
}))
}
const $__0 = computed(() => $useName().value);
const $name = computed(() => $__0.value.name);
const $nameSignal = $useName();
此编译策略的优点
- 无需手动导入 API,像普通变量一样使用 Signal
- 和 TS 类型的结合非常好,特别和 JSX 的类型结合非常完美
- 不怕解构
- 标记变量和常规变量一起用不会有混淆
一个简单的鼠标位置hook
const $usePosition = () => {
let $x = 0;
let $y = 0;
const $pos = {
x: $x,
y: $y
};
mounted(() => {
document.addEventListener('mousemove', (e) => {
$x = e.pageX;
$y = e.pageY;
});
})
return {
$pos,
}
}
const App = () => {
const { $pos } = $usePosition();
return <div>x: {$pos.x}; y:{$pos.y}</div>
}
是不是清爽很多,简单应用的代码量差距不是很明显,但是如果代码量增加,那么代码量的差距还是非常可观的。
同时这样的设计,甚至不需要手动导入 API ,它在编译期间自动导入,让人无需关心 Signal 本身,真正做到了无感,开发体验得到了提升。
Web Component 支持
Vue Solid Svelte 都支持封装 Web Component,但是在开发体验上并没有多好,需要额外操作才能集成到框架中使用,做不到在框架内无缝使用,这样也限制了 Web Component 的推广和使用。
所以我们希望框架能够做好以下几点来支持 Web Component:
- 和框架本身可以无缝集成,像普通组件一样方便使用
- 组件 TS 类型易用且完善
- 可以按照常规 Web Component 一样可以独立使用
- 可以供给原生 HTML 或者其他框架使用
有这样的框架吗?
有啊 J20 框架 J20
点个 Star 吧。
说在最后
这大概是我最后一个前端框架了,也算是完成了之前对前端框架的想法(中间隔了很久才想起来还有个东西没完成)。
歼20框架大量代码都是AI写的,我负责设计,它负责实现,同时帮我写测试,速度大幅提升。
AI 时代,也许框架不再重要了吧。哈哈
谢谢大家!
微前端:从“大前端”到“积木式开发”的架构演进
记得那些年我们维护的“巨石应用”吗?一个package.json里塞满了几百个依赖,每次npm install都像是一场赌博;团队协作时,git merge冲突解决到怀疑人生;技术栈升级?那意味着“全盘推翻重来”……
随着前端复杂度的爆炸式增长,传统单体架构已不堪重负。而微前端,正是为了解决这些痛点而生的一种架构范式。本文将以qiankun为切入点,学习一下微前端的模式。
基础概念
微前端是什么?
微前端不是框架,而是一种架构理念 ——将大型前端应用拆分为多个独立开发、独立部署、技术栈无关的小型应用,再将其组合为一个完整的应用。
一句话,它让前端开发从“造大楼”变成了 “搭乐高” 。
为什么需要微前端?
痛点真实存在:
- 🐌 开发效率低下:几百人维护一个仓库,每次上线都需全量回归
- 🔒 技术栈锁定:三年前选的框架,现在想升级?代价巨大
- 👥 团队协作困难:功能边界模糊,代码相互渗透
- 🚢 部署风险高:一个小改动,可能导致整个系统崩溃
微前端带来的改变:
- ✅ 独立自治:每个团队负责自己的“微应用”,从开发到部署全流程自主
- ✅ 技术栈自由:React、Vue、Angular、甚至jQuery,和平共处
- ✅ 增量升级:老系统可以一点点替换,而不是“一夜重构”
- ✅ 容错隔离:一个子应用崩溃,不影响其他功能
微前端的核心思想:
- 拆分:将大型前端应用拆分为多个独立的小型应用。
- 集成:通过某种方式将这些小型应用集成在一起,形成一个整体。
- 自治:每个小型应用都可以独立开发、测试、部署。
// 微前端架构
├── container/ // 主应用(基座)
├── app-react/ // React子应用(团队A)
├── app-vue/ // Vue子应用(团队B)
├── app-angular/ // Angular子应用(团队C)
└── app-legacy/ // 老系统(jQuery)
// 优势:
// 1. ✅ 技术栈无关
// 2. ✅ 独立开发、独立部署
// 3. ✅ 增量更新
// 4. ✅ 容错性高(一个子应用挂了不影响其他)
应用场景
渐进式重构:对于一个老项目一点点进行架构的升级
老系统(jQuery + PHP) → 逐步替换为现代框架
↓
保留核心业务模块 + 逐步添加React/Vue新模块
多团队协作:不同部门人员之间技术栈存在差异,需要单独开发
团队A(React专家) → 负责电商商品模块
团队B(Vue专家) → 负责购物车模块
团队C(Angular专家)→ 负责用户中心
主应用协调所有模块
中后台系统:复杂系统的功能拆分
一个后台管理系统包含:
- 权限管理(React)
- 数据报表(Vue + ECharts)
- 工作流(Angular)
- 监控面板(React + Three.js)
四种架构模式
基座模式(也称为中心化路由模式)
- 基座模式是最常见的微前端架构。它有一个主应用(通常称为基座或容器),负责整个应用的布局、路由和公共逻辑。子应用根据路由被动态加载和卸载。
┌─────────────────────────────────────────┐
│ 主应用(Container) │
│ 负责:路由、鉴权、布局、共享状态、公共依赖 │
├─────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ │ 子应用A │ │ 子应用B │ │ 子应用C │ │
│ │ (React) │ │ (Vue) │ │(Angular) │
│ └──────────┘ └──────────┘ └──────────┘
└─────────────────────────────────────────┘
工作流程
graph TD
用户访问主应用-->主应用根据当前URL匹配子应用--> A["加载对应子应用的资源(JS、CSS)"]-->将子应用渲染到指定容器中-->子应用运行并处理自己的内部路由和逻辑
优点
- 集中控制,易于管理
- 路由逻辑清晰
- 公共依赖容易处理(基座可提供共享库)
- 子应用间隔离性好
缺点
- 主应用成为单点故障
- 基座和子应用耦合(通过协议通信)
- 基座需要知道所有子应用的信息
适用场景
- 企业级中后台系统
- 需要统一导航和布局的应用
- 子应用技术栈差异大
自组织模式(也称为去中心化模式)
- 在自组织模式中,没有中心化的基座。每个微前端应用都是独立的,它们通过某种通信机制(如自定义事件、消息总线)来协调。通常,每个应用都可以动态发现和加载其他应用。
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 应用A │ │ 应用B │ │ 应用C │
│ (React) │ │ (Vue) │ │(Angular) │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────────┼───────────────┘
│
┌────────┴─────────┐
│ 运行时协调器 │
│ (Runtime Bus) │
└──────────────────┘
graph TD
1["应用A启动,并注册到消息总线"]
-->2["应用B启动,并注册到消息总线"]
-->用户操作触发应用A需要应用B的某个功能
-->应用A通过消息总线请求应用B的资源
-->3["应用B响应请求,提供资源(或直接渲染)"]
优点
- 去中心化,避免单点故障
- 应用之间完全解耦
- 更灵活的通信方式
缺点
- 通信复杂,容易混乱
- 难以统一管理(如路由、权限)
- 依赖公共协议,版本更新可能破坏通信
适用场景
- 高度自治的团队
- 应用间功能相对独立
- 需要动态组合的页面
微件模式(也称为组合式模式)
- 微件模式类似于传统门户网站,页面由多个独立的微件(Widget)组成。每个微件都是一个独立的微前端应用,可以独立开发、部署,然后动态组合到页面中。
┌───────────────────────────────────┐
│ Dashboard页面 │
│ ┌────────┬────────┬─────────┐ │
│ │ 天气 │ 新闻 │ 股票 │ │
│ │ Widget │ Widget │ Widget │ │
│ ├────────┼────────┼─────────┤ │
│ │ 待办 │ 日历 │ 邮件 │ │
│ │ Widget │ Widget │ Widget │ │
│ └────────┴────────┴─────────┘ │
└───────────────────────────────────┘
graph TD
用户访问页面
-->
页面布局引擎根据配置加载微件
-->
每个微件独立加载资源并渲染
-->
微件之间通过预定义的接口通信
优点:
- 组件可以复用
- 用户可以自定义布局
- 所有widget在同一个页面
- 可以按需加载widget
缺点:
- 样式管理复杂,需要处理widget间样式冲突
- 通信限制,widget间通信需要经过主应用
- 版本管理,大量widget的版本管理困难
- 性能问题,太多widget可能影响性能
适用场景:
- 数据可视化大屏
- 门户网站首页
- 个人工作台
- 可配置的管理后台
混合模式(实战中最常见)
- 在实际项目中,我们常常根据需求混合使用以上模式。例如,在基座模式中,某个子应用内部使用微件模式来组合多个微前端模块。
- 比如一个电商系统的架构
主应用(基座模式)
├── 商品管理(React子应用)
├── 订单管理(Vue子应用)
└── 用户管理(Angular子应用)
在用户管理内部,使用微件模式:
├── 用户统计(微件A)
├── 用户列表(微件B)
└── 用户权限(微件C)
┌─────────────────────────────────────────────────┐
│ 主应用(基座模式) │
│ 统一路由、权限、用户中心、消息中心、全局状态 │
└─────────────────┬───────────────────────────────┘
│
┌─────────────┼─────────────┐
│ │ │
┌───▼───┐ ┌────▼────┐ ┌────▼────┐
│订单中心│ │商品管理 │ │用户管理 │
│(React)│ │ (Vue) │ │(Angular)│
└───┬───┘ └────┬────┘ └────┬────┘
│ │ │
└────────────┼─────────────┘
│
┌──────▼──────┐
│ 数据分析模块 │
│ (微件模式) │
│┌───┬───┬───┐│
││图表│地图│报表│
│└───┴───┴───┘│
快速上手
- 新建三个项目,分别为
main-app,sub-app1,sub-app2,项目结构一目了然:
├── main-app/ // 主应用(基座)
├── sub-app1/ // vue3子应用(团队A)
├── app-vue/ // vue3子应用(团队B)
安装qiankun
yarn add qiankun # 或者 npm i qiankun -S
主项目中注册微应用
// 主应用main-app/main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { registerMicroApps, start } from 'qiankun'
createApp(App).mount('#app1')
registerMicroApps(
[
{
name: 'sub-app1', // app name registered
entry: 'http://localhost:5175',
container: '#micro-app-container',
activeRule: (location) => location.hash.startsWith('#/app-a'),
props: {
name: 'kuitos'
}
}
],
{
beforeLoad: (app) => console.log('before load', app.name),
beforeMount: [(app) => console.log('before mount', app.name)]
}
)
// start()
// 启动 qiankun,配置沙箱模式
start({
sandbox: {
strictStyleIsolation: true,
},
})
微应用导出钩子
- 由于
qiankun不支持module,所以对于vue3项目,需要使用vite-plugin-qiankun来集成 -
renderWithQiankun用来对外暴露钩子 -
qiankunWindow替代window变量
// 子应用 sub-app1/mian.js
import { createApp } from 'vue'
import {
renderWithQiankun,
qiankunWindow
} from 'vite-plugin-qiankun/dist/helper'
import './style.css'
import App from './App.vue'
let instance = null
function render(props = {}) {
const container = props.container || '#app'
console.log('子应用挂载容器:', container)
instance = createApp(App)
instance.mount(container)
}
console.log('qiankunWindow',qiankunWindow);
console.log('window.__POWERED_BY_QIANKUN__',window.__POWERED_BY_QIANKUN__);
// 独立运行时,直接渲染
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
console.log('独立运行时,直接渲染')
render()
}
renderWithQiankun({
mount(props) {
console.log(props)
render(props)
},
bootstrap() {
console.log('bootstrap')
},
unmount(props) {
console.log('unmount', props)
},
update(props) {
console.log('update', props)
}
})
在子应用的vite.config.js中注册插件
// 子应用 sub-app1/vite.config.js
plugins: [
vue(),
qiankun('sub-app1', {
useDevMode: true,
})
],
进阶场景
应用通信
应用拆分后,不可避免的会涉及到通信问题,那么如何让它们“愉快地对话”?
props
- 最简单的方式,正如目前的主流框架,
qiankun也提供了一个props属性,可以实现父->子之间的数据通信,当主应用注册registerMicroApps子应用的时候,利用props传递
// 主应用 main-app/main.js
registerMicroApps(
[
{
name: 'sub-app1', // app name registered
entry: 'http://localhost:5175',
container: '#micro-app-container',
activeRule: (location) => location.hash.startsWith('#/app-a'),
props: {
// name: 'kuitos' //该属性会被覆盖?
count: 100,
time: new Date().getTime()
}
}
],
{
beforeLoad: (app) => console.log('before load', app.name),
beforeMount: [(app) => console.log('before mount', app.name)]
}
)
// 子应用 sub-app1/main.js
renderWithQiankun({
mount(props) {
render(props)
},
})
// 子应用 sub-app1/main.js
function render(props = {}) {
const container = props.container || '#app'
console.log('子应用挂载容器:', container)
instance = createApp(App)
instance.config.globalProperties.__SUBAPP__ = props //vue3的globalProperties全局挂载
window.__SUBAPP__ = props //挂载到子应用的window对象
instance.mount(container)
}
子应用的其他组件使用时
//子应用 sub-app1/src/components/HelloWord.vue
<script setup>
import { ref,getCurrentInstance } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
console.log('window方式获取数据',window.__SUBAPP__)
console.log('getCurrentInstance方式获取数据', getCurrentInstance().proxy.__SUBAPP__)
</script>
initGlobalState
父->子传递
首先在父应用中创建一个globalState.js初始化一下state
//main-app/globalState.js
import { initGlobalState } from 'qiankun';
// 定义初始状态
export const initialState = {
user: { id: null, name: '', token: '' },
globalConfig: { theme: 'light', language: 'zh-CN' },
sharedData: {},
currentRoute: {}
};
// 当前全局状态
export let currentGlobalState = { ...initialState };
// 全局状态管理器实例
export let globalActions = null;
// 初始化全局状态管理
export const initGlobalStateManager = () => {
// 初始化 state
const actions = initGlobalState(initialState);
// 监听状态变更
actions.onGlobalStateChange((state, prev) => {
currentGlobalState = { ...state };
console.log('主应用:全局状态变更', { newState: state, prevState: prev });
});
// 设置初始状态
actions.setGlobalState(initialState);
globalActions = actions;
return actions;
};
// 更新全局状态
export const updateGlobalState = (newState) => {
if (!globalActions) {
globalActions = initGlobalStateManager();
}
globalActions.setGlobalState(newState);
};
其中关键方法:
// 定义初始状态
export const initialState = {
user: { id: null, name: '', token: '' },
globalConfig: { theme: 'light', language: 'zh-CN' },
sharedData: {},
currentRoute: {}
};
//初始化 state
const actions = initGlobalState(initialState);
// 监听状态变更
actions.onGlobalStateChange((state, prev) => {
currentGlobalState = { ...state };
console.log('主应用:全局状态变更', { newState: state, prevState: prev });
});
// 更新全局状态
actions.setGlobalState(newState);
// 取消监听
actions.offGlobalStateChange();
// main-app/login.vue
import { updateGlobalState } from './globalState'
const handleLogin =()=>{
// 。。。主应用的业务逻辑
// 更新state
updateGlobalState({
isLoggedIn: true,
});
}
在子应用中监听
// sub-app1/main.js
function render(props = {}) {
const container = props.container || '#app'
instance = createApp(App)
// 监听全局状态变化
//props 里面有setGlobalState和onGlobalStateChange 方法,可用于监听和修改状态
if (props.onGlobalStateChange) {
props.onGlobalStateChange((state, prev) => {
console.log('子变更后的状态', state, '子变更前的状态', prev);
});
}
// 挂载一下props,以便于在其他组件中使用setGlobalState和onGlobalStateChange
// 挂载的方式有很多, pinia等,总之其他地方能获取到props对象就行
window.__SUBAPP__ = props
pinia = createPinia()
instance.use(pinia)
instance.mount(container)
}
子->父传递
在子应用创建的时候,已经将props保存了window.__SUBAPP__ = props,在子应用的任何组件中都可以使用
所以只需要在某个组件中调用setGlobalState方法就可
// sub-app1/HelloWord.vue
// 获取全局状态管理方法
const { setGlobalState } = window.__SUBAPP__ || {}
if (setGlobalState) {
// 更新全局状态
setGlobalState({
sharedData: {
count: newValue
}
})
}
微前端选型指南:何时用?用哪个?
适合场景 ✅
- 大型企业级应用(100+页面)
- 多团队协作开发(3+前端团队)
- 老系统渐进式重构
- 需要支持多技术栈
- 独立部署需求强烈
不适合场景 ❌
- 小型项目(页面<20)
- 单人/小团队开发
- 对性能要求极致(首屏加载时间<1s)
- 无技术栈异构需求
结语
千万不要手里攥着锤子看啥都像钉子。 微前端不是银弹,而是一种架构选择。它用复杂度换来了灵活性、独立性和可维护性。就像乐高积木,单个模块简单,但组合起来却能构建出无限可能的世界。
后续有时间将继续深入学习一下微前端的生命周期、样式隔离、部署发布这几个部分。
最后,觉得有用的话三连一下~