阅读视图

发现新文章,点击刷新页面。

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打包,出现了我们需要的声明的文件

image.png

可以看到打包时打包了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

目录结构如下

image.png

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

运行效果:

image.png

vite+ts+monorepo从0搭建vue3组件库(三):开发一个组件

1.在packages下新建components和utils文件夹,分别执行pnpm init,并将他们的包名改为@dlx-ui/components@dlx-ui/utils,目录结构如下:

组件目录

image.png

组件编写

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>

image.png

全局挂载组件

有的时候我们使用组件的时候想要直直接使用 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>

image.png

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 信号的前端框架设计

logo

大家好,我是 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();

此编译策略的优点

  1. 无需手动导入 API,像普通变量一样使用 Signal
  2. 和 TS 类型的结合非常好,特别和 JSX 的类型结合非常完美
  3. 不怕解构
  4. 标记变量和常规变量一起用不会有混淆

一个简单的鼠标位置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

logo

点个 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可能影响性能

适用场景

  1. 数据可视化大屏
  2. 门户网站首页
  3. 个人工作台
  4. 可配置的管理后台

混合模式(实战中最常见)

  • 在实际项目中,我们常常根据需求混合使用以上模式。例如,在基座模式中,某个子应用内部使用微件模式来组合多个微前端模块。
  • 比如一个电商系统的架构
主应用(基座模式)
    ├── 商品管理(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>

image-20251208221830086

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)
}
image-20251209221654305
子->父传递

在子应用创建的时候,已经将props保存了window.__SUBAPP__ = props,在子应用的任何组件中都可以使用

所以只需要在某个组件中调用setGlobalState方法就可

// sub-app1/HelloWord.vue
// 获取全局状态管理方法
const { setGlobalState } = window.__SUBAPP__ || {}
if (setGlobalState) {
// 更新全局状态
    setGlobalState({
      sharedData: {
        count: newValue
      }
    })
}

image-20251209222325435

微前端选型指南:何时用?用哪个?

适合场景 ✅

  • 大型企业级应用(100+页面)
  • 多团队协作开发(3+前端团队)
  • 老系统渐进式重构
  • 需要支持多技术栈
  • 独立部署需求强烈

不适合场景 ❌

  • 小型项目(页面<20)
  • 单人/小团队开发
  • 对性能要求极致(首屏加载时间<1s)
  • 无技术栈异构需求

结语

千万不要手里攥着锤子看啥都像钉子。 微前端不是银弹,而是一种架构选择。它用复杂度换来了灵活性、独立性和可维护性。就像乐高积木,单个模块简单,但组合起来却能构建出无限可能的世界。

后续有时间将继续深入学习一下微前端的生命周期、样式隔离、部署发布这几个部分。

最后,觉得有用的话三连一下~

❌